From d79d388f841767f2b2e31225527952afadf8586e Mon Sep 17 00:00:00 2001 From: HV NQRust Date: Fri, 17 Jul 2026 05:43:04 +0000 Subject: [PATCH 01/25] feat(mobile-api): Bearer JWT auth helper + mobile login endpoint --- src/app/api/mobile/login/route.ts | 34 ++++++++++++++ src/lib/mobile-auth.ts | 74 +++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 src/app/api/mobile/login/route.ts create mode 100644 src/lib/mobile-auth.ts diff --git a/src/app/api/mobile/login/route.ts b/src/app/api/mobile/login/route.ts new file mode 100644 index 00000000..ef1f7f0f --- /dev/null +++ b/src/app/api/mobile/login/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server" + +import { loginMobile } from "@/lib/mobile-auth" + +/** + * POST /api/mobile/login + * Body: { email, password } + * Sukses: { token, user } — token dipakai sebagai `Authorization: Bearer` oleh app mobile. + */ +export async function POST(request: Request) { + let body: { email?: string; password?: string } + try { + body = await request.json() + } catch { + body = {} + } + + if (!body.email || !body.password) { + return NextResponse.json( + { error: "Email dan password wajib diisi" }, + { status: 400 } + ) + } + + const result = await loginMobile(body.email, body.password) + if (!result) { + return NextResponse.json( + { error: "Email atau password salah" }, + { status: 401 } + ) + } + + return NextResponse.json(result) +} diff --git a/src/lib/mobile-auth.ts b/src/lib/mobile-auth.ts new file mode 100644 index 00000000..60adda5a --- /dev/null +++ b/src/lib/mobile-auth.ts @@ -0,0 +1,74 @@ +/** + * Autentikasi untuk klien mobile (React Native). + * + * Web memakai sesi cookie next-auth; mobile memakai JWT Bearer. Modul ini + * menyediakan penerbitan token saat login dan verifikasi token pada request, + * ditandatangani dengan NEXTAUTH_SECRET (algoritma HS256) via `jose`. + */ +import { compare } from "bcryptjs" +import { SignJWT, jwtVerify } from "jose" + +import { auth } from "@/lib/auth" +import { prisma } from "@/lib/prisma" + +const secret = new TextEncoder().encode(process.env.NEXTAUTH_SECRET ?? "") + +export interface MobileUser { + id: string + email: string + name: string | null + role: string +} + +/** Terbitkan JWT untuk user (berlaku 30 hari). */ +export async function signMobileToken(user: { + id: string + email: string + role?: string | null +}): Promise { + return new SignJWT({ email: user.email, role: user.role ?? undefined }) + .setProtectedHeader({ alg: "HS256" }) + .setSubject(user.id) + .setIssuedAt() + .setExpirationTime("30d") + .sign(secret) +} + +/** Verifikasi email+password lalu kembalikan token + data user, atau null. */ +export async function loginMobile( + email: string, + password: string +): Promise<{ token: string; user: MobileUser } | null> { + const user = await prisma.user.findUnique({ where: { email } }) + if (!user) return null + + const isValid = await compare(password, user.passwordHash) + if (!isValid) return null + + const token = await signMobileToken(user) + return { + token, + user: { id: user.id, email: user.email, name: user.name, role: user.role }, + } +} + +async function userIdFromBearer(header: string | null): Promise { + if (!header?.startsWith("Bearer ")) return null + try { + const { payload } = await jwtVerify(header.slice(7), secret) + return typeof payload.sub === "string" ? payload.sub : null + } catch { + return null + } +} + +/** + * Ambil userId dari request: sesi next-auth (web) ATAU header + * `Authorization: Bearer ` (mobile). Mengembalikan null bila keduanya + * tidak valid. + */ +export async function getRequestUserId(request: Request): Promise { + const session = await auth() + if (session?.user?.id) return session.user.id + return userIdFromBearer(request.headers.get("authorization")) +} From dfce275deb43de617eb03f67d7656571b5422dd5 Mon Sep 17 00:00:00 2001 From: HV NQRust Date: Fri, 17 Jul 2026 05:43:04 +0000 Subject: [PATCH 02/25] feat(mobile-api): mobile chat generation, regenerate & skills endpoints --- src/app/api/mobile/chat/regenerate/route.ts | 41 +++ src/app/api/mobile/chat/route.ts | 64 +++++ src/app/api/mobile/skills/route.ts | 22 ++ src/lib/mobile-chat.ts | 298 ++++++++++++++++++++ 4 files changed, 425 insertions(+) create mode 100644 src/app/api/mobile/chat/regenerate/route.ts create mode 100644 src/app/api/mobile/chat/route.ts create mode 100644 src/app/api/mobile/skills/route.ts create mode 100644 src/lib/mobile-chat.ts diff --git a/src/app/api/mobile/chat/regenerate/route.ts b/src/app/api/mobile/chat/regenerate/route.ts new file mode 100644 index 00000000..068fbfe8 --- /dev/null +++ b/src/app/api/mobile/chat/regenerate/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server" + +import { regenerateMobileReply } from "@/lib/mobile-chat" +import { getRequestUserId } from "@/lib/mobile-auth" +import { readChatOptions } from "../route" + +/** + * POST /api/mobile/chat/regenerate + * Body: { sessionId, ...opsi tools/skills/canvas } + * Membuang balasan asisten terakhir lalu menghasilkan balasan baru dari + * riwayat yang tersisa. Pesan user tidak ditulis ulang. + */ +export async function POST(req: Request) { + const userId = await getRequestUserId(req) + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + let body: Record + try { + body = await req.json() + } catch { + body = {} + } + + const sessionId = typeof body.sessionId === "string" ? body.sessionId.trim() : "" + if (!sessionId) { + return NextResponse.json({ error: "sessionId wajib diisi" }, { status: 400 }) + } + + const result = await regenerateMobileReply({ + userId, + sessionId, + options: readChatOptions(body), + }) + if ("error" in result) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + + return NextResponse.json(result) +} diff --git a/src/app/api/mobile/chat/route.ts b/src/app/api/mobile/chat/route.ts new file mode 100644 index 00000000..46f525f6 --- /dev/null +++ b/src/app/api/mobile/chat/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from "next/server" + +import { generateMobileReply, type MobileChatOptions } from "@/lib/mobile-chat" +import { getRequestUserId } from "@/lib/mobile-auth" + +/** Ambil opsi tools/skills/canvas dari body request. */ +export function readChatOptions(body: Record): MobileChatOptions { + const strings = (v: unknown) => + Array.isArray(v) ? v.filter((n): n is string => typeof n === "string") : undefined + const canvasMode = typeof body.canvasMode === "string" ? body.canvasMode.trim() : "" + const fileContext = typeof body.fileContext === "string" ? body.fileContext.trim() : "" + + return { + enableWebSearch: body.enableWebSearch === true, + enableCodeInterpreter: body.enableCodeInterpreter === true, + enabledToolNames: strings(body.enabledToolNames), + enabledSkillIds: strings(body.enabledSkillIds), + canvasMode: canvasMode || undefined, + fileContext: fileContext || undefined, + } +} + +/** + * POST /api/mobile/chat + * Body: { sessionId, content, enableWebSearch?, enableCodeInterpreter?, + * enabledToolNames?, enabledSkillIds?, canvasMode?, fileContext? } + * Menyimpan pesan user, menghasilkan balasan AI (non-streaming), menyimpannya, + * lalu mengembalikan { reply }. + */ +export async function POST(req: Request) { + const userId = await getRequestUserId(req) + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + let body: Record + try { + body = await req.json() + } catch { + body = {} + } + + const sessionId = typeof body.sessionId === "string" ? body.sessionId.trim() : "" + const content = typeof body.content === "string" ? body.content.trim() : "" + if (!sessionId || !content) { + return NextResponse.json( + { error: "sessionId dan content wajib diisi" }, + { status: 400 } + ) + } + + const result = await generateMobileReply({ + userId, + sessionId, + content, + replyTo: typeof body.replyTo === "string" ? body.replyTo.trim() || undefined : undefined, + options: readChatOptions(body), + }) + if ("error" in result) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + + return NextResponse.json(result) +} diff --git a/src/app/api/mobile/skills/route.ts b/src/app/api/mobile/skills/route.ts new file mode 100644 index 00000000..e01cd28c --- /dev/null +++ b/src/app/api/mobile/skills/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server" + +import { listMobileSkills } from "@/lib/mobile-chat" +import { getRequestUserId } from "@/lib/mobile-auth" + +/** + * GET /api/mobile/skills + * Daftar skill yang bisa dipilih user pada composer mobile. + */ +export async function GET(req: Request) { + const userId = await getRequestUserId(req) + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + try { + return NextResponse.json(await listMobileSkills(userId)) + } catch (error) { + console.error("[Mobile Skills] error:", error) + return NextResponse.json({ error: "Gagal memuat skills" }, { status: 500 }) + } +} diff --git a/src/lib/mobile-chat.ts b/src/lib/mobile-chat.ts new file mode 100644 index 00000000..cdee3711 --- /dev/null +++ b/src/lib/mobile-chat.ts @@ -0,0 +1,298 @@ +import "server-only" +import { generateText, stepCountIs, tool, zodSchema } from "ai" + +import { + addDashboardChatSessionMessages, + deleteDashboardChatSessionMessages, + getDashboardChatSession, +} from "@/features/conversations/sessions/service" +import { isHttpServiceError } from "@/features/shared/http-service-error" +import { getChatProvider, resolveModelId } from "@/lib/llm/provider" +import { prisma } from "@/lib/prisma" +import { buildToolInstruction } from "@/lib/prompts/instructions" +import { resolveSkillsForAssistant } from "@/lib/skills/resolver" + +export interface MobileSkill { + id: string + displayName: string + description: string + icon: string | null +} + +/** + * Skill yang bisa dipakai user di mobile: yang aktif, dan bersifat global + * (organizationId null) atau milik organisasi tempat user menjadi anggota. + * Filter ini sengaja disamakan dengan resolveSkillsForAssistant. + */ +export async function listMobileSkills(userId: string): Promise { + return prisma.skill.findMany({ + where: { + enabled: true, + OR: [ + { organizationId: null }, + { organization: { memberships: { some: { userId } } } }, + ], + }, + select: { id: true, displayName: true, description: true, icon: true }, + orderBy: { displayName: "asc" }, + }) +} + +/** Opsi per-pesan dari composer mobile. */ +export interface MobileChatOptions { + enableWebSearch?: boolean + enableCodeInterpreter?: boolean + /** Nama tool bawaan yang dipilih user (mis. calculator, date_time). */ + enabledToolNames?: string[] + /** Skill yang dipilih; prompt-nya disisipkan ke system prompt. */ + enabledSkillIds?: string[] + /** Mode Canvas: "auto" atau tipe artifact (mis. "text/html"). */ + canvasMode?: string + /** Teks hasil ekstraksi lampiran (dari /api/chat/upload). */ + fileContext?: string +} + +type ServiceError = { status: number; error: string } + +/** + * Balasan chat non-streaming untuk klien mobile. Sengaja ringkas (tanpa + * streaming) agar mudah dikonsumsi React Native. + */ +const DEFAULT_MODEL = "openai/gpt-4o-mini" +const MAX_OUTPUT_TOKENS = 1024 + +/** + * Bangun tools bawaan sesuai toggle dari toolbar mobile. Memakai ulang + * BUILTIN_TOOLS yang sama dengan chat web. + */ +async function buildTools(params: { + userId: string + sessionId: string + assistantId?: string + options: MobileChatOptions +}) { + const { options } = params + const wanted = new Set(options.enabledToolNames ?? []) + if (options.enableWebSearch) wanted.add("web_search") + if (options.enableCodeInterpreter) wanted.add("code_interpreter") + if (options.canvasMode) { + wanted.add("create_artifact") + wanted.add("update_artifact") + } + if (wanted.size === 0) return undefined + + const { BUILTIN_TOOLS } = await import("@/lib/tools/builtin") + // canvasMode diperlukan tool artifact; tool lain mengabaikannya. + const ctx = { + userId: params.userId, + assistantId: params.assistantId, + sessionId: params.sessionId, + canvasMode: options.canvasMode, + } + const tools: Record> = {} + + for (const name of wanted) { + const builtin = BUILTIN_TOOLS[name] + if (!builtin) continue + tools[name] = tool({ + description: builtin.description, + inputSchema: zodSchema(builtin.parameters), + execute: async (args) => builtin.execute(args as Record, ctx), + }) + } + + return Object.keys(tools).length ? tools : undefined +} + +/** + * Inti generasi: menerima riwayat siap-pakai, mengembalikan teks balasan. + * Dipakai bersama oleh kirim-pesan biasa dan regenerate. + */ +async function runGeneration(params: { + userId: string + sessionId: string + assistantId: string + history: Array<{ role: "user" | "assistant"; content: string }> + options: MobileChatOptions +}): Promise<{ reply: string } | ServiceError> { + const tools = await buildTools({ + userId: params.userId, + sessionId: params.sessionId, + assistantId: params.assistantId, + options: params.options, + }) + + let system = "You are RantAI, a helpful assistant. Answer clearly and concisely." + if (params.options.enabledSkillIds?.length) { + const skillPrompt = await resolveSkillsForAssistant( + params.assistantId, + params.options.enabledSkillIds, + params.userId + ) + if (skillPrompt) system = `${system}\n\n${skillPrompt}` + } + if (tools) { + system += buildToolInstruction(Object.keys(tools), { + canvasMode: params.options.canvasMode, + }) + } + + try { + const result = await generateText({ + model: getChatProvider()(resolveModelId(DEFAULT_MODEL)), + system, + messages: params.history, + maxTokens: MAX_OUTPUT_TOKENS, + // Canvas perlu langkah ekstra: buat artifact lalu simpulkan hasilnya. + ...(tools ? { tools, stopWhen: stepCountIs(params.options.canvasMode ? 8 : 5) } : {}), + }) + + const reply = result.text.trim() + // Tool yang gagal bisa membuat model berhenti tanpa teks akhir. Jangan + // simpan balasan kosong — laporkan sebagai error agar UI bisa memberi tahu. + if (!reply) { + return { + status: 502, + error: "AI tidak menghasilkan balasan (kemungkinan tool gagal dijalankan).", + } + } + return { reply } + } catch (error) { + console.error("[Mobile Chat] generate error:", error) + return { status: 502, error: "Gagal menghasilkan balasan AI" } + } +} + +/** + * Alur: ambil riwayat sesi → simpan pesan user → hasilkan balasan AI → + * simpan balasan → kembalikan teksnya. + */ +export async function generateMobileReply(params: { + userId: string + sessionId: string + content: string + /** Id pesan yang dibalas (fitur reply). Disimpan & dikutip ke prompt. */ + replyTo?: string + options: MobileChatOptions +}): Promise<{ reply: string } | ServiceError> { + const detail = await getDashboardChatSession({ + userId: params.userId, + sessionId: params.sessionId, + }) + if (isHttpServiceError(detail)) { + return { status: detail.status, error: detail.error } + } + + const history = detail.messages.map((message) => ({ + role: message.role === "assistant" ? ("assistant" as const) : ("user" as const), + content: message.content, + })) + + // Simpan pesan user lebih dulu (agar tetap tercatat walau generasi gagal). + // Konten disimpan bersih; kutipan & lampiran hanya menempel di prompt. + const persistUser = await addDashboardChatSessionMessages({ + userId: params.userId, + sessionId: params.sessionId, + input: { + messages: [ + { + role: "user", + content: params.content, + ...(params.replyTo ? { replyTo: params.replyTo } : {}), + }, + ], + }, + }) + if (isHttpServiceError(persistUser)) { + return { status: persistUser.status, error: persistUser.error } + } + + let userContent = params.content + + // Beri tahu model pesan mana yang sedang dibalas. + if (params.replyTo) { + const quoted = detail.messages.find((m) => m.id === params.replyTo) + if (quoted) { + userContent = `--- Membalas pesan berikut ---\n${quoted.content}\n--- Akhir kutipan ---\n\n${userContent}` + } + } + + // Sisipkan isi lampiran (bila ada) sebagai konteks pada pesan user. + if (params.options.fileContext) { + userContent = `${userContent}\n\n--- Isi lampiran ---\n${params.options.fileContext}` + } + + const generated = await runGeneration({ + userId: params.userId, + sessionId: params.sessionId, + assistantId: detail.assistantId, + history: [...history, { role: "user", content: userContent }], + options: params.options, + }) + if ("error" in generated) return generated + + await addDashboardChatSessionMessages({ + userId: params.userId, + sessionId: params.sessionId, + input: { messages: [{ role: "assistant", content: generated.reply }] }, + }) + + return generated +} + +/** + * Regenerate: buang balasan asisten paling akhir, lalu hasilkan balasan baru + * dari riwayat yang tersisa. Pesan user TIDAK ditulis ulang. + */ +export async function regenerateMobileReply(params: { + userId: string + sessionId: string + options: MobileChatOptions +}): Promise<{ reply: string } | ServiceError> { + const detail = await getDashboardChatSession({ + userId: params.userId, + sessionId: params.sessionId, + }) + if (isHttpServiceError(detail)) { + return { status: detail.status, error: detail.error } + } + + const messages = [...detail.messages] + const staleIds: string[] = [] + while (messages.length > 0 && messages[messages.length - 1].role === "assistant") { + staleIds.push(messages[messages.length - 1].id) + messages.pop() + } + if (messages.length === 0) { + return { status: 400, error: "Tidak ada pesan untuk dibuat ulang." } + } + + const generated = await runGeneration({ + userId: params.userId, + sessionId: params.sessionId, + assistantId: detail.assistantId, + history: messages.map((m) => ({ + role: m.role === "assistant" ? ("assistant" as const) : ("user" as const), + content: m.content, + })), + options: params.options, + }) + if ("error" in generated) return generated + + // Hapus balasan lama hanya setelah balasan baru berhasil dibuat. + if (staleIds.length > 0) { + await deleteDashboardChatSessionMessages({ + userId: params.userId, + sessionId: params.sessionId, + input: { messageIds: staleIds }, + }) + } + + await addDashboardChatSessionMessages({ + userId: params.userId, + sessionId: params.sessionId, + input: { messages: [{ role: "assistant", content: generated.reply }] }, + }) + + return generated +} From fb1b651800d09d35c8b35bf0da1bbcb1572e8f4d Mon Sep 17 00:00:00 2001 From: HV NQRust Date: Fri, 17 Jul 2026 05:43:04 +0000 Subject: [PATCH 03/25] feat(mobile-api): accept Bearer auth on dashboard chat sessions & messages --- .../chat/sessions/[id]/messages/route.ts | 13 ++++++------ .../api/dashboard/chat/sessions/[id]/route.ts | 20 +++++++++---------- src/app/api/dashboard/chat/sessions/route.ts | 16 +++++++-------- 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/src/app/api/dashboard/chat/sessions/[id]/messages/route.ts b/src/app/api/dashboard/chat/sessions/[id]/messages/route.ts index 06d7ca7b..d4a376dc 100644 --- a/src/app/api/dashboard/chat/sessions/[id]/messages/route.ts +++ b/src/app/api/dashboard/chat/sessions/[id]/messages/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server" import { auth } from "@/lib/auth" +import { getRequestUserId } from "@/lib/mobile-auth" import { DashboardChatSessionIdParamsSchema, DashboardChatSessionMessageDeleteBodySchema, @@ -18,8 +19,8 @@ export async function POST( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth() - if (!session?.user?.id) { + const userId = await getRequestUserId(req) + if (!userId) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } @@ -36,7 +37,7 @@ export async function POST( ) } const result = await addDashboardChatSessionMessages({ - userId: session.user.id, + userId, sessionId: parsedParams.data.id, input: parsedBody.data, }) @@ -96,8 +97,8 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth() - if (!session?.user?.id) { + const userId = await getRequestUserId(req) + if (!userId) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } @@ -114,7 +115,7 @@ export async function DELETE( ) } const result = await deleteDashboardChatSessionMessages({ - userId: session.user.id, + userId, sessionId: parsedParams.data.id, input: parsedBody.data, }) diff --git a/src/app/api/dashboard/chat/sessions/[id]/route.ts b/src/app/api/dashboard/chat/sessions/[id]/route.ts index 21dff891..c4596bc0 100644 --- a/src/app/api/dashboard/chat/sessions/[id]/route.ts +++ b/src/app/api/dashboard/chat/sessions/[id]/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server" -import { auth } from "@/lib/auth" +import { getRequestUserId } from "@/lib/mobile-auth" import { DashboardChatSessionIdParamsSchema, DashboardChatSessionUpdateBodySchema, @@ -16,8 +16,8 @@ export async function GET( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth() - if (!session?.user?.id) { + const userId = await getRequestUserId(req) + if (!userId) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } @@ -27,7 +27,7 @@ export async function GET( } const result = await getDashboardChatSession({ - userId: session.user.id, + userId, sessionId: parsedParams.data.id, }) @@ -47,8 +47,8 @@ export async function PATCH( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth() - if (!session?.user?.id) { + const userId = await getRequestUserId(req) + if (!userId) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } @@ -67,7 +67,7 @@ export async function PATCH( ) } const result = await updateDashboardChatSession({ - userId: session.user.id, + userId, sessionId: parsedParams.data.id, input: parsedBody.data, }) @@ -88,8 +88,8 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth() - if (!session?.user?.id) { + const userId = await getRequestUserId(req) + if (!userId) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } @@ -99,7 +99,7 @@ export async function DELETE( } const result = await deleteDashboardChatSession({ - userId: session.user.id, + userId, sessionId: parsedParams.data.id, }) diff --git a/src/app/api/dashboard/chat/sessions/route.ts b/src/app/api/dashboard/chat/sessions/route.ts index 9edbb193..d19f3c5a 100644 --- a/src/app/api/dashboard/chat/sessions/route.ts +++ b/src/app/api/dashboard/chat/sessions/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server" -import { auth } from "@/lib/auth" +import { getRequestUserId } from "@/lib/mobile-auth" import { DashboardChatSessionCreateBodySchema, } from "@/features/conversations/sessions/schema" @@ -9,14 +9,14 @@ import { } from "@/features/conversations/sessions/service" import { isHttpServiceError } from "@/features/shared/http-service-error" -export async function GET() { +export async function GET(request: Request) { try { - const session = await auth() - if (!session?.user?.id) { + const userId = await getRequestUserId(request) + if (!userId) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } - const result = await listDashboardChatSessions({ userId: session.user.id }) + const result = await listDashboardChatSessions({ userId }) return NextResponse.json(result) } catch (error) { console.error("[Chat Sessions API] GET error:", error) @@ -26,8 +26,8 @@ export async function GET() { export async function POST(req: Request) { try { - const session = await auth() - if (!session?.user?.id) { + const userId = await getRequestUserId(req) + if (!userId) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } @@ -43,7 +43,7 @@ export async function POST(req: Request) { ) } const result = await createDashboardChatSession({ - userId: session.user.id, + userId, input: parsedBody.data, }) From 6c31c266fc744643bb6997a6ee34a14db282f44d Mon Sep 17 00:00:00 2001 From: HV NQRust Date: Fri, 17 Jul 2026 05:43:04 +0000 Subject: [PATCH 04/25] fix(chat/upload): strip media-type params before MIME allowlist check --- src/app/api/chat/upload/route.ts | 8 ++++---- src/features/chat-public/service.ts | 11 ++++++++--- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/app/api/chat/upload/route.ts b/src/app/api/chat/upload/route.ts index 9500fafe..7af2ec6c 100644 --- a/src/app/api/chat/upload/route.ts +++ b/src/app/api/chat/upload/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server" -import { auth } from "@/lib/auth" +import { getRequestUserId } from "@/lib/mobile-auth" import { ChatUploadFormSchema } from "@/features/chat-public/schema" import { isChatPublicServiceError, @@ -7,8 +7,8 @@ import { } from "@/features/chat-public/service" export async function POST(req: Request) { - const session = await auth() - if (!session?.user?.id) { + const userId = await getRequestUserId(req) + if (!userId) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } @@ -28,7 +28,7 @@ export async function POST(req: Request) { const result = await uploadChatAttachment({ file: parsedForm.data.file, sessionId: parsedForm.data.sessionId, - userId: session.user.id, + userId, }) if (isChatPublicServiceError(result)) { diff --git a/src/features/chat-public/service.ts b/src/features/chat-public/service.ts index 3ed58b9b..bf3f4f00 100644 --- a/src/features/chat-public/service.ts +++ b/src/features/chat-public/service.ts @@ -1449,7 +1449,12 @@ export async function uploadChatAttachment(params: { sessionId?: string | null }) { try { - if (!ALLOWED_TYPES.includes(params.file.type)) { + // Buang parameter media-type (mis. "text/plain;charset=utf-8" -> "text/plain"). + // Klien yang sah (browser, React Native, curl) kerap menyertakan charset, + // dan tanpa normalisasi ini file yang sebenarnya diizinkan ikut ditolak. + const mimeType = params.file.type.split(";")[0].trim().toLowerCase() + + if (!ALLOWED_TYPES.includes(mimeType)) { return { status: 400, error: `File type not allowed. Allowed: ${ALLOWED_TYPES.join(", ")}`, @@ -1469,10 +1474,10 @@ export async function uploadChatAttachment(params: { const buffer = Buffer.from(arrayBuffer) const storedFileName = await saveChatAttachment({ buffer, - mimeType: params.file.type, + mimeType, }) - const result = await processChatFile(buffer, params.file.type, params.file.name, { + const result = await processChatFile(buffer, mimeType, params.file.name, { sessionId: params.sessionId || undefined, userId: params.userId, }) From fe4d90fdfe6c27c6fa6e196e66d216def77b3221 Mon Sep 17 00:00:00 2001 From: HV NQRust Date: Fri, 17 Jul 2026 05:53:52 +0000 Subject: [PATCH 05/25] feat(mobile-api): agent (assistant) CRUD endpoints + models list - getMobileContext helper (Bearer userId + auto-resolved org) - GET/POST /api/mobile/assistants (list w/ user default id, create) - GET/PUT/DELETE /api/mobile/assistants/[id] - POST /api/mobile/assistants/[id]/default (per-user default agent) - POST /api/mobile/assistants/[id]/duplicate - GET /api/mobile/models (slim list for the builder dropdown) --- .../mobile/assistants/[id]/default/route.ts | 51 ++++++++ .../mobile/assistants/[id]/duplicate/route.ts | 76 ++++++++++++ src/app/api/mobile/assistants/[id]/route.ts | 111 ++++++++++++++++++ src/app/api/mobile/assistants/route.ts | 81 +++++++++++++ src/app/api/mobile/models/route.ts | 58 +++++++++ src/lib/mobile-org.ts | 34 ++++++ 6 files changed, 411 insertions(+) create mode 100644 src/app/api/mobile/assistants/[id]/default/route.ts create mode 100644 src/app/api/mobile/assistants/[id]/duplicate/route.ts create mode 100644 src/app/api/mobile/assistants/[id]/route.ts create mode 100644 src/app/api/mobile/assistants/route.ts create mode 100644 src/app/api/mobile/models/route.ts create mode 100644 src/lib/mobile-org.ts diff --git a/src/app/api/mobile/assistants/[id]/default/route.ts b/src/app/api/mobile/assistants/[id]/default/route.ts new file mode 100644 index 00000000..63931896 --- /dev/null +++ b/src/app/api/mobile/assistants/[id]/default/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server" + +import { AssistantIdParamsSchema } from "@/features/assistants/core/schema" +import { getAssistantForUser } from "@/features/assistants/core/service" +import { isHttpServiceError } from "@/features/shared/http-service-error" +import { updateUserPreferences } from "@/features/user/preferences/service" +import { getMobileContext } from "@/lib/mobile-org" + +interface RouteParams { + params: Promise<{ id: string }> +} + +/** + * POST /api/mobile/assistants/[id]/default — set this agent as the current + * user's personal default (UserPreference.defaultAssistantId). Unlike the web + * org-wide system default, this is per-user so any member can set it. + */ +export async function POST(request: Request, { params }: RouteParams) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const parsedParams = AssistantIdParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json({ error: "Invalid assistant id" }, { status: 400 }) + } + + // Enforce org scoping: only agents the user can see may become the default. + const assistant = await getAssistantForUser({ + id: parsedParams.data.id, + context: { organizationId: ctx.organizationId, role: ctx.role }, + }) + if (isHttpServiceError(assistant)) { + return NextResponse.json({ error: assistant.error }, { status: assistant.status }) + } + + const preferences = await updateUserPreferences(ctx.userId, { + defaultAssistantId: parsedParams.data.id, + }) + if (isHttpServiceError(preferences)) { + return NextResponse.json({ error: preferences.error }, { status: preferences.status }) + } + + return NextResponse.json(preferences) + } catch (error) { + console.error("[Mobile Assistants API] set default error:", error) + return NextResponse.json({ error: "Failed to set default agent" }, { status: 500 }) + } +} diff --git a/src/app/api/mobile/assistants/[id]/duplicate/route.ts b/src/app/api/mobile/assistants/[id]/duplicate/route.ts new file mode 100644 index 00000000..210d6067 --- /dev/null +++ b/src/app/api/mobile/assistants/[id]/duplicate/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server" + +import { AssistantIdParamsSchema } from "@/features/assistants/core/schema" +import type { CreateAssistantInput } from "@/features/assistants/core/schema" +import { + createAssistantForUser, + getAssistantForUser, +} from "@/features/assistants/core/service" +import { isHttpServiceError } from "@/features/shared/http-service-error" +import { getMobileContext } from "@/lib/mobile-org" + +interface RouteParams { + params: Promise<{ id: string }> +} + +/** + * POST /api/mobile/assistants/[id]/duplicate — clone an agent's core config + * into a new org-scoped agent named " (Copy)". Tool/skill/etc. bindings + * are out of MVP scope and not copied. + */ +export async function POST(request: Request, { params }: RouteParams) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const parsedParams = AssistantIdParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json({ error: "Invalid assistant id" }, { status: 400 }) + } + + const source = await getAssistantForUser({ + id: parsedParams.data.id, + context: { organizationId: ctx.organizationId, role: ctx.role }, + }) + if (isHttpServiceError(source)) { + return NextResponse.json({ error: source.error }, { status: source.status }) + } + + const s = source as Record + const input: CreateAssistantInput = { + name: `${String(s.name)} (Copy)`, + description: (s.description as string | null) ?? undefined, + emoji: (s.emoji as string | undefined) ?? undefined, + systemPrompt: String(s.systemPrompt), + model: (s.model as string | undefined) ?? undefined, + useKnowledgeBase: (s.useKnowledgeBase as boolean | undefined) ?? undefined, + knowledgeBaseGroupIds: (s.knowledgeBaseGroupIds as string[] | undefined) ?? undefined, + liveChatEnabled: (s.liveChatEnabled as boolean | undefined) ?? undefined, + modelConfig: (s.modelConfig as unknown) ?? undefined, + openingMessage: (s.openingMessage as string | null) ?? undefined, + openingQuestions: (s.openingQuestions as string[] | undefined) ?? undefined, + chatConfig: (s.chatConfig as unknown) ?? undefined, + guardRails: (s.guardRails as unknown) ?? undefined, + memoryConfig: (s.memoryConfig as unknown) ?? undefined, + avatarS3Key: (s.avatarS3Key as string | null) ?? undefined, + tags: (s.tags as string[] | undefined) ?? undefined, + } + + const created = await createAssistantForUser({ + userId: ctx.userId, + input, + organizationId: ctx.organizationId, + role: ctx.role, + }) + if (isHttpServiceError(created)) { + return NextResponse.json({ error: created.error }, { status: created.status }) + } + + return NextResponse.json(created, { status: 201 }) + } catch (error) { + console.error("[Mobile Assistants API] duplicate error:", error) + return NextResponse.json({ error: "Failed to duplicate assistant" }, { status: 500 }) + } +} diff --git a/src/app/api/mobile/assistants/[id]/route.ts b/src/app/api/mobile/assistants/[id]/route.ts new file mode 100644 index 00000000..3d98ea68 --- /dev/null +++ b/src/app/api/mobile/assistants/[id]/route.ts @@ -0,0 +1,111 @@ +import { NextResponse } from "next/server" + +import { + AssistantIdParamsSchema, + UpdateAssistantSchema, +} from "@/features/assistants/core/schema" +import { + deleteAssistantForUser, + getAssistantForUser, + updateAssistantForUser, +} from "@/features/assistants/core/service" +import { isHttpServiceError } from "@/features/shared/http-service-error" +import { getMobileContext } from "@/lib/mobile-org" + +interface RouteParams { + params: Promise<{ id: string }> +} + +// GET /api/mobile/assistants/[id] +export async function GET(request: Request, { params }: RouteParams) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const parsedParams = AssistantIdParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json({ error: "Invalid assistant id" }, { status: 400 }) + } + + const assistant = await getAssistantForUser({ + id: parsedParams.data.id, + context: { organizationId: ctx.organizationId, role: ctx.role }, + }) + if (isHttpServiceError(assistant)) { + return NextResponse.json({ error: assistant.error }, { status: assistant.status }) + } + + return NextResponse.json(assistant) + } catch (error) { + console.error("[Mobile Assistants API] GET [id] error:", error) + return NextResponse.json({ error: "Failed to fetch assistant" }, { status: 500 }) + } +} + +// PUT /api/mobile/assistants/[id] +export async function PUT(request: Request, { params }: RouteParams) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const parsedParams = AssistantIdParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json({ error: "Invalid assistant id" }, { status: 400 }) + } + + const bodyParse = UpdateAssistantSchema.safeParse(await request.json()) + if (!bodyParse.success) { + return NextResponse.json( + { error: "Invalid request payload", details: bodyParse.error.flatten() }, + { status: 400 } + ) + } + + const assistant = await updateAssistantForUser({ + id: parsedParams.data.id, + userId: ctx.userId, + input: bodyParse.data, + context: { organizationId: ctx.organizationId, role: ctx.role }, + }) + if (isHttpServiceError(assistant)) { + return NextResponse.json({ error: assistant.error }, { status: assistant.status }) + } + + return NextResponse.json(assistant) + } catch (error) { + console.error("[Mobile Assistants API] PUT [id] error:", error) + return NextResponse.json({ error: "Failed to update assistant" }, { status: 500 }) + } +} + +// DELETE /api/mobile/assistants/[id] +export async function DELETE(request: Request, { params }: RouteParams) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const parsedParams = AssistantIdParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json({ error: "Invalid assistant id" }, { status: 400 }) + } + + const result = await deleteAssistantForUser({ + id: parsedParams.data.id, + context: { organizationId: ctx.organizationId, role: ctx.role }, + }) + if (isHttpServiceError(result)) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + + return NextResponse.json(result) + } catch (error) { + console.error("[Mobile Assistants API] DELETE [id] error:", error) + return NextResponse.json({ error: "Failed to delete assistant" }, { status: 500 }) + } +} diff --git a/src/app/api/mobile/assistants/route.ts b/src/app/api/mobile/assistants/route.ts new file mode 100644 index 00000000..888247b7 --- /dev/null +++ b/src/app/api/mobile/assistants/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from "next/server" + +import { CreateAssistantSchema } from "@/features/assistants/core/schema" +import { + createAssistantForUser, + listAssistantsForUser, +} from "@/features/assistants/core/service" +import { isHttpServiceError } from "@/features/shared/http-service-error" +import { getUserPreferences } from "@/features/user/preferences/service" +import { getMobileContext } from "@/lib/mobile-org" + +/** + * GET /api/mobile/assistants — list agents visible to the user's org, plus the + * user's personal default agent id (so the app can mark it in the list). + */ +export async function GET(request: Request) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const [assistants, preferences] = await Promise.all([ + listAssistantsForUser({ + organizationId: ctx.organizationId, + role: ctx.role, + }), + getUserPreferences(ctx.userId), + ]) + + return NextResponse.json({ + assistants, + defaultAssistantId: preferences.defaultAssistantId, + }) + } catch (error) { + console.error("[Mobile Assistants API] GET error:", error) + return NextResponse.json( + { error: "Failed to fetch assistants" }, + { status: 500 } + ) + } +} + +/** + * POST /api/mobile/assistants — create an agent. Only `name` and `systemPrompt` + * are required; the rest is defaulted server-side. + */ +export async function POST(request: Request) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const parsed = CreateAssistantSchema.safeParse(await request.json()) + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request payload", details: parsed.error.flatten() }, + { status: 400 } + ) + } + + const assistant = await createAssistantForUser({ + userId: ctx.userId, + input: parsed.data, + organizationId: ctx.organizationId, + role: ctx.role, + }) + if (isHttpServiceError(assistant)) { + return NextResponse.json({ error: assistant.error }, { status: assistant.status }) + } + + return NextResponse.json(assistant, { status: 201 }) + } catch (error) { + console.error("[Mobile Assistants API] POST error:", error) + return NextResponse.json( + { error: "Failed to create assistant" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/mobile/models/route.ts b/src/app/api/mobile/models/route.ts new file mode 100644 index 00000000..42c24f0b --- /dev/null +++ b/src/app/api/mobile/models/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server" + +import { AVAILABLE_MODELS } from "@/lib/models" +import { getRequestUserId } from "@/lib/mobile-auth" +import { prisma } from "@/lib/prisma" + +/** + * GET /api/mobile/models — slim list of active LLM models for the agent + * builder's model dropdown. Falls back to the static AVAILABLE_MODELS list when + * the DB hasn't been synced yet. + * + * Query: ?tools=true to only return models that support function calling. + */ +export async function GET(request: Request) { + try { + const userId = await getRequestUserId(request) + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const toolsOnly = new URL(request.url).searchParams.get("tools") === "true" + + const where: Record = { isActive: true } + if (toolsOnly) where.hasToolCalling = true + + const models = await prisma.llmModel.findMany({ + where, + orderBy: [{ provider: "asc" }, { name: "asc" }], + select: { + id: true, + name: true, + provider: true, + hasToolCalling: true, + isFree: true, + }, + }) + + if (models.length > 0) { + return NextResponse.json(models) + } + + // Fallback: static list before the first model sync. + const staticModels = AVAILABLE_MODELS.filter( + (m) => !toolsOnly || m.capabilities.functionCalling + ).map((m) => ({ + id: m.id, + name: m.name, + provider: m.provider, + hasToolCalling: m.capabilities.functionCalling, + isFree: m.pricing.input === 0 && m.pricing.output === 0, + })) + + return NextResponse.json(staticModels) + } catch (error) { + console.error("[Mobile Models API] GET error:", error) + return NextResponse.json({ error: "Failed to fetch models" }, { status: 500 }) + } +} diff --git a/src/lib/mobile-org.ts b/src/lib/mobile-org.ts new file mode 100644 index 00000000..305e125e --- /dev/null +++ b/src/lib/mobile-org.ts @@ -0,0 +1,34 @@ +/** + * Konteks request untuk endpoint mobile yang butuh scope organisasi. + * + * Menggabungkan autentikasi mobile (Bearer JWT via {@link getRequestUserId}) + * dengan resolusi organisasi aktif ({@link resolveActiveOrg}). Karena klien + * mobile tidak mengirim cookie/header org, resolver otomatis memilih membership + * pertama user — cukup untuk MVP satu-organisasi. + */ +import { getRequestUserId } from "@/lib/mobile-auth" +import { resolveActiveOrg } from "@/lib/org-context" + +export interface MobileContext { + userId: string + organizationId: string | null + role: string | null +} + +/** + * Ambil userId + konteks organisasi dari request mobile. Mengembalikan null + * bila token tidak valid (caller balas 401). + */ +export async function getMobileContext( + request: Request +): Promise { + const userId = await getRequestUserId(request) + if (!userId) return null + + const org = await resolveActiveOrg(request, userId) + return { + userId, + organizationId: org?.organizationId ?? null, + role: org?.role ?? null, + } +} From 98fab986e90d918a27b9e0af981d0b911fcc2b69 Mon Sep 17 00:00:00 2001 From: HV NQRust Date: Fri, 17 Jul 2026 05:53:52 +0000 Subject: [PATCH 06/25] feat(mobile-api): honor agent config in mobile chat generation Load the session's assistant and use its systemPrompt (persona), model, and modelConfig (temperature/topP/penalties). Clamp output tokens via the correct AI SDK v6 field maxOutputTokens (the old maxTokens was silently ignored). --- src/lib/mobile-chat.ts | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/src/lib/mobile-chat.ts b/src/lib/mobile-chat.ts index cdee3711..194f964e 100644 --- a/src/lib/mobile-chat.ts +++ b/src/lib/mobile-chat.ts @@ -1,6 +1,7 @@ import "server-only" import { generateText, stepCountIs, tool, zodSchema } from "ai" +import { findAssistantById } from "@/features/assistants/core/repository" import { addDashboardChatSessionMessages, deleteDashboardChatSessionMessages, @@ -60,6 +61,16 @@ type ServiceError = { status: number; error: string } */ const DEFAULT_MODEL = "openai/gpt-4o-mini" const MAX_OUTPUT_TOKENS = 1024 +/** Batas atas token keluaran untuk mobile — mencegah modelConfig.maxTokens yang + * besar memicu limit kredit provider (mis. permintaan 65k token). */ +const HARD_MAX_OUTPUT_TOKENS = 4096 +const GENERIC_SYSTEM = + "You are RantAI, a helpful assistant. Answer clearly and concisely." + +/** Ambil angka valid dari nilai modelConfig, atau undefined. */ +function num(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined +} /** * Bangun tools bawaan sesuai toggle dari toolbar mobile. Memakai ulang @@ -122,7 +133,16 @@ async function runGeneration(params: { options: params.options, }) - let system = "You are RantAI, a helpful assistant. Answer clearly and concisely." + // Muat konfigurasi agent dari sesi: persona (systemPrompt), model, dan + // parameter (temperature dll). Bila agent tak ditemukan, pakai default umum. + const assistant = await findAssistantById(params.assistantId).catch(() => null) + let system = assistant?.systemPrompt?.trim() ? assistant.systemPrompt : GENERIC_SYSTEM + const modelId = assistant?.model || DEFAULT_MODEL + const modelConfig = + assistant?.modelConfig && typeof assistant.modelConfig === "object" + ? (assistant.modelConfig as Record) + : null + if (params.options.enabledSkillIds?.length) { const skillPrompt = await resolveSkillsForAssistant( params.assistantId, @@ -137,12 +157,25 @@ async function runGeneration(params: { }) } + const temperature = num(modelConfig?.temperature) + const topP = num(modelConfig?.topP) + const presencePenalty = num(modelConfig?.presencePenalty) + const frequencyPenalty = num(modelConfig?.frequencyPenalty) + const maxTokens = Math.min( + num(modelConfig?.maxTokens) ?? MAX_OUTPUT_TOKENS, + HARD_MAX_OUTPUT_TOKENS + ) + try { const result = await generateText({ - model: getChatProvider()(resolveModelId(DEFAULT_MODEL)), + model: getChatProvider()(resolveModelId(modelId)), system, messages: params.history, - maxTokens: MAX_OUTPUT_TOKENS, + maxOutputTokens: maxTokens, + ...(temperature != null && { temperature }), + ...(topP != null && { topP }), + ...(presencePenalty != null && { presencePenalty }), + ...(frequencyPenalty != null && { frequencyPenalty }), // Canvas perlu langkah ekstra: buat artifact lalu simpulkan hasilnya. ...(tools ? { tools, stopWhen: stepCountIs(params.options.canvasMode ? 8 : 5) } : {}), }) From d1f5bb059c3c871adbe83a067bc180e502d94658 Mon Sep 17 00:00:00 2001 From: HV NQRust Date: Mon, 20 Jul 2026 09:38:55 +0000 Subject: [PATCH 07/25] feat(mobile-api): workflow read/run/monitor endpoints Bearer-auth mobile routes over the existing (auth-agnostic) workflow service: - GET /api/mobile/workflows (org-scoped list) - GET/PUT(status)/DELETE /api/mobile/workflows/[id] - POST /api/mobile/workflows/[id]/execute (STANDARD only; CHATFLOW guarded) - GET /api/mobile/workflows/[id]/runs and /runs/[runId] authorizeMobileWorkflow enforces org scoping (web dashboard routes don't). --- .../mobile/workflows/[id]/execute/route.ts | 73 +++++++++++ src/app/api/mobile/workflows/[id]/route.ts | 116 ++++++++++++++++++ .../workflows/[id]/runs/[runId]/route.ts | 46 +++++++ .../api/mobile/workflows/[id]/runs/route.ts | 37 ++++++ src/app/api/mobile/workflows/route.ts | 27 ++++ src/lib/mobile-workflow.ts | 29 +++++ 6 files changed, 328 insertions(+) create mode 100644 src/app/api/mobile/workflows/[id]/execute/route.ts create mode 100644 src/app/api/mobile/workflows/[id]/route.ts create mode 100644 src/app/api/mobile/workflows/[id]/runs/[runId]/route.ts create mode 100644 src/app/api/mobile/workflows/[id]/runs/route.ts create mode 100644 src/app/api/mobile/workflows/route.ts create mode 100644 src/lib/mobile-workflow.ts diff --git a/src/app/api/mobile/workflows/[id]/execute/route.ts b/src/app/api/mobile/workflows/[id]/execute/route.ts new file mode 100644 index 00000000..003faf15 --- /dev/null +++ b/src/app/api/mobile/workflows/[id]/execute/route.ts @@ -0,0 +1,73 @@ +import { NextResponse } from "next/server" + +import { + WorkflowExecuteSchema, + WorkflowIdParamsSchema, +} from "@/features/workflows/schema" +import { executeDashboardWorkflow } from "@/features/workflows/service" +import { isHttpServiceError } from "@/features/shared/http-service-error" +import { getMobileContext } from "@/lib/mobile-org" +import { authorizeMobileWorkflow } from "@/lib/mobile-workflow" + +interface RouteParams { + params: Promise<{ id: string }> +} + +/** + * POST /api/mobile/workflows/[id]/execute — run a STANDARD workflow. Returns the + * created run (status RUNNING) immediately; the client polls the run endpoint. + * CHATFLOW workflows stream and are not supported by the mobile MVP. + */ +export async function POST(request: Request, { params }: RouteParams) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const parsedParams = WorkflowIdParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json({ error: "Invalid workflow id" }, { status: 400 }) + } + + const parsedBody = WorkflowExecuteSchema.safeParse(await request.json().catch(() => ({}))) + if (!parsedBody.success) { + return NextResponse.json( + { error: "Invalid request payload", details: parsedBody.error.flatten() }, + { status: 400 } + ) + } + + const workflow = await authorizeMobileWorkflow(ctx, parsedParams.data.id) + if (isHttpServiceError(workflow)) { + return NextResponse.json({ error: workflow.error }, { status: workflow.status }) + } + + if ((workflow as { mode?: string }).mode === "CHATFLOW") { + return NextResponse.json( + { error: "Chatflow workflows are not supported on mobile yet." }, + { status: 400 } + ) + } + + const result = await executeDashboardWorkflow({ + workflowId: parsedParams.data.id, + userId: ctx.userId, + organizationId: ctx.organizationId, + input: parsedBody.data.input ?? {}, + threadId: parsedBody.data.threadId, + }) + if (isHttpServiceError(result)) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + + // STANDARD mode always returns a JSON run; the streaming branch is guarded above. + if (result.kind === "response") { + return result.response + } + return NextResponse.json(result.body, { status: result.status }) + } catch (error) { + console.error("[Mobile Workflows API] execute error:", error) + return NextResponse.json({ error: "Failed to execute workflow" }, { status: 500 }) + } +} diff --git a/src/app/api/mobile/workflows/[id]/route.ts b/src/app/api/mobile/workflows/[id]/route.ts new file mode 100644 index 00000000..879050d5 --- /dev/null +++ b/src/app/api/mobile/workflows/[id]/route.ts @@ -0,0 +1,116 @@ +import { NextResponse } from "next/server" +import { z } from "zod" + +import { WorkflowIdParamsSchema } from "@/features/workflows/schema" +import { + deleteDashboardWorkflow, + updateDashboardWorkflow, +} from "@/features/workflows/service" +import { isHttpServiceError } from "@/features/shared/http-service-error" +import { getMobileContext } from "@/lib/mobile-org" +import { authorizeMobileWorkflow } from "@/lib/mobile-workflow" + +interface RouteParams { + params: Promise<{ id: string }> +} + +/** Mobile can only flip the run status (deploy/pause/archive), not edit the graph. */ +const MobileWorkflowStatusSchema = z.object({ + status: z.enum(["DRAFT", "ACTIVE", "PAUSED", "ARCHIVED"]), +}) + +// GET /api/mobile/workflows/[id] +export async function GET(request: Request, { params }: RouteParams) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const parsedParams = WorkflowIdParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json({ error: "Invalid workflow id" }, { status: 400 }) + } + + const workflow = await authorizeMobileWorkflow(ctx, parsedParams.data.id) + if (isHttpServiceError(workflow)) { + return NextResponse.json({ error: workflow.error }, { status: workflow.status }) + } + + return NextResponse.json(workflow) + } catch (error) { + console.error("[Mobile Workflows API] GET [id] error:", error) + return NextResponse.json({ error: "Failed to fetch workflow" }, { status: 500 }) + } +} + +// PUT /api/mobile/workflows/[id] — status change only (deploy/pause/archive) +export async function PUT(request: Request, { params }: RouteParams) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const parsedParams = WorkflowIdParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json({ error: "Invalid workflow id" }, { status: 400 }) + } + + const parsedBody = MobileWorkflowStatusSchema.safeParse(await request.json()) + if (!parsedBody.success) { + return NextResponse.json( + { error: "Invalid request payload", details: parsedBody.error.flatten() }, + { status: 400 } + ) + } + + const auth = await authorizeMobileWorkflow(ctx, parsedParams.data.id) + if (isHttpServiceError(auth)) { + return NextResponse.json({ error: auth.error }, { status: auth.status }) + } + + const workflow = await updateDashboardWorkflow({ + id: parsedParams.data.id, + input: { status: parsedBody.data.status }, + }) + if (isHttpServiceError(workflow)) { + return NextResponse.json({ error: workflow.error }, { status: workflow.status }) + } + + return NextResponse.json(workflow) + } catch (error) { + console.error("[Mobile Workflows API] PUT [id] error:", error) + return NextResponse.json({ error: "Failed to update workflow" }, { status: 500 }) + } +} + +// DELETE /api/mobile/workflows/[id] +export async function DELETE(request: Request, { params }: RouteParams) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const parsedParams = WorkflowIdParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json({ error: "Invalid workflow id" }, { status: 400 }) + } + + const auth = await authorizeMobileWorkflow(ctx, parsedParams.data.id) + if (isHttpServiceError(auth)) { + return NextResponse.json({ error: auth.error }, { status: auth.status }) + } + + const result = await deleteDashboardWorkflow(parsedParams.data.id) + if (isHttpServiceError(result)) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + + return NextResponse.json(result) + } catch (error) { + console.error("[Mobile Workflows API] DELETE [id] error:", error) + return NextResponse.json({ error: "Failed to delete workflow" }, { status: 500 }) + } +} diff --git a/src/app/api/mobile/workflows/[id]/runs/[runId]/route.ts b/src/app/api/mobile/workflows/[id]/runs/[runId]/route.ts new file mode 100644 index 00000000..a23557fb --- /dev/null +++ b/src/app/api/mobile/workflows/[id]/runs/[runId]/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server" + +import { WorkflowRunIdParamsSchema } from "@/features/workflows/schema" +import { getWorkflowRun } from "@/features/workflows/service" +import { isHttpServiceError } from "@/features/shared/http-service-error" +import { getMobileContext } from "@/lib/mobile-org" +import { authorizeMobileWorkflow } from "@/lib/mobile-workflow" + +interface RouteParams { + params: Promise<{ id: string; runId: string }> +} + +// GET /api/mobile/workflows/[id]/runs/[runId] — one run with its full step trace +export async function GET(request: Request, { params }: RouteParams) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const parsedParams = WorkflowRunIdParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json({ error: "Invalid parameters" }, { status: 400 }) + } + + const auth = await authorizeMobileWorkflow(ctx, parsedParams.data.id) + if (isHttpServiceError(auth)) { + return NextResponse.json({ error: auth.error }, { status: auth.status }) + } + + const run = await getWorkflowRun(parsedParams.data.runId) + if (isHttpServiceError(run)) { + return NextResponse.json({ error: run.error }, { status: run.status }) + } + + // Ensure the run actually belongs to the authorized workflow. + if ((run as { workflowId?: string }).workflowId !== parsedParams.data.id) { + return NextResponse.json({ error: "Run not found" }, { status: 404 }) + } + + return NextResponse.json(run) + } catch (error) { + console.error("[Mobile Workflows API] run detail error:", error) + return NextResponse.json({ error: "Failed to fetch run" }, { status: 500 }) + } +} diff --git a/src/app/api/mobile/workflows/[id]/runs/route.ts b/src/app/api/mobile/workflows/[id]/runs/route.ts new file mode 100644 index 00000000..28688393 --- /dev/null +++ b/src/app/api/mobile/workflows/[id]/runs/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server" + +import { WorkflowIdParamsSchema } from "@/features/workflows/schema" +import { listWorkflowRuns } from "@/features/workflows/service" +import { isHttpServiceError } from "@/features/shared/http-service-error" +import { getMobileContext } from "@/lib/mobile-org" +import { authorizeMobileWorkflow } from "@/lib/mobile-workflow" + +interface RouteParams { + params: Promise<{ id: string }> +} + +// GET /api/mobile/workflows/[id]/runs — last 50 runs for a workflow +export async function GET(request: Request, { params }: RouteParams) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const parsedParams = WorkflowIdParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json({ error: "Invalid workflow id" }, { status: 400 }) + } + + const auth = await authorizeMobileWorkflow(ctx, parsedParams.data.id) + if (isHttpServiceError(auth)) { + return NextResponse.json({ error: auth.error }, { status: auth.status }) + } + + const runs = await listWorkflowRuns(parsedParams.data.id) + return NextResponse.json(runs) + } catch (error) { + console.error("[Mobile Workflows API] runs error:", error) + return NextResponse.json({ error: "Failed to fetch runs" }, { status: 500 }) + } +} diff --git a/src/app/api/mobile/workflows/route.ts b/src/app/api/mobile/workflows/route.ts new file mode 100644 index 00000000..55348696 --- /dev/null +++ b/src/app/api/mobile/workflows/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server" + +import { listDashboardWorkflows } from "@/features/workflows/service" +import { getMobileContext } from "@/lib/mobile-org" + +/** + * GET /api/mobile/workflows — list workflows visible to the user's org. + * Read & monitor only; creating/editing graphs is not supported on mobile. + */ +export async function GET(request: Request) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const workflows = await listDashboardWorkflows({ + organizationId: ctx.organizationId, + assistantId: null, + }) + + return NextResponse.json(workflows) + } catch (error) { + console.error("[Mobile Workflows API] GET error:", error) + return NextResponse.json({ error: "Failed to fetch workflows" }, { status: 500 }) + } +} diff --git a/src/lib/mobile-workflow.ts b/src/lib/mobile-workflow.ts new file mode 100644 index 00000000..27461ace --- /dev/null +++ b/src/lib/mobile-workflow.ts @@ -0,0 +1,29 @@ +/** + * Org-scoping guard for mobile workflow routes. + * + * The web dashboard workflow routes only check that the user is authenticated, + * not that the workflow belongs to their org. For the mobile API we enforce the + * same scoping rule as assistants: a workflow is accessible only if it is global + * (organizationId null) or belongs to the caller's active org. + */ +import { getDashboardWorkflow } from "@/features/workflows/service" +import { isHttpServiceError, type HttpServiceError } from "@/features/shared/http-service-error" +import type { MobileContext } from "@/lib/mobile-org" + +/** + * Load a workflow by id and authorize it for the mobile caller. Returns the + * workflow record, or an HttpServiceError (404 if missing or cross-org). + */ +export async function authorizeMobileWorkflow( + ctx: MobileContext, + id: string +): Promise | HttpServiceError> { + const workflow = await getDashboardWorkflow(id) + if (isHttpServiceError(workflow)) return workflow + + const orgId = (workflow as { organizationId: string | null }).organizationId + if (orgId && orgId !== ctx.organizationId) { + return { status: 404, error: "Workflow not found" } + } + return workflow as unknown as Record +} From 025f0afe45bcf4b15b1c9dbc57dcd911a441eb75 Mon Sep 17 00:00:00 2001 From: HV NQRust Date: Tue, 21 Jul 2026 04:10:50 +0000 Subject: [PATCH 08/25] feat(mobile-api): media studio endpoints (generate, gallery, byte-proxy, uploads) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bearer-auth mobile routes over the existing (auth-agnostic) media service: - POST/GET /api/mobile/media/jobs (generate image/audio, synchronous; VIDEO rejected) - GET /api/mobile/media/assets (org gallery) + /[id] GET/PATCH(favorite)/DELETE - GET /api/mobile/media/assets/[id]/file (proxy bytes so the phone never hits the internal S3 host — the key delivery fix) - GET /api/mobile/media/models?modality= - POST /api/mobile/media/uploads (multipart image → reference asset) Org context is required (media is org-scoped). --- .../mobile/media/assets/[id]/file/route.ts | 48 ++++++++++ src/app/api/mobile/media/assets/[id]/route.ts | 64 ++++++++++++++ src/app/api/mobile/media/assets/route.ts | 35 ++++++++ src/app/api/mobile/media/jobs/route.ts | 81 +++++++++++++++++ src/app/api/mobile/media/models/route.ts | 48 ++++++++++ src/app/api/mobile/media/uploads/route.ts | 87 +++++++++++++++++++ 6 files changed, 363 insertions(+) create mode 100644 src/app/api/mobile/media/assets/[id]/file/route.ts create mode 100644 src/app/api/mobile/media/assets/[id]/route.ts create mode 100644 src/app/api/mobile/media/assets/route.ts create mode 100644 src/app/api/mobile/media/jobs/route.ts create mode 100644 src/app/api/mobile/media/models/route.ts create mode 100644 src/app/api/mobile/media/uploads/route.ts diff --git a/src/app/api/mobile/media/assets/[id]/file/route.ts b/src/app/api/mobile/media/assets/[id]/file/route.ts new file mode 100644 index 00000000..167b0ba5 --- /dev/null +++ b/src/app/api/mobile/media/assets/[id]/file/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server" + +import { findAssetById } from "@/features/media/repository" +import { downloadMediaBytes } from "@/features/media/storage" +import { getMobileContext } from "@/lib/mobile-org" + +interface RouteParams { + params: Promise<{ id: string }> +} + +/** + * GET /api/mobile/media/assets/[id]/file — stream the asset bytes through the + * app server so the phone never needs to reach the (often internal) S3 host. + * Add ?download=1 to force an attachment disposition. + */ +export async function GET(req: Request, { params }: RouteParams) { + const ctx = await getMobileContext(req) + if (!ctx || !ctx.organizationId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { id } = await params + const asset = await findAssetById(id) + if (!asset || asset.organizationId !== ctx.organizationId) { + return NextResponse.json({ error: "Not found" }, { status: 404 }) + } + + try { + const { bytes, mimeType } = await downloadMediaBytes(asset.s3Key) + const contentType = asset.mimeType || mimeType || "application/octet-stream" + const wantsDownload = new URL(req.url).searchParams.get("download") === "1" + const extension = contentType.split("/")[1]?.split(";")[0] ?? "bin" + + const headers: Record = { + "Content-Type": contentType, + "Content-Length": String(asset.sizeBytes ?? bytes.byteLength), + "Cache-Control": "private, max-age=3600", + } + if (wantsDownload) { + headers["Content-Disposition"] = `attachment; filename="${asset.id}.${extension}"` + } + + return new NextResponse(Buffer.from(bytes), { headers }) + } catch (error) { + console.error("[Mobile Media API] file proxy failed:", error) + return NextResponse.json({ error: "Failed to load asset" }, { status: 500 }) + } +} diff --git a/src/app/api/mobile/media/assets/[id]/route.ts b/src/app/api/mobile/media/assets/[id]/route.ts new file mode 100644 index 00000000..094cd623 --- /dev/null +++ b/src/app/api/mobile/media/assets/[id]/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from "next/server" + +import { UpdateAssetInputSchema } from "@/features/media/schema" +import { + deleteAssetById, + findAssetById, + toggleAssetFavorite, +} from "@/features/media/repository" +import { getMobileContext } from "@/lib/mobile-org" + +interface RouteParams { + params: Promise<{ id: string }> +} + +async function loadOwnedAsset(assetId: string, organizationId: string) { + const asset = await findAssetById(assetId) + if (!asset || asset.organizationId !== organizationId) return null + return asset +} + +// GET /api/mobile/media/assets/[id] +export async function GET(req: Request, { params }: RouteParams) { + const ctx = await getMobileContext(req) + if (!ctx || !ctx.organizationId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const { id } = await params + const asset = await loadOwnedAsset(id, ctx.organizationId) + if (!asset) return NextResponse.json({ error: "Not found" }, { status: 404 }) + return NextResponse.json(asset) +} + +// PATCH /api/mobile/media/assets/[id] — favorite toggle +export async function PATCH(req: Request, { params }: RouteParams) { + const ctx = await getMobileContext(req) + if (!ctx || !ctx.organizationId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const { id } = await params + const asset = await loadOwnedAsset(id, ctx.organizationId) + if (!asset) return NextResponse.json({ error: "Not found" }, { status: 404 }) + + const parsed = UpdateAssetInputSchema.safeParse(await req.json().catch(() => ({}))) + if (!parsed.success) return NextResponse.json({ error: "Invalid body" }, { status: 400 }) + + if (parsed.data.isFavorite !== undefined) { + const updated = await toggleAssetFavorite(id, parsed.data.isFavorite) + return NextResponse.json(updated) + } + return NextResponse.json(asset) +} + +// DELETE /api/mobile/media/assets/[id] +export async function DELETE(req: Request, { params }: RouteParams) { + const ctx = await getMobileContext(req) + if (!ctx || !ctx.organizationId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const { id } = await params + const asset = await loadOwnedAsset(id, ctx.organizationId) + if (!asset) return NextResponse.json({ error: "Not found" }, { status: 404 }) + await deleteAssetById(id) + return NextResponse.json({ ok: true }) +} diff --git a/src/app/api/mobile/media/assets/route.ts b/src/app/api/mobile/media/assets/route.ts new file mode 100644 index 00000000..1495d46b --- /dev/null +++ b/src/app/api/mobile/media/assets/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server" + +import { ListAssetsQuerySchema } from "@/features/media/schema" +import { listAssetsForOrg } from "@/features/media/repository" +import { getMobileContext } from "@/lib/mobile-org" + +/** GET /api/mobile/media/assets — the org's media library (all members' outputs). */ +export async function GET(req: Request) { + const ctx = await getMobileContext(req) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + if (!ctx.organizationId) { + return NextResponse.json({ error: "No organization context" }, { status: 400 }) + } + + const url = new URL(req.url) + const parsed = ListAssetsQuerySchema.safeParse({ + modality: url.searchParams.get("modality") ?? undefined, + favorite: url.searchParams.get("favorite") ?? undefined, + q: url.searchParams.get("q") ?? undefined, + cursor: url.searchParams.get("cursor") ?? undefined, + limit: url.searchParams.get("limit") ?? undefined, + sort: url.searchParams.get("sort") ?? undefined, + }) + if (!parsed.success) { + return NextResponse.json({ error: "Invalid query" }, { status: 400 }) + } + + const result = await listAssetsForOrg({ + organizationId: ctx.organizationId, + ...parsed.data, + }) + return NextResponse.json(result) +} diff --git a/src/app/api/mobile/media/jobs/route.ts b/src/app/api/mobile/media/jobs/route.ts new file mode 100644 index 00000000..24c66fae --- /dev/null +++ b/src/app/api/mobile/media/jobs/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from "next/server" + +import { CreateMediaJobInputSchema, ListJobsQuerySchema } from "@/features/media/schema" +import { createMediaJob } from "@/features/media/service" +import { listJobsForUser } from "@/features/media/repository" +import { getMobileContext } from "@/lib/mobile-org" + +// Image/audio generation is synchronous; give the route headroom beyond 60s. +export const maxDuration = 300 + +/** + * POST /api/mobile/media/jobs — generate media (image/audio). Synchronous: the + * response contains the finished job with its assets. Video is not supported on + * mobile yet (the web path blocks for minutes). + */ +export async function POST(req: Request) { + const ctx = await getMobileContext(req) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + if (!ctx.organizationId) { + return NextResponse.json({ error: "No organization context" }, { status: 400 }) + } + + let body: unknown + try { + body = await req.json() + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }) + } + + const parsed = CreateMediaJobInputSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: "Validation failed", details: parsed.error.flatten() }, + { status: 400 } + ) + } + + if (parsed.data.modality === "VIDEO") { + return NextResponse.json( + { error: "Video generation is not supported on mobile yet." }, + { status: 400 } + ) + } + + try { + const result = await createMediaJob({ + userId: ctx.userId, + organizationId: ctx.organizationId, + ...parsed.data, + }) + return NextResponse.json(result) + } catch (error) { + console.error("[Mobile Media API] POST /jobs failed:", error) + const message = error instanceof Error ? error.message : "Unknown error" + return NextResponse.json({ error: message }, { status: 500 }) + } +} + +/** GET /api/mobile/media/jobs — the current user's generation history. */ +export async function GET(req: Request) { + const ctx = await getMobileContext(req) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const url = new URL(req.url) + const parsed = ListJobsQuerySchema.safeParse({ + modality: url.searchParams.get("modality") ?? undefined, + status: url.searchParams.get("status") ?? undefined, + cursor: url.searchParams.get("cursor") ?? undefined, + limit: url.searchParams.get("limit") ?? undefined, + }) + if (!parsed.success) { + return NextResponse.json({ error: "Invalid query" }, { status: 400 }) + } + + const result = await listJobsForUser({ userId: ctx.userId, ...parsed.data }) + return NextResponse.json(result) +} diff --git a/src/app/api/mobile/media/models/route.ts b/src/app/api/mobile/media/models/route.ts new file mode 100644 index 00000000..7d8282d2 --- /dev/null +++ b/src/app/api/mobile/media/models/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server" + +import { MediaModalitySchema } from "@/features/media/schema" +import { getRequestUserId } from "@/lib/mobile-auth" +import { prisma } from "@/lib/prisma" + +const MODALITY_TO_OUTPUT: Record = { + IMAGE: "image", + AUDIO: "audio", + VIDEO: "video", +} + +/** + * GET /api/mobile/media/models?modality=IMAGE|AUDIO|VIDEO — active models that + * can output the requested modality (slim shape for the mobile picker). + */ +export async function GET(req: Request) { + const userId = await getRequestUserId(req) + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const modalityParam = new URL(req.url).searchParams.get("modality") + const parsed = modalityParam ? MediaModalitySchema.safeParse(modalityParam) : null + if (modalityParam && !parsed?.success) { + return NextResponse.json({ error: "Invalid modality" }, { status: 400 }) + } + + const where: Record = { isActive: true } + if (parsed?.success) { + where.outputModalities = { has: MODALITY_TO_OUTPUT[parsed.data] } + } + + const models = await prisma.llmModel.findMany({ + where, + orderBy: [{ provider: "asc" }, { name: "asc" }], + select: { + id: true, + name: true, + provider: true, + isFree: true, + outputModalities: true, + inputModalities: true, + }, + }) + + return NextResponse.json(models) +} diff --git a/src/app/api/mobile/media/uploads/route.ts b/src/app/api/mobile/media/uploads/route.ts new file mode 100644 index 00000000..15ffbe9b --- /dev/null +++ b/src/app/api/mobile/media/uploads/route.ts @@ -0,0 +1,87 @@ +import { NextResponse } from "next/server" + +import { uploadMediaBytes } from "@/features/media/storage" +import { getMobileContext } from "@/lib/mobile-org" +import { prisma } from "@/lib/prisma" + +export const maxDuration = 60 + +const MAX_SIZE = 15 * 1024 * 1024 // 15 MB +const ALLOWED_MIMES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]) + +/** + * POST /api/mobile/media/uploads — multipart `file` (image). Stores it and + * creates a synthetic upload job + asset so it can be passed as a reference + * image in a later generation. Returns `{ assetId }`. + */ +export async function POST(req: Request) { + const ctx = await getMobileContext(req) + if (!ctx || !ctx.organizationId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + let form: FormData + try { + form = await req.formData() + } catch { + return NextResponse.json({ error: "Invalid multipart form" }, { status: 400 }) + } + + const file = form.get("file") + if (!(file instanceof File)) { + return NextResponse.json({ error: "Missing `file` field" }, { status: 400 }) + } + const mime = file.type.split(";")[0].trim().toLowerCase() + if (!ALLOWED_MIMES.has(mime)) { + return NextResponse.json({ error: `Unsupported file type: ${file.type}` }, { status: 400 }) + } + if (file.size > MAX_SIZE) { + return NextResponse.json( + { error: `File too large (max ${MAX_SIZE / 1024 / 1024}MB)` }, + { status: 400 } + ) + } + + const bytes = new Uint8Array(await file.arrayBuffer()) + const extension = mime.split("/")[1] ?? "png" + + const job = await prisma.mediaJob.create({ + data: { + organizationId: ctx.organizationId, + userId: ctx.userId, + modality: "IMAGE", + modelId: "user/upload", + prompt: `(uploaded) ${file.name}`, + parameters: {}, + referenceAssetIds: [], + status: "SUCCEEDED", + estimatedCostCents: 0, + costCents: 0, + startedAt: new Date(), + completedAt: new Date(), + }, + }) + + const upload = await uploadMediaBytes({ + organizationId: ctx.organizationId, + modality: "IMAGE", + assetId: job.id, + mimeType: mime, + extension, + bytes, + }) + + const asset = await prisma.mediaAsset.create({ + data: { + jobId: job.id, + organizationId: ctx.organizationId, + modality: "IMAGE", + mimeType: mime, + s3Key: upload.s3Key, + sizeBytes: upload.sizeBytes, + metadata: { uploadedFilename: file.name }, + }, + }) + + return NextResponse.json({ assetId: asset.id, mimeType: asset.mimeType, size: asset.sizeBytes }) +} From 899664d7088859711d31dcb6abe641d825cd71aa Mon Sep 17 00:00:00 2001 From: HV NQRust Date: Tue, 21 Jul 2026 04:28:24 +0000 Subject: [PATCH 09/25] feat(mobile-api): mobile-only audio generation path (pcm16 stream -> WAV) OpenRouter requires stream:true for audio, and streaming only allows raw pcm16. Rather than change the shared web generateAudio, mobile audio uses its own path that streams pcm16 and wraps it into a playable WAV. The mobile jobs route branches AUDIO -> createMobileAudioJob, IMAGE -> shared createMediaJob. No web file is touched. Reuses the shared repository/storage (data layer) only. --- src/app/api/mobile/media/jobs/route.ts | 23 ++- src/lib/mobile-media-audio.ts | 210 +++++++++++++++++++++++++ 2 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 src/lib/mobile-media-audio.ts diff --git a/src/app/api/mobile/media/jobs/route.ts b/src/app/api/mobile/media/jobs/route.ts index 24c66fae..0a7ad17d 100644 --- a/src/app/api/mobile/media/jobs/route.ts +++ b/src/app/api/mobile/media/jobs/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server" import { CreateMediaJobInputSchema, ListJobsQuerySchema } from "@/features/media/schema" import { createMediaJob } from "@/features/media/service" import { listJobsForUser } from "@/features/media/repository" +import { createMobileAudioJob } from "@/lib/mobile-media-audio" import { getMobileContext } from "@/lib/mobile-org" // Image/audio generation is synchronous; give the route headroom beyond 60s. @@ -45,11 +46,23 @@ export async function POST(req: Request) { } try { - const result = await createMediaJob({ - userId: ctx.userId, - organizationId: ctx.organizationId, - ...parsed.data, - }) + // Audio uses a mobile-only non-streaming path (playable WAV) so the shared + // web generateAudio is left untouched. Image stays on the shared service. + const result = + parsed.data.modality === "AUDIO" + ? await createMobileAudioJob({ + userId: ctx.userId, + organizationId: ctx.organizationId, + modelId: parsed.data.modelId, + prompt: parsed.data.prompt, + parameters: parsed.data.parameters, + referenceAssetIds: parsed.data.referenceAssetIds, + }) + : await createMediaJob({ + userId: ctx.userId, + organizationId: ctx.organizationId, + ...parsed.data, + }) return NextResponse.json(result) } catch (error) { console.error("[Mobile Media API] POST /jobs failed:", error) diff --git a/src/lib/mobile-media-audio.ts b/src/lib/mobile-media-audio.ts new file mode 100644 index 00000000..8fdf5c84 --- /dev/null +++ b/src/lib/mobile-media-audio.ts @@ -0,0 +1,210 @@ +/** + * Mobile-only audio generation path. + * + * OpenRouter requires `stream: true` for audio output, and streaming only + * supports the raw `pcm16` format (not directly playable). The shared web + * `generateAudio` (src/features/media/provider/openrouter.ts) stores that raw + * pcm16 as-is. To avoid changing that shared web behavior, mobile audio has its + * own path that requests `pcm16`, accumulates the streamed chunks, and wraps + * them in a **WAV container** (24kHz / mono / 16-bit) so the result is a + * standard, playable `.wav`. It reuses the shared data/storage layer only. + */ +import { + createMediaJobRow, + failMediaJob, + finalizeMediaJobAsSucceeded, + setJobRunning, +} from "@/features/media/repository" +import { uploadMediaBytes } from "@/features/media/storage" +import { prisma } from "@/lib/prisma" + +const OPENROUTER_BASE = "https://openrouter.ai/api/v1" + +function getOpenRouterApiKey(): string { + const key = process.env.OPENROUTER_API_KEY + if (!key) throw new Error("OPENROUTER_API_KEY is not set") + return key +} + +/** Collect every base64 chunk under an `audio`/`input_audio` key in an event. */ +function collectAudioChunks(value: unknown, out: (b64: string) => void): void { + if (!value || typeof value !== "object") return + for (const [key, v] of Object.entries(value as Record)) { + if ((key === "audio" || key === "input_audio") && v && typeof v === "object") { + const d = (v as { data?: unknown }).data + if (typeof d === "string" && d.length > 0) out(d) + } + if (v && typeof v === "object") collectAudioChunks(v, out) + } +} + +/** Wrap raw little-endian PCM in a minimal WAV container. */ +function pcmToWav(pcm: Buffer, sampleRate = 24000, channels = 1, bits = 16): Buffer { + const byteRate = (sampleRate * channels * bits) / 8 + const blockAlign = (channels * bits) / 8 + const header = Buffer.alloc(44) + header.write("RIFF", 0) + header.writeUInt32LE(36 + pcm.length, 4) + header.write("WAVE", 8) + header.write("fmt ", 12) + header.writeUInt32LE(16, 16) + header.writeUInt16LE(1, 20) // PCM + header.writeUInt16LE(channels, 22) + header.writeUInt32LE(sampleRate, 24) + header.writeUInt32LE(byteRate, 28) + header.writeUInt16LE(blockAlign, 32) + header.writeUInt16LE(bits, 34) + header.write("data", 36) + header.writeUInt32LE(pcm.length, 40) + return Buffer.concat([header, pcm]) +} + +interface MobileAudioResult { + bytes: Uint8Array + mimeType: string + costCents: number +} + +/** Streaming pcm16 TTS request, assembled into a playable WAV file. */ +async function generateMobileAudio(input: { + modelId: string + prompt: string + voice: string +}): Promise { + const res = await fetch(`${OPENROUTER_BASE}/chat/completions`, { + method: "POST", + headers: { + Authorization: `Bearer ${getOpenRouterApiKey()}`, + "Content-Type": "application/json", + Accept: "text/event-stream", + "HTTP-Referer": "https://rantai.dev", + "X-Title": "RantAI Agents Mobile Media", + }, + body: JSON.stringify({ + model: input.modelId, + messages: [{ role: "user", content: input.prompt }], + modalities: ["text", "audio"], + audio: { voice: input.voice, format: "pcm16" }, + stream: true, + }), + }) + + if (!res.ok || !res.body) { + const errText = await res.text().catch(() => "") + throw new Error(`OpenRouter audio generation failed: ${errText}`) + } + + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = "" + const chunks: Buffer[] = [] + let totalCost: number | undefined + + // Parse the SSE stream; decode each pcm16 chunk to bytes and concatenate. + for (;;) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + let idx: number + while ((idx = buffer.indexOf("\n\n")) !== -1) { + const rawEvent = buffer.slice(0, idx) + buffer = buffer.slice(idx + 2) + for (const line of rawEvent.split("\n")) { + const trimmed = line.trim() + if (!trimmed.startsWith("data:")) continue + const payload = trimmed.slice(5).trim() + if (!payload || payload === "[DONE]") continue + let evt: Record + try { + evt = JSON.parse(payload) + } catch { + continue + } + const err = evt.error as { message?: string } | undefined + if (err) throw new Error(`Audio provider error: ${err.message ?? "unknown"}`) + collectAudioChunks(evt, (b64) => chunks.push(Buffer.from(b64, "base64"))) + const usage = evt.usage as { cost?: number; total_cost?: number } | undefined + const cost = usage?.cost ?? usage?.total_cost + if (typeof cost === "number") totalCost = cost + } + } + } + + if (chunks.length === 0) { + throw new Error("No audio returned by the provider") + } + + const wav = pcmToWav(Buffer.concat(chunks)) + const costCents = typeof totalCost === "number" ? Math.max(1, Math.round(totalCost * 100)) : 0 + return { bytes: new Uint8Array(wav), mimeType: "audio/wav", costCents } +} + +/** + * Full mobile audio job lifecycle (create → run → generate → store → finalize), + * mirroring the shared image path but with the mobile-only generator above. + */ +export async function createMobileAudioJob(input: { + userId: string + organizationId: string + modelId: string + prompt: string + parameters: Record + referenceAssetIds: string[] +}) { + const model = await prisma.llmModel.findUnique({ where: { id: input.modelId } }) + if (!model) throw new Error(`Model not found: ${input.modelId}`) + if (!model.isActive) throw new Error(`Model is inactive: ${input.modelId}`) + + const job = await createMediaJobRow({ + organizationId: input.organizationId, + userId: input.userId, + modality: "AUDIO", + modelId: input.modelId, + prompt: input.prompt, + parameters: input.parameters, + referenceAssetIds: input.referenceAssetIds, + estimatedCostCents: 0, + }) + + try { + await setJobRunning(job.id) + + const voice = + typeof input.parameters.voice === "string" ? input.parameters.voice : "alloy" + const { bytes, mimeType, costCents } = await generateMobileAudio({ + modelId: input.modelId, + prompt: input.prompt, + voice, + }) + + const assetId = `${job.id}_0` + const extension = mimeType.split("/")[1] ?? "wav" + const upload = await uploadMediaBytes({ + organizationId: input.organizationId, + modality: "AUDIO", + assetId, + mimeType, + extension, + bytes, + }) + + return await finalizeMediaJobAsSucceeded({ + jobId: job.id, + costCents, + assets: [ + { + modality: "AUDIO", + mimeType, + s3Key: upload.s3Key, + sizeBytes: upload.sizeBytes, + metadata: { modelId: input.modelId }, + }, + ], + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`[Mobile Media] audio job ${job.id} failed:`, error) + await failMediaJob(job.id, message) + throw error + } +} From 2849517b167451fa6f3753352b76c011a00f1548 Mon Sep 17 00:00:00 2001 From: HV NQRust Date: Tue, 21 Jul 2026 05:50:10 +0000 Subject: [PATCH 10/25] fix(mobile-api): accept ?token= on media file proxy for RN Image/Video React Native's /