import { makeFunctionReference } from "convex/server"; import { ConvexError, v } from "convex/values"; import type { Doc, Id } from "./_generated/dataModel"; import { env, internalAction, internalMutation, internalQuery, mutation, query, } from "./_generated/server"; import { requireOrganizationMember } from "./authz"; const MAX_ATTEMPTS = 3; const runTurnRef = makeFunctionReference< "action", { turnId: Id<"conversationTurns">; attempt: number } >("conversationMessages:runTurn"); const getTurnRef = makeFunctionReference< "query", { turnId: Id<"conversationTurns"> }, { turn: Doc<"conversationTurns">; organizationId: Id<"organizations">; user: Doc<"conversationMessages">; assistant: Doc<"conversationMessages">; attachments: Doc<"conversationAttachments">[]; } | null >("conversationMessages:getTurn"); const markProcessingRef = makeFunctionReference< "mutation", { turnId: Id<"conversationTurns"> }, boolean >("conversationMessages:markProcessing"); const completeTurnRef = makeFunctionReference< "mutation", { turnId: Id<"conversationTurns">; submissionId: string; text: string }, null >("conversationMessages:completeTurn"); const failTurnRef = makeFunctionReference< "mutation", { turnId: Id<"conversationTurns">; error: string; retry: boolean }, null >("conversationMessages:failTurn"); export const generateUploadUrl = mutation({ args: {}, handler: async (ctx): Promise => { if (!(await ctx.auth.getUserIdentity())) { throw new ConvexError("Authentication required"); } return await ctx.storage.generateUploadUrl(); }, }); export const send = mutation({ args: { organizationId: v.id("organizations"), clientRequestId: v.string(), rawText: v.string(), images: v.array( v.object({ storageId: v.id("_storage"), filename: v.optional(v.string()), mimeType: v.string(), }) ), }, handler: async (ctx, args) => { await requireOrganizationMember(ctx, args.organizationId); let conversation = await ctx.db .query("conversations") .withIndex("by_organizationId", (q) => q.eq("organizationId", args.organizationId) ) .unique(); if (!conversation) { const conversationId = await ctx.db.insert("conversations", { createdAt: Date.now(), organizationId: args.organizationId, }); conversation = await ctx.db.get(conversationId); } if (!conversation) { throw new Error("Conversation could not be created"); } const existing = await ctx.db .query("conversationTurns") .withIndex("by_conversationId_and_clientRequestId", (q) => q .eq("conversationId", conversation._id) .eq("clientRequestId", args.clientRequestId) ) .unique(); if (existing) { const user = await ctx.db .query("conversationMessages") .withIndex("by_turnId_and_role", (q) => q.eq("turnId", existing._id).eq("role", "user") ) .unique(); return { messageId: user?._id ?? null, turnId: existing._id }; } const createdAt = Date.now(); const lastMessage = await ctx.db .query("conversationMessages") .withIndex("by_conversationId_and_ordinal", (q) => q.eq("conversationId", conversation._id) ) .order("desc") .first(); const ordinal = (lastMessage?.ordinal ?? -1) + 1; const turnId = await ctx.db.insert("conversationTurns", { clientRequestId: args.clientRequestId, conversationId: conversation._id, createdAt, status: "queued", }); const messageId = await ctx.db.insert("conversationMessages", { content: args.rawText, conversationId: conversation._id, createdAt, ordinal, role: "user", turnId, }); for (const image of args.images) { await ctx.db.insert("conversationAttachments", { ...image, createdAt, messageId, }); } await ctx.db.insert("conversationMessages", { content: "", conversationId: conversation._id, createdAt: createdAt + 1, ordinal: ordinal + 1, role: "assistant", turnId, }); await ctx.scheduler.runAfter(0, runTurnRef, { attempt: 1, turnId }); return { messageId, turnId }; }, }); export const listForCurrentOrganization = query({ args: { organizationId: v.id("organizations") }, handler: async (ctx, args) => { await requireOrganizationMember(ctx, args.organizationId); const conversation = await ctx.db .query("conversations") .withIndex("by_organizationId", (q) => q.eq("organizationId", args.organizationId) ) .unique(); if (!conversation) { return []; } const messages = await ctx.db .query("conversationMessages") .withIndex("by_conversationId_and_ordinal", (q) => q.eq("conversationId", conversation._id) ) .order("asc") .take(200); return await Promise.all( messages.map(async (message) => { const turn = await ctx.db.get(message.turnId); const attachments = await ctx.db .query("conversationAttachments") .withIndex("by_messageId", (q) => q.eq("messageId", message._id)) .collect(); return { attachments: await Promise.all( attachments.map(async (attachment) => ({ filename: attachment.filename ?? null, id: String(attachment._id), mediaType: attachment.mimeType, url: await ctx.storage.getUrl(attachment.storageId), })) ), error: turn?.error ?? null, messageId: String(message._id), rawText: message.content, role: message.role, status: turn?.status ?? "failed", }; }) ); }, }); export const getTurn = internalQuery({ args: { turnId: v.id("conversationTurns") }, handler: async (ctx, args) => { const turn = await ctx.db.get(args.turnId); if (!turn) { return null; } const conversation = await ctx.db.get(turn.conversationId); const user = await ctx.db .query("conversationMessages") .withIndex("by_turnId_and_role", (q) => q.eq("turnId", turn._id).eq("role", "user") ) .unique(); const assistant = await ctx.db .query("conversationMessages") .withIndex("by_turnId_and_role", (q) => q.eq("turnId", turn._id).eq("role", "assistant") ) .unique(); if (!conversation || !user || !assistant) { return null; } const attachments = await ctx.db .query("conversationAttachments") .withIndex("by_messageId", (q) => q.eq("messageId", user._id)) .collect(); return { assistant, attachments, organizationId: conversation.organizationId, turn, user, }; }, }); export const markProcessing = internalMutation({ args: { turnId: v.id("conversationTurns") }, handler: async (ctx, args): Promise => { const turn = await ctx.db.get(args.turnId); if (!turn || turn.status === "completed") { return false; } await ctx.db.patch(turn._id, { error: undefined, status: "processing" }); return true; }, }); export const completeTurn = internalMutation({ args: { turnId: v.id("conversationTurns"), submissionId: v.string(), text: v.string(), }, handler: async (ctx, args): Promise => { const assistant = await ctx.db .query("conversationMessages") .withIndex("by_turnId_and_role", (q) => q.eq("turnId", args.turnId).eq("role", "assistant") ) .unique(); if (assistant) { await ctx.db.patch(assistant._id, { content: args.text }); } await ctx.db.patch(args.turnId, { completedAt: Date.now(), error: undefined, status: "completed", submissionId: args.submissionId, }); return null; }, }); export const failTurn = internalMutation({ args: { turnId: v.id("conversationTurns"), error: v.string(), retry: v.boolean(), }, handler: async (ctx, args): Promise => { const turn = await ctx.db.get(args.turnId); if (turn && turn.status !== "completed") { await ctx.db.patch(turn._id, { completedAt: args.retry ? undefined : Date.now(), error: args.error, status: args.retry ? "queued" : "failed", }); } return null; }, }); const toBase64 = (buffer: ArrayBuffer): string => { let binary = ""; for (const byte of new Uint8Array(buffer)) { binary += String.fromCodePoint(byte); } return btoa(binary); }; export const runTurn = internalAction({ args: { turnId: v.id("conversationTurns"), attempt: v.number() }, handler: async (ctx, args): Promise => { const turn = await ctx.runQuery(getTurnRef, { turnId: args.turnId }); if ( !turn || !(await ctx.runMutation(markProcessingRef, { turnId: args.turnId })) ) { return null; } try { const flueUrl = (env as typeof env & { readonly FLUE_URL?: string }) .FLUE_URL; if (!flueUrl) { throw new Error("FLUE_URL is not configured in Convex"); } const images = await Promise.all( turn.attachments.map(async (attachment) => { const url = await ctx.storage.getUrl(attachment.storageId); const response = url ? await fetch(url) : null; if (!response?.ok) { throw new Error("Conversation image could not be loaded"); } return { data: toBase64(await response.arrayBuffer()), mimeType: attachment.mimeType, type: "image" as const, }; }) ); const endpoint = new URL( `agents/zopu/${encodeURIComponent(String(turn.organizationId))}`, `${flueUrl.replace(/\/+$/u, "")}/` ); endpoint.searchParams.set("wait", "result"); const response = await fetch(endpoint, { body: JSON.stringify({ images, message: turn.user.content }), headers: { authorization: `Bearer ${env.FLUE_DB_TOKEN}`, "content-type": "application/json", "x-zopu-organization-id": String(turn.organizationId), "x-zopu-request-id": turn.turn.clientRequestId, }, method: "POST", }); const payload: unknown = await response.json().catch(() => null); if ( !response.ok || typeof payload !== "object" || payload === null || !("submissionId" in payload) || typeof payload.submissionId !== "string" || !("result" in payload) || typeof payload.result !== "object" || payload.result === null || !("text" in payload.result) || typeof payload.result.text !== "string" ) { throw new Error(`Flue turn failed (${response.status})`); } await ctx.runMutation(completeTurnRef, { submissionId: payload.submissionId, text: payload.result.text, turnId: args.turnId, }); } catch (error) { const retry = args.attempt < MAX_ATTEMPTS; await ctx.runMutation(failTurnRef, { error: error instanceof Error ? error.message : String(error), retry, turnId: args.turnId, }); if (retry) { await ctx.scheduler.runAfter(args.attempt * 1000, runTurnRef, { attempt: args.attempt + 1, turnId: args.turnId, }); } } return null; }, });