Compare commits
No commits in common. "main" and "codex/production-48-runtime-snapshot" have entirely different histories.
main
...
codex/prod
@ -9,7 +9,7 @@ import { Input } from "@/components/ui/input";
|
||||
import Image from "next/image";
|
||||
import { toast } from "sonner";
|
||||
import { requestLongTaskResult } from "@/lib/client/long-task";
|
||||
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import { inferOriginalImageUrl } from "@/lib/image-url";
|
||||
import { formatBeijingTime } from "@/lib/utils";
|
||||
|
||||
@ -32,7 +32,7 @@ type Pagination = {
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
function buildGalleryDownloadFilename(url: string, alt: string, contentType: string) {
|
||||
function buildGalleryDownloadFilename(url: string, alt: string) {
|
||||
const rawUrl = String(url || "").trim();
|
||||
const rawAlt = String(alt || "").trim();
|
||||
const safeBase =
|
||||
@ -58,7 +58,7 @@ function buildGalleryDownloadFilename(url: string, alt: string, contentType: str
|
||||
}
|
||||
}
|
||||
|
||||
return `${safeBase}-${Date.now()}.${getImageDownloadExtension(contentType) || extension}`;
|
||||
return `${safeBase}-${Date.now()}.${extension}`;
|
||||
}
|
||||
|
||||
export default function GalleryPage() {
|
||||
@ -103,7 +103,7 @@ export default function GalleryPage() {
|
||||
};
|
||||
|
||||
const handleDownload = async (url: string, alt: string) => {
|
||||
const originalUrl = String(url || "").trim();
|
||||
const originalUrl = inferOriginalImageUrl(String(url || "").trim());
|
||||
if (!originalUrl) {
|
||||
toast.error("图片地址无效");
|
||||
return;
|
||||
@ -112,14 +112,8 @@ export default function GalleryPage() {
|
||||
try {
|
||||
toast.info("正在准备下载...");
|
||||
|
||||
// Validate the supplied URL before any legacy thumbnail inference can
|
||||
// remove signed storage transformation parameters.
|
||||
buildImageProxyUrl(originalUrl, { delivery: "original" });
|
||||
const parsed = new URL(originalUrl, window.location.origin);
|
||||
const source = parsed.pathname === "/api/image-proxy" ? originalUrl : inferOriginalImageUrl(originalUrl);
|
||||
|
||||
const response = await fetch(
|
||||
buildImageProxyUrl(source, { delivery: "original" })
|
||||
buildImageProxyUrl(originalUrl, { delivery: "proxy" })
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`download failed: ${response.status}`);
|
||||
@ -128,7 +122,7 @@ export default function GalleryPage() {
|
||||
const blobUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = blobUrl;
|
||||
link.download = buildGalleryDownloadFilename(originalUrl, alt, blob.type);
|
||||
link.download = buildGalleryDownloadFilename(originalUrl, alt);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
@ -136,7 +130,14 @@ export default function GalleryPage() {
|
||||
toast.success("图片已开始下载");
|
||||
} catch (error) {
|
||||
console.error("Download failed:", error);
|
||||
toast.error(error instanceof Error && error.message.includes("无法确认原图") ? error.message : "原图下载失败,请重试");
|
||||
const fallbackLink = document.createElement("a");
|
||||
fallbackLink.href = originalUrl;
|
||||
fallbackLink.target = "_blank";
|
||||
fallbackLink.rel = "noopener noreferrer";
|
||||
document.body.appendChild(fallbackLink);
|
||||
fallbackLink.click();
|
||||
document.body.removeChild(fallbackLink);
|
||||
toast.error("下载失败,已为你打开原图,可直接另存为");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -127,15 +127,13 @@ function buildImageResponse(
|
||||
etag: string;
|
||||
vary?: string;
|
||||
cacheStatus?: "HIT" | "MISS";
|
||||
cacheControl?: string;
|
||||
attachment?: boolean;
|
||||
}
|
||||
) {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": params.contentType,
|
||||
"Content-Length": String(body.length),
|
||||
ETag: params.etag,
|
||||
"Cache-Control": params.cacheControl || "public, max-age=31536000, immutable, stale-while-revalidate=86400",
|
||||
"Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400",
|
||||
};
|
||||
if (params.vary) {
|
||||
headers.Vary = params.vary;
|
||||
@ -143,11 +141,6 @@ function buildImageResponse(
|
||||
if (params.cacheStatus) {
|
||||
headers["X-Image-Proxy-Cache"] = params.cacheStatus;
|
||||
}
|
||||
if (params.attachment) {
|
||||
headers["Content-Disposition"] = "attachment";
|
||||
headers["X-Content-Type-Options"] = "nosniff";
|
||||
headers["Content-Security-Policy"] = "default-src 'none'; sandbox";
|
||||
}
|
||||
return new NextResponse(body, {
|
||||
status: 200,
|
||||
headers,
|
||||
@ -292,16 +285,8 @@ export async function GET(request: NextRequest) {
|
||||
const directQuality = requestedQuality ? clamp(requestedQuality, MIN_QUALITY, MAX_QUALITY) : null;
|
||||
const requestedFormat = searchParams.get("f");
|
||||
const forceProxy = searchParams.get("proxy") === "1";
|
||||
const original = searchParams.get("original") === "1";
|
||||
|
||||
if (original && (requestedWidth || requestedQuality || requestedFormat)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Original images cannot be transformed" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!forceProxy && !original) {
|
||||
if (!forceProxy) {
|
||||
const directImageTarget = resolveDirectImageTarget(
|
||||
request,
|
||||
target,
|
||||
@ -314,12 +299,10 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
const cacheKey = original
|
||||
? `${target.toString()}|original-v1`
|
||||
: `${target.toString()}|${width || "auto"}|${quality}|${requestedFormat || "auto"}`;
|
||||
const cacheKey = `${target.toString()}|${width || "auto"}|${quality}|${requestedFormat || "auto"}`;
|
||||
const etag = buildEtag(cacheKey);
|
||||
const ifNoneMatch = request.headers.get("if-none-match");
|
||||
if (!original && ifNoneMatch && ifNoneMatch === etag) {
|
||||
if (ifNoneMatch && ifNoneMatch === etag) {
|
||||
return new NextResponse(null, {
|
||||
status: 304,
|
||||
headers: {
|
||||
@ -329,7 +312,7 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
const cached = original ? null : getCachedImageResponse(cacheKey);
|
||||
const cached = getCachedImageResponse(cacheKey);
|
||||
if (cached) {
|
||||
return buildImageResponse(cached.body, {
|
||||
contentType: cached.contentType,
|
||||
@ -344,7 +327,7 @@ export async function GET(request: NextRequest) {
|
||||
upstreamResponse = await fetch(target.toString(), {
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
headers: {
|
||||
Accept: original ? "*/*" : "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
Accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@ -365,14 +348,6 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
|
||||
if (original) {
|
||||
return buildImageResponse(originalBuffer, {
|
||||
contentType,
|
||||
etag: `"${crypto.createHash("sha256").update(originalBuffer).digest("hex")}"`,
|
||||
cacheControl: "private, no-store",
|
||||
attachment: true,
|
||||
});
|
||||
}
|
||||
const isSvg = contentType.toLowerCase().includes("image/svg");
|
||||
const isGif = contentType.toLowerCase().includes("image/gif");
|
||||
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { advancePastAtomicRanges, scanMarkdownImageStreaming } from "@/lib/canvas/markdown-image-streaming";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { shouldUseCanvasAgentRuntime } from "@/lib/ai/canvas-agent-runtime/routing";
|
||||
@ -1918,7 +1917,6 @@ type CanvasTypewriterTextProps = {
|
||||
className?: string;
|
||||
renderMarkdown?: boolean;
|
||||
preserveMarkdownTables?: boolean;
|
||||
isMarkdownStreaming?: boolean;
|
||||
enablePdfDownload?: boolean;
|
||||
pdfFileName?: string;
|
||||
pdfSiteLogo?: string | null;
|
||||
@ -2359,6 +2357,20 @@ function getMarkdownTableBlockRanges(content: string): MarkdownTableBlockRange[]
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function advanceTypewriterCursor(
|
||||
contentLength: number,
|
||||
targetCursor: number,
|
||||
tableRanges: MarkdownTableBlockRange[]
|
||||
) {
|
||||
const clampedCursor = Math.min(contentLength, targetCursor);
|
||||
for (const range of tableRanges) {
|
||||
if (clampedCursor >= range.start && clampedCursor < range.end) {
|
||||
return Math.min(contentLength, range.end);
|
||||
}
|
||||
}
|
||||
return clampedCursor;
|
||||
}
|
||||
|
||||
function CanvasTypewriterCursor() {
|
||||
return (
|
||||
<span
|
||||
@ -2375,7 +2387,6 @@ function CanvasTypewriterText({
|
||||
className,
|
||||
renderMarkdown = false,
|
||||
preserveMarkdownTables = false,
|
||||
isMarkdownStreaming = false,
|
||||
enablePdfDownload = false,
|
||||
pdfFileName,
|
||||
pdfSiteLogo,
|
||||
@ -2402,15 +2413,12 @@ function CanvasTypewriterText({
|
||||
const tableRanges = preserveMarkdownTables
|
||||
? getMarkdownTableBlockRanges(content)
|
||||
: [];
|
||||
const atomicRanges = renderMarkdown
|
||||
? [...tableRanges, ...scanMarkdownImageStreaming(content).ranges]
|
||||
: tableRanges;
|
||||
activeRef.current = true;
|
||||
onActiveChange?.(typingKey, true);
|
||||
setDisplayText("");
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
cursor = advancePastAtomicRanges(content.length, cursor + 1, atomicRanges);
|
||||
cursor = advanceTypewriterCursor(content.length, cursor + 1, tableRanges);
|
||||
if (cursor >= content.length) {
|
||||
setDisplayText(content);
|
||||
window.clearInterval(timer);
|
||||
@ -2431,7 +2439,7 @@ function CanvasTypewriterText({
|
||||
onActiveChange?.(typingKey, false);
|
||||
}
|
||||
};
|
||||
}, [content, enabled, onActiveChange, onComplete, preserveMarkdownTables, renderMarkdown, typingKey]);
|
||||
}, [content, enabled, onActiveChange, onComplete, preserveMarkdownTables, typingKey]);
|
||||
|
||||
const showCursor = enabled && Boolean(content) && displayText.length < content.length;
|
||||
|
||||
@ -2440,7 +2448,6 @@ function CanvasTypewriterText({
|
||||
<div className="w-full min-w-0">
|
||||
<Response
|
||||
className={className}
|
||||
isMarkdownStreaming={isMarkdownStreaming}
|
||||
enablePdfDownload={enablePdfDownload}
|
||||
pdfFileName={pdfFileName}
|
||||
pdfSiteLogo={pdfSiteLogo}
|
||||
@ -16570,10 +16577,6 @@ export function CanvasChatPanel({
|
||||
(isClawdMode || isClawdChatMessage(message));
|
||||
const isLatestClawAssistantMessage =
|
||||
message.id === latestClawAssistantMessageId;
|
||||
const isMessageMarkdownStreaming =
|
||||
message.role === "assistant" &&
|
||||
index === renderableVisibleMessages.length - 1 &&
|
||||
(isLoading || (isClawAssistantMessage && isClawdSubmitting));
|
||||
const openClawArtifacts = isClawAssistantMessage
|
||||
? getOpenClawMessageArtifacts(message)
|
||||
: [];
|
||||
@ -16901,7 +16904,6 @@ export function CanvasChatPanel({
|
||||
<Response
|
||||
key={key}
|
||||
className="break-words [overflow-wrap:anywhere]"
|
||||
isMarkdownStreaming={isMessageMarkdownStreaming}
|
||||
>
|
||||
{text}
|
||||
</Response>
|
||||
@ -16963,7 +16965,6 @@ export function CanvasChatPanel({
|
||||
className="break-words [overflow-wrap:anywhere]"
|
||||
renderMarkdown
|
||||
preserveMarkdownTables={shouldTypeClawAssistantMessage}
|
||||
isMarkdownStreaming={isMessageMarkdownStreaming}
|
||||
enablePdfDownload={message.role === "assistant" && !shouldAnimateAnalysis}
|
||||
pdfFileName={`Claw分析报告-${message.id}`}
|
||||
pdfSiteLogo={siteSettings?.siteLogo || siteSettings?.siteLogoDark || null}
|
||||
@ -16986,7 +16987,6 @@ export function CanvasChatPanel({
|
||||
className="break-words [overflow-wrap:anywhere]"
|
||||
renderMarkdown
|
||||
preserveMarkdownTables={shouldTypeClawAssistantMessage}
|
||||
isMarkdownStreaming={isMessageMarkdownStreaming}
|
||||
enablePdfDownload={message.role === "assistant" && !shouldAnimatePlainText}
|
||||
pdfFileName={`Claw分析报告-${message.id}`}
|
||||
pdfSiteLogo={siteSettings?.siteLogo || siteSettings?.siteLogoDark || null}
|
||||
|
||||
@ -16,8 +16,6 @@ import {
|
||||
} from "@/components/elements/openclaw-chart-card";
|
||||
import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-download";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import { splitMarkdownImages } from "@/lib/canvas/markdown-image-segments";
|
||||
import { stableStreamingMarkdown } from "@/lib/canvas/markdown-image-streaming";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@ -28,7 +26,6 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
type ResponseProps = ComponentProps<typeof Streamdown> & {
|
||||
collapsibleImageAnalysis?: boolean;
|
||||
isMarkdownStreaming?: boolean;
|
||||
enablePdfDownload?: boolean;
|
||||
pdfFileName?: string;
|
||||
pdfSiteLogo?: string | null;
|
||||
@ -365,6 +362,43 @@ function normalizeMarkdownContent(value: string) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function splitMarkdownImages(content: string) {
|
||||
const segments: Array<
|
||||
| { type: "text"; value: string }
|
||||
| { type: "image"; alt?: string; src: string }
|
||||
> = [];
|
||||
|
||||
const imageRegex = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = imageRegex.exec(content)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({
|
||||
type: "text",
|
||||
value: content.slice(lastIndex, match.index),
|
||||
});
|
||||
}
|
||||
|
||||
segments.push({
|
||||
type: "image",
|
||||
alt: match[1],
|
||||
src: match[2],
|
||||
});
|
||||
|
||||
lastIndex = imageRegex.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < content.length) {
|
||||
segments.push({
|
||||
type: "text",
|
||||
value: content.slice(lastIndex),
|
||||
});
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function isLikelyImageUrl(value?: string | null) {
|
||||
return IMAGE_URL_REGEX.test(String(value || "").trim());
|
||||
}
|
||||
@ -763,7 +797,6 @@ function ResponseReportTableDownloadButton({
|
||||
export const Response = ({
|
||||
className,
|
||||
collapsibleImageAnalysis = false,
|
||||
isMarkdownStreaming = false,
|
||||
enablePdfDownload = false,
|
||||
pdfFileName,
|
||||
pdfSiteLogo,
|
||||
@ -1028,7 +1061,7 @@ export const Response = ({
|
||||
|
||||
const rawText =
|
||||
typeof props.children === "string"
|
||||
? normalizeMarkdownContent(stableStreamingMarkdown(props.children, isMarkdownStreaming))
|
||||
? normalizeMarkdownContent(props.children)
|
||||
: null;
|
||||
const shouldShowPdfDownload =
|
||||
Boolean(enablePdfDownload && rawText && hasDownloadableReportContent(rawText));
|
||||
@ -1058,7 +1091,7 @@ export const Response = ({
|
||||
);
|
||||
};
|
||||
|
||||
if (rawText !== null) {
|
||||
if (rawText) {
|
||||
const analysisSection = collapsibleImageAnalysis
|
||||
? extractImageAnalysisSection(rawText)
|
||||
: null;
|
||||
|
||||
@ -4,7 +4,7 @@ import dynamic from "next/dynamic";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors, Expand, Maximize2, Move3D, Droplet } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
|
||||
const ImageAnnotator = dynamic(
|
||||
() => import("./image-annotator").then((module) => module.ImageAnnotator),
|
||||
@ -237,24 +237,34 @@ export function ImagePreview({
|
||||
toast.error("下载失败,请重试");
|
||||
return;
|
||||
}
|
||||
const filenameBase = `generated-image-${Date.now()}`;
|
||||
const filename = `generated-image-${Date.now()}.jpg`;
|
||||
try {
|
||||
const response = await fetch(
|
||||
buildImageProxyUrl(safeOriginalUrl, { delivery: "original" })
|
||||
buildImageProxyUrl(safeOriginalUrl, { delivery: "proxy" })
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`下载失败(${response.status})`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const extension = getImageDownloadExtension(blob.type);
|
||||
const filename = extension ? `${filenameBase}.${extension}` : filenameBase;
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
triggerBrowserDownload(url, filename);
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success("下载成功");
|
||||
} catch (error) {
|
||||
console.error("下载失败:", error);
|
||||
toast.error(error instanceof Error && error.message.includes("无法确认原图") ? error.message : "原图下载失败,请重试");
|
||||
try {
|
||||
triggerBrowserDownload(safeOriginalUrl, filename);
|
||||
toast.message("代理下载失败,已切换为原图直链下载");
|
||||
} catch (fallbackError) {
|
||||
console.error("原图直链下载失败:", fallbackError);
|
||||
try {
|
||||
triggerBrowserDownload(safeOriginalUrl, undefined, { openInNewTab: true });
|
||||
toast.message("已打开原图,可直接另存为");
|
||||
} catch (openError) {
|
||||
console.error("打开原图失败:", openError);
|
||||
toast.error("下载失败,请重试");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -60,22 +60,10 @@ python scripts/production48.py rollback --release <发布ID>
|
||||
|
||||
`activate` 会重新核对生产版本和任务状态,拒绝在最近一小时仍有未结束生成任务时切换。
|
||||
更早的历史挂起记录只写入发布回执,不会自动修改数据库中的状态。
|
||||
它保存旧 PM2 配置和启动快照,然后仅重建 `ai-chatbot` 这一项 PM2 定义,
|
||||
让新版本的工作目录、可执行路径和环境变量全部生效,不改变其他应用,
|
||||
它保存旧 PM2 配置和启动快照,然后仅更新 `ai-chatbot` 的工作目录并重启这个应用,
|
||||
因此会有短暂切换窗口。健康检查同时核对进程目录、构建 ID、首页和路由。
|
||||
检查失败自动恢复旧配置并再次验活;通过后执行 `pm2 save`,让重启后使用已验收版本。
|
||||
|
||||
PM2 的普通 restart 会保留旧工作目录和程序路径,因此不能用它替代本流程的单应用定义重建。
|
||||
若一次切换已经明确回滚并验证原版本健康,修复发布工具后,可保留已校验制品并显式重试:
|
||||
|
||||
```powershell
|
||||
python scripts/production48.py activate --release <发布ID> --retry-rolled-back
|
||||
```
|
||||
|
||||
只有日志阶段为 `rolled-back` 且当前工作目录/构建目录仍匹配原版本才允许重试。
|
||||
旧尝试日志另外保留。运行制品的 `sourceCommit` 始终指向当时实际构建的 main 提交,
|
||||
后续工具修复不改写这个来源;`operatorCommit` 单独记录执行切换的维护工具版本。
|
||||
|
||||
## 运行资料与边界
|
||||
|
||||
每个发布目录包含构建机按锁文件安装的完整运行依赖和 public 源码资源。
|
||||
|
||||
@ -1,113 +0,0 @@
|
||||
export type MarkdownImageSegment =
|
||||
| { type: "text"; value: string }
|
||||
| { type: "image"; alt?: string; src: string };
|
||||
|
||||
// Markdown destinations may include balanced parentheses and backslash escapes.
|
||||
function readDestination(content: string, open: number) {
|
||||
if (content[open] !== "(") return null;
|
||||
let depth = 1;
|
||||
for (let index = open + 1; index < content.length; index += 1) {
|
||||
const character = content[index];
|
||||
if (character === "\\") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (character === "(") depth += 1;
|
||||
if (character === ")" && --depth === 0) {
|
||||
const raw = content.slice(open + 1, index);
|
||||
if (!/^https?:\/\//i.test(raw) || /\s/.test(raw)) return null;
|
||||
return { url: raw.replace(/\\([()\\])/g, "$1"), end: index + 1 };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readImage(content: string, start: number) {
|
||||
if (!content.startsWith("![", start)) return null;
|
||||
let depth = 1;
|
||||
for (let index = start + 2; index < content.length; index += 1) {
|
||||
const character = content[index];
|
||||
if (character === "\\") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (character === "[") depth += 1;
|
||||
if (character !== "]" || --depth !== 0) continue;
|
||||
const destination = readDestination(content, index + 1);
|
||||
if (!destination) return null;
|
||||
return {
|
||||
alt: content.slice(start + 2, index).replace(/\\([\[\]\\])/g, "$1"),
|
||||
src: destination.url,
|
||||
end: destination.end,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function splitMarkdownImages(content: string): MarkdownImageSegment[] {
|
||||
const segments: MarkdownImageSegment[] = [];
|
||||
let textStart = 0;
|
||||
let cursor = 0;
|
||||
while (cursor < content.length) {
|
||||
// Leave Markdown examples in code spans and fences untouched.
|
||||
if (content[cursor] === "`") {
|
||||
let endOfRun = cursor + 1;
|
||||
while (content[endOfRun] === "`") endOfRun += 1;
|
||||
const marker = content.slice(cursor, endOfRun);
|
||||
const close = content.indexOf(marker, endOfRun);
|
||||
cursor = close < 0 ? content.length : close + marker.length;
|
||||
continue;
|
||||
}
|
||||
const lineStart = cursor === 0 || content[cursor - 1] === "\n";
|
||||
if (lineStart) {
|
||||
const fence = content.slice(cursor).match(/^ {0,3}(~{3,})[^\n]*(?:\n|$)/);
|
||||
if (fence) {
|
||||
const marker = fence[1];
|
||||
const closing = new RegExp(`^ {0,3}~{${marker.length},}\\s*$`);
|
||||
let line = cursor + fence[0].length;
|
||||
while (line < content.length) {
|
||||
const next = content.indexOf("\n", line);
|
||||
const end = next < 0 ? content.length : next;
|
||||
if (closing.test(content.slice(line, end))) {
|
||||
line = next < 0 ? end : end + 1;
|
||||
break;
|
||||
}
|
||||
line = next < 0 ? end : end + 1;
|
||||
}
|
||||
cursor = line;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (content[cursor] === "\\") {
|
||||
cursor += 2;
|
||||
continue;
|
||||
}
|
||||
const wrapped = content.startsWith("[![", cursor);
|
||||
const image = readImage(content, cursor + (wrapped ? 1 : 0));
|
||||
if (!image) {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
let end = image.end;
|
||||
if (wrapped) {
|
||||
const outer = content[end] === "]" ? readDestination(content, end + 1) : null;
|
||||
// Preserve incomplete, unsafe and different-target links as Markdown so
|
||||
// Streamdown applies its normal link sanitization and click behavior.
|
||||
if (!outer || outer.url !== image.src) {
|
||||
cursor = outer ? outer.end : end;
|
||||
continue;
|
||||
}
|
||||
end = outer.end;
|
||||
}
|
||||
if (cursor > textStart) {
|
||||
segments.push({ type: "text", value: content.slice(textStart, cursor) });
|
||||
}
|
||||
segments.push({ type: "image", alt: image.alt, src: image.src });
|
||||
cursor = end;
|
||||
textStart = end;
|
||||
}
|
||||
if (textStart < content.length) {
|
||||
segments.push({ type: "text", value: content.slice(textStart) });
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
@ -1,146 +0,0 @@
|
||||
/** A half-open span containing a complete Markdown link or image. */
|
||||
export type MarkdownImageRange = { start: number; end: number };
|
||||
|
||||
type Parsed = { kind: "complete"; end: number } | { kind: "pending" } | { kind: "invalid" };
|
||||
|
||||
// This scanner recognizes syntax only; rendering and URL safety remain the
|
||||
// responsibility of the existing Markdown renderer and image splitter.
|
||||
function readDestination(text: string, start: number, allowWhitespace = false): Parsed {
|
||||
if (start >= text.length) return { kind: "pending" };
|
||||
if (text[start] !== "(") return { kind: "invalid" };
|
||||
let depth = 1;
|
||||
for (let i = start + 1; i < text.length; i += 1) {
|
||||
const char = text[i];
|
||||
if (char === "\\") {
|
||||
if (i + 1 >= text.length) return { kind: "pending" };
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "\n" || char === "\r" || (!allowWhitespace && /\s/.test(char))) return { kind: "invalid" };
|
||||
if (char === "(") depth += 1;
|
||||
if (char === ")" && --depth === 0) return { kind: "complete", end: i + 1 };
|
||||
}
|
||||
return { kind: "pending" };
|
||||
}
|
||||
|
||||
function readImage(text: string, start: number, wrapped: boolean): Parsed {
|
||||
const imageStart = start + (wrapped ? 1 : 0);
|
||||
if (imageStart + 1 >= text.length) return { kind: "pending" };
|
||||
if (text[imageStart] !== "!" || text[imageStart + 1] !== "[") return { kind: "invalid" };
|
||||
let depth = 1;
|
||||
for (let i = imageStart + 2; i < text.length; i += 1) {
|
||||
const char = text[i];
|
||||
if (char === "\n" || char === "\r") return { kind: "invalid" };
|
||||
if (char === "\\") {
|
||||
if (i + 1 >= text.length) return { kind: "pending" };
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "[") depth += 1;
|
||||
if (char !== "]" || --depth !== 0) continue;
|
||||
const inner = readDestination(text, i + 1);
|
||||
if (inner.kind !== "complete" || !wrapped) return inner;
|
||||
if (inner.end >= text.length) return { kind: "pending" };
|
||||
if (text[inner.end] !== "]") return { kind: "invalid" };
|
||||
return readDestination(text, inner.end + 1);
|
||||
}
|
||||
return { kind: "pending" };
|
||||
}
|
||||
|
||||
function readLink(text: string, start: number): Parsed {
|
||||
let depth = 1;
|
||||
for (let i = start + 1; i < text.length; i += 1) {
|
||||
const char = text[i];
|
||||
// An unfinished bracket on an earlier line must not hide later output.
|
||||
if (char === "\n" || char === "\r") return { kind: "invalid" };
|
||||
if (char === "\\") {
|
||||
if (i + 1 >= text.length) return { kind: "pending" };
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "[") depth += 1;
|
||||
if (char !== "]" || --depth !== 0) continue;
|
||||
if (text[i + 1] === "[") {
|
||||
for (let j = i + 2; j < text.length; j += 1) {
|
||||
if (text[j] === "\n" || text[j] === "\r") return { kind: "invalid" };
|
||||
if (text[j] === "\\") {
|
||||
if (j + 1 >= text.length) return { kind: "pending" };
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
if (text[j] === "]") return { kind: "complete", end: j + 1 };
|
||||
}
|
||||
return { kind: "pending" };
|
||||
}
|
||||
return readDestination(text, i + 1, true);
|
||||
}
|
||||
return { kind: "pending" };
|
||||
}
|
||||
|
||||
/** Locate complete Markdown links/images and an incomplete trailing construct.
|
||||
* Code spans/fences and escaped markers remain normal text. */
|
||||
export function scanMarkdownImageStreaming(text: string): {
|
||||
ranges: MarkdownImageRange[];
|
||||
pendingStart: number | null;
|
||||
} {
|
||||
const ranges: MarkdownImageRange[] = [];
|
||||
let cursor = 0;
|
||||
while (cursor < text.length) {
|
||||
if (text[cursor] === "\\") { cursor += 2; continue; }
|
||||
if (text[cursor] === "`") {
|
||||
let end = cursor + 1;
|
||||
while (text[end] === "`") end += 1;
|
||||
const marker = text.slice(cursor, end);
|
||||
const close = text.indexOf(marker, end);
|
||||
cursor = close < 0 ? text.length : close + marker.length;
|
||||
continue;
|
||||
}
|
||||
if (cursor === 0 || text[cursor - 1] === "\n") {
|
||||
const fence = text.slice(cursor).match(/^ {0,3}(~{3,})[^\n]*(?:\n|$)/);
|
||||
if (fence) {
|
||||
const closing = new RegExp(`^ {0,3}~{${fence[1].length},}\\s*$`);
|
||||
let line = cursor + fence[0].length;
|
||||
while (line < text.length) {
|
||||
const next = text.indexOf("\n", line);
|
||||
const end = next < 0 ? text.length : next;
|
||||
if (closing.test(text.slice(line, end))) { line = next < 0 ? end : end + 1; break; }
|
||||
line = next < 0 ? end : end + 1;
|
||||
}
|
||||
cursor = line;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const char = text[cursor];
|
||||
if (char !== "[" && char !== "!") { cursor += 1; continue; }
|
||||
const wrapped = char === "[";
|
||||
const parsed = wrapped && text[cursor + 1] !== "!"
|
||||
? readLink(text, cursor)
|
||||
: readImage(text, cursor, wrapped);
|
||||
if (parsed.kind === "pending") return { ranges, pendingStart: cursor };
|
||||
if (parsed.kind === "complete") { ranges.push({ start: cursor, end: parsed.end }); cursor = parsed.end; }
|
||||
else cursor += 1;
|
||||
}
|
||||
return { ranges, pendingStart: null };
|
||||
}
|
||||
|
||||
/** During active delivery, withhold only unfinished link/image syntax at the tail. */
|
||||
export function stableStreamingMarkdown(text: string, streaming: boolean): string {
|
||||
if (!streaming) return text;
|
||||
const { pendingStart } = scanMarkdownImageStreaming(text);
|
||||
return pendingStart === null ? text : text.slice(0, pendingStart);
|
||||
}
|
||||
|
||||
export function advancePastAtomicRanges(length: number, target: number, ranges: MarkdownImageRange[]) {
|
||||
let position = Math.min(length, target);
|
||||
// Overlapping table/link ranges can require more than one jump.
|
||||
for (let changed = true; changed;) {
|
||||
changed = false;
|
||||
for (const range of ranges) {
|
||||
if (position > range.start && position < range.end) {
|
||||
position = Math.min(length, range.end);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return position;
|
||||
}
|
||||
@ -6,24 +6,9 @@ export type ImageProxyOptions = {
|
||||
width?: number;
|
||||
quality?: number;
|
||||
format?: ImageProxyFormat;
|
||||
delivery?: "display" | "proxy" | "original";
|
||||
delivery?: "display" | "proxy";
|
||||
};
|
||||
|
||||
/** Preserve the actual response format when naming an original download. */
|
||||
export function getImageDownloadExtension(contentType: string) {
|
||||
const mime = contentType.split(";")[0]?.trim().toLowerCase();
|
||||
const extensions: Record<string, string> = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/jpg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
"image/avif": "avif",
|
||||
"image/gif": "gif",
|
||||
"image/svg+xml": "svg",
|
||||
};
|
||||
return extensions[mime] || "";
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
@ -156,40 +141,11 @@ function resolveAliyunOssDisplayUrl(
|
||||
}
|
||||
|
||||
export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {}): string {
|
||||
let source = String(url || "").trim();
|
||||
// Some messages retain our preview URL instead of its source URL. Rebuild
|
||||
// that local proxy request without the preview's resize/format parameters.
|
||||
if (options.delivery === "original") {
|
||||
const base = typeof window === "undefined" ? "https://image-proxy.local" : window.location.origin;
|
||||
try {
|
||||
const parsed = new URL(source, base);
|
||||
if (parsed.origin === base && parsed.pathname === IMAGE_PROXY_PATH) {
|
||||
const nestedSource = parsed.searchParams.get("url") || "";
|
||||
if (isAbsoluteHttpUrl(nestedSource)) source = nestedSource;
|
||||
}
|
||||
} catch {
|
||||
// Leave malformed and non-URL sources to the existing handling below.
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(source);
|
||||
if ([...parsed.searchParams.keys()].some((key) => /^(?:imageview2(?:\/|$)|x-oss-process$)/i.test(key))) {
|
||||
throw new Error("图片链接包含处理参数,无法确认原图");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "图片链接包含处理参数,无法确认原图") throw error;
|
||||
}
|
||||
}
|
||||
const source = String(url || "").trim();
|
||||
if (isBypassUrl(source)) {
|
||||
return source;
|
||||
}
|
||||
|
||||
// An original download must go through the same-origin byte-preserving
|
||||
// proxy even for trusted storage hosts: a direct fetch may fail CORS.
|
||||
if (options.delivery === "original") {
|
||||
const params = new URLSearchParams({ url: source, original: "1" });
|
||||
return `${IMAGE_PROXY_PATH}?${params.toString()}`;
|
||||
}
|
||||
|
||||
const trustedDirectStorageUrl = resolveTrustedDirectStorageDisplayUrl(
|
||||
source,
|
||||
options
|
||||
|
||||
@ -226,13 +226,11 @@ def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("action", choices=["status", "jobs", "build", "stage", "activate", "rollback"])
|
||||
parser.add_argument("--release")
|
||||
parser.add_argument("--retry-rolled-back", action="store_true")
|
||||
parser.add_argument("--key", type=Path, default=Path.home() / ".ssh/45.192.99.48.pem")
|
||||
parser.add_argument("--jump-key", type=Path, default=Path.home() / ".ssh/youyidian_prod_ed25519")
|
||||
parser.add_argument("--known-hosts", type=Path, default=Path.home() / ".ssh/known_hosts")
|
||||
parser.add_argument("--receipts", type=Path, default=ROOT / ".git/releases")
|
||||
args = parser.parse_args()
|
||||
require(not args.retry_rolled_back or args.action == "activate", "Retry flag only applies to activation")
|
||||
if args.action in {"stage", "activate", "rollback"}:
|
||||
require(args.release and re.fullmatch(r"\d{8}T\d{6}Z-[0-9a-f]{12}", args.release), "--release is required")
|
||||
args.receipts = args.receipts.resolve()
|
||||
@ -243,10 +241,6 @@ def main():
|
||||
stage(connections, args)
|
||||
else:
|
||||
kwargs = {"release": args.release} if args.release else {}
|
||||
if args.action == "activate":
|
||||
kwargs["operator-commit"] = git("rev-parse", "HEAD")
|
||||
if args.retry_rolled_back:
|
||||
kwargs["retry"] = "rolled-back"
|
||||
result = connections.helper(args.action, **kwargs)
|
||||
if args.release:
|
||||
write_json(args.receipts / args.release / (args.action + ".json"), result)
|
||||
|
||||
@ -384,34 +384,21 @@ def healthy(cwd, build_id):
|
||||
|
||||
|
||||
def restart(config):
|
||||
apps = json.loads(config.read_text())["apps"]
|
||||
require(len(apps) == 1 and apps[0]["name"] == NAME, "Refusing a multi-application PM2 definition")
|
||||
current = [app for app in json.loads(run(["pm2", "jlist"])) if app["name"] == NAME]
|
||||
require(len(current) <= 1, "Duplicate ai-chatbot definitions; operator required")
|
||||
# PM2 restart updates env but retains the old executable/cwd. Recreate only
|
||||
# this name so the new immutable release's process definition really applies.
|
||||
if current:
|
||||
run(["pm2", "delete", NAME], timeout=150)
|
||||
run(["pm2", "start", str(config), "--only", NAME], timeout=150)
|
||||
run(["pm2", "startOrRestart", str(config), "--only", NAME, "--update-env"], timeout=150)
|
||||
|
||||
|
||||
def activate(release_id, retry=False, operator_commit=None):
|
||||
def activate(release_id):
|
||||
destination = release_path(release_id)
|
||||
manifest = json.loads((destination / "release.json").read_text())
|
||||
staged = json.loads((destination / "staged.json").read_text())
|
||||
app, current, dist = app_state()
|
||||
require(str(current) == staged["previousCwd"] and dist == staged["previousDistDir"],
|
||||
"Production changed after staging")
|
||||
journal = destination / "activation.json"
|
||||
prior = json.loads(journal.read_text()) if journal.exists() else None
|
||||
require(not prior or (retry and prior.get("phase") == "rolled-back"),
|
||||
"Already activated; inspect status before explicitly retrying a rolled-back attempt")
|
||||
require(not (destination / "activation.json").exists(), "Already activated; inspect status")
|
||||
verify_runtime(manifest, destination)
|
||||
jobs = job_status()
|
||||
require(not any(row["recent"] for rows in jobs.values() for row in rows),
|
||||
"Recent active generation jobs: defer activation")
|
||||
if prior:
|
||||
json_write(destination / f"activation-history-{time.time_ns()}.json", prior)
|
||||
before = ecosystem(app, current, dist)
|
||||
previous_build = (current / dist / "BUILD_ID").read_text().strip()
|
||||
json_write(destination / "previous.pm2.json", before)
|
||||
@ -423,7 +410,7 @@ def activate(release_id, retry=False, operator_commit=None):
|
||||
"previousBuildId": previous_build, "previousCwd": str(current),
|
||||
"activatedAtUtc": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"historicalTaskStates": jobs, "artifactSha256": staged["artifactSha256"],
|
||||
"phase": "activating", "operatorCommit": operator_commit}
|
||||
"phase": "activating"}
|
||||
json_write(destination / "activation.json", receipt)
|
||||
try:
|
||||
restart(destination / "candidate.pm2.json")
|
||||
@ -466,8 +453,6 @@ def main():
|
||||
parser.add_argument("--release")
|
||||
parser.add_argument("--digest")
|
||||
parser.add_argument("--workspace", type=Path)
|
||||
parser.add_argument("--retry", choices=["rolled-back"])
|
||||
parser.add_argument("--operator-commit")
|
||||
args = parser.parse_args()
|
||||
os.umask(0o077)
|
||||
if args.action in {"pack", "smoke"}:
|
||||
@ -484,8 +469,7 @@ def main():
|
||||
if args.action == "stage":
|
||||
result = stage(args.release, args.digest)
|
||||
elif args.action == "activate":
|
||||
result = activate(args.release, retry=args.retry == "rolled-back",
|
||||
operator_commit=args.operator_commit)
|
||||
result = activate(args.release)
|
||||
else:
|
||||
result = rollback(args.release)
|
||||
print(json.dumps(result))
|
||||
|
||||
@ -1,72 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { splitMarkdownImages } from "../../lib/canvas/markdown-image-segments";
|
||||
|
||||
const image = "https://example.com/folder/test.jpg";
|
||||
|
||||
test("SKU output consumes the entire redundant linked-image wrapper", () => {
|
||||
const text = `生成图\n[](${image})\n下载完成`;
|
||||
assert.deepEqual(splitMarkdownImages(text), [
|
||||
{ type: "text", value: "生成图\n" },
|
||||
{ type: "image", alt: "第2行", src: image },
|
||||
{ type: "text", value: "\n下载完成" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("several linked and ordinary images do not leave broken wrapper text", () => {
|
||||
const parts = splitMarkdownImages(`[](${image})\n`);
|
||||
assert.deepEqual(parts, [
|
||||
{ type: "image", alt: "a", src: image },
|
||||
{ type: "text", value: "\n" },
|
||||
{ type: "image", alt: "b", src: "https://example.com/b.png" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("different outer links and ordinary links are preserved verbatim", () => {
|
||||
const nested = `[](https://example.com/full.jpg)`;
|
||||
const input = `[文档](https://example.com/doc) ${nested} `;
|
||||
assert.deepEqual(splitMarkdownImages(input), [
|
||||
{ type: "text", value: `[文档](https://example.com/doc) ${nested} ` },
|
||||
{ type: "image", alt: "plain", src: "https://example.com/next.jpg" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("supports balanced and escaped parentheses and nested/escaped alt text", () => {
|
||||
const url = "https://example.com/a_(1)/x.jpg";
|
||||
assert.deepEqual(splitMarkdownImages(`[![a [b] \\] c](${url})](${url})`), [
|
||||
{ type: "image", alt: "a [b] ] c", src: url },
|
||||
]);
|
||||
assert.deepEqual(splitMarkdownImages(".jpg)"), [
|
||||
{ type: "image", alt: "a", src: "https://example.com/a(1).jpg" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not extract examples in inline and fenced code", () => {
|
||||
const input = `\`\`\n\`\`\`md\n[](${image})\n\`\`\`\n~~~md\n\n~~~\n`;
|
||||
const parts = splitMarkdownImages(input);
|
||||
assert.equal(parts.filter((part) => part.type === "image").length, 1);
|
||||
assert.equal(parts[0].type, "text");
|
||||
assert.ok(parts[0].type === "text" && parts[0].value.includes("![code]"));
|
||||
assert.deepEqual(parts[1], { type: "image", alt: "actual", src: image });
|
||||
});
|
||||
|
||||
test("preserves unsafe, incomplete, and escaped markup for the existing renderer", () => {
|
||||
for (const content of [
|
||||
`[](javascript:alert(1))`,
|
||||
`[](`,
|
||||
`\\`,
|
||||
"",
|
||||
]) assert.deepEqual(splitMarkdownImages(content), [{ type: "text", value: content }]);
|
||||
});
|
||||
|
||||
test("retains existing thumbnail metadata in alt text", () => {
|
||||
for (const alt of [
|
||||
"thumb|||https://example.com/original.jpg|||",
|
||||
"thumb$$https://example.com/original.jpg$$",
|
||||
"thumb|https://example.com/original.jpg|",
|
||||
]) {
|
||||
assert.deepEqual(splitMarkdownImages(``), [
|
||||
{ type: "image", alt, src: image },
|
||||
]);
|
||||
}
|
||||
});
|
||||
@ -1,104 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { advancePastAtomicRanges, scanMarkdownImageStreaming, stableStreamingMarkdown } from "../../lib/canvas/markdown-image-streaming";
|
||||
import { splitMarkdownImages } from "../../lib/canvas/markdown-image-segments";
|
||||
|
||||
const url = "https://scqny.jiansun.vip/ps-skill-api/results/job_test/01-%E6%A0%B7%E4%BE%8B.jpg";
|
||||
const wrapped = `[](${url})`;
|
||||
const other = `[](https://example.com/original.jpg)`;
|
||||
|
||||
test("every live network prefix withholds the entire unfinished linked image", () => {
|
||||
const before = "## 生成图\n";
|
||||
for (let n = 1; n < wrapped.length; n += 1) {
|
||||
assert.equal(stableStreamingMarkdown(before + wrapped.slice(0, n), true), before, `prefix ${n}`);
|
||||
}
|
||||
assert.equal(stableStreamingMarkdown(before + wrapped, true), before + wrapped);
|
||||
// An interrupted / completed response keeps malformed original text visible.
|
||||
for (const suffix of ["[", "[!", "[]("]) {
|
||||
assert.equal(stableStreamingMarkdown(before + suffix, false), before + suffix);
|
||||
}
|
||||
});
|
||||
|
||||
test("every synthetic typewriter cursor exposes complete image markup or none", () => {
|
||||
const content = `生成图\n${wrapped}\n${other}\n可编辑文件`;
|
||||
const ranges = scanMarkdownImageStreaming(content).ranges;
|
||||
assert.equal(ranges.length, 2);
|
||||
let cursor = 0;
|
||||
while (cursor < content.length) {
|
||||
cursor = advancePastAtomicRanges(content.length, cursor + 1, ranges);
|
||||
const visible = content.slice(0, cursor);
|
||||
for (const range of ranges) {
|
||||
assert.ok(cursor <= range.start || cursor >= range.end, `split at ${cursor}`);
|
||||
}
|
||||
if (cursor >= ranges[0].end) {
|
||||
assert.ok(splitMarkdownImages(visible).some((item) => item.type === "image"));
|
||||
}
|
||||
}
|
||||
assert.equal(cursor, content.length);
|
||||
assert.deepEqual(splitMarkdownImages(other), [{ type: "text", value: other }]);
|
||||
});
|
||||
|
||||
test("handles ordinary images, URL brackets, escaped text and code safely", () => {
|
||||
const simple = ".jpg)";
|
||||
for (let n = 1; n < simple.length; n += 1) {
|
||||
assert.equal(stableStreamingMarkdown("前文 " + simple.slice(0, n), true), "前文 ", `prefix ${n}`);
|
||||
}
|
||||
const literal = "`";
|
||||
assert.equal(stableStreamingMarkdown(literal, true), literal);
|
||||
const fence = "~~~md\n[, fence);
|
||||
assert.deepEqual(scanMarkdownImageStreaming(fence + simple).ranges, [{ start: fence.length, end: fence.length + simple.length }]);
|
||||
const invalid = " and later text";
|
||||
assert.equal(stableStreamingMarkdown(invalid, true), invalid);
|
||||
});
|
||||
|
||||
test("does not change normal links or security-sensitive complete markup", () => {
|
||||
const values = ["[普通链接](https://example.com/a)", "[](javascript:alert(1))", other];
|
||||
for (const value of values) assert.equal(stableStreamingMarkdown(value, true), value);
|
||||
});
|
||||
|
||||
test("coalesces overlapping typewriter table and image ranges without looping", () => {
|
||||
const ranges = [{ start: 0, end: 20 }, { start: 10, end: 35 }, { start: 35, end: 40 }];
|
||||
assert.equal(advancePastAtomicRanges(50, 1, ranges), 35);
|
||||
assert.equal(advancePastAtomicRanges(50, 36, ranges), 40);
|
||||
assert.equal(advancePastAtomicRanges(50, 50, ranges), 50);
|
||||
});
|
||||
|
||||
test("SKU result links are atomic across every typewriter cursor and live fragment", () => {
|
||||
const links = [
|
||||
"[beike-sku-template2-batch.json](https://scqny.jiansun.vip/ps-skill-api/results/job_test/795c72cb-batch.json)",
|
||||
"[beike-sku-template2-report.json](https://scqny.jiansun.vip/ps-skill-api/results/job_test/af588253-report.json)",
|
||||
];
|
||||
const text = `生成图\n${wrapped}\n可编辑文件\n- ${links[0]}\n- ${links[1]}\n完成`;
|
||||
const ranges = scanMarkdownImageStreaming(text).ranges;
|
||||
assert.equal(ranges.length, 3);
|
||||
for (const link of links) {
|
||||
const start = text.indexOf(link);
|
||||
for (let n = 1; n < link.length; n += 1) {
|
||||
const prefix = text.slice(0, start + n);
|
||||
assert.equal(stableStreamingMarkdown(prefix, true), text.slice(0, start), `link prefix ${n}`);
|
||||
}
|
||||
assert.equal(stableStreamingMarkdown(text.slice(0, start + link.length), true), text.slice(0, start + link.length));
|
||||
}
|
||||
let cursor = 0;
|
||||
while (cursor < text.length) {
|
||||
cursor = advancePastAtomicRanges(text.length, cursor + 1, ranges);
|
||||
assert.ok(ranges.every((range) => cursor <= range.start || cursor >= range.end), `split at ${cursor}`);
|
||||
}
|
||||
assert.equal(cursor, text.length);
|
||||
});
|
||||
|
||||
test("ordinary brackets, code, escaped markers and unfinished final links remain available", () => {
|
||||
for (const value of ["普通文字", "[状态] 已完成", "[引用][reference]", "\\[转义](https://example.com)", "`[code](https://example.com`", "~~~md\n[example](https://example.com\n~~~\n"]) {
|
||||
assert.equal(stableStreamingMarkdown(value, true), value);
|
||||
assert.equal(stableStreamingMarkdown(value, false), value);
|
||||
}
|
||||
for (const partial of ["[状态", "[文件](https://example.com/", "[文件]"]) {
|
||||
assert.equal(stableStreamingMarkdown(partial, false), partial);
|
||||
assert.equal(stableStreamingMarkdown(partial + "\n普通文字", true), partial + "\n普通文字");
|
||||
}
|
||||
const titleLink = '[文档](https://example.com/a "标题")';
|
||||
assert.deepEqual(scanMarkdownImageStreaming(titleLink).ranges, [{ start: 0, end: titleLink.length }]);
|
||||
const unsafeLink = "[危险链接](javascript:alert(1))";
|
||||
assert.equal(stableStreamingMarkdown(unsafeLink, false), unsafeLink);
|
||||
});
|
||||
@ -1,8 +1,5 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { NextRequest } from "next/server";
|
||||
import sharp from "sharp";
|
||||
import { GET } from "@/app/api/image-proxy/route";
|
||||
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
|
||||
test.describe("Qiniu image delivery", () => {
|
||||
test("uses Qiniu image processing for direct canvas previews", () => {
|
||||
@ -41,119 +38,4 @@ test.describe("Qiniu image delivery", () => {
|
||||
});
|
||||
expect(result).toContain("/api/image-proxy?");
|
||||
});
|
||||
|
||||
test("original downloads use an explicit same-origin proxy even for trusted signed CDN URLs", () => {
|
||||
const source = "https://yybb.top/uploads/result.png?token=example&expires=123";
|
||||
const result = new URL(buildImageProxyUrl(source, { delivery: "original" }), "https://app.example");
|
||||
expect(result.pathname).toBe("/api/image-proxy");
|
||||
expect(result.searchParams.get("url")).toBe(source);
|
||||
expect(result.searchParams.get("original")).toBe("1");
|
||||
expect(result.searchParams.has("w")).toBe(false);
|
||||
expect(buildImageProxyUrl("data:image/png;base64,AA==", { delivery: "original" })).toBe("data:image/png;base64,AA==");
|
||||
expect(buildImageProxyUrl("blob:example", { delivery: "original" })).toBe("blob:example");
|
||||
expect(buildImageProxyUrl("/api/canvas/file/1", { delivery: "original" })).toBe("/api/canvas/file/1");
|
||||
const nested = `/api/image-proxy?url=${encodeURIComponent(source)}&w=320&f=webp`;
|
||||
expect(new URL(buildImageProxyUrl(nested, { delivery: "original" }), "https://app.example").searchParams.get("url")).toBe(source);
|
||||
expect(buildImageProxyUrl(nested, { delivery: "original" })).not.toContain("w=320");
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: { location: { origin: "https://ai.yydd.ai" } } });
|
||||
try {
|
||||
const sameOrigin = `https://ai.yydd.ai${nested}`;
|
||||
expect(new URL(buildImageProxyUrl(sameOrigin, { delivery: "original" }), "https://ai.yydd.ai").searchParams.get("url")).toBe(source);
|
||||
const external = `https://other.example${nested}`;
|
||||
expect(new URL(buildImageProxyUrl(external, { delivery: "original" }), "https://ai.yydd.ai").searchParams.get("url")).toBe(external);
|
||||
} finally {
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
}
|
||||
});
|
||||
|
||||
test("original proxy preserves JPEG bytes and ICC and does not reuse transformed or stale responses", async () => {
|
||||
const source = "https://public.example/original.jpg?signature=abc";
|
||||
const jpeg = await sharp({ create: { width: 12, height: 12, channels: 3, background: "#345678" } })
|
||||
.withIccProfile("srgb").jpeg().toBuffer();
|
||||
const newer = await sharp({ create: { width: 12, height: 12, channels: 3, background: "#123456" } })
|
||||
.withIccProfile("srgb").jpeg().toBuffer();
|
||||
expect((await sharp(jpeg).metadata()).icc).toBeTruthy();
|
||||
const previousFetch = globalThis.fetch;
|
||||
let body = jpeg;
|
||||
globalThis.fetch = async () => new Response(body, { headers: { "Content-Type": "image/jpeg" } });
|
||||
try {
|
||||
const transformedUrl = new URL("http://localhost/api/image-proxy");
|
||||
transformedUrl.searchParams.set("url", source);
|
||||
transformedUrl.searchParams.set("proxy", "1");
|
||||
const transformed = await GET(new NextRequest(transformedUrl));
|
||||
expect(Buffer.from(await transformed.arrayBuffer()).equals(jpeg)).toBe(false);
|
||||
|
||||
const rawUrl = new URL("http://localhost/api/image-proxy");
|
||||
rawUrl.searchParams.set("url", source);
|
||||
rawUrl.searchParams.set("original", "1");
|
||||
const first = await GET(new NextRequest(rawUrl));
|
||||
const downloaded = Buffer.from(await first.arrayBuffer());
|
||||
expect(downloaded.equals(jpeg)).toBe(true);
|
||||
expect((await sharp(downloaded).metadata()).icc).toEqual((await sharp(jpeg).metadata()).icc);
|
||||
expect(first.headers.get("content-type")).toBe("image/jpeg");
|
||||
expect(first.headers.get("cache-control")).toBe("private, no-store");
|
||||
expect(first.headers.get("content-disposition")).toBe("attachment");
|
||||
expect(first.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
|
||||
body = newer;
|
||||
const next = await GET(new NextRequest(rawUrl, { headers: { "If-None-Match": first.headers.get("etag") || "" } }));
|
||||
expect(next.status).toBe(200);
|
||||
expect(Buffer.from(await next.arrayBuffer()).equals(newer)).toBe(true);
|
||||
} finally {
|
||||
globalThis.fetch = previousFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects processed storage URLs without removing signatures", () => {
|
||||
const qiniu = "https://yybb.top/a.jpg?imageView2/2/w/320&token=signed";
|
||||
const aliyun = "https://bucket.oss-cn.aliyuncs.com/a.jpg?x-oss-process=image%2Fresize%2Cw_320&signature=signed";
|
||||
for (const source of [qiniu, aliyun, `/api/image-proxy?url=${encodeURIComponent(qiniu)}&w=300`]) {
|
||||
expect(() => buildImageProxyUrl(source, { delivery: "original" })).toThrow("无法确认原图");
|
||||
}
|
||||
const signed = "https://yybb.top/a.jpg?token=example%2Fvalue&expires=123";
|
||||
expect(new URL(buildImageProxyUrl(signed, { delivery: "original" }), "https://app.example").searchParams.get("url")).toBe(signed);
|
||||
});
|
||||
|
||||
test("original proxy rejects private hosts, conflicting transforms and non-image content", async () => {
|
||||
const previousFetch = globalThis.fetch;
|
||||
let fetchCount = 0;
|
||||
globalThis.fetch = async () => { fetchCount++; return new Response("not an image", { headers: { "Content-Type": "text/html" } }); };
|
||||
try {
|
||||
expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=http%3A%2F%2F192.168.1.1%2Fsecret&original=1"))).status).toBe(403);
|
||||
expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fa.jpg&original=1&w=200"))).status).toBe(400);
|
||||
expect(fetchCount).toBe(0);
|
||||
expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fa.jpg&original=1"))).status).toBe(400);
|
||||
} finally {
|
||||
globalThis.fetch = previousFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("original proxy returns PNG and WebP data without format conversion", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
try {
|
||||
for (const format of ["png", "webp"] as const) {
|
||||
const image = sharp({ create: { width: 4, height: 4, channels: 4, background: "#11447780" } });
|
||||
const data = await (format === "png" ? image.png() : image.webp()).toBuffer();
|
||||
globalThis.fetch = async () => new Response(data, { headers: { "Content-Type": `image/${format}` } });
|
||||
const url = new URL("http://localhost/api/image-proxy");
|
||||
url.searchParams.set("url", `https://public.example/original.${format}`);
|
||||
url.searchParams.set("original", "1");
|
||||
const response = await GET(new NextRequest(url));
|
||||
expect(response.headers.get("content-type")).toBe(`image/${format}`);
|
||||
expect(Buffer.from(await response.arrayBuffer()).equals(data)).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("download filename extension follows the original image MIME", () => {
|
||||
expect(getImageDownloadExtension("image/png")).toBe("png");
|
||||
expect(getImageDownloadExtension("image/jpeg; charset=binary")).toBe("jpg");
|
||||
expect(getImageDownloadExtension("image/webp")).toBe("webp");
|
||||
expect(getImageDownloadExtension("image/avif")).toBe("avif");
|
||||
expect(getImageDownloadExtension("application/octet-stream")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@ -222,59 +222,6 @@ class ActivationTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(RuntimeError, "Rollback health check failed"):
|
||||
host.activate(self.release_id)
|
||||
|
||||
def test_explicit_retry_preserves_the_failed_attempt(self):
|
||||
host.json_write(self.target / "activation.json", {"phase": "rolled-back", "previousBuildId": "old-build"})
|
||||
with patch.object(host, "healthy", return_value=True):
|
||||
result = host.activate(self.release_id, retry=True, operator_commit="tool-commit")
|
||||
self.assertEqual(result["phase"], "active")
|
||||
self.assertEqual(result["operatorCommit"], "tool-commit")
|
||||
history = list(self.target.glob("activation-history-*.json"))
|
||||
self.assertEqual(len(history), 1)
|
||||
self.assertEqual(json.loads(history[0].read_text())["phase"], "rolled-back")
|
||||
|
||||
def test_retry_cannot_replace_an_active_attempt(self):
|
||||
host.json_write(self.target / "activation.json", {"phase": "active"})
|
||||
with self.assertRaisesRegex(RuntimeError, "Already activated"):
|
||||
host.activate(self.release_id, retry=True)
|
||||
self.restart.assert_not_called()
|
||||
|
||||
def test_failed_process_start_also_restores_previous_definition(self):
|
||||
self.restart.side_effect = [RuntimeError("candidate start failed"), None]
|
||||
with patch.object(host, "healthy", return_value=True):
|
||||
with self.assertRaisesRegex(RuntimeError, "candidate start failed"):
|
||||
host.activate(self.release_id)
|
||||
self.assertEqual(self.restart.call_args_list[-1].args[0].name, "previous.pm2.json")
|
||||
|
||||
|
||||
class ProcessDefinitionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.config = Path(self.temporary.name) / "candidate.json"
|
||||
self.config.write_text(json.dumps({"apps": [{"name": "ai-chatbot", "cwd": "/new/release"}]}))
|
||||
|
||||
def tearDown(self):
|
||||
self.temporary.cleanup()
|
||||
|
||||
def test_recreates_only_target_application(self):
|
||||
with patch.object(host, "run", side_effect=[json.dumps([{"name": "ai-chatbot"}, {"name": "other-service"}]), "", ""]) as run:
|
||||
host.restart(self.config)
|
||||
self.assertEqual([call.args[0] for call in run.call_args_list], [
|
||||
["pm2", "jlist"], ["pm2", "delete", "ai-chatbot"],
|
||||
["pm2", "start", str(self.config), "--only", "ai-chatbot"]])
|
||||
|
||||
def test_can_restore_after_failed_candidate_left_no_process(self):
|
||||
with patch.object(host, "run", side_effect=["[]", ""]) as run:
|
||||
host.restart(self.config)
|
||||
self.assertEqual(len(run.call_args_list), 2)
|
||||
self.assertEqual(run.call_args_list[-1].args[0][1], "start")
|
||||
|
||||
def test_refuses_definitions_that_include_other_applications(self):
|
||||
self.config.write_text(json.dumps({"apps": [{"name": "ai-chatbot"}, {"name": "other"}]}))
|
||||
with patch.object(host, "run") as run:
|
||||
with self.assertRaisesRegex(RuntimeError, "multi-application"):
|
||||
host.restart(self.config)
|
||||
run.assert_not_called()
|
||||
|
||||
|
||||
class HelperTests(unittest.TestCase):
|
||||
def test_production_e2e_switch_blocks_release(self):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user