diff --git a/app/api/canvas/text-edit/route.ts b/app/api/canvas/text-edit/route.ts index f72b99d34..c08192485 100644 --- a/app/api/canvas/text-edit/route.ts +++ b/app/api/canvas/text-edit/route.ts @@ -15,10 +15,13 @@ import { createRunway } from "@/lib/ai/providers/runway"; import { createKrea } from "@/lib/ai/providers/krea"; import { createVolcanoVision } from "@/lib/ai/providers/volcano-vision"; import { isImageGenerationModelDescriptor } from "@/lib/ai/model-classification"; +import { createLogger } from "@/lib/observability/logger"; export const runtime = "nodejs"; export const maxDuration = 120; +const logger = createLogger("canvas-text-edit"); + const textItemSchema = z.object({ id: z.string().min(1), originalText: z.string().min(1), @@ -115,20 +118,118 @@ function createTextEditProvider( } } -function parseImageFromMarkdown(markdown: string): { originalUrl: string; thumbnailUrl: string } { +function normalizeGeneratedImageUrl(value: unknown) { + const raw = String(value || "").trim(); + if (!raw) return ""; + return raw + .replace(/^["'([{<]+/, "") + .replace(/[)"'\]},;。]+$/, "") + .trim(); +} + +function isHttpImageUrl(value: unknown) { + const raw = normalizeGeneratedImageUrl(value); + if (!raw || !/^https?:\/\//i.test(raw)) { + return false; + } + + try { + const parsed = new URL(raw); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +function collectImageUrlsFromUnknown(value: unknown, output: Set) { + if (value === null || value === undefined) return; + + if (typeof value === "string") { + const raw = normalizeGeneratedImageUrl(value); + if (isHttpImageUrl(raw)) { + output.add(raw); + return; + } + const matches = raw.match(/https?:\/\/[^\s"'<>),\]}]+/gi) || []; + for (const match of matches) { + const normalized = normalizeGeneratedImageUrl(match); + if (isHttpImageUrl(normalized)) { + output.add(normalized); + } + } + return; + } + + if (Array.isArray(value)) { + value.forEach((item) => collectImageUrlsFromUnknown(item, output)); + return; + } + + if (typeof value === "object") { + Object.entries(value as Record).forEach(([key, entryValue]) => { + if ( + /(url|uri|image|thumbnail|original|output|result|file)/i.test(key) || + typeof entryValue === "object" + ) { + collectImageUrlsFromUnknown(entryValue, output); + } + }); + } +} + +function pickGeneratedImageUrl(urls: Iterable) { + const candidates = Array.from(new Set(Array.from(urls).map(normalizeGeneratedImageUrl))) + .filter(isHttpImageUrl); + const imageLike = candidates.find((url) => + /\.(avif|gif|jpe?g|png|webp)(?:[?#]|$)/i.test(url) + ); + return imageLike || candidates[0] || ""; +} + +function parseImageFromGenerationText(text: string): { originalUrl: string; thumbnailUrl: string } { + const markdown = String(text || "").trim(); const strictMatch = markdown.match(/!\[thumb\|\|\|([^|]+)\|\|\|[^\]]*\]\(([^)]+)\)/); if (strictMatch) { + const originalUrl = normalizeGeneratedImageUrl(strictMatch[1]); + const thumbnailUrl = normalizeGeneratedImageUrl(strictMatch[2]); + if (isHttpImageUrl(originalUrl)) { + return { + originalUrl, + thumbnailUrl: isHttpImageUrl(thumbnailUrl) ? thumbnailUrl : originalUrl, + }; + } + } + + const markdownUrls = new Set(); + const markdownImagePattern = /!\[[^\]]*\]\(([^)]+)\)/g; + let markdownMatch: RegExpExecArray | null; + while ((markdownMatch = markdownImagePattern.exec(markdown)) !== null) { + const url = normalizeGeneratedImageUrl(markdownMatch[1]); + if (isHttpImageUrl(url)) { + markdownUrls.add(url); + } + } + + const markdownUrl = pickGeneratedImageUrl(markdownUrls); + if (markdownUrl) { return { - originalUrl: strictMatch[1], - thumbnailUrl: strictMatch[2], + originalUrl: markdownUrl, + thumbnailUrl: markdownUrl, }; } - const simpleMatch = markdown.match(/!\[[^\]]*\]\(([^)]+)\)/); - if (simpleMatch) { + const collectedUrls = new Set(); + try { + collectImageUrlsFromUnknown(JSON.parse(markdown), collectedUrls); + } catch { + collectImageUrlsFromUnknown(markdown, collectedUrls); + } + + const url = pickGeneratedImageUrl(collectedUrls); + if (url) { return { - originalUrl: simpleMatch[1], - thumbnailUrl: simpleMatch[1], + originalUrl: url, + thumbnailUrl: url, }; } @@ -238,7 +339,7 @@ export async function POST(request: Request) { ); } - console.info("[Canvas Text Edit] selected model:", { + logger.info("selected_model", { providerType: selected.provider.type, providerName: selected.provider.name, modelId: selected.model.modelId, @@ -270,7 +371,7 @@ export async function POST(request: Request) { throw new Error("模型未返回结果"); } - const { originalUrl, thumbnailUrl } = parseImageFromMarkdown(result.text); + const { originalUrl, thumbnailUrl } = parseImageFromGenerationText(result.text); return NextResponse.json({ success: true, @@ -281,7 +382,7 @@ export async function POST(request: Request) { }, }); } catch (error) { - console.error("[Canvas Text Edit] Error:", error); + logger.error("request_failed", error); return NextResponse.json( { success: false,