Files
SheetHappens/lib/ai.ts
T
billisdead a74d27085b
Release APK / build (push) Has been cancelled
feat: multi-provider AI assistant, repo URL simplification
- lib/ai.ts: unified AI client supporting Anthropic (native format) and
  OpenAI-compatible providers (OpenAI, Perplexity, Ollama, OpenRouter, custom)
- settings: 6-provider chip selector, base URL auto-filled on preset select,
  Anthropic URL hidden (fixed), model/key placeholders per provider
- settings: git repo config replaced by single Repo URL field (.git paste)
  auto-parsed to instance URL + owner + repo at save time
- new.tsx: Suggest button between Symptom and Root Cause, visible when AI
  enabled and title+symptom filled; pre-fills rootCause and fix inline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 14:32:44 +02:00

129 lines
3.7 KiB
TypeScript

import * as SecureStore from "expo-secure-store";
export type AIProvider =
| "anthropic"
| "openai"
| "perplexity"
| "ollama"
| "openrouter"
| "custom";
const KEYS = {
AI_PROVIDER: "pref_ai_provider",
AI_BASE_URL: "pref_ai_base_url",
AI_MODEL: "pref_ai_model",
AI_KEY: "pref_ai_key",
};
export const AI_PROVIDER_BASE_URLS: Record<AIProvider, string> = {
anthropic: "https://api.anthropic.com",
openai: "https://api.openai.com/v1",
perplexity: "https://api.perplexity.ai",
ollama: "http://localhost:11434/v1",
openrouter: "https://openrouter.ai/api/v1",
custom: "",
};
export const AI_PROVIDER_MODEL_PLACEHOLDERS: Record<AIProvider, string> = {
anthropic: "claude-haiku-4-5-20251001",
openai: "gpt-4o-mini",
perplexity: "llama-3.1-sonar-small-128k-online",
ollama: "llama3.2",
openrouter: "openai/gpt-4o-mini",
custom: "model-name",
};
const SYSTEM_PROMPT =
"You are a systems reliability engineer. Analyze the incident and respond with JSON ONLY " +
"(no prose, no markdown fences):\n" +
'{"root_cause":"...","fix":"..."}\n' +
"Be concise and technical. Max 200 chars per field.";
async function getConfig() {
const [provider, baseUrl, model, key] = await Promise.all([
SecureStore.getItemAsync(KEYS.AI_PROVIDER),
SecureStore.getItemAsync(KEYS.AI_BASE_URL),
SecureStore.getItemAsync(KEYS.AI_MODEL),
SecureStore.getItemAsync(KEYS.AI_KEY),
]);
const p = (provider ?? "openai") as AIProvider;
return {
provider: p,
baseUrl: baseUrl || AI_PROVIDER_BASE_URLS[p],
model: model || AI_PROVIDER_MODEL_PLACEHOLDERS[p],
key: key ?? "",
};
}
export async function callAI(
title: string,
symptom: string
): Promise<{ rootCause: string; fix: string }> {
const config = await getConfig();
const userMsg = `Title: ${title}\nSymptom: ${symptom}`;
const raw =
config.provider === "anthropic"
? await callAnthropic(config, userMsg)
: await callOpenAICompat(config, userMsg);
try {
const parsed = JSON.parse(raw) as { root_cause?: string; fix?: string };
return {
rootCause: parsed.root_cause ?? "",
fix: parsed.fix ?? "",
};
} catch {
throw new Error(`Unexpected AI response: ${raw.slice(0, 120)}`);
}
}
async function callAnthropic(
config: { baseUrl: string; model: string; key: string },
userMsg: string
): Promise<string> {
const res = await fetch(`${config.baseUrl}/v1/messages`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": config.key,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: config.model,
max_tokens: 512,
system: SYSTEM_PROMPT,
messages: [{ role: "user", content: userMsg }],
}),
});
if (!res.ok) throw new Error(`Anthropic ${res.status}: ${await res.text()}`);
const data = (await res.json()) as { content: { text: string }[] };
return data.content[0].text;
}
async function callOpenAICompat(
config: { baseUrl: string; model: string; key: string },
userMsg: string
): Promise<string> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (config.key) headers["Authorization"] = `Bearer ${config.key}`;
const res = await fetch(`${config.baseUrl}/chat/completions`, {
method: "POST",
headers,
body: JSON.stringify({
model: config.model,
max_tokens: 512,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userMsg },
],
}),
});
if (!res.ok) throw new Error(`AI ${res.status}: ${await res.text()}`);
const data = (await res.json()) as {
choices: { message: { content: string } }[];
};
return data.choices[0].message.content;
}