feat: multi-provider AI assistant, repo URL simplification
Release APK / build (push) Has been cancelled

- 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>
This commit is contained in:
2026-06-23 14:32:44 +02:00
parent fe1fdb9f9c
commit a74d27085b
3 changed files with 289 additions and 117 deletions
+47
View File
@@ -19,6 +19,7 @@ import {
} from "@/lib/db"; } from "@/lib/db";
import { Colors } from "@/constants/theme"; import { Colors } from "@/constants/theme";
import { useVoice } from "@/hooks/useVoice"; import { useVoice } from "@/hooks/useVoice";
import { callAI } from "@/lib/ai";
import type { IncidentDraft } from "@/types/incident"; import type { IncidentDraft } from "@/types/incident";
const FIELD_STYLE = { const FIELD_STYLE = {
@@ -72,6 +73,9 @@ export default function NewIncidentScreen() {
}); });
const [tagInput, setTagInput] = useState(""); const [tagInput, setTagInput] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [suggesting, setSuggesting] = useState(false);
const [suggestError, setSuggestError] = useState("");
const [aiEnabled, setAiEnabled] = useState(false);
const [knownServices, setKnownServices] = useState<string[]>([]); const [knownServices, setKnownServices] = useState<string[]>([]);
const [serviceFocused, setServiceFocused] = useState(false); const [serviceFocused, setServiceFocused] = useState(false);
@@ -81,6 +85,7 @@ export default function NewIncidentScreen() {
useEffect(() => { useEffect(() => {
getDistinctServices().then(setKnownServices); getDistinctServices().then(setKnownServices);
SecureStore.getItemAsync("pref_ai_enabled").then((v) => setAiEnabled(v === "true"));
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -149,6 +154,19 @@ export default function NewIncidentScreen() {
setForm((f) => ({ ...f, tags: f.tags.filter((t) => t !== tag) })); setForm((f) => ({ ...f, tags: f.tags.filter((t) => t !== tag) }));
} }
async function handleSuggest() {
setSuggesting(true);
setSuggestError("");
try {
const result = await callAI(form.title, form.symptom);
setForm((f) => ({ ...f, rootCause: result.rootCause, fix: result.fix }));
} catch (e) {
setSuggestError(e instanceof Error ? e.message : "AI call failed");
} finally {
setSuggesting(false);
}
}
async function handleSave() { async function handleSave() {
if (!form.title.trim()) { if (!form.title.trim()) {
Alert.alert("Required", "Title is required."); Alert.alert("Required", "Title is required.");
@@ -298,6 +316,35 @@ export default function NewIncidentScreen() {
returnKeyType="next" returnKeyType="next"
/> />
{aiEnabled && form.title.trim() && form.symptom.trim() && (
<View style={{ marginBottom: 16 }}>
<Pressable
onPress={handleSuggest}
disabled={suggesting}
style={{
backgroundColor: suggesting ? Colors.surface2 : Colors.surface,
borderRadius: 8,
padding: 12,
alignItems: "center",
borderWidth: 1,
borderColor: Colors.primary,
flexDirection: "row",
justifyContent: "center",
gap: 8,
}}
>
<Text style={{ color: Colors.primary, fontSize: 14, fontWeight: "700" }}>
{suggesting ? "Analyzing…" : "✦ Suggest root cause & fix"}
</Text>
</Pressable>
{suggestError !== "" && (
<Text style={{ color: Colors.danger, fontSize: 12, marginTop: 6 }}>
{suggestError}
</Text>
)}
</View>
)}
{labelRow("Root Cause", "rootCause")} {labelRow("Root Cause", "rootCause")}
<TextInput <TextInput
style={MONO_FIELD_STYLE} style={MONO_FIELD_STYLE}
+110 -113
View File
@@ -11,11 +11,14 @@ import * as SecureStore from "expo-secure-store";
import { Colors } from "@/constants/theme"; import { Colors } from "@/constants/theme";
import { DEFAULT_TEMPLATE } from "@/lib/markdown"; import { DEFAULT_TEMPLATE } from "@/lib/markdown";
import type { GitProvider } from "@/lib/git"; import type { GitProvider } from "@/lib/git";
import type { AIProvider } from "@/lib/ai";
import { AI_PROVIDER_BASE_URLS, AI_PROVIDER_MODEL_PLACEHOLDERS } from "@/lib/ai";
const KEYS = { const KEYS = {
INPUT_MODE: "pref_input_mode", INPUT_MODE: "pref_input_mode",
HOME_VIEW: "pref_home_view", HOME_VIEW: "pref_home_view",
AI_ENABLED: "pref_ai_enabled", AI_ENABLED: "pref_ai_enabled",
AI_PROVIDER: "pref_ai_provider",
AI_KEY: "pref_ai_key", AI_KEY: "pref_ai_key",
AI_BASE_URL: "pref_ai_base_url", AI_BASE_URL: "pref_ai_base_url",
AI_MODEL: "pref_ai_model", AI_MODEL: "pref_ai_model",
@@ -70,6 +73,28 @@ const PROVIDER_LABELS: Record<GitProvider, string> = {
gitlab: "GitLab", gitlab: "GitLab",
}; };
function parseRepoUrl(raw: string, provider: GitProvider): { instanceUrl?: string; owner?: string; repo?: string } {
try {
const cleaned = raw.trim().replace(/\.git$/, "");
const u = new URL(cleaned);
const parts = u.pathname.split("/").filter(Boolean);
if (parts.length < 2) return {};
const repo = parts[parts.length - 1];
const owner = parts[parts.length - 2];
const instanceUrl = provider !== "github" ? `${u.protocol}//${u.host}` : undefined;
return { instanceUrl, owner, repo };
} catch {
return {};
}
}
function buildRepoUrl(instanceUrl: string | null, owner: string | null, repo: string | null, provider: GitProvider): string {
if (!owner || !repo) return "";
const base = provider === "github" ? "https://github.com" : (instanceUrl ?? "");
if (!base) return "";
return `${base}/${owner}/${repo}.git`;
}
// Defined outside SettingsScreen — prevents remount on every rerender, which // Defined outside SettingsScreen — prevents remount on every rerender, which
// would cause TextInput to lose focus after each keystroke. // would cause TextInput to lose focus after each keystroke.
@@ -110,22 +135,24 @@ function SegmentRow({
options, options,
value, value,
onChange, onChange,
wrap,
}: { }: {
label: string; label: string;
options: { key: string; label: string }[]; options: { key: string; label: string }[];
value: string; value: string;
onChange: (v: string) => void; onChange: (v: string) => void;
wrap?: boolean;
}) { }) {
return ( return (
<View style={{ marginBottom: 16 }}> <View style={{ marginBottom: 16 }}>
<Text style={LABEL}>{label}</Text> <Text style={LABEL}>{label}</Text>
<View style={{ flexDirection: "row", gap: 8 }}> <View style={{ flexDirection: "row", gap: 8, flexWrap: wrap ? "wrap" : "nowrap" }}>
{options.map((opt) => ( {options.map((opt) => (
<Pressable <Pressable
key={opt.key} key={opt.key}
onPress={() => onChange(opt.key)} onPress={() => onChange(opt.key)}
style={{ style={{
flex: 1, ...(wrap ? { width: "31%" } : { flex: 1 }),
backgroundColor: backgroundColor:
value === opt.key ? Colors.primary : Colors.surface, value === opt.key ? Colors.primary : Colors.surface,
borderRadius: 8, borderRadius: 8,
@@ -189,6 +216,7 @@ export default function SettingsScreen() {
const [inputMode, setInputMode] = useState<"voice" | "form">("form"); const [inputMode, setInputMode] = useState<"voice" | "form">("form");
const [homeView, setHomeView] = useState<"dashboard" | "capture">("dashboard"); const [homeView, setHomeView] = useState<"dashboard" | "capture">("dashboard");
const [aiEnabled, setAiEnabled] = useState(false); const [aiEnabled, setAiEnabled] = useState(false);
const [aiProvider, setAiProvider] = useState<AIProvider>("openai");
const [aiKey, setAiKey] = useState(""); const [aiKey, setAiKey] = useState("");
const [aiBaseUrl, setAiBaseUrl] = useState(""); const [aiBaseUrl, setAiBaseUrl] = useState("");
const [aiModel, setAiModel] = useState(""); const [aiModel, setAiModel] = useState("");
@@ -197,22 +225,17 @@ export default function SettingsScreen() {
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const [gitProvider, setGitProvider] = useState<GitProvider>("gitea"); const [gitProvider, setGitProvider] = useState<GitProvider>("gitea");
const [giteaUrl, setGiteaUrl] = useState(""); const [giteaRepoUrl, setGiteaRepoUrl] = useState("");
const [giteaToken, setGiteaToken] = useState(""); const [giteaToken, setGiteaToken] = useState("");
const [giteaOwner, setGiteaOwner] = useState(""); const [githubRepoUrl, setGithubRepoUrl] = useState("");
const [giteaRepo, setGiteaRepo] = useState("");
const [githubToken, setGithubToken] = useState(""); const [githubToken, setGithubToken] = useState("");
const [githubOwner, setGithubOwner] = useState(""); const [gitlabRepoUrl, setGitlabRepoUrl] = useState("");
const [githubRepo, setGithubRepo] = useState("");
const [gitlabUrl, setGitlabUrl] = useState("");
const [gitlabToken, setGitlabToken] = useState(""); const [gitlabToken, setGitlabToken] = useState("");
const [gitlabOwner, setGitlabOwner] = useState("");
const [gitlabRepo, setGitlabRepo] = useState("");
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const [ const [
im, hv, ai, key, aiUrl, aiMdl, titleTpl, tpl, im, hv, ai, aip, key, aiUrl, aiMdl, titleTpl, tpl,
gp, gu, gt, go, gr, gp, gu, gt, go, gr,
ghu, ghown, ghrepo, ghu, ghown, ghrepo,
glu, glt, glo, glr, glu, glt, glo, glr,
@@ -220,6 +243,7 @@ export default function SettingsScreen() {
SecureStore.getItemAsync(KEYS.INPUT_MODE), SecureStore.getItemAsync(KEYS.INPUT_MODE),
SecureStore.getItemAsync(KEYS.HOME_VIEW), SecureStore.getItemAsync(KEYS.HOME_VIEW),
SecureStore.getItemAsync(KEYS.AI_ENABLED), SecureStore.getItemAsync(KEYS.AI_ENABLED),
SecureStore.getItemAsync(KEYS.AI_PROVIDER),
SecureStore.getItemAsync(KEYS.AI_KEY), SecureStore.getItemAsync(KEYS.AI_KEY),
SecureStore.getItemAsync(KEYS.AI_BASE_URL), SecureStore.getItemAsync(KEYS.AI_BASE_URL),
SecureStore.getItemAsync(KEYS.AI_MODEL), SecureStore.getItemAsync(KEYS.AI_MODEL),
@@ -241,48 +265,48 @@ export default function SettingsScreen() {
if (im) setInputMode(im as "voice" | "form"); if (im) setInputMode(im as "voice" | "form");
if (hv) setHomeView(hv as "dashboard" | "capture"); if (hv) setHomeView(hv as "dashboard" | "capture");
if (ai) setAiEnabled(ai === "true"); if (ai) setAiEnabled(ai === "true");
if (aip) setAiProvider(aip as AIProvider);
if (key) setAiKey(key); if (key) setAiKey(key);
if (aiUrl) setAiBaseUrl(aiUrl); if (aiUrl) setAiBaseUrl(aiUrl);
if (aiMdl) setAiModel(aiMdl); if (aiMdl) setAiModel(aiMdl);
if (titleTpl) setTitleTemplate(titleTpl); if (titleTpl) setTitleTemplate(titleTpl);
if (tpl) setMdTemplate(tpl); if (tpl) setMdTemplate(tpl);
if (gp) setGitProvider(gp as GitProvider); if (gp) setGitProvider(gp as GitProvider);
if (gu) setGiteaUrl(gu);
if (gt) setGiteaToken(gt); if (gt) setGiteaToken(gt);
if (go) setGiteaOwner(go); setGiteaRepoUrl(buildRepoUrl(gu, go, gr, "gitea"));
if (gr) setGiteaRepo(gr);
if (ghu) setGithubToken(ghu); if (ghu) setGithubToken(ghu);
if (ghown) setGithubOwner(ghown); setGithubRepoUrl(buildRepoUrl(null, ghown, ghrepo, "github"));
if (ghrepo) setGithubRepo(ghrepo);
if (glu) setGitlabUrl(glu);
if (glt) setGitlabToken(glt); if (glt) setGitlabToken(glt);
if (glo) setGitlabOwner(glo); setGitlabRepoUrl(buildRepoUrl(glu, glo, glr, "gitlab"));
if (glr) setGitlabRepo(glr);
})(); })();
}, []); }, []);
async function handleSave() { async function handleSave() {
const gitea = parseRepoUrl(giteaRepoUrl, "gitea");
const github = parseRepoUrl(githubRepoUrl, "github");
const gitlab = parseRepoUrl(gitlabRepoUrl, "gitlab");
await Promise.all([ await Promise.all([
SecureStore.setItemAsync(KEYS.INPUT_MODE, inputMode), SecureStore.setItemAsync(KEYS.INPUT_MODE, inputMode),
SecureStore.setItemAsync(KEYS.HOME_VIEW, homeView), SecureStore.setItemAsync(KEYS.HOME_VIEW, homeView),
SecureStore.setItemAsync(KEYS.AI_ENABLED, String(aiEnabled)), SecureStore.setItemAsync(KEYS.AI_ENABLED, String(aiEnabled)),
SecureStore.setItemAsync(KEYS.AI_PROVIDER, aiProvider),
SecureStore.setItemAsync(KEYS.AI_KEY, aiKey), SecureStore.setItemAsync(KEYS.AI_KEY, aiKey),
SecureStore.setItemAsync(KEYS.AI_BASE_URL, aiBaseUrl), SecureStore.setItemAsync(KEYS.AI_BASE_URL, aiBaseUrl),
SecureStore.setItemAsync(KEYS.AI_MODEL, aiModel), SecureStore.setItemAsync(KEYS.AI_MODEL, aiModel),
SecureStore.setItemAsync(KEYS.TITLE_TEMPLATE, titleTemplate), SecureStore.setItemAsync(KEYS.TITLE_TEMPLATE, titleTemplate),
SecureStore.setItemAsync(KEYS.MD_TEMPLATE, mdTemplate), SecureStore.setItemAsync(KEYS.MD_TEMPLATE, mdTemplate),
SecureStore.setItemAsync(KEYS.GIT_PROVIDER, gitProvider), SecureStore.setItemAsync(KEYS.GIT_PROVIDER, gitProvider),
SecureStore.setItemAsync(KEYS.GITEA_URL, giteaUrl), SecureStore.setItemAsync(KEYS.GITEA_URL, gitea.instanceUrl ?? ""),
SecureStore.setItemAsync(KEYS.GITEA_TOKEN, giteaToken), SecureStore.setItemAsync(KEYS.GITEA_TOKEN, giteaToken),
SecureStore.setItemAsync(KEYS.GITEA_OWNER, giteaOwner), SecureStore.setItemAsync(KEYS.GITEA_OWNER, gitea.owner ?? ""),
SecureStore.setItemAsync(KEYS.GITEA_REPO, giteaRepo), SecureStore.setItemAsync(KEYS.GITEA_REPO, gitea.repo ?? ""),
SecureStore.setItemAsync(KEYS.GITHUB_TOKEN, githubToken), SecureStore.setItemAsync(KEYS.GITHUB_TOKEN, githubToken),
SecureStore.setItemAsync(KEYS.GITHUB_OWNER, githubOwner), SecureStore.setItemAsync(KEYS.GITHUB_OWNER, github.owner ?? ""),
SecureStore.setItemAsync(KEYS.GITHUB_REPO, githubRepo), SecureStore.setItemAsync(KEYS.GITHUB_REPO, github.repo ?? ""),
SecureStore.setItemAsync(KEYS.GITLAB_URL, gitlabUrl), SecureStore.setItemAsync(KEYS.GITLAB_URL, gitlab.instanceUrl ?? ""),
SecureStore.setItemAsync(KEYS.GITLAB_TOKEN, gitlabToken), SecureStore.setItemAsync(KEYS.GITLAB_TOKEN, gitlabToken),
SecureStore.setItemAsync(KEYS.GITLAB_OWNER, gitlabOwner), SecureStore.setItemAsync(KEYS.GITLAB_OWNER, gitlab.owner ?? ""),
SecureStore.setItemAsync(KEYS.GITLAB_REPO, gitlabRepo), SecureStore.setItemAsync(KEYS.GITLAB_REPO, gitlab.repo ?? ""),
]); ]);
setSaved(true); setSaved(true);
setTimeout(() => setSaved(false), 2000); setTimeout(() => setSaved(false), 2000);
@@ -352,10 +376,10 @@ export default function SettingsScreen() {
}} }}
> >
<Text style={{ color: Colors.text2, fontSize: 13, lineHeight: 20 }}> <Text style={{ color: Colors.text2, fontSize: 13, lineHeight: 20 }}>
When enabled, a button appears on the capture form to suggest a root When enabled, a Suggest button appears between Symptom and Root Cause.
cause and fix based on the incident title and symptom. Uses any It sends the title and symptom to your AI provider and pre-fills the
OpenAI-compatible API set the base URL below to point at a local analysis fields. Supports Anthropic natively; all others use the
Ollama instance, or leave empty for OpenAI (api.openai.com). OpenAI-compatible API.
</Text> </Text>
</View> </View>
<ToggleRow <ToggleRow
@@ -365,24 +389,44 @@ export default function SettingsScreen() {
/> />
{aiEnabled && ( {aiEnabled && (
<> <>
<SegmentRow
label="Provider"
value={aiProvider}
wrap
onChange={(v) => {
const p = v as AIProvider;
setAiProvider(p);
setAiBaseUrl(AI_PROVIDER_BASE_URLS[p]);
}}
options={[
{ key: "anthropic", label: "Anthropic" },
{ key: "openai", label: "OpenAI" },
{ key: "perplexity", label: "Perplexity" },
{ key: "ollama", label: "Ollama" },
{ key: "openrouter", label: "OpenRouter" },
{ key: "custom", label: "Custom" },
]}
/>
{aiProvider !== "anthropic" && (
<Field <Field
label="API Base URL" label="Base URL"
value={aiBaseUrl} value={aiBaseUrl}
onChange={setAiBaseUrl} onChange={setAiBaseUrl}
placeholder="http://192.168.x.x:11434/v1 (empty = OpenAI)" placeholder={AI_PROVIDER_BASE_URLS[aiProvider] || "https://your-api.example.com/v1"}
url url
/> />
)}
<Field <Field
label="Model" label="Model"
value={aiModel} value={aiModel}
onChange={setAiModel} onChange={setAiModel}
placeholder="gpt-4o-mini or llama3.2" placeholder={AI_PROVIDER_MODEL_PLACEHOLDERS[aiProvider]}
/> />
<Field <Field
label="API Key" label="API Key"
value={aiKey} value={aiKey}
onChange={setAiKey} onChange={setAiKey}
placeholder="sk-... (leave empty for Ollama)" placeholder={aiProvider === "ollama" ? "no key required" : "paste your API key"}
secure secure
/> />
</> </>
@@ -402,70 +446,47 @@ export default function SettingsScreen() {
{gitProvider === "gitea" && ( {gitProvider === "gitea" && (
<> <>
<View
style={{
backgroundColor: Colors.surface,
borderRadius: 8,
padding: 12,
marginBottom: 14,
borderLeftWidth: 3,
borderLeftColor: Colors.text2,
}}
>
<Text style={{ color: Colors.text2, fontSize: 12, lineHeight: 18 }}>
<Text style={{ fontWeight: "700" }}>Instance URL</Text>
{" — base URL only, no trailing slash, no /api/v1.\n"}
<Text style={{ fontWeight: "700" }}>Token</Text>
{" — Gitea PAT with write:repository scope. Paste the token value only (no \"Bearer\" prefix).\n"}
<Text style={{ fontWeight: "700" }}>Repository</Text>
{" — must have at least one commit (initialize it with a README if empty)."}
</Text>
</View>
<Field <Field
label="Instance URL" label="Repo URL"
value={giteaUrl} value={giteaRepoUrl}
onChange={setGiteaUrl} onChange={setGiteaRepoUrl}
placeholder="https://homegit.example.com" placeholder="https://homegit.example.com/username/incidents.git"
url url
/> />
<Field <Field
label="Token (PAT, write:repository)" label="Token (PAT, write:repository)"
value={giteaToken} value={giteaToken}
onChange={setGiteaToken} onChange={setGiteaToken}
placeholder="paste token value here" placeholder="paste token value — no Bearer prefix"
secure secure
/> />
<Field <View
label="Owner" style={{
value={giteaOwner} backgroundColor: Colors.surface,
onChange={setGiteaOwner} borderRadius: 8,
placeholder="username or org" padding: 10,
/> marginTop: -8,
<Field marginBottom: 14,
label="Repository" borderLeftWidth: 3,
value={giteaRepo} borderLeftColor: Colors.text2,
onChange={setGiteaRepo} }}
placeholder="incidents" >
/> <Text style={{ color: Colors.text2, fontSize: 12, lineHeight: 18 }}>
The repo must have at least one commit initialize it with a README if empty.
</Text>
</View>
</> </>
)} )}
{gitProvider === "github" && ( {gitProvider === "github" && (
<> <>
<View <Field
style={{ label="Repo URL"
backgroundColor: Colors.surface, value={githubRepoUrl}
borderRadius: 8, onChange={setGithubRepoUrl}
padding: 12, placeholder="https://github.com/username/incidents.git"
marginBottom: 14, url
borderLeftWidth: 3, />
borderLeftColor: Colors.primary,
}}
>
<Text style={{ color: Colors.text2, fontSize: 13 }}>
Uses api.github.com no instance URL required.
</Text>
</View>
<Field <Field
label="Personal Access Token" label="Personal Access Token"
value={githubToken} value={githubToken}
@@ -473,28 +494,16 @@ export default function SettingsScreen() {
placeholder="ghp_..." placeholder="ghp_..."
secure secure
/> />
<Field
label="Owner (username or org)"
value={githubOwner}
onChange={setGithubOwner}
placeholder="your-username"
/>
<Field
label="Repository"
value={githubRepo}
onChange={setGithubRepo}
placeholder="incidents"
/>
</> </>
)} )}
{gitProvider === "gitlab" && ( {gitProvider === "gitlab" && (
<> <>
<Field <Field
label="Instance URL" label="Repo URL"
value={gitlabUrl} value={gitlabRepoUrl}
onChange={setGitlabUrl} onChange={setGitlabRepoUrl}
placeholder="https://gitlab.com" placeholder="https://gitlab.com/username/incidents.git"
url url
/> />
<Field <Field
@@ -504,18 +513,6 @@ export default function SettingsScreen() {
placeholder="glpat-..." placeholder="glpat-..."
secure secure
/> />
<Field
label="Namespace (user or group)"
value={gitlabOwner}
onChange={setGitlabOwner}
placeholder="your-username"
/>
<Field
label="Repository"
value={gitlabRepo}
onChange={setGitlabRepo}
placeholder="incidents"
/>
</> </>
)} )}
+128
View File
@@ -0,0 +1,128 @@
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;
}