diff --git a/app/api/canvas/text-detect/route.ts b/app/api/canvas/text-detect/route.ts index cd66f6049..01407e508 100644 --- a/app/api/canvas/text-detect/route.ts +++ b/app/api/canvas/text-detect/route.ts @@ -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 { + 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 }, ); diff --git a/lib/ai/providers/vip98k.ts b/lib/ai/providers/vip98k.ts index d3e2d88cd..5afd52c48 100644 --- a/lib/ai/providers/vip98k.ts +++ b/lib/ai/providers/vip98k.ts @@ -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; diff --git a/playwright.helpers.config.ts b/playwright.helpers.config.ts index 8d8843141..a90588d07 100644 --- a/playwright.helpers.config.ts +++ b/playwright.helpers.config.ts @@ -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/, diff --git a/tests/routes/canvas-text-detect.test.ts b/tests/routes/canvas-text-detect.test.ts new file mode 100644 index 000000000..052d98fe1 --- /dev/null +++ b/tests/routes/canvas-text-detect.test.ts @@ -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 } }; + const mocks: Record = { + "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([]); +}); diff --git a/tests/routes/vip98k-reference-routing.test.ts b/tests/routes/vip98k-reference-routing.test.ts index 6f807d1b9..47ac0d5ea 100644 --- a/tests/routes/vip98k-reference-routing.test.ts +++ b/tests/routes/vip98k-reference-routing.test.ts @@ -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: