feat: multi-provider AI assistant, repo URL simplification
Release APK / build (push) Has been cancelled
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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user