9f163d3b76
Release APK / build (push) Has been cancelled
- Wrap root layout with SafeAreaProvider, use useSafeAreaInsets in all screens so FAB/action bar/scroll content clear Android nav bar - Add deleteIncidentFromGit() (Gitea/GitHub/GitLab) — detail screen now offers Cancel / Local only / Local + Repo when incident was pushed - Add Mistral as AI provider (OpenAI-compat, api.mistral.ai/v1, mistral-small-latest default) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
132 lines
3.8 KiB
TypeScript
132 lines
3.8 KiB
TypeScript
import * as SecureStore from "expo-secure-store";
|
|
|
|
export type AIProvider =
|
|
| "anthropic"
|
|
| "openai"
|
|
| "mistral"
|
|
| "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",
|
|
mistral: "https://api.mistral.ai/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",
|
|
mistral: "mistral-small-latest",
|
|
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;
|
|
}
|