15 changed files with 1876 additions and 893 deletions
@ -0,0 +1,63 @@ |
|||
import { NextRequest, NextResponse } from "next/server"; |
|||
import { requireLogin } from "@/app/api/_auth"; |
|||
import { ApiError } from "@/app/api/_errors"; |
|||
|
|||
const POPI_CHARACTER_LIST_PATH = "/api_client/anime/character/list"; |
|||
|
|||
type PopiserverResponse<T> = { |
|||
status?: string; |
|||
message?: string; |
|||
data?: T; |
|||
}; |
|||
|
|||
function getPopiBaseUrl(request: NextRequest): string { |
|||
const baseUrl = |
|||
process.env.POPIART_API_BASE_URL || |
|||
process.env.NEXT_PUBLIC_APP_URL || |
|||
request.nextUrl.origin; |
|||
return baseUrl.replace(/\/+$/, ""); |
|||
} |
|||
|
|||
export async function GET(request: NextRequest) { |
|||
try { |
|||
const login = requireLogin(request); |
|||
const upstreamUrl = new URL(getPopiBaseUrl(request) + POPI_CHARACTER_LIST_PATH); |
|||
request.nextUrl.searchParams.forEach((value, key) => { |
|||
upstreamUrl.searchParams.set(key, value); |
|||
}); |
|||
if (!upstreamUrl.searchParams.has("keyword")) { |
|||
upstreamUrl.searchParams.set("keyword", ""); |
|||
} |
|||
|
|||
const upstream = await fetch(upstreamUrl, { |
|||
method: "GET", |
|||
headers: { |
|||
Accept: "application/json", |
|||
Authorization: `Bearer ${login.token}`, |
|||
token: login.token, |
|||
}, |
|||
cache: "no-store", |
|||
}); |
|||
|
|||
const payload = (await upstream.json().catch(() => null)) as PopiserverResponse<unknown> | null; |
|||
if (!upstream.ok) { |
|||
throw ApiError.provider(payload?.message || "Popiserver character list failed.", upstream.status); |
|||
} |
|||
if (!payload || payload.status !== "0000") { |
|||
throw ApiError.provider(payload?.message || "Popiserver character list returned an error.", 502); |
|||
} |
|||
|
|||
return NextResponse.json({ |
|||
success: true, |
|||
data: payload.data, |
|||
}); |
|||
} catch (error) { |
|||
if (error instanceof ApiError) { |
|||
return NextResponse.json({ success: false, error: error.message }, { status: error.status }); |
|||
} |
|||
return NextResponse.json( |
|||
{ success: false, error: error instanceof Error ? error.message : "Character list failed." }, |
|||
{ status: 500 } |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,207 @@ |
|||
"use client"; |
|||
|
|||
import { useCallback, useEffect, useMemo, useState } from "react"; |
|||
import { createPortal } from "react-dom"; |
|||
import { Pagination } from "antd"; |
|||
import { authFetch } from "@/utils/authFetch"; |
|||
import { useI18n } from "@/i18n"; |
|||
|
|||
export interface CharacterItem { |
|||
id: number; |
|||
title?: string; |
|||
description?: string; |
|||
avatar?: string; |
|||
image?: string; |
|||
characterName?: string; |
|||
defaultPrompt?: string; |
|||
} |
|||
|
|||
interface CharacterPickerModalProps { |
|||
onClose: () => void; |
|||
onSelect: (character: CharacterItem) => void; |
|||
} |
|||
|
|||
interface CharacterListPayload { |
|||
list?: CharacterItem[]; |
|||
pageInfo?: { |
|||
page?: number; |
|||
pageSize?: number; |
|||
pageCount?: number; |
|||
total?: number; |
|||
}; |
|||
} |
|||
|
|||
interface CharacterListResponse { |
|||
success?: boolean; |
|||
data?: CharacterListPayload; |
|||
error?: string; |
|||
} |
|||
|
|||
const PAGE_SIZE = 20; |
|||
|
|||
function getCharacterTitle(character: CharacterItem): string { |
|||
return character.title?.trim() || `#${character.id}`; |
|||
} |
|||
|
|||
export function CharacterPickerModal({ onClose, onSelect }: CharacterPickerModalProps) { |
|||
const { t } = useI18n(); |
|||
const [page, setPage] = useState(1); |
|||
const [items, setItems] = useState<CharacterItem[]>([]); |
|||
const [total, setTotal] = useState(0); |
|||
const [isLoading, setIsLoading] = useState(false); |
|||
const [error, setError] = useState<string | null>(null); |
|||
|
|||
const loadCharacters = useCallback(async (signal: AbortSignal) => { |
|||
setIsLoading(true); |
|||
setError(null); |
|||
try { |
|||
const params = new URLSearchParams({ |
|||
keyword: "", |
|||
page: String(page), |
|||
pageSize: String(PAGE_SIZE), |
|||
}); |
|||
const response = await authFetch(`/api/popi/character/list?${params.toString()}`, { signal }); |
|||
const payload = (await response.json().catch(() => null)) as CharacterListResponse | null; |
|||
if (!response.ok || !payload?.success) { |
|||
throw new Error(payload?.error || t("characterPicker.loadFailed")); |
|||
} |
|||
|
|||
const pageInfo = payload.data?.pageInfo; |
|||
setItems(payload.data?.list || []); |
|||
setTotal(pageInfo?.total ?? (pageInfo?.pageCount ?? 1) * (pageInfo?.pageSize ?? PAGE_SIZE)); |
|||
} catch (err) { |
|||
if (err instanceof DOMException && err.name === "AbortError") return; |
|||
setError(err instanceof Error ? err.message : t("characterPicker.loadFailed")); |
|||
setItems([]); |
|||
setTotal(0); |
|||
} finally { |
|||
if (!signal.aborted) setIsLoading(false); |
|||
} |
|||
}, [page, t]); |
|||
|
|||
useEffect(() => { |
|||
const controller = new AbortController(); |
|||
void loadCharacters(controller.signal); |
|||
return () => controller.abort(); |
|||
}, [loadCharacters]); |
|||
|
|||
useEffect(() => { |
|||
const handleKeyDown = (event: KeyboardEvent) => { |
|||
if (event.key === "Escape") onClose(); |
|||
}; |
|||
document.addEventListener("keydown", handleKeyDown); |
|||
return () => document.removeEventListener("keydown", handleKeyDown); |
|||
}, [onClose]); |
|||
|
|||
const content = useMemo(() => { |
|||
if (isLoading) { |
|||
return ( |
|||
<div className="flex h-80 items-center justify-center text-sm text-neutral-400"> |
|||
{t("characterPicker.loading")} |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
if (error) { |
|||
return ( |
|||
<div className="flex h-80 flex-col items-center justify-center gap-3 text-sm text-neutral-400"> |
|||
<span>{error}</span> |
|||
<button |
|||
type="button" |
|||
onClick={() => { |
|||
const controller = new AbortController(); |
|||
void loadCharacters(controller.signal); |
|||
}} |
|||
className="rounded-md border border-neutral-600 bg-neutral-800 px-3 py-1.5 text-neutral-200 hover:bg-neutral-700" |
|||
> |
|||
{t("characterPicker.retry")} |
|||
</button> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
const selectableItems = items.filter((item) => item.image && (item.avatar || item.image)); |
|||
if (selectableItems.length === 0) { |
|||
return ( |
|||
<div className="flex h-80 items-center justify-center text-sm text-neutral-500"> |
|||
{t("characterPicker.empty")} |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
return ( |
|||
<div className="grid max-h-[68vh] grid-cols-2 gap-3 overflow-y-auto pr-1 sm:grid-cols-3 lg:grid-cols-4"> |
|||
{selectableItems.map((character) => { |
|||
const title = getCharacterTitle(character); |
|||
const previewUrl = character.avatar || character.image; |
|||
if (!previewUrl || !character.image) return null; |
|||
|
|||
return ( |
|||
<button |
|||
key={character.id} |
|||
type="button" |
|||
onClick={() => onSelect(character)} |
|||
className="group overflow-hidden rounded-lg border border-neutral-700 bg-neutral-900 text-left transition-colors hover:border-neutral-500 hover:bg-neutral-800" |
|||
> |
|||
<div className="relative aspect-square bg-neutral-950"> |
|||
<img src={previewUrl} alt={title} className="h-full w-full object-cover" /> |
|||
</div> |
|||
<div className="flex min-h-12 flex-col justify-center gap-0.5 px-2 py-1.5"> |
|||
<span className="truncate text-xs text-neutral-300">{title}</span> |
|||
<span className="truncate text-[10px] text-neutral-500"> |
|||
{character.description?.trim() || t("characterPicker.character")} |
|||
</span> |
|||
</div> |
|||
</button> |
|||
); |
|||
})} |
|||
</div> |
|||
); |
|||
}, [error, isLoading, items, loadCharacters, onSelect, t]); |
|||
|
|||
if (typeof document === "undefined") return null; |
|||
|
|||
return createPortal( |
|||
<div |
|||
role="dialog" |
|||
aria-modal="true" |
|||
aria-label={t("characterPicker.title")} |
|||
className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/70 p-5" |
|||
onClick={onClose} |
|||
> |
|||
<div |
|||
className="flex w-full max-w-4xl flex-col rounded-xl border border-neutral-700 bg-neutral-950 p-4 shadow-2xl" |
|||
onClick={(event) => event.stopPropagation()} |
|||
> |
|||
<div className="mb-4 flex items-center justify-between gap-3"> |
|||
<h2 className="text-base font-medium text-neutral-100">{t("characterPicker.title")}</h2> |
|||
<button |
|||
type="button" |
|||
aria-label={t("common.close")} |
|||
onClick={onClose} |
|||
className="flex h-8 w-8 items-center justify-center rounded-md text-neutral-400 hover:bg-neutral-800 hover:text-neutral-100" |
|||
> |
|||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> |
|||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" /> |
|||
</svg> |
|||
</button> |
|||
</div> |
|||
|
|||
{content} |
|||
|
|||
<div className="mt-4 flex justify-end"> |
|||
<Pagination |
|||
size="small" |
|||
current={page} |
|||
total={total} |
|||
pageSize={PAGE_SIZE} |
|||
showSizeChanger={false} |
|||
hideOnSinglePage |
|||
onChange={setPage} |
|||
/> |
|||
</div> |
|||
</div> |
|||
</div>, |
|||
document.body |
|||
); |
|||
} |
|||
File diff suppressed because it is too large
@ -0,0 +1,101 @@ |
|||
import type { ModelInputDef, SelectedModel } from "@/types"; |
|||
|
|||
export type ReferenceMediaType = "image" | "video" | "audio"; |
|||
|
|||
const VIDEO_INPUT_PARAMETER_NAMES = new Set([ |
|||
"video", |
|||
"videos", |
|||
"video_url", |
|||
"video_urls", |
|||
"reference_video", |
|||
"reference_video_url", |
|||
"reference_video_urls", |
|||
]); |
|||
|
|||
const AUDIO_INPUT_PARAMETER_NAMES = new Set([ |
|||
"voice", |
|||
"voices", |
|||
"audio", |
|||
"audios", |
|||
"audio_url", |
|||
"audio_urls", |
|||
"reference_audio", |
|||
"reference_audio_url", |
|||
"reference_audio_urls", |
|||
]); |
|||
|
|||
const IMAGE_INPUT_PARAMETER_NAMES = new Set([ |
|||
"image", |
|||
"images", |
|||
"image_url", |
|||
"image_urls", |
|||
"first_frame", |
|||
"first_frame_url", |
|||
"last_frame", |
|||
"last_frame_url", |
|||
"tail_image_url", |
|||
"reference_image", |
|||
"reference_image_url", |
|||
"reference_image_urls", |
|||
]); |
|||
|
|||
function hasSchemaInput(inputSchema: ModelInputDef[] | undefined, mediaType: ReferenceMediaType): boolean { |
|||
return Boolean(inputSchema?.some((input) => input.type === mediaType)); |
|||
} |
|||
|
|||
function hasNamedParameterInput(parameters: Record<string, unknown> | undefined, names: Set<string>): boolean { |
|||
if (!parameters) return false; |
|||
return Object.keys(parameters).some((key) => names.has(key)); |
|||
} |
|||
|
|||
function metadataBoolean(model: SelectedModel, key: string): boolean | null { |
|||
const value = model.metadata?.[key]; |
|||
return typeof value === "boolean" ? value : null; |
|||
} |
|||
|
|||
export function modelSupportsReferenceMedia( |
|||
model: SelectedModel, |
|||
mediaType: ReferenceMediaType, |
|||
inputSchema?: ModelInputDef[], |
|||
parameters?: Record<string, unknown> |
|||
): boolean { |
|||
if (hasSchemaInput(inputSchema, mediaType)) return true; |
|||
|
|||
if (mediaType === "image") { |
|||
const supportImages = metadataBoolean(model, "isSupportImages"); |
|||
if (supportImages !== null) return supportImages; |
|||
if (hasNamedParameterInput(parameters, IMAGE_INPUT_PARAMETER_NAMES)) return true; |
|||
return Boolean(model.capabilities?.some((capability) => |
|||
capability === "image-to-image" || |
|||
capability === "image-to-video" || |
|||
capability === "image-to-3d" |
|||
)); |
|||
} |
|||
|
|||
if (mediaType === "video") { |
|||
const supportVideos = metadataBoolean(model, "isSupportVideos"); |
|||
if (supportVideos !== null) return supportVideos; |
|||
if (hasNamedParameterInput(parameters, VIDEO_INPUT_PARAMETER_NAMES)) return true; |
|||
return Boolean(model.capabilities?.includes("video-to-video")); |
|||
} |
|||
|
|||
const supportAudios = metadataBoolean(model, "isSupportAudios"); |
|||
if (supportAudios !== null) return supportAudios; |
|||
if (hasNamedParameterInput(parameters, AUDIO_INPUT_PARAMETER_NAMES)) return true; |
|||
return Boolean(model.capabilities?.includes("audio-to-video")); |
|||
} |
|||
|
|||
export function filterReferenceSubjectList( |
|||
referenceSubjectList: unknown, |
|||
model: SelectedModel, |
|||
inputSchema?: ModelInputDef[], |
|||
parameters?: Record<string, unknown> |
|||
): unknown { |
|||
if (!Array.isArray(referenceSubjectList)) return referenceSubjectList; |
|||
return referenceSubjectList.filter((item) => { |
|||
if (!item || typeof item !== "object" || Array.isArray(item)) return false; |
|||
const type = (item as Record<string, unknown>).type; |
|||
if (type !== "image" && type !== "video" && type !== "audio") return true; |
|||
return modelSupportsReferenceMedia(model, type, inputSchema, parameters); |
|||
}); |
|||
} |
|||
Loading…
Reference in new issue