feat(settings): tombol Test API di AI (tanpa simpan)
All checks were successful
CI / test (push) Successful in 2m26s

- endpoint POST /admin/settings/test-ai (provider, key, base URL, model)
- UI status valid/tidak + latensi
- versi 0.5.14
This commit is contained in:
oxpoe
2026-11-05 17:49:54 +07:00
parent 7db4cb81a3
commit 1e3067989a
6 changed files with 122 additions and 3 deletions

View File

@@ -2,6 +2,11 @@
Format mengikuti [Keep a Changelog](https://keepachangelog.com/) dan [Semantic Versioning](https://semver.org/).
## [0.5.14] — 2026-11-05 — "ai-test"
### Ditambahkan
- Tombol **Test API** di Pengaturan → AI: memvalidasi provider, API key, base URL, dan model yang sedang diisi **tanpa harus menyimpan**. Menampilkan status valid/tidak, HTTP status, dan latensi.
## [0.5.13] — 2026-11-05 — "lunas-notify"
### Diperbaiki

View File

@@ -11,7 +11,7 @@
<p align="center">
<img src="https://img.shields.io/badge/lisensi-ISC-ADFF2F" alt="Lisensi: ISC">
<img src="https://img.shields.io/badge/versi-0.5.13_lunas--notify-ADFF2F" alt="Versi 0.5.13 (lunas-notify)">
<img src="https://img.shields.io/badge/versi-0.5.14_ai--test-ADFF2F" alt="Versi 0.5.14 (ai-test)">
<img src="https://img.shields.io/badge/Node.js-%E2%89%A520-339933" alt="Node.js >= 20">
<img src="https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white" alt="Docker">
<a href="https://demo-openisp.oxpoe.xyz"><img src="https://img.shields.io/badge/demo-live-ADFF2F?logo=googlechrome&logoColor=black" alt="Demo"></a>

View File

@@ -1,6 +1,6 @@
{
"name": "open-isp",
"version": "0.5.13",
"version": "0.5.14",
"description": "OPEN-ISP \u2014 sistem billing & manajemen ISP open-source (PWA pelanggan, multi-OLT, RADIUS, WhatsApp, QRIS)",
"main": "app-customer.js",
"scripts": {

View File

@@ -4788,6 +4788,76 @@ router.post('/api/telegram/sync', requireAdminSession, async (req, res) => {
}
});
router.post('/settings/test-ai', requireAdminSession, express.json(), async (req, res) => {
const presets = {
deepseek: { baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat' },
openai: { baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
groq: { baseUrl: 'https://api.groq.com/openai/v1', model: 'llama-3.3-70b-versatile' },
openrouter: { baseUrl: 'https://openrouter.ai/api/v1', model: 'meta-llama/llama-3.3-70b-instruct' },
custom: { baseUrl: '', model: '' }
};
try {
const b = req.body || {};
const provider = String(b.provider || 'deepseek').trim().toLowerCase();
const p = presets[provider] || presets.deepseek;
const apiKey = String(b.apiKey || '').trim();
const baseUrl = String(b.baseUrl || p.baseUrl || '').trim().replace(/\/+$/, '');
const model = String(b.model || p.model || '').trim();
if (!baseUrl) return res.status(400).json({ ok: false, message: 'Base URL AI kosong.' });
if (!model) return res.status(400).json({ ok: false, message: 'Model AI kosong.' });
if (!apiKey && provider !== 'custom') return res.status(400).json({ ok: false, message: 'API key kosong.' });
const started = Date.now();
const controller = new AbortController();
const timer = setTimeout(function () { controller.abort(); }, 15000);
let r;
let text = '';
try {
r = await fetch(baseUrl + '/chat/completions', {
method: 'POST',
headers: Object.assign(
{ 'Content-Type': 'application/json' },
apiKey ? { Authorization: 'Bearer ' + apiKey } : {}
),
body: JSON.stringify({
model: model,
messages: [{ role: 'user', content: 'ping' }],
max_tokens: 5,
stream: false
}),
signal: controller.signal
});
text = await r.text();
} finally {
clearTimeout(timer);
}
const latencyMs = Date.now() - started;
let json = null;
try { json = JSON.parse(text); } catch (e) {}
if (!r.ok) {
const detail = (json && json.error && (json.error.message || json.error.code)) || text || ('HTTP ' + r.status);
const message = (r.status === 401 || r.status === 403)
? 'API key tidak valid / tidak punya akses (HTTP ' + r.status + ').'
: 'Gagal (HTTP ' + r.status + '): ' + String(detail).replace(/\s+/g, ' ').slice(0, 200);
return res.json({ ok: false, provider, model, baseUrl, status: r.status, latencyMs, message });
}
const reply = (json && json.choices && json.choices[0] &&
((json.choices[0].message && json.choices[0].message.content) || json.choices[0].text)) || '';
return res.json({
ok: true, provider, model, baseUrl, status: r.status, latencyMs,
message: 'API valid — ' + (reply ? ('balasan: ' + String(reply).trim().slice(0, 80)) : 'respon OK') + ' (' + latencyMs + ' ms)'
});
} catch (e) {
const message = e && e.name === 'AbortError'
? 'Timeout 15 detik — server AI tidak merespons.'
: ('Tidak bisa menghubungi API: ' + (e && e.message ? e.message : String(e)));
return res.status(500).json({ ok: false, message });
}
});
router.post('/settings', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
const newSettings = { ...req.body };

View File

@@ -1 +1 @@
0.5.13
0.5.14

View File

@@ -238,6 +238,10 @@
<div class="fg"><label class="fl">Custom Base URL</label><input class="fc" name="custom_ai_base_url" value="<%= settings.custom_ai_base_url || '' %>" placeholder="http://localhost:11434/v1"></div>
<div class="fg"><label class="fl">Custom API Key</label><input class="fc" type="password" autocomplete="new-password" name="custom_ai_api_key" value="<%= settings.custom_ai_api_key || '' %>" placeholder="opsional untuk Ollama"></div>
<div class="fg"><label class="fl">Custom Model</label><input class="fc" name="custom_ai_model" value="<%= settings.custom_ai_model || 'llama3' %>"></div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:12px">
<button type="button" class="btn btn-g" onclick="testAiApi(this)"><i class="bi bi-plug"></i> Test API</button>
<span id="ai-test-status" style="font-size:12px;color:var(--muted)">Menguji API key &amp; model yang sedang diisi (tanpa simpan).</span>
</div>
</div>
<div>
<h6 style="font-size:13px;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:1px"><i class="bi bi-activity"></i> Auto Repair</h6>
@@ -1252,6 +1256,46 @@ function setSearch(q) {
})();
</script>
<script>
async function testAiApi(btn) {
var st = document.getElementById('ai-test-status');
var provider = (document.querySelector('select[name="ai_provider"]') || {}).value || 'deepseek';
var map = {
deepseek: { key: 'deepseek_api_key', model: 'deepseek_model', base: '' },
openai: { key: 'openai_api_key', model: 'openai_model', base: '' },
groq: { key: 'groq_api_key', model: 'groq_model', base: '' },
openrouter: { key: 'openrouter_api_key', model: 'openrouter_model', base: '' },
custom: { key: 'custom_ai_api_key', model: 'custom_ai_model', base: 'custom_ai_base_url' }
};
var m = map[provider] || map.deepseek;
function val(name) { var el = document.querySelector('[name="' + name + '"]'); return el ? el.value : ''; }
var old = btn ? btn.innerHTML : '';
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Menguji...'; }
if (st) { st.style.color = 'var(--muted)'; st.textContent = 'Menguji ' + provider + '...'; }
try {
var r = await fetch('/admin/settings/test-ai', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: provider,
apiKey: val(m.key),
model: val(m.model),
baseUrl: m.base ? val(m.base) : ''
})
});
var d = await r.json();
if (st) {
st.style.color = d.ok ? 'var(--success)' : 'var(--danger)';
st.textContent = (d.ok ? '✅ ' : '❌ ') + (d.message || (d.ok ? 'API valid.' : 'API tidak valid.'));
}
} catch (e) {
if (st) { st.style.color = 'var(--danger)'; st.textContent = '❌ Gagal menguji: ' + e.message; }
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = old; }
}
}
</script>
<script>
async function selfHeal(mode) {
const out = document.getElementById('selfheal-out');