"use server"; import { prisma } from "@/lib/db"; import { requireUserId } from "@/lib/auth-helpers"; import { categoryAccessFilter } from "@/lib/access"; import { getAiCredentials, getAiSettingsView, joinApiUrl } from "@/lib/ai-settings"; export interface GroupAiMessage { role: "user" | "assistant"; content: string; } export type GroupAiResult = | { action: "ask"; question: string } | { action: "finalize"; notes: string } | { error: string }; // Keeps the model's job narrow and its output parseable: always exactly // one of "ask" (need more from the user) or "finalize" (here's the // notes), never free-form prose mixed in around it. `{{GROUP_TITLE}}` is // substituted per call. const SYSTEM_PROMPT = `You are helping a user write the notes/description for a group called "{{GROUP_TITLE}}" in a to-do list app. A "group" is a card that holds a list of related to-dos; its notes are a short markdown description of what the group is about and any context worth remembering. Have a brief conversation to understand what this group is about. Ask at most one short, specific follow-up question at a time if you genuinely need more detail to write something useful. Once you have enough -- or the user asks you to wrap up -- respond with the final notes. You must respond with ONLY a single JSON object and nothing else -- no preamble, no code block, no text before or after it. It must match exactly one of these two shapes: Still gathering information: {"action": "ask", "question": ""} Ready to finalize: {"action": "finalize", "notes_markdown": ""} Respond with the raw JSON object only.`; function extractMessageContent(body: unknown): string | null { if (!body || typeof body !== "object") return null; const choices = (body as { choices?: unknown }).choices; if (!Array.isArray(choices) || !choices[0]) return null; const message = (choices[0] as { message?: unknown }).message; if (!message || typeof message !== "object") return null; const content = (message as { content?: unknown }).content; return typeof content === "string" ? content : null; } /** Model output sometimes arrives fenced in a ```json code block despite * instructions not to -- strip that before parsing rather than failing. */ function stripCodeFence(text: string): string { const trimmed = text.trim(); const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i); return fenced ? fenced[1] : trimmed; } function parseModelOutput(content: string): GroupAiResult | null { let json: unknown; try { json = JSON.parse(stripCodeFence(content)); } catch { return null; } if (!json || typeof json !== "object") return null; const action = (json as { action?: unknown }).action; if (action === "ask") { const question = (json as { question?: unknown }).question; return typeof question === "string" && question.trim() ? { action: "ask", question: question.trim() } : null; } if (action === "finalize") { const notes = (json as { notes_markdown?: unknown }).notes_markdown; return typeof notes === "string" && notes.trim() ? { action: "finalize", notes: notes.trim() } : null; } return null; } /** * Sends the popover's conversation so far to the configured AI provider * and returns either a follow-up question or the final notes. Reuses the * same admin-configured endpoint/key as the "AI generation" admin * settings (see lib/ai-settings.ts) -- there's nothing group- or * user-specific to configure beyond that. */ export async function converseAboutGroup( groupId: string, groupTitle: string, messages: GroupAiMessage[], forceFinalize: boolean ): Promise { const userId = await requireUserId(); const group = await prisma.group.findFirst({ where: { id: groupId, category: categoryAccessFilter(userId) }, select: { id: true }, }); if (!group) return { error: "Group not found." }; const [{ apiUrl, apiKey }, settings] = await Promise.all([ getAiCredentials(), getAiSettingsView(), ]); if (!apiUrl || !settings.model) { return { error: "AI generation isn't configured yet. Ask an administrator to set it up." }; } let chatUrl: URL; try { chatUrl = joinApiUrl(apiUrl, "chat/completions"); } catch { return { error: "The configured AI API URL is invalid." }; } let systemPrompt = SYSTEM_PROMPT.replace("{{GROUP_TITLE}}", groupTitle); if (forceFinalize) { systemPrompt += '\n\nThe user has asked you to wrap up now with whatever information you have, even if it feels incomplete. Do not ask another question -- respond with {"action": "finalize", ...} using your best effort.'; } let response: Response; try { response = await fetch(chatUrl, { method: "POST", headers: { "Content-Type": "application/json", ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), }, body: JSON.stringify({ model: settings.model, messages: [{ role: "system", content: systemPrompt }, ...messages], temperature: 0.4, }), signal: AbortSignal.timeout(30_000), }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; return { error: `Couldn't reach the AI provider -- ${message}` }; } if (!response.ok) { if (response.status === 401 || response.status === 403) { return { error: "The AI provider rejected the configured API key." }; } return { error: `The AI provider responded with ${response.status} ${response.statusText}.` }; } let body: unknown; try { body = await response.json(); } catch { return { error: "The AI provider didn't return valid JSON." }; } const content = extractMessageContent(body); if (!content) return { error: "The AI provider's response was empty." }; const parsed = parseModelOutput(content); // Not every backend reliably follows the "JSON only" instruction -- // rather than erroring out, treat unparseable output as a follow-up // question so the conversation degrades gracefully instead of dying. return parsed ?? { action: "ask", question: content.trim() }; }