Organize/lib/actions/group-ai.ts

164 lines
6.3 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 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": "<one short, specific question>"}
Ready to finalize:
{"action": "finalize", "notes_markdown": "<the notes, in markdown, 1-4 short paragraphs and/or a short list. Do not repeat the group's title as a heading. Do not add commentary, disclaimers, or phrases like \\"Here are your notes\\" -- output only the notes content itself.>"}
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<GroupAiResult> {
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() };
}