190 lines
5.2 KiB
TypeScript
190 lines
5.2 KiB
TypeScript
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 },
|
||
);
|
||
}
|
||
}
|