92 lines
3.1 KiB
TypeScript
92 lines
3.1 KiB
TypeScript
import "server-only";
|
|
|
|
import { getAiCredentials, getAiSettingsView, joinApiUrl } from "@/lib/ai-settings";
|
|
|
|
export interface ChatCompletionMessage {
|
|
role: "system" | "user" | "assistant";
|
|
content: string;
|
|
}
|
|
|
|
export type ChatCompletionResult = { content: string } | { error: string };
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Shared fetch/parse/error-handling for calling the configured
|
|
* OpenAI-compatible AI provider (see lib/ai-settings.ts) with a plain chat
|
|
* completion -- no structured ask/finalize contract, just "here's a
|
|
* conversation, give me back the assistant's reply". Used by features
|
|
* that want the model to answer freely (global chat, group status
|
|
* updates) rather than produce a JSON artifact to review-and-save; those
|
|
* artifact-producing features (lib/actions/group-ai.ts,
|
|
* lib/actions/todo-ai.ts) predate this helper and have their own
|
|
* ask/finalize parsing layered on top of the same fetch shape, so they're
|
|
* left as-is rather than retrofitted onto this.
|
|
*/
|
|
export async function callChatCompletion(
|
|
messages: ChatCompletionMessage[],
|
|
opts?: { temperature?: number }
|
|
): Promise<ChatCompletionResult> {
|
|
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 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,
|
|
temperature: opts?.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." };
|
|
|
|
return { content: content.trim() };
|
|
}
|