diff --git a/src/app/api/_popiserverModels.ts b/src/app/api/_popiserverModels.ts index a7265400..b925e177 100644 --- a/src/app/api/_popiserverModels.ts +++ b/src/app/api/_popiserverModels.ts @@ -107,11 +107,17 @@ const SUBTYPE_TO_CAPABILITIES: Record = { }; export function getPopiserverBaseUrl(request: NextRequest): string { + const requestOrigin = + "nextUrl" in request && request.nextUrl + ? request.nextUrl.origin + : "url" in request && request.url + ? new URL(request.url).origin + : ""; const baseUrl = - process.env.POPISERVER_BASE_URL || process.env.POPIART_API_BASE_URL || + process.env.POPISERVER_BASE_URL || process.env.NEXT_PUBLIC_APP_URL || - request.nextUrl.origin; + requestOrigin; return baseUrl.replace(/\/+$/, ""); } diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index e692d9c7..2ed30ff1 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -6,14 +6,88 @@ import { extractSubgraph } from '@/lib/chat/subgraphExtractor'; import { WorkflowNode } from '@/types'; import { WorkflowEdge } from '@/types/workflow'; import { normalizeNewApiWGBaseUrl } from '@/app/api/generate/providers/newapiwg'; -import { DEFAULT_NEWAPIWG_LLM_MODEL_ID } from '@/lib/llmModels'; +import { DEFAULT_NEWAPIWG_LLM_MODEL_ID, DEFAULT_POPISERVER_LLM_MODEL_ID } from '@/lib/llmModels'; import { buildUserForwardHeaders, requireLogin } from '@/app/api/_auth'; import { ApiError, apiErrorResponse } from '@/app/api/_errors'; import { resolveUserGatewayApiKey } from '@/app/api/_gatewayApiKey'; import { logGatewayRequest, logGatewayResponse } from '@/app/api/_gatewayLogging'; +import { isPopiProviderMode } from '@/lib/providerMode'; export const maxDuration = 60; // 1 minute timeout +type PopiserverChatResponse = { + status?: string; + message?: string; + data?: { + choices?: Array<{ + message?: { + content?: string; + }; + }>; + }; +}; + +function getPopiserverBaseUrl(request: Request): string { + const baseUrl = + process.env.POPIART_API_BASE_URL || + process.env.NEXT_PUBLIC_APP_URL || + new URL(request.url).origin; + return baseUrl.replace(/\/+$/, ""); +} + +function buildPopiserverForwardHeaders(request: Request, token: string): HeadersInit { + const headers: Record = { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + token, + }; + const cookie = request.headers.get("cookie"); + if (cookie) headers.Cookie = cookie; + return headers; +} + +async function callPopiserverChat( + request: Request, + token: string, + prompt: string +): Promise { + const url = `${getPopiserverBaseUrl(request)}/api_client/anime/task/llmChat`; + const init: RequestInit = { + method: "POST", + headers: buildPopiserverForwardHeaders(request, token), + body: JSON.stringify({ + messages: [ + { role: "system", content: "You are a helpful assistant." }, + { role: "user", content: prompt }, + ], + model: DEFAULT_POPISERVER_LLM_MODEL_ID, + stream: false, + aiModelId: 31, + }), + }; + const context = { + model: DEFAULT_POPISERVER_LLM_MODEL_ID, + provider: "popiserver", + }; + const startedAt = Date.now(); + logGatewayRequest("Calling chat PopiServer API", url, init, context); + const response = await fetch(url, init); + await logGatewayResponse("Chat PopiServer API response", url, init, response, context, startedAt); + + const body = (await response.json().catch(() => null)) as PopiserverChatResponse | null; + if (!response.ok) { + throw new Error(body?.message || `PopiServer chat API error: ${response.status}`); + } + if (!body || body.status !== "0000") { + throw new Error(body?.message || "PopiServer chat API returned an error"); + } + + const text = body.data?.choices?.[0]?.message?.content; + if (!text) throw new Error("No text in PopiServer chat response"); + return text; +} + export async function POST(request: Request) { try { const login = requireLogin(request); @@ -23,11 +97,6 @@ export async function POST(request: Request) { selectedNodeIds?: string[]; }; - const apiKey = await resolveUserGatewayApiKey(request); - const baseUrl = normalizeNewApiWGBaseUrl( - request.headers.get('X-NewApiWG-Base-URL') || process.env.NEWAPIWG_BASE_URL - ); - // Extract subgraph if nodes are selected, otherwise use full workflow const subgraph = extractSubgraph( workflowState?.nodes || [], @@ -44,6 +113,23 @@ export async function POST(request: Request) { // Build context-aware system prompt with optional rest summary const systemPrompt = buildEditSystemPrompt(context, subgraph.restSummary); + if (isPopiProviderMode()) { + const lastMessage = messages[messages.length - 1] as { parts?: Array<{ type?: string; text?: string }>; content?: string } | undefined; + const userPrompt = + lastMessage?.parts?.map((part) => part.text).filter(Boolean).join("\n") || + lastMessage?.content || + ""; + const text = await callPopiserverChat(request, login.token, `${systemPrompt}\n\n${userPrompt}`); + return new Response(text, { + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); + } + + const apiKey = await resolveUserGatewayApiKey(request); + const baseUrl = normalizeNewApiWGBaseUrl( + request.headers.get('X-NewApiWG-Base-URL') || process.env.NEWAPIWG_BASE_URL + ); + // Extract node IDs for tool validation const nodeIds = (workflowState?.nodes || []).map(n => n.id); diff --git a/src/app/api/popi/asset/create/route.ts b/src/app/api/popi/asset/create/route.ts new file mode 100644 index 00000000..726af711 --- /dev/null +++ b/src/app/api/popi/asset/create/route.ts @@ -0,0 +1,176 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireLogin } from "@/app/api/_auth"; +import { ApiError } from "@/app/api/_errors"; + +const POPI_MEDIA_UPLOAD_BASE64_PATH = "/api_client/media/uploadFileBase64"; +const POPI_ASSET_CREATE_PATH = "/api_client/anime/asset/create"; + +type PopiserverResponse = { + status?: string; + message?: string; + data?: T; +}; + +type PopiMedia = { + path?: string; + url?: string; + originPath?: string; + originUrl?: string; +}; + +type PopiAsset = { + id?: number; + type?: number; + images?: string[]; + video?: string; +}; + +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(/\/+$/, ""); +} + +function parseDataUrl(value: string): { mimeType: string; base64: string } | undefined { + const match = value.match(/^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$/); + if (!match) return undefined; + return { + mimeType: match[1] || "application/octet-stream", + base64: match[2], + }; +} + +function extensionForMimeType(mimeType: string): string { + const normalized = mimeType.toLowerCase(); + if (normalized === "image/jpeg") return "jpg"; + if (normalized === "image/png") return "png"; + if (normalized === "image/webp") return "webp"; + if (normalized === "image/gif") return "gif"; + if (normalized === "video/mp4") return "mp4"; + if (normalized === "video/webm") return "webm"; + return normalized.split("/")[1]?.replace(/[^a-z0-9]/g, "") || "bin"; +} + +function mediaResourceUrl(media: PopiMedia): string | null { + return media.url || media.originUrl || media.path || media.originPath || null; +} + +function isPopiserverMediaUrl(value: string): boolean { + if (value.startsWith("/media/") || value.startsWith("/media_private/")) return true; + try { + const url = new URL(value); + return url.pathname.startsWith("/media/") || url.pathname.startsWith("/media_private/"); + } catch { + return false; + } +} + +async function postPopiJson( + request: NextRequest, + token: string, + path: string, + body: Record +): Promise { + const upstream = await fetch(getPopiBaseUrl(request) + path, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + token, + }, + body: JSON.stringify(body), + cache: "no-store", + }); + + const payload = (await upstream.json().catch(() => null)) as PopiserverResponse | null; + if (!upstream.ok) { + throw ApiError.provider(payload?.message || "Popiserver API request failed.", upstream.status); + } + if (!payload || payload.status !== "0000") { + throw ApiError.provider(payload?.message || "Popiserver API returned an error.", 502); + } + return payload.data as T; +} + +async function ensurePopiserverMedia( + request: NextRequest, + token: string, + value: string, + kind: "image" | "video", + fileName?: string +): Promise { + if (isPopiserverMediaUrl(value)) return value; + + const dataUrl = parseDataUrl(value); + if (!dataUrl) { + throw ApiError.badRequest("Only popiserver media URLs or base64 data URLs can be saved as assets."); + } + + const extension = extensionForMimeType(dataUrl.mimeType); + const media = await postPopiJson(request, token, POPI_MEDIA_UPLOAD_BASE64_PATH, { + data: dataUrl.base64, + fileName: fileName || `canvas-${kind}-${Date.now()}.${extension}`, + }); + const url = mediaResourceUrl(media); + if (!url) { + throw ApiError.provider("Popiserver media upload response is missing resource URL.", 502); + } + return url; +} + +export async function POST(request: NextRequest) { + try { + const login = requireLogin(request); + const body = (await request.json().catch(() => null)) as { + type?: unknown; + media?: unknown; + title?: unknown; + description?: unknown; + fileName?: unknown; + } | null; + + const type = body?.type === "video" ? "video" : body?.type === "image" ? "image" : null; + const media = typeof body?.media === "string" ? body.media : ""; + if (!type || !media) { + return NextResponse.json( + { success: false, error: "type=image|video and media are required." }, + { status: 400 } + ); + } + + const resourceUrl = await ensurePopiserverMedia( + request, + login.token, + media, + type, + typeof body?.fileName === "string" ? body.fileName : undefined + ); + + const title = typeof body?.title === "string" ? body.title : ""; + const description = typeof body?.description === "string" ? body.description : ""; + const asset = await postPopiJson(request, login.token, POPI_ASSET_CREATE_PATH, { + type: type === "video" ? 2 : 1, + title, + description, + images: type === "image" ? [resourceUrl] : undefined, + video: type === "video" ? resourceUrl : undefined, + }); + + return NextResponse.json({ + success: true, + url: resourceUrl, + asset, + }); + } 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 : "Asset create failed." }, + { status: 500 } + ); + } +} diff --git a/src/app/api/popi/media/upload/route.ts b/src/app/api/popi/media/upload/route.ts new file mode 100644 index 00000000..6ed268c3 --- /dev/null +++ b/src/app/api/popi/media/upload/route.ts @@ -0,0 +1,137 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireLogin } from "@/app/api/_auth"; +import { ApiError } from "@/app/api/_errors"; + +const POPI_MEDIA_UPLOAD_BASE64_PATH = "/api_client/media/uploadFileBase64"; + +type PopiserverResponse = { + status?: string; + message?: string; + data?: T; +}; + +type PopiMedia = { + id?: number; + path?: string; + url?: string; + originPath?: string; + originUrl?: string; + thumbPath?: string; + thumbUrl?: string; +}; + +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(/\/+$/, ""); +} + +function parseDataUrl(value: string): { mimeType: string; base64: string } | undefined { + const match = value.match(/^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$/); + if (!match) return undefined; + return { + mimeType: match[1] || "application/octet-stream", + base64: match[2], + }; +} + +function extensionForMimeType(mimeType: string): string { + const normalized = mimeType.toLowerCase(); + if (normalized === "image/jpeg") return "jpg"; + if (normalized === "image/png") return "png"; + if (normalized === "image/webp") return "webp"; + if (normalized === "image/gif") return "gif"; + if (normalized === "video/mp4") return "mp4"; + if (normalized === "video/webm") return "webm"; + if (normalized === "audio/mpeg" || normalized === "audio/mp3") return "mp3"; + if (normalized === "audio/wav" || normalized === "audio/x-wav") return "wav"; + if (normalized === "model/gltf-binary") return "glb"; + return normalized.split("/")[1]?.replace(/[^a-z0-9]/g, "") || "bin"; +} + +function mediaResourceUrl(media: PopiMedia): string | null { + return media.url || media.originUrl || media.path || media.originPath || null; +} + +export async function POST(request: NextRequest) { + try { + const login = requireLogin(request); + const body = (await request.json().catch(() => null)) as { + data?: unknown; + fileName?: unknown; + contentType?: unknown; + } | null; + + const value = typeof body?.data === "string" ? body.data : ""; + const dataUrl = parseDataUrl(value); + if (!dataUrl) { + return NextResponse.json( + { success: false, error: "Only base64 data URLs can be uploaded." }, + { status: 400 } + ); + } + + const extension = extensionForMimeType( + typeof body?.contentType === "string" ? body.contentType : dataUrl.mimeType + ); + const rawFileName = + typeof body?.fileName === "string" && body.fileName.trim() + ? body.fileName.trim() + : `canvas-media-${Date.now()}.${extension}`; + const fileName = rawFileName.includes(".") ? rawFileName : `${rawFileName}.${extension}`; + + const upstream = await fetch(getPopiBaseUrl(request) + POPI_MEDIA_UPLOAD_BASE64_PATH, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${login.token}`, + token: login.token, + }, + body: JSON.stringify({ + data: dataUrl.base64, + fileName, + }), + cache: "no-store", + }); + + const payload = (await upstream.json().catch(() => null)) as PopiserverResponse | null; + if (!upstream.ok) { + return NextResponse.json( + { success: false, error: payload?.message || "Popiserver media upload failed." }, + { status: upstream.status } + ); + } + if (!payload || payload.status !== "0000" || !payload.data) { + return NextResponse.json( + { success: false, error: payload?.message || "Popiserver media upload returned an error." }, + { status: 502 } + ); + } + + const resourceUrl = mediaResourceUrl(payload.data); + if (!resourceUrl) { + return NextResponse.json( + { success: false, error: "Popiserver media upload response is missing resource URL." }, + { status: 502 } + ); + } + + return NextResponse.json({ + success: true, + url: resourceUrl, + path: payload.data.path, + media: 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 : "Upload failed." }, + { status: 500 } + ); + } +} diff --git a/src/app/api/quickstart/propose/route.ts b/src/app/api/quickstart/propose/route.ts index 22e0a9e8..dfad0f82 100644 --- a/src/app/api/quickstart/propose/route.ts +++ b/src/app/api/quickstart/propose/route.ts @@ -5,10 +5,12 @@ import { getQuickstartMissingConfigError, resolveQuickstartModelConfig, } from "@/lib/quickstart/modelClient"; +import { requireLogin } from "@/app/api/_auth"; import { resolveUserGatewayApiKey } from "@/app/api/_gatewayApiKey"; import { ApiError } from "@/app/api/_errors"; import { parseJSONFromResponse } from "@/lib/quickstart/validation"; import type { WorkflowProposal, WorkflowComplexity, NodeType } from "@/types"; +import { isPopiProviderMode } from "@/lib/providerMode"; export const maxDuration = 60; // 1 minute timeout @@ -196,7 +198,10 @@ export async function POST(request: NextRequest) { ); } - const newApiWGKey = await resolveUserGatewayApiKey(request); + const login = isPopiProviderMode() ? requireLogin(request) : null; + const newApiWGKey = isPopiProviderMode() + ? null + : await resolveUserGatewayApiKey(request); const modelConfig = resolveQuickstartModelConfig(request.headers, { newApiWGApiKey: newApiWGKey, }); @@ -222,6 +227,7 @@ export async function POST(request: NextRequest) { config: modelConfig, maxOutputTokens: 8192, requestId, + loginToken: login?.token, }); const duration = Date.now() - startTime; diff --git a/src/app/api/quickstart/route.ts b/src/app/api/quickstart/route.ts index 5e88799c..23e8ccce 100644 --- a/src/app/api/quickstart/route.ts +++ b/src/app/api/quickstart/route.ts @@ -7,8 +7,10 @@ import { getQuickstartMissingConfigError, resolveQuickstartModelConfig, } from "@/lib/quickstart/modelClient"; +import { requireLogin } from "@/app/api/_auth"; import { resolveUserGatewayApiKey } from "@/app/api/_gatewayApiKey"; import { ApiError } from "@/app/api/_errors"; +import { isPopiProviderMode } from "@/lib/providerMode"; import { validateWorkflowJSON, repairWorkflowJSON, @@ -131,7 +133,10 @@ export async function POST(request: NextRequest) { ); } - const newApiWGKey = await resolveUserGatewayApiKey(request); + const login = isPopiProviderMode() ? requireLogin(request) : null; + const newApiWGKey = isPopiProviderMode() + ? null + : await resolveUserGatewayApiKey(request); const modelConfig = resolveQuickstartModelConfig(request.headers, { newApiWGApiKey: newApiWGKey, }); @@ -157,6 +162,7 @@ export async function POST(request: NextRequest) { config: modelConfig, maxOutputTokens: 16384, // Increased for complex workflows with many nodes requestId, + loginToken: login?.token, }); const duration = Date.now() - startTime; diff --git a/src/components/ProjectSetupModal.tsx b/src/components/ProjectSetupModal.tsx index e0341723..19b328f7 100644 --- a/src/components/ProjectSetupModal.tsx +++ b/src/components/ProjectSetupModal.tsx @@ -81,7 +81,7 @@ const getProviderIcon = (provider: ProviderType) => { } }; -const DEFAULT_LLM_PROVIDER: LLMProvider = "newapiwg"; +const DEFAULT_LLM_PROVIDER: LLMProvider = isPopiProviderMode() ? "popiserver" : "newapiwg"; const DEFAULT_LLM_TEMPERATURE = 0.7; const DEFAULT_LLM_MAX_TOKENS = 8192; diff --git a/src/components/composer/GenerationComposer.tsx b/src/components/composer/GenerationComposer.tsx index d0d2a451..99bdc550 100644 --- a/src/components/composer/GenerationComposer.tsx +++ b/src/components/composer/GenerationComposer.tsx @@ -107,9 +107,10 @@ const DEFAULT_VIDEO_MODEL = isPopiProviderMode() : createNewApiWGDefaultVideoModel(); const DEFAULT_AUDIO_MODEL = createDefaultAudioModel(); const DEFAULT_PROMPT_TEXT_MODEL: SelectedModel = { - provider: "newapiwg", + provider: isPopiProviderMode() ? "popiserver" : "newapiwg", modelId: DEFAULT_NEWAPIWG_LLM_MODEL_ID, - displayName: LLM_MODELS.newapiwg.find((model) => model.value === DEFAULT_NEWAPIWG_LLM_MODEL_ID)?.label || DEFAULT_NEWAPIWG_LLM_MODEL_ID, + displayName: (isPopiProviderMode() ? LLM_MODELS.popiserver : LLM_MODELS.newapiwg) + .find((model) => model.value === DEFAULT_NEWAPIWG_LLM_MODEL_ID)?.label || DEFAULT_NEWAPIWG_LLM_MODEL_ID, capabilities: ["text-to-text"], }; diff --git a/src/lib/llmModels.ts b/src/lib/llmModels.ts index 19939f25..9891a97f 100644 --- a/src/lib/llmModels.ts +++ b/src/lib/llmModels.ts @@ -24,7 +24,7 @@ export const LLM_PROVIDERS: { value: LLMProvider; label: string }[] = export const LLM_MODELS: Record = { popiserver: [ - { value: "kimi-k2.6", label: "Kimi K2.6" }, + { value: DEFAULT_POPISERVER_LLM_MODEL_ID, label: "Kimi K2.6" }, ], newapiwg: [ { value: "doubao-seed-2-0-lite-260428", label: "Doubao Seed 2.0 Lite" }, diff --git a/src/lib/quickstart/modelClient.ts b/src/lib/quickstart/modelClient.ts index 10ac156b..189fd133 100644 --- a/src/lib/quickstart/modelClient.ts +++ b/src/lib/quickstart/modelClient.ts @@ -1,9 +1,14 @@ import { GoogleGenAI } from "@google/genai"; -import { DEFAULT_NEWAPIWG_LLM_MODEL_ID } from "@/lib/llmModels"; +import { DEFAULT_NEWAPIWG_LLM_MODEL_ID, DEFAULT_POPISERVER_LLM_MODEL_ID } from "@/lib/llmModels"; import { DEFAULT_NEWAPIWG_BASE_URL } from "@/store/utils/localStorage"; import { logGatewayRequest, logGatewayResponse } from "@/app/api/_gatewayLogging"; +import { isPopiProviderMode } from "@/lib/providerMode"; export type QuickstartModelConfig = + | { + provider: "popiserver"; + model: string; + } | { provider: "newapiwg"; apiKey: string; @@ -26,6 +31,7 @@ type GenerateQuickstartTextOptions = { config: QuickstartModelConfig; maxOutputTokens: number; requestId: string; + loginToken?: string | null; fetchFn?: typeof fetch; }; @@ -34,6 +40,13 @@ export function resolveQuickstartModelConfig( options: ResolveQuickstartModelConfigOptions = {} ): QuickstartModelConfig | null { const env = options.env ?? process.env; + if (isPopiProviderMode()) { + return { + provider: "popiserver", + model: DEFAULT_POPISERVER_LLM_MODEL_ID, + }; + } + const newApiWGKey = options.newApiWGApiKey; if (newApiWGKey) { return { @@ -57,13 +70,22 @@ export function resolveQuickstartModelConfig( } export function getQuickstartMissingConfigError(): string { - return "Gateway API key not found for current user."; + return isPopiProviderMode() + ? "Please log in to use Popi Models." + : "Gateway API key not found for current user."; } export async function generateQuickstartText( prompt: string, options: GenerateQuickstartTextOptions ): Promise { + if (options.config.provider === "popiserver") { + return generateWithPopiserver(prompt, { + ...options, + config: options.config, + }); + } + if (options.config.provider === "newapiwg") { return generateWithNewApiWG(prompt, { ...options, @@ -81,6 +103,69 @@ function normalizeBaseUrl(baseUrl?: string | null): string { return (baseUrl || DEFAULT_NEWAPIWG_BASE_URL).replace(/\/+$/, ""); } +function normalizePopiserverBaseUrl(env: EnvLike = process.env): string { + return (env.POPIART_API_BASE_URL || env.NEXT_PUBLIC_APP_URL || "").replace(/\/+$/, ""); +} + +async function generateWithPopiserver( + prompt: string, + { config, maxOutputTokens, requestId, loginToken, fetchFn = fetch }: GenerateQuickstartTextOptions & { + config: Extract; + } +): Promise { + const baseUrl = normalizePopiserverBaseUrl(); + if (!baseUrl) { + throw new Error("POPIART_API_BASE_URL is not configured."); + } + if (!loginToken) { + throw new Error("Please log in to use Popi Models."); + } + + const url = `${baseUrl}/api_client/anime/task/llmChat`; + const init: RequestInit = { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${loginToken}`, + token: loginToken, + }, + body: JSON.stringify({ + messages: [ + { role: "system", content: "You are a helpful assistant." }, + { role: "user", content: prompt }, + ], + model: config.model, + stream: false, + max_tokens: maxOutputTokens, + aiModelId: config.model === DEFAULT_POPISERVER_LLM_MODEL_ID ? 31 : undefined, + }), + }; + const context = { + requestId, + model: config.model, + provider: config.provider, + }; + const startedAt = Date.now(); + logGatewayRequest("Calling quickstart PopiServer API", url, init, context); + const response = await fetchFn(url, init); + await logGatewayResponse("Quickstart PopiServer API response", url, init, response, context, startedAt); + + const data = await response.json().catch(() => null); + if (!response.ok) { + throw new Error(getNestedString(data, ["message"]) || `PopiServer API error: ${response.status}`); + } + if (!data || data.status !== "0000") { + throw new Error(getNestedString(data, ["message"]) || "PopiServer API returned an error"); + } + + const text = getNestedString(data, ["data", "choices", "0", "message", "content"]); + if (!text) { + throw new Error("No text in PopiServer response"); + } + return text; +} + async function generateWithNewApiWG( prompt: string, { config, maxOutputTokens, requestId, fetchFn = fetch }: GenerateQuickstartTextOptions & { diff --git a/src/store/execution/simpleNodeExecutors.ts b/src/store/execution/simpleNodeExecutors.ts index f499ad39..7e9f0bbb 100644 --- a/src/store/execution/simpleNodeExecutors.ts +++ b/src/store/execution/simpleNodeExecutors.ts @@ -21,6 +21,123 @@ import type { NodeExecutionContext } from "./types"; import { resolveTextSourcesThroughRouters } from "@/store/utils/connectedInputs"; import { parseTextToArray } from "@/utils/arrayParser"; import { parseVarTags } from "@/utils/parseVarTags"; +import { isPopiProviderMode } from "@/lib/providerMode"; + +const popiserverOutputAssetCache = new Map>(); +const USER_SESSION_STORAGE_KEY = "node-banana-user-session"; + +function isPopiserverMediaUrl(value: string): boolean { + if (value.startsWith("/media/") || value.startsWith("/media_private/")) return true; + try { + const url = new URL(value); + return url.pathname.startsWith("/media/") || url.pathname.startsWith("/media_private/"); + } catch { + return false; + } +} + +function isLocalMediaSource(value: string): boolean { + return value.startsWith("data:") || value.startsWith("blob:"); +} + +function extensionForMimeType(mimeType: string, kind: "image" | "video"): string { + const normalized = mimeType.toLowerCase(); + if (normalized === "image/jpeg") return "jpg"; + if (normalized === "image/png") return "png"; + if (normalized === "image/webp") return "webp"; + if (normalized === "image/gif") return "gif"; + if (normalized === "video/mp4") return "mp4"; + if (normalized === "video/webm") return "webm"; + return kind === "video" ? "mp4" : "png"; +} + +async function blobToDataUrl(value: string): Promise { + const response = await fetch(value); + if (!response.ok) { + throw new Error(`Failed to read local blob output: HTTP ${response.status}`); + } + const blob = await response.blob(); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error("FileReader error while reading local output")); + reader.onabort = () => reject(new Error("FileReader aborted while reading local output")); + reader.readAsDataURL(blob); + }); +} + +function mimeTypeFromDataUrl(value: string): string | null { + const match = value.match(/^data:([^;,]+)?(?:;[^,]*)?;base64,/); + return match?.[1] || null; +} + +function getStoredLoginToken(): string | null { + if (typeof window === "undefined") return null; + try { + const stored = window.localStorage.getItem(USER_SESSION_STORAGE_KEY); + if (!stored) return null; + const parsed = JSON.parse(stored) as { state?: { token?: unknown } }; + const token = parsed.state?.token; + return typeof token === "string" && token.trim() ? token.trim() : null; + } catch { + return null; + } +} + +async function createPopiserverAssetForOutput( + kind: "image" | "video", + source: string | null | undefined, + title?: string +): Promise { + if (!source || !isPopiProviderMode() || isPopiserverMediaUrl(source) || !isLocalMediaSource(source)) { + return source; + } + + const token = getStoredLoginToken(); + if (!token) return source; + + const cacheKey = `${kind}:${source}`; + const cached = popiserverOutputAssetCache.get(cacheKey); + if (cached) return cached; + + const uploadPromise = (async () => { + try { + const dataUrl = source.startsWith("blob:") ? await blobToDataUrl(source) : source; + const mimeType = mimeTypeFromDataUrl(dataUrl) || (kind === "video" ? "video/mp4" : "image/png"); + const extension = extensionForMimeType(mimeType, kind); + const response = await fetch("/api/popi/asset/create", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + token, + }, + body: JSON.stringify({ + type: kind, + media: dataUrl, + title: title || "", + fileName: `canvas-output-${Date.now()}.${extension}`, + }), + }); + + const payload = (await response.json().catch(() => null)) as { + success?: boolean; + url?: unknown; + error?: string; + } | null; + + if (!response.ok || !payload?.success || typeof payload.url !== "string") { + throw new Error(payload?.error || `Popiserver asset create failed: HTTP ${response.status}`); + } + return payload.url; + } catch (error) { + console.error("Failed to create popiserver asset for output:", error); + return source; + } + })(); + popiserverOutputAssetCache.set(cacheKey, uploadPromise); + return uploadPromise; +} /** * Annotation node: receives upstream image as source, passes through if no annotations. @@ -221,9 +338,16 @@ export async function executeOutput(ctx: NodeExecutionContext): Promise { model3d ? "3d" : undefined; + const uploadedImage = rawImage && !imageLooksLikeVideo + ? await createPopiserverAssetForOutput("image", rawImage, outputNodeData.outputFilename) + : rawImage; + const uploadedVideo = videoContent + ? await createPopiserverAssetForOutput("video", videoContent, outputNodeData.outputFilename) + : videoContent; + updateNodeData(node.id, { - image: imageContent, - video: videoContent, + image: uploadedImage ?? imageContent, + video: uploadedVideo, audio: audioContent, model3d: model3d ?? null, contentType, @@ -251,15 +375,23 @@ export async function executeOutputGallery(ctx: NodeExecutionContext): Promise = {}; const existingImages = new Set(galleryImages); - const newImages = images.filter((img) => !existingImages.has(img)); + const newImages = await Promise.all( + images + .filter((img) => !existingImages.has(img)) + .map((img) => createPopiserverAssetForOutput("image", img)) + ); if (newImages.length > 0) { - updates.images = [...newImages, ...galleryImages]; + updates.images = [...newImages.filter((img): img is string => !!img), ...galleryImages]; } const existingVideos = new Set(galleryVideos); - const newVideos = videos.filter((v) => !existingVideos.has(v)); + const newVideos = await Promise.all( + videos + .filter((v) => !existingVideos.has(v)) + .map((video) => createPopiserverAssetForOutput("video", video)) + ); if (newVideos.length > 0) { - updates.videos = [...newVideos, ...galleryVideos]; + updates.videos = [...newVideos.filter((video): video is string => !!video), ...galleryVideos]; } if (Object.keys(updates).length > 0) { diff --git a/src/store/utils/buildApiHeaders.ts b/src/store/utils/buildApiHeaders.ts index e8f62929..427cc3ae 100644 --- a/src/store/utils/buildApiHeaders.ts +++ b/src/store/utils/buildApiHeaders.ts @@ -59,6 +59,10 @@ export function buildLlmHeaders( "Content-Type": "application/json", }; + if (llmProvider === "popiserver") { + return headers; + } + if (llmProvider === "newapiwg") { const newApiConfig = providerSettings.providers.newapiwg; if (newApiConfig?.baseUrl) { diff --git a/src/store/utils/nodeDefaults.ts b/src/store/utils/nodeDefaults.ts index 3ed1fa0a..b640fa76 100644 --- a/src/store/utils/nodeDefaults.ts +++ b/src/store/utils/nodeDefaults.ts @@ -245,7 +245,7 @@ export const createDefaultNodeData = (type: NodeType): WorkflowNodeData => { inputImages: [], inputVideos: [], outputText: null, - provider: llmDefaults?.provider ?? "newapiwg", + provider: llmDefaults?.provider ?? (isPopiProviderMode() ? "popiserver" : "newapiwg"), model: llmDefaults?.model ?? DEFAULT_NEWAPIWG_LLM_MODEL_ID, temperature: llmDefaults?.temperature ?? 0.7, maxTokens: llmDefaults?.maxTokens ?? 8192,