Compare commits
2 Commits
c915d5f2ad
...
v0.1.18
| Author | SHA1 | Date | |
|---|---|---|---|
| a74d27085b | |||
| fe1fdb9f9c |
+47
@@ -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<string[]>([]);
|
||||
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() && (
|
||||
<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")}
|
||||
<TextInput
|
||||
style={MONO_FIELD_STYLE}
|
||||
|
||||
+111
-95
@@ -11,11 +11,14 @@ import * as SecureStore from "expo-secure-store";
|
||||
import { Colors } from "@/constants/theme";
|
||||
import { DEFAULT_TEMPLATE } from "@/lib/markdown";
|
||||
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 = {
|
||||
INPUT_MODE: "pref_input_mode",
|
||||
HOME_VIEW: "pref_home_view",
|
||||
AI_ENABLED: "pref_ai_enabled",
|
||||
AI_PROVIDER: "pref_ai_provider",
|
||||
AI_KEY: "pref_ai_key",
|
||||
AI_BASE_URL: "pref_ai_base_url",
|
||||
AI_MODEL: "pref_ai_model",
|
||||
@@ -70,6 +73,28 @@ const PROVIDER_LABELS: Record<GitProvider, string> = {
|
||||
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 (
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<Text style={LABEL}>{label}</Text>
|
||||
<View style={{ flexDirection: "row", gap: 8 }}>
|
||||
<View style={{ flexDirection: "row", gap: 8, flexWrap: wrap ? "wrap" : "nowrap" }}>
|
||||
{options.map((opt) => (
|
||||
<Pressable
|
||||
key={opt.key}
|
||||
onPress={() => 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<AIProvider>("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<GitProvider>("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() {
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: Colors.text2, fontSize: 13, lineHeight: 20 }}>
|
||||
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.
|
||||
</Text>
|
||||
</View>
|
||||
<ToggleRow
|
||||
@@ -365,24 +389,44 @@ export default function SettingsScreen() {
|
||||
/>
|
||||
{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
|
||||
label="API Base URL"
|
||||
label="Base URL"
|
||||
value={aiBaseUrl}
|
||||
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
|
||||
/>
|
||||
)}
|
||||
<Field
|
||||
label="Model"
|
||||
value={aiModel}
|
||||
onChange={setAiModel}
|
||||
placeholder="gpt-4o-mini or llama3.2"
|
||||
placeholder={AI_PROVIDER_MODEL_PLACEHOLDERS[aiProvider]}
|
||||
/>
|
||||
<Field
|
||||
label="API Key"
|
||||
value={aiKey}
|
||||
onChange={setAiKey}
|
||||
placeholder="sk-... (leave empty for Ollama)"
|
||||
placeholder={aiProvider === "ollama" ? "no key required" : "paste your API key"}
|
||||
secure
|
||||
/>
|
||||
</>
|
||||
@@ -403,50 +447,46 @@ export default function SettingsScreen() {
|
||||
{gitProvider === "gitea" && (
|
||||
<>
|
||||
<Field
|
||||
label="Instance URL"
|
||||
value={giteaUrl}
|
||||
onChange={setGiteaUrl}
|
||||
placeholder="https://homegit.example.com"
|
||||
label="Repo URL"
|
||||
value={giteaRepoUrl}
|
||||
onChange={setGiteaRepoUrl}
|
||||
placeholder="https://homegit.example.com/username/incidents.git"
|
||||
url
|
||||
/>
|
||||
<Field
|
||||
label="Token"
|
||||
label="Token (PAT, write:repository)"
|
||||
value={giteaToken}
|
||||
onChange={setGiteaToken}
|
||||
placeholder="Bearer token"
|
||||
placeholder="paste token value — no Bearer prefix"
|
||||
secure
|
||||
/>
|
||||
<Field
|
||||
label="Owner"
|
||||
value={giteaOwner}
|
||||
onChange={setGiteaOwner}
|
||||
placeholder="username or org"
|
||||
/>
|
||||
<Field
|
||||
label="Repository"
|
||||
value={giteaRepo}
|
||||
onChange={setGiteaRepo}
|
||||
placeholder="incidents"
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: Colors.surface,
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
marginTop: -8,
|
||||
marginBottom: 14,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: Colors.text2,
|
||||
}}
|
||||
>
|
||||
<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" && (
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: Colors.surface,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 14,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: Colors.primary,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: Colors.text2, fontSize: 13 }}>
|
||||
Uses api.github.com — no instance URL required.
|
||||
</Text>
|
||||
</View>
|
||||
<Field
|
||||
label="Repo URL"
|
||||
value={githubRepoUrl}
|
||||
onChange={setGithubRepoUrl}
|
||||
placeholder="https://github.com/username/incidents.git"
|
||||
url
|
||||
/>
|
||||
<Field
|
||||
label="Personal Access Token"
|
||||
value={githubToken}
|
||||
@@ -454,28 +494,16 @@ export default function SettingsScreen() {
|
||||
placeholder="ghp_..."
|
||||
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" && (
|
||||
<>
|
||||
<Field
|
||||
label="Instance URL"
|
||||
value={gitlabUrl}
|
||||
onChange={setGitlabUrl}
|
||||
placeholder="https://gitlab.com"
|
||||
label="Repo URL"
|
||||
value={gitlabRepoUrl}
|
||||
onChange={setGitlabRepoUrl}
|
||||
placeholder="https://gitlab.com/username/incidents.git"
|
||||
url
|
||||
/>
|
||||
<Field
|
||||
@@ -485,18 +513,6 @@ export default function SettingsScreen() {
|
||||
placeholder="glpat-..."
|
||||
secure
|
||||
/>
|
||||
<Field
|
||||
label="Namespace (user or group)"
|
||||
value={gitlabOwner}
|
||||
onChange={setGitlabOwner}
|
||||
placeholder="your-username"
|
||||
/>
|
||||
<Field
|
||||
label="Repository"
|
||||
value={gitlabRepo}
|
||||
onChange={setGitlabRepo}
|
||||
placeholder="incidents"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+10
-2
@@ -71,7 +71,10 @@ async function pushGitea(
|
||||
if (existing.ok) {
|
||||
const data = await existing.json() as { sha?: string };
|
||||
sha = data.sha;
|
||||
} else if (existing.status !== 404) {
|
||||
} else if (existing.status === 404 || existing.status === 409) {
|
||||
// 404 = file not found (normal for first push)
|
||||
// 409 = repo is empty / no branch yet — proceed, we'll create with branch:"main"
|
||||
} else {
|
||||
const body = await existing.text();
|
||||
return { success: false, error: `GET ${existing.status}: ${body.slice(0, 200)}` };
|
||||
}
|
||||
@@ -79,6 +82,7 @@ async function pushGitea(
|
||||
const payload: Record<string, string> = {
|
||||
message: `incident: ${incident.title}`,
|
||||
content: base64Content,
|
||||
branch: "main",
|
||||
};
|
||||
if (sha) payload.sha = sha;
|
||||
|
||||
@@ -94,7 +98,11 @@ async function pushGitea(
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
return { success: false, error: `HTTP ${res.status}: ${body.slice(0, 300)}` };
|
||||
let hint = "";
|
||||
if (res.status === 404) {
|
||||
hint = "\n\nCheck: instance URL (no /api/v1), owner and repo names are exact, token has write:repository scope, and the repo exists on Gitea.";
|
||||
}
|
||||
return { success: false, error: `HTTP ${res.status}: ${body.slice(0, 300)}${hint}` };
|
||||
}
|
||||
const data = await res.json() as { content?: { html_url?: string } };
|
||||
return { success: true, url: data.content?.html_url };
|
||||
|
||||
Reference in New Issue
Block a user