Merge pull request '恢复画布文字识别' (#63) from codex/canvas-ocr-and-outpaint-20261110 into main

This commit is contained in:
WuYongJie 2026-11-10 13:57:28 +08:00
commit b3c4f2763a
5 changed files with 151 additions and 10 deletions

View File

@ -1,7 +1,11 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { and, eq, inArray } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
import { executeConfiguredVip98kTask, getVip98kRuntimeProviders } from "@/lib/ai/vip98k-runtime";
import { db } from "@/lib/db";
import { aiModel } from "@/lib/db/schema";
import { createLogger } from "@/lib/observability/logger";
import sharp from "sharp";
@ -90,6 +94,39 @@ function fallbackItemsFromRaw(raw: string): OcrItem[] {
}));
}
async function detectTextWithVip98k(imageUrl: string): Promise<OcrItem[] | null> {
const providers = await getVip98kRuntimeProviders();
if (!providers.length) return null;
const model = await db.query.aiModel.findFirst({
columns: { providerId: true },
where: and(
inArray(aiModel.providerId, providers.map(({ id }) => id)),
eq(aiModel.modelId, "ocr"),
eq(aiModel.isEnabled, true),
),
});
if (!model) return null;
const result = await executeConfiguredVip98kTask({
modelId: "ocr",
kind: "image-tool",
imageUrls: [imageUrl],
preferredProviderId: model.providerId,
});
const textUrl = result.outputUrls[0];
if (!textUrl) throw new Error("98K 文字识别未返回文本文件");
const response = await fetch(textUrl, { signal: AbortSignal.timeout(15_000) });
const type = response.headers.get("content-type") || "";
if (!response.ok || !/^(?:text\/plain|application\/octet-stream)(?:;|$)/i.test(type)) {
await response.body?.cancel();
throw new Error(`无法读取 98K 文字识别结果(HTTP ${response.status})`);
}
return (await response.text()).split(/\r\n?|\n/).map((line) => line.trim())
.filter(Boolean).slice(0, 50).map((text, index) => ({ id: String(index + 1), text }));
}
function shouldUseDataUrlFallback(error: unknown) {
const message =
error instanceof Error ? error.message.toLowerCase() : String(error || "").toLowerCase();
@ -147,6 +184,11 @@ export async function POST(request: Request) {
return NextResponse.json({ success: false, error: "仅支持 URL 图片,不允许 base64 传输" }, { status: 400 });
}
const detected = await detectTextWithVip98k(imageUrl);
if (detected !== null) {
return NextResponse.json({ success: true, data: { items: detected, model: "ocr" } });
}
logger.debug("model_selected", { model: "qwen3-vl" });
const prompt = [
"你是图片文字识别助手。",
@ -191,15 +233,9 @@ export async function POST(request: Request) {
}
const parsedJson = parseJsonText(text || "");
let items: OcrItem[] = [];
if (parsedJson && Array.isArray(parsedJson.items)) {
items = sanitizeItems(parsedJson.items);
}
if (items.length === 0 && text) {
items = fallbackItemsFromRaw(text);
}
const items = Array.isArray(parsedJson?.items)
? sanitizeItems(parsedJson.items)
: fallbackItemsFromRaw(text);
return NextResponse.json({
success: true,
@ -213,7 +249,7 @@ export async function POST(request: Request) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "文字识别失败",
error: "图片文字识别暂不可用,请稍后重试",
},
{ status: 500 },
);

View File

@ -1647,6 +1647,15 @@ export function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvoca
if (params.modelId.toLowerCase() === "image-expansion") {
bindVip98kExpansionParams(body, imageParams);
}
if (params.kind === "image-tool" && params.modelId.toLowerCase() === "ocr") {
// Old synced examples include generic image fields and both language aliases.
const metadata = toRecord(body.metadata);
const language = String(metadata?.language || body.language || "").trim();
body.metadata = { language: language || "English,ChinesePRC,Digits" };
body.output_format = "txt";
delete body.language;
delete body.size;
}
const imageBindingKeys = bindVip98kImages(body, params.imageUrls);
if (params.imageUrls.length > 0 && !invocation && imageBindingKeys.length === 0) {
body.images = params.imageUrls;

View File

@ -15,6 +15,7 @@ export default defineConfig({
/routes\/runtime-active-work\.test\.ts/,
/routes\/canvas-capability-task-recovery\.test\.ts/,
/routes\/canvas-toolbar-capability\.test\.ts/,
/routes\/canvas-text-detect\.test\.ts/,
/routes\/agent-project-recovery\.test\.ts/,
/routes\/admin-media-loading\.test\.ts/,
/routes\/video-canvas-production\.test\.ts/,

View File

@ -0,0 +1,75 @@
import { expect, test } from "@playwright/test";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { resolve } from "node:path";
import { runInNewContext } from "node:vm";
import ts from "typescript";
const filename = resolve("app/api/canvas/text-detect/route.ts");
const requireModule = createRequire(filename);
const source = ts.transpileModule(readFileSync(filename, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, esModuleInterop: true },
}).outputText;
function createRoute(params: {
enableOcr: boolean;
recognize: () => unknown;
qwen: () => unknown;
fetchResult: () => Response;
}) {
const module = { exports: {} as { POST: (request: Request) => Promise<Response> } };
const mocks: Record<string, unknown> = {
"next/server": { NextResponse: Response },
"drizzle-orm": { and() {}, inArray() {}, eq() {} },
"@/app/(auth)/auth": { auth: async () => ({ user: { id: "signed-in" } }) },
"@/lib/ai/qwen3-vl": { callQwen3Vl: params.qwen },
"@/lib/ai/vip98k-runtime": {
getVip98kRuntimeProviders: async () => [{ id: "98k" }],
executeConfiguredVip98kTask: params.recognize,
},
"@/lib/db": {
db: { query: { aiModel: { findFirst: async () => params.enableOcr ? { providerId: "98k" } : undefined } } },
},
"@/lib/db/schema": { aiModel: {} },
"@/lib/observability/logger": { createLogger: () => ({ debug() {}, warn() {}, error() {} }) },
};
runInNewContext(source, {
exports: module.exports,
require: (id: string) => mocks[id] ?? requireModule(id),
fetch: params.fetchResult,
AbortSignal,
});
return module.exports.POST;
}
const request = () => new Request("http://test/api/canvas/text-detect", {
method: "POST",
body: JSON.stringify({ imageUrl: "https://assets.test/item.png" }),
});
test("configured OCR returns numbered product labels without sending a second model request", async () => {
const post = createRoute({
enableOcr: true,
recognize: () => ({ outputUrls: ["https://assets.test/result"] }),
fetchResult: () => new Response("\uFEFFSKU 731\r\n60 CM", { headers: { "Content-Type": "text/plain" } }),
qwen: () => { throw new Error("The working OCR tool must not call Qwen"); },
});
const response = await post(request());
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
success: true,
data: { model: "ocr", items: [{ id: "1", text: "SKU 731" }, { id: "2", text: "60 CM" }] },
});
});
test("a valid empty OCR response shows no editable text", async () => {
const post = createRoute({
enableOcr: false,
recognize: () => { throw new Error("Disabled OCR must not submit"); },
fetchResult: () => { throw new Error("No OCR file expected"); },
qwen: () => ({ model: "qwen", text: '{"items":[]}' }),
});
const response = await post(request());
expect(response.status).toBe(200);
expect((await response.json()).data.items).toEqual([]);
});

View File

@ -89,6 +89,26 @@ test("does not inject output count into a task contract without n", () => {
expect(body).not.toHaveProperty("n");
});
test("OCR submits a text request even with a stale synced example", () => {
const invocation: Vip98kTaskInvocation = {
kind: "task", method: "POST", path: "/extensions/v1/tasks",
contentType: "application/json", resultMethod: "GET",
resultPath: "/extensions/v1/tasks/{task_id}/result",
requestExample: {
model: "ocr", size: "auto", language: "", file_urls: [""],
output_format: "txt", metadata: { language: "English,ChinesePRC,Digits" },
},
};
const { body } = buildTaskBody({
apiKey: "test", modelId: "ocr", kind: "image-tool", prompt: "",
imageUrls: ["https://assets.test/item.png"], videoUrls: [], modelConfig: {},
}, invocation);
expect(body).toEqual({
model: "ocr", file_urls: ["https://assets.test/item.png"],
output_format: "txt", metadata: { language: "English,ChinesePRC,Digits" },
});
});
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: