164 lines
6.3 KiB
TypeScript
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() };
|
|
}
|