2026-02-07 21:48:49 +08:00

190 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NextResponse } from "next/server";
import { z } from "zod";
import { generateText } from "ai";
import { auth } from "@/app/(auth)/auth";
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
export const runtime = "nodejs";
export const maxDuration = 60;
const requestSchema = z.object({
imageUrl: z.string().min(1, "图片地址不能为空"),
});
interface OcrItem {
id: string;
text: string;
confidence?: number;
}
function pickVisionModel(models: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>) {
const normalized = models
.filter((item) => item.model.supportsVision)
.sort((a, b) => {
const aScore =
(a.model.modelId?.toLowerCase().includes("qwen3-vl") ? 100 : 0) +
(a.model.displayName?.toLowerCase().includes("qwen3-vl") ? 100 : 0) +
(a.provider.type === "alibaba" ? 20 : 0) +
(a.model.isDefault ? 5 : 0);
const bScore =
(b.model.modelId?.toLowerCase().includes("qwen3-vl") ? 100 : 0) +
(b.model.displayName?.toLowerCase().includes("qwen3-vl") ? 100 : 0) +
(b.provider.type === "alibaba" ? 20 : 0) +
(b.model.isDefault ? 5 : 0);
return bScore - aScore;
});
return normalized[0];
}
function parseJsonText(raw: string): any | null {
if (!raw) return null;
const trimmed = raw.trim();
try {
return JSON.parse(trimmed);
} catch {
// continue
}
const markdownJsonMatch = trimmed.match(/```json\s*([\s\S]*?)\s*```/i);
if (markdownJsonMatch?.[1]) {
try {
return JSON.parse(markdownJsonMatch[1]);
} catch {
// continue
}
}
const objectMatch = trimmed.match(/\{[\s\S]*\}/);
if (objectMatch?.[0]) {
try {
return JSON.parse(objectMatch[0]);
} catch {
// continue
}
}
return null;
}
function sanitizeItems(items: unknown[]): OcrItem[] {
const result: OcrItem[] = [];
for (let index = 0; index < items.length; index += 1) {
const item = items[index] as Record<string, unknown>;
const rawText = typeof item?.text === "string" ? item.text : "";
const text = rawText.replace(/\s+/g, " ").trim();
if (!text) continue;
const confidenceValue =
typeof item?.confidence === "number" && Number.isFinite(item.confidence)
? Math.min(Math.max(item.confidence, 0), 1)
: undefined;
result.push({
id: String(item?.id || index + 1),
text,
confidence: confidenceValue,
});
}
return result.slice(0, 50);
}
function fallbackItemsFromRaw(raw: string): OcrItem[] {
const lines = raw
.split("\n")
.map((line) => line.replace(/^[-*\d.\s]+/, "").trim())
.filter(Boolean)
.slice(0, 20);
return lines.map((text, index) => ({
id: String(index + 1),
text,
}));
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const body = await request.json();
const parsed = requestSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ success: false, error: parsed.error.errors[0]?.message || "参数错误" }, { status: 400 });
}
const imageUrl = parsed.data.imageUrl.trim();
if (!/^https?:\/\//i.test(imageUrl)) {
return NextResponse.json({ success: false, error: "仅支持 URL 图片,不允许 base64 传输" }, { status: 400 });
}
const models = await getEnabledAiModelsWithProvider();
const selected = pickVisionModel(models);
if (!selected) {
return NextResponse.json({ success: false, error: "未配置可用的视觉模型" }, { status: 500 });
}
const model = await getLanguageModelById(selected.model.id);
if (!model) {
return NextResponse.json({ success: false, error: "视觉模型加载失败" }, { status: 500 });
}
const prompt = [
"你是图片文字识别助手。",
"请识别图片中可见文字并按阅读顺序输出 JSON。",
"只返回 JSON,不要额外说明。",
"格式:{\"items\":[{\"id\":\"1\",\"text\":\"示例\",\"confidence\":0.95}]}",
"如果没有识别到文字,返回 {\"items\":[]}。",
].join("\n");
const { text } = await generateText({
model,
messages: [
{
role: "user",
content: [
{ type: "text", text: prompt },
{ type: "image", image: imageUrl },
],
},
],
temperature: 0.1,
});
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);
}
return NextResponse.json({
success: true,
data: {
items,
model: selected.model.displayName,
},
});
} catch (error) {
console.error("[Canvas Text Detect] Error:", error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "文字识别失败",
},
{ status: 500 },
);
}
}