openpencil/apps/web/server/api/ai/image-search.ts
Kayshen-X c55807e432 ci: remove TS/Electron workflows (build-electron / ci / docker / publish-cli)
Rust-ification 阶段,CI 只保留 Rust 相关:
- rust-check.yml: cargo fmt + build + test (with STEP1A_REQUIRE_GPU=1 on Linux) + clippy + cargo-deny
- wasm-bundle-check.yml: wasm32 target check

删除:
- build-electron.yml: Electron desktop build (Rust 化后用 openpencil-shell-native)
- ci.yml: TS type-check + Vitest + web build (Rust 化后已废)
- docker.yml: TS Docker image (Rust 化后重做)
- publish-cli.yml: npm packages (Rust 化后改 cargo publish)
2026-05-05 22:09:00 +08:00

371 lines
9.1 KiB
TypeScript

import { defineEventHandler, readBody, setResponseHeaders } from 'h3';
import type { ImageSearchResult, ImageSearchResponse } from '../../../src/types/image-service';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface OpenverseImageResult {
id: string;
url: string;
thumbnail: string;
width: number;
height: number;
license: string;
license_version: string;
attribution: string;
}
interface OpenverseSearchResponse {
results: OpenverseImageResult[];
}
interface WikimediaImageInfo {
url: string;
thumburl: string;
width: number;
height: number;
mime: string;
extmetadata?: {
LicenseShortName?: { value: string };
};
}
interface WikimediaPage {
pageid: number;
title: string;
imageinfo?: WikimediaImageInfo[];
}
interface WikimediaQueryResponse {
query?: {
pages?: Record<string, WikimediaPage>;
};
}
// ---------------------------------------------------------------------------
// OAuth token cache
// ---------------------------------------------------------------------------
let cachedToken: string | null = null;
let tokenExpiresAt = 0;
async function getOpenverseToken(clientId: string, clientSecret: string): Promise<string | null> {
const now = Date.now();
if (cachedToken && now < tokenExpiresAt) {
return cachedToken;
}
try {
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret,
});
const res = await fetch('https://api.openverse.org/v1/auth_tokens/token/', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!res.ok) return null;
const data = (await res.json()) as { access_token: string; expires_in: number };
cachedToken = data.access_token;
// Refresh 60 seconds before expiry
tokenExpiresAt = now + (data.expires_in - 60) * 1000;
return cachedToken;
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Query simplification — convert verbose AI prompts to search keywords
// ---------------------------------------------------------------------------
const STOP_WORDS = new Set([
'a',
'an',
'the',
'and',
'or',
'but',
'in',
'on',
'at',
'to',
'for',
'of',
'with',
'by',
'from',
'is',
'are',
'was',
'were',
'be',
'been',
'being',
'have',
'has',
'had',
'do',
'does',
'did',
'will',
'would',
'could',
'should',
'may',
'might',
'shall',
'can',
'that',
'this',
'these',
'those',
'it',
'its',
'very',
'really',
'just',
'also',
'about',
'above',
'after',
'before',
'between',
'into',
'through',
'during',
'each',
'some',
'such',
'no',
'not',
'only',
'same',
'so',
'than',
'too',
'up',
'out',
'if',
'then',
'once',
'here',
'there',
'when',
'where',
'how',
'all',
'both',
'few',
'more',
'most',
'other',
'any',
'as',
'while',
'using',
'showing',
'featuring',
'looking',
'style',
'styled',
'inspired',
'based',
]);
/**
* Simplify a verbose image generation prompt into 2-4 search keywords.
* "delicious burger with fries and fresh vegetables" → "burger fries vegetables"
* "modern office workspace with natural lighting" → "modern office workspace"
*/
export function simplifySearchQuery(prompt: string): string {
const words = prompt
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, ' ')
.split(/\s+/)
.filter((w) => w.length > 2 && !STOP_WORDS.has(w));
// Take up to 4 keywords
const keywords = words.slice(0, 4);
return keywords.join(' ') || prompt.slice(0, 30);
}
// ---------------------------------------------------------------------------
// Mapping helpers (exported for testing)
// ---------------------------------------------------------------------------
export function mapOpenverseResult(r: OpenverseImageResult): ImageSearchResult {
return {
id: r.id,
url: r.url,
thumbUrl: r.thumbnail,
width: r.width,
height: r.height,
source: 'openverse',
license: `${r.license} ${r.license_version}`.trim(),
attribution: r.attribution,
};
}
export function mapWikimediaPages(pages: Record<string, WikimediaPage>): ImageSearchResult[] {
const results: ImageSearchResult[] = [];
for (const page of Object.values(pages)) {
const info = page.imageinfo?.[0];
if (!info) continue;
results.push({
id: String(page.pageid),
url: info.url,
thumbUrl: info.thumburl ?? info.url,
width: info.width,
height: info.height,
source: 'wikimedia',
license: info.extmetadata?.LicenseShortName?.value ?? '',
});
}
return results;
}
// ---------------------------------------------------------------------------
// Source fetchers
// ---------------------------------------------------------------------------
async function fetchFromOpenverse(
query: string,
count: number,
aspectRatio: string | undefined,
clientId: string | undefined,
clientSecret: string | undefined,
): Promise<ImageSearchResult[] | null> {
const url = new URL('https://api.openverse.org/v1/images/');
url.searchParams.set('q', query);
url.searchParams.set('page_size', String(count));
if (aspectRatio) {
url.searchParams.set('aspect_ratio', aspectRatio);
}
const headers: Record<string, string> = {};
if (clientId && clientSecret) {
const token = await getOpenverseToken(clientId, clientSecret);
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}
// Network failures (ConnectTimeoutError on restricted networks, DNS
// failures, etc.) need to behave like a 429: return null so the caller
// falls back to Wikimedia. Without this, fetch() throws, the throw
// bubbles up to nitro's default handler, and the user sees a 500
// instead of placeholder images.
try {
const res = await fetch(url.toString(), {
headers,
signal: AbortSignal.timeout(8000),
});
if (res.status === 429) {
// Rate limited — signal fallback
return null;
}
if (!res.ok) {
return null;
}
const data = (await res.json()) as OpenverseSearchResponse;
return (data.results ?? []).map(mapOpenverseResult);
} catch {
return null;
}
}
async function fetchFromWikimedia(query: string, count: number): Promise<ImageSearchResult[]> {
const url = new URL('https://commons.wikimedia.org/w/api.php');
url.searchParams.set('action', 'query');
url.searchParams.set('generator', 'search');
url.searchParams.set('gsrsearch', query);
url.searchParams.set('gsrnamespace', '6');
url.searchParams.set('gsrlimit', String(count));
url.searchParams.set('prop', 'imageinfo');
url.searchParams.set('iiprop', 'url|size|mime|extmetadata');
url.searchParams.set('iiurlwidth', '800');
url.searchParams.set('format', 'json');
url.searchParams.set('origin', '*');
// Same network-failure shielding as Openverse: an empty result is the
// documented fallback signal, returning [] keeps the endpoint at 200
// with placeholder-friendly results instead of bubbling the throw.
try {
const res = await fetch(url.toString(), { signal: AbortSignal.timeout(8000) });
if (!res.ok) return [];
const data = (await res.json()) as WikimediaQueryResponse;
const pages = data.query?.pages;
if (!pages) return [];
return mapWikimediaPages(pages);
} catch {
return [];
}
}
// ---------------------------------------------------------------------------
// Endpoint
// ---------------------------------------------------------------------------
/**
* POST /api/ai/image-search
*
* Searches for freely-licensed images.
* Primary source: Openverse. Falls back to Wikimedia Commons on 429.
*
* Body: { query, count?, aspectRatio?, openverseClientId?, openverseClientSecret? }
*/
export default defineEventHandler(async (event) => {
setResponseHeaders(event, { 'Content-Type': 'application/json' });
const body = (await readBody(event)) as {
query?: string;
count?: number;
aspectRatio?: string;
openverseClientId?: string;
openverseClientSecret?: string;
};
const rawQuery = body?.query?.trim() ?? '';
if (!rawQuery) {
return { error: 'Missing required field: query' };
}
// Simplify verbose AI prompts into search-friendly keywords
const query = simplifySearchQuery(rawQuery);
const count = Math.min(Math.max(Number(body?.count ?? 10), 1), 50);
const aspectRatio = body?.aspectRatio;
const clientId = body?.openverseClientId;
const clientSecret = body?.openverseClientSecret;
// Try Openverse first
const openverseResults = await fetchFromOpenverse(
query,
count,
aspectRatio,
clientId,
clientSecret,
);
if (openverseResults !== null) {
return {
results: openverseResults,
source: 'openverse',
} satisfies ImageSearchResponse;
}
// Openverse returned 429 or failed — fall back to Wikimedia
const wikimediaResults = await fetchFromWikimedia(query, count);
return {
results: wikimediaResults,
source: 'wikimedia',
} satisfies ImageSearchResponse;
});