Merge pull request '恢复画布文字识别' (#63) from codex/canvas-ocr-and-outpaint-20261110 into main
This commit is contained in:
commit
b3c4f2763a
@ -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 },
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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/,
|
||||
|
||||
75
tests/routes/canvas-text-detect.test.ts
Normal file
75
tests/routes/canvas-text-detect.test.ts
Normal 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([]);
|
||||
});
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user