diff --git a/app/new.tsx b/app/new.tsx index 0c75a00..1789009 100644 --- a/app/new.tsx +++ b/app/new.tsx @@ -19,6 +19,7 @@ import { } from "@/lib/db"; import { Colors } from "@/constants/theme"; import { useVoice } from "@/hooks/useVoice"; +import { callAI } from "@/lib/ai"; import type { IncidentDraft } from "@/types/incident"; const FIELD_STYLE = { @@ -72,6 +73,9 @@ export default function NewIncidentScreen() { }); const [tagInput, setTagInput] = useState(""); const [saving, setSaving] = useState(false); + const [suggesting, setSuggesting] = useState(false); + const [suggestError, setSuggestError] = useState(""); + const [aiEnabled, setAiEnabled] = useState(false); const [knownServices, setKnownServices] = useState([]); const [serviceFocused, setServiceFocused] = useState(false); @@ -81,6 +85,7 @@ export default function NewIncidentScreen() { useEffect(() => { getDistinctServices().then(setKnownServices); + SecureStore.getItemAsync("pref_ai_enabled").then((v) => setAiEnabled(v === "true")); }, []); useEffect(() => { @@ -149,6 +154,19 @@ export default function NewIncidentScreen() { 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() { if (!form.title.trim()) { Alert.alert("Required", "Title is required."); @@ -298,6 +316,35 @@ export default function NewIncidentScreen() { returnKeyType="next" /> + {aiEnabled && form.title.trim() && form.symptom.trim() && ( + + + + {suggesting ? "Analyzing…" : "✦ Suggest root cause & fix"} + + + {suggestError !== "" && ( + + {suggestError} + + )} + + )} + {labelRow("Root Cause", "rootCause")} = { 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 // would cause TextInput to lose focus after each keystroke. @@ -110,22 +135,24 @@ function SegmentRow({ options, value, onChange, + wrap, }: { label: string; options: { key: string; label: string }[]; value: string; onChange: (v: string) => void; + wrap?: boolean; }) { return ( {label} - + {options.map((opt) => ( onChange(opt.key)} style={{ - flex: 1, + ...(wrap ? { width: "31%" } : { flex: 1 }), backgroundColor: value === opt.key ? Colors.primary : Colors.surface, borderRadius: 8, @@ -189,6 +216,7 @@ export default function SettingsScreen() { const [inputMode, setInputMode] = useState<"voice" | "form">("form"); const [homeView, setHomeView] = useState<"dashboard" | "capture">("dashboard"); const [aiEnabled, setAiEnabled] = useState(false); + const [aiProvider, setAiProvider] = useState("openai"); const [aiKey, setAiKey] = useState(""); const [aiBaseUrl, setAiBaseUrl] = useState(""); const [aiModel, setAiModel] = useState(""); @@ -197,22 +225,17 @@ export default function SettingsScreen() { const [saved, setSaved] = useState(false); const [gitProvider, setGitProvider] = useState("gitea"); - const [giteaUrl, setGiteaUrl] = useState(""); + const [giteaRepoUrl, setGiteaRepoUrl] = useState(""); const [giteaToken, setGiteaToken] = useState(""); - const [giteaOwner, setGiteaOwner] = useState(""); - const [giteaRepo, setGiteaRepo] = useState(""); + const [githubRepoUrl, setGithubRepoUrl] = useState(""); const [githubToken, setGithubToken] = useState(""); - const [githubOwner, setGithubOwner] = useState(""); - const [githubRepo, setGithubRepo] = useState(""); - const [gitlabUrl, setGitlabUrl] = useState(""); + const [gitlabRepoUrl, setGitlabRepoUrl] = useState(""); const [gitlabToken, setGitlabToken] = useState(""); - const [gitlabOwner, setGitlabOwner] = useState(""); - const [gitlabRepo, setGitlabRepo] = useState(""); useEffect(() => { (async () => { const [ - im, hv, ai, key, aiUrl, aiMdl, titleTpl, tpl, + im, hv, ai, aip, key, aiUrl, aiMdl, titleTpl, tpl, gp, gu, gt, go, gr, ghu, ghown, ghrepo, glu, glt, glo, glr, @@ -220,6 +243,7 @@ export default function SettingsScreen() { SecureStore.getItemAsync(KEYS.INPUT_MODE), SecureStore.getItemAsync(KEYS.HOME_VIEW), SecureStore.getItemAsync(KEYS.AI_ENABLED), + SecureStore.getItemAsync(KEYS.AI_PROVIDER), SecureStore.getItemAsync(KEYS.AI_KEY), SecureStore.getItemAsync(KEYS.AI_BASE_URL), SecureStore.getItemAsync(KEYS.AI_MODEL), @@ -241,48 +265,48 @@ export default function SettingsScreen() { if (im) setInputMode(im as "voice" | "form"); if (hv) setHomeView(hv as "dashboard" | "capture"); if (ai) setAiEnabled(ai === "true"); + if (aip) setAiProvider(aip as AIProvider); if (key) setAiKey(key); if (aiUrl) setAiBaseUrl(aiUrl); if (aiMdl) setAiModel(aiMdl); if (titleTpl) setTitleTemplate(titleTpl); if (tpl) setMdTemplate(tpl); if (gp) setGitProvider(gp as GitProvider); - if (gu) setGiteaUrl(gu); if (gt) setGiteaToken(gt); - if (go) setGiteaOwner(go); - if (gr) setGiteaRepo(gr); + setGiteaRepoUrl(buildRepoUrl(gu, go, gr, "gitea")); if (ghu) setGithubToken(ghu); - if (ghown) setGithubOwner(ghown); - if (ghrepo) setGithubRepo(ghrepo); - if (glu) setGitlabUrl(glu); + setGithubRepoUrl(buildRepoUrl(null, ghown, ghrepo, "github")); if (glt) setGitlabToken(glt); - if (glo) setGitlabOwner(glo); - if (glr) setGitlabRepo(glr); + setGitlabRepoUrl(buildRepoUrl(glu, glo, glr, "gitlab")); })(); }, []); async function handleSave() { + const gitea = parseRepoUrl(giteaRepoUrl, "gitea"); + const github = parseRepoUrl(githubRepoUrl, "github"); + const gitlab = parseRepoUrl(gitlabRepoUrl, "gitlab"); await Promise.all([ SecureStore.setItemAsync(KEYS.INPUT_MODE, inputMode), SecureStore.setItemAsync(KEYS.HOME_VIEW, homeView), SecureStore.setItemAsync(KEYS.AI_ENABLED, String(aiEnabled)), + SecureStore.setItemAsync(KEYS.AI_PROVIDER, aiProvider), SecureStore.setItemAsync(KEYS.AI_KEY, aiKey), SecureStore.setItemAsync(KEYS.AI_BASE_URL, aiBaseUrl), SecureStore.setItemAsync(KEYS.AI_MODEL, aiModel), SecureStore.setItemAsync(KEYS.TITLE_TEMPLATE, titleTemplate), SecureStore.setItemAsync(KEYS.MD_TEMPLATE, mdTemplate), 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_OWNER, giteaOwner), - SecureStore.setItemAsync(KEYS.GITEA_REPO, giteaRepo), + SecureStore.setItemAsync(KEYS.GITEA_OWNER, gitea.owner ?? ""), + SecureStore.setItemAsync(KEYS.GITEA_REPO, gitea.repo ?? ""), SecureStore.setItemAsync(KEYS.GITHUB_TOKEN, githubToken), - SecureStore.setItemAsync(KEYS.GITHUB_OWNER, githubOwner), - SecureStore.setItemAsync(KEYS.GITHUB_REPO, githubRepo), - SecureStore.setItemAsync(KEYS.GITLAB_URL, gitlabUrl), + SecureStore.setItemAsync(KEYS.GITHUB_OWNER, github.owner ?? ""), + SecureStore.setItemAsync(KEYS.GITHUB_REPO, github.repo ?? ""), + SecureStore.setItemAsync(KEYS.GITLAB_URL, gitlab.instanceUrl ?? ""), SecureStore.setItemAsync(KEYS.GITLAB_TOKEN, gitlabToken), - SecureStore.setItemAsync(KEYS.GITLAB_OWNER, gitlabOwner), - SecureStore.setItemAsync(KEYS.GITLAB_REPO, gitlabRepo), + SecureStore.setItemAsync(KEYS.GITLAB_OWNER, gitlab.owner ?? ""), + SecureStore.setItemAsync(KEYS.GITLAB_REPO, gitlab.repo ?? ""), ]); setSaved(true); setTimeout(() => setSaved(false), 2000); @@ -352,10 +376,10 @@ export default function SettingsScreen() { }} > - When enabled, a button appears on the capture form to suggest a root - cause and fix based on the incident title and symptom. Uses any - OpenAI-compatible API — set the base URL below to point at a local - Ollama instance, or leave empty for OpenAI (api.openai.com). + When enabled, a Suggest button appears between Symptom and Root Cause. + It sends the title and symptom to your AI provider and pre-fills the + analysis fields. Supports Anthropic natively; all others use the + OpenAI-compatible API. {aiEnabled && ( <> - { + 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" && ( + + )} @@ -402,70 +446,47 @@ export default function SettingsScreen() { {gitProvider === "gitea" && ( <> - - - Instance URL - {" — base URL only, no trailing slash, no /api/v1.\n"} - Token - {" — Gitea PAT with write:repository scope. Paste the token value only (no \"Bearer\" prefix).\n"} - Repository - {" — must have at least one commit (initialize it with a README if empty)."} - - - - + + + The repo must have at least one commit — initialize it with a README if empty. + + )} {gitProvider === "github" && ( <> - - - Uses api.github.com — no instance URL required. - - + - - )} {gitProvider === "gitlab" && ( <> - - )} diff --git a/lib/ai.ts b/lib/ai.ts new file mode 100644 index 0000000..dbf28f3 --- /dev/null +++ b/lib/ai.ts @@ -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 = { + 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 = { + 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 { + 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 { + const headers: Record = { "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; +}