Organize/lib/actions/todo-ai.ts

193 lines
7.2 KiB
TypeScript

"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": "<one short, specific question>"}
Ready to finalize:
{"action": "finalize", "todos": [{"title": "<${TITLE_MAX} characters or fewer>", "details": "<optional short markdown notes, omit or leave empty if none>"}]}
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<TodoAiResult> {
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() };
}