"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 TodoAiMessage { role: "user" | "assistant"; content: string; } export interface TodoAiProposal { title: string; details?: string; } export type TodoAiResult = | { action: "ask"; question: string } | { action: "finalize"; todos: TodoAiProposal[] } | { error: string }; const TITLE_MAX = 20; // Same "respond with exactly one JSON shape" contract as converseAboutGroup // (lib/actions/group-ai.ts) -- the AI backend is a user-configured // OpenAI-compatible endpoint reached over raw fetch, so there's no // function-calling/JSON-mode to lean on; the format has to be held // together by the prompt plus defensive parsing below. const SYSTEM_PROMPT = `You are helping a user create one or more to-dos for a group called "{{GROUP_TITLE}}" in a to-do list app. Have a brief conversation to understand what they need to track. Ask at most one short, specific follow-up question at a time if genuinely needed. A to-do's title is hard-capped at ${TITLE_MAX} characters -- keep every title short and punchy, and put anything else worth remembering in that to-do's own "details" (short markdown notes, optional). Split unrelated tasks into separate to-dos rather than cramming them into one.{{EXISTING_TITLES_CLAUSE}} 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", "todos": [{"title": "<${TITLE_MAX} characters or fewer>", "details": ""}]} 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 parseTodos(value: unknown): TodoAiProposal[] | null { if (!Array.isArray(value)) return null; const todos: TodoAiProposal[] = []; for (const entry of value) { if (!entry || typeof entry !== "object") continue; const rawTitle = (entry as { title?: unknown }).title; if (typeof rawTitle !== "string") continue; const title = rawTitle.trim().slice(0, TITLE_MAX); if (!title) continue; const rawDetails = (entry as { details?: unknown }).details; const details = typeof rawDetails === "string" ? rawDetails.trim() : ""; todos.push(details ? { title, details } : { title }); } return todos.length > 0 ? todos : null; } function parseModelOutput(content: string): TodoAiResult | 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 todos = parseTodos((json as { todos?: unknown }).todos); return todos ? { action: "finalize", todos } : null; } return null; } /** * Sends the interview's conversation so far to the configured AI provider * and returns either a follow-up question or a finalized list of to-do * proposals. Reuses the same admin-configured endpoint/key as the group * notes "Ask AI" feature (see lib/ai-settings.ts). */ export async function converseAboutTodos( groupId: string, groupTitle: string, existingTodoTitles: string[], messages: TodoAiMessage[], 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." }; } const existingTitlesClause = existingTodoTitles.length > 0 ? ` This group already has these to-dos, don't duplicate them: ${existingTodoTitles .map((t) => `"${t}"`) .join(", ")}.` : ""; let systemPrompt = SYSTEM_PROMPT.replace("{{GROUP_TITLE}}", groupTitle).replace( "{{EXISTING_TITLES_CLAUSE}}", existingTitlesClause ); 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() }; }