Agent-Mode mit Aufgabenteilung und automatischer Recherche
This commit is contained in:
+20
-4
@@ -18,16 +18,18 @@ import (
|
||||
"github.com/local/glpi-neural-brain/internal/engine"
|
||||
"github.com/local/glpi-neural-brain/internal/graph"
|
||||
"github.com/local/glpi-neural-brain/internal/model"
|
||||
"github.com/local/glpi-neural-brain/internal/sourceagent"
|
||||
)
|
||||
|
||||
//go:embed static/*
|
||||
var assets embed.FS
|
||||
|
||||
type Server struct {
|
||||
Engine *engine.Engine
|
||||
Graph *graph.Store
|
||||
Broker *activity.Broker
|
||||
APIKey string
|
||||
Engine *engine.Engine
|
||||
Graph *graph.Store
|
||||
Broker *activity.Broker
|
||||
APIKey string
|
||||
SourceAgents *sourceagent.Store
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
@@ -55,6 +57,20 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /api/glpi-kb/sync", s.handleGLPIKBSync)
|
||||
mux.HandleFunc("POST /api/flush", s.handleFlush)
|
||||
mux.HandleFunc("GET /api/state/export", s.handleStateExport)
|
||||
mux.HandleFunc("GET /api/source-agents", s.handleListSourceAgents)
|
||||
mux.HandleFunc("POST /api/source-agents", s.handleCreateSourceAgent)
|
||||
mux.HandleFunc("PATCH /api/source-agents/{id}", s.handlePatchSourceAgent)
|
||||
mux.HandleFunc("DELETE /api/source-agents/{id}", s.handleDeleteSourceAgent)
|
||||
mux.HandleFunc("POST /api/source-agents/{id}/rotate-token", s.handleRotateSourceAgentToken)
|
||||
mux.HandleFunc("GET /api/source-agents/{id}/tasks", s.handleListSourceTasks)
|
||||
mux.HandleFunc("POST /api/source-agents/{id}/tasks", s.handleCreateSourceTask)
|
||||
mux.HandleFunc("PUT /api/source-tasks/{id}", s.handleUpdateSourceTask)
|
||||
mux.HandleFunc("DELETE /api/source-tasks/{id}", s.handleDeleteSourceTask)
|
||||
mux.HandleFunc("GET /api/source-inbox", s.handleSourceInbox)
|
||||
mux.HandleFunc("GET /api/source-inbox/status", s.handleSourceInboxStatus)
|
||||
mux.HandleFunc("GET /api/v1/agent/config", s.handleAgentConfig)
|
||||
mux.HandleFunc("POST /api/v1/agent/heartbeat", s.handleAgentHeartbeat)
|
||||
mux.HandleFunc("POST /api/v1/agent/ingest", s.handleAgentIngest)
|
||||
sub, _ := fs.Sub(assets, "static")
|
||||
mux.HandleFunc("GET /analysis", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/analysis.html", http.StatusTemporaryRedirect)
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/local/glpi-neural-brain/internal/sourceagent"
|
||||
)
|
||||
|
||||
func (s *Server) sourceStoreAvailable(w http.ResponseWriter) bool {
|
||||
if s.SourceAgents != nil {
|
||||
return true
|
||||
}
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "source agent service is not initialized"})
|
||||
return false
|
||||
}
|
||||
func (s *Server) adminAuthorized(w http.ResponseWriter, r *http.Request) bool {
|
||||
if s.authorized(r) {
|
||||
return true
|
||||
}
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) handleListSourceAgents(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sourceStoreAvailable(w) || !s.adminAuthorized(w, r) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := contextTimeout(r, 10*time.Second)
|
||||
defer cancel()
|
||||
agents, err := s.SourceAgents.ListAgents(ctx)
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
tasks, _ := s.SourceAgents.ListTasks(ctx, "")
|
||||
stats, _ := s.SourceAgents.Stats(ctx)
|
||||
writeJSON(w, 200, map[string]any{"agents": agents, "tasks": tasks, "inbox": stats})
|
||||
}
|
||||
func (s *Server) handleCreateSourceAgent(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sourceStoreAvailable(w) || !s.adminAuthorized(w, r) {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := decode(r, &in); err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx, cancel := contextTimeout(r, 10*time.Second)
|
||||
defer cancel()
|
||||
a, token, err := s.SourceAgents.CreateAgent(ctx, in.ID, in.Name)
|
||||
if err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"agent": a, "token": token, "token_notice": "Der Token wird nur in dieser Antwort im Klartext ausgegeben."})
|
||||
}
|
||||
func (s *Server) handlePatchSourceAgent(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sourceStoreAvailable(w) || !s.adminAuthorized(w, r) {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := decode(r, &in); err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if in.Enabled == nil {
|
||||
writeJSON(w, 400, map[string]string{"error": "enabled is required"})
|
||||
return
|
||||
}
|
||||
ctx, cancel := contextTimeout(r, 10*time.Second)
|
||||
defer cancel()
|
||||
if err := s.SourceAgents.SetAgentEnabled(ctx, r.PathValue("id"), *in.Enabled); err != nil {
|
||||
writeJSON(w, 404, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
func (s *Server) handleDeleteSourceAgent(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sourceStoreAvailable(w) || !s.adminAuthorized(w, r) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := contextTimeout(r, 10*time.Second)
|
||||
defer cancel()
|
||||
if err := s.SourceAgents.DeleteAgent(ctx, r.PathValue("id")); err != nil {
|
||||
writeJSON(w, 500, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
func (s *Server) handleRotateSourceAgentToken(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sourceStoreAvailable(w) || !s.adminAuthorized(w, r) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := contextTimeout(r, 10*time.Second)
|
||||
defer cancel()
|
||||
token, err := s.SourceAgents.RotateToken(ctx, r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, 404, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"token": token, "token_notice": "Der neue Token wird nur in dieser Antwort im Klartext ausgegeben."})
|
||||
}
|
||||
func (s *Server) handleListSourceTasks(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sourceStoreAvailable(w) || !s.adminAuthorized(w, r) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := contextTimeout(r, 10*time.Second)
|
||||
defer cancel()
|
||||
tasks, err := s.SourceAgents.ListTasks(ctx, r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"tasks": tasks})
|
||||
}
|
||||
func (s *Server) handleCreateSourceTask(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sourceStoreAvailable(w) || !s.adminAuthorized(w, r) {
|
||||
return
|
||||
}
|
||||
var t sourceagent.Task
|
||||
if err := decode(r, &t); err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
t.AgentID = r.PathValue("id")
|
||||
if strings.TrimSpace(t.ID) == "" {
|
||||
t.ID = "task-" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
}
|
||||
ctx, cancel := contextTimeout(r, 10*time.Second)
|
||||
defer cancel()
|
||||
out, err := s.SourceAgents.UpsertTask(ctx, t)
|
||||
if err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
func (s *Server) handleUpdateSourceTask(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sourceStoreAvailable(w) || !s.adminAuthorized(w, r) {
|
||||
return
|
||||
}
|
||||
var t sourceagent.Task
|
||||
if err := decode(r, &t); err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
t.ID = r.PathValue("id")
|
||||
if t.AgentID == "" {
|
||||
writeJSON(w, 400, map[string]string{"error": "agent_id required"})
|
||||
return
|
||||
}
|
||||
ctx, cancel := contextTimeout(r, 10*time.Second)
|
||||
defer cancel()
|
||||
out, err := s.SourceAgents.UpsertTask(ctx, t)
|
||||
if err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
func (s *Server) handleDeleteSourceTask(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sourceStoreAvailable(w) || !s.adminAuthorized(w, r) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := contextTimeout(r, 10*time.Second)
|
||||
defer cancel()
|
||||
if err := s.SourceAgents.DeleteTask(ctx, r.PathValue("id")); err != nil {
|
||||
writeJSON(w, 500, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
func (s *Server) handleSourceInbox(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sourceStoreAvailable(w) || !s.adminAuthorized(w, r) {
|
||||
return
|
||||
}
|
||||
limit := 100
|
||||
if n, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && n > 0 && n <= 1000 {
|
||||
limit = n
|
||||
}
|
||||
ctx, cancel := contextTimeout(r, 10*time.Second)
|
||||
defer cancel()
|
||||
docs, err := s.SourceAgents.ListInbox(ctx, strings.TrimSpace(r.URL.Query().Get("status")), limit)
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"documents": docs})
|
||||
}
|
||||
func (s *Server) handleSourceInboxStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sourceStoreAvailable(w) || !s.adminAuthorized(w, r) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := contextTimeout(r, 10*time.Second)
|
||||
defer cancel()
|
||||
st, err := s.SourceAgents.Stats(ctx)
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, st)
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
v := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
if strings.HasPrefix(v, "Bearer ") {
|
||||
return strings.TrimSpace(strings.TrimPrefix(v, "Bearer "))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func (s *Server) authenticateSourceAgent(r *http.Request) (sourceagent.Agent, error) {
|
||||
if s.SourceAgents == nil {
|
||||
return sourceagent.Agent{}, errors.New("source agent service unavailable")
|
||||
}
|
||||
ctx, cancel := contextTimeout(r, 10*time.Second)
|
||||
defer cancel()
|
||||
a, err := s.SourceAgents.Authenticate(ctx, bearerToken(r))
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
if want := strings.TrimSpace(r.Header.Get("X-Brain-Agent-ID")); want != "" && want != a.ID {
|
||||
return sourceagent.Agent{}, errors.New("agent id does not match token")
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
func (s *Server) handleAgentConfig(w http.ResponseWriter, r *http.Request) {
|
||||
a, err := s.authenticateSourceAgent(r)
|
||||
if err != nil {
|
||||
writeJSON(w, 401, map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
ctx, cancel := contextTimeout(r, 10*time.Second)
|
||||
defer cancel()
|
||||
cfg, err := s.SourceAgents.RemoteConfig(ctx, a)
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, cfg)
|
||||
}
|
||||
func (s *Server) handleAgentHeartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
a, err := s.authenticateSourceAgent(r)
|
||||
if err != nil {
|
||||
writeJSON(w, 401, map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
var h sourceagent.Heartbeat
|
||||
if err := decode(r, &h); err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if h.AgentID != "" && h.AgentID != a.ID {
|
||||
writeJSON(w, 403, map[string]string{"error": "agent_id mismatch"})
|
||||
return
|
||||
}
|
||||
ctx, cancel := contextTimeout(r, 10*time.Second)
|
||||
defer cancel()
|
||||
if err := s.SourceAgents.Heartbeat(ctx, a.ID, h); err != nil {
|
||||
writeJSON(w, 500, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true, "server_time": time.Now().UTC()})
|
||||
}
|
||||
func (s *Server) handleAgentIngest(w http.ResponseWriter, r *http.Request) {
|
||||
a, err := s.authenticateSourceAgent(r)
|
||||
if err != nil {
|
||||
writeJSON(w, 401, map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
var batch sourceagent.IngestBatch
|
||||
dec := json.NewDecoder(io.LimitReader(r.Body, 32<<20))
|
||||
if err := dec.Decode(&batch); err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if batch.AgentID != "" && batch.AgentID != a.ID {
|
||||
writeJSON(w, 403, map[string]string{"error": "agent_id mismatch"})
|
||||
return
|
||||
}
|
||||
if batch.SchemaVersion != 0 && batch.SchemaVersion != sourceagent.SchemaVersion {
|
||||
writeJSON(w, 400, map[string]string{"error": "unsupported schema_version"})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(batch.TaskID) == "" || len(batch.Documents) == 0 || len(batch.Documents) > 500 {
|
||||
writeJSON(w, 400, map[string]string{"error": "task_id and 1..500 documents are required"})
|
||||
return
|
||||
}
|
||||
ctx, cancel := contextTimeout(r, 60*time.Second)
|
||||
defer cancel()
|
||||
result, err := s.SourceAgents.Ingest(ctx, a.ID, batch.TaskID, batch.Documents)
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_ = s.SourceAgents.Heartbeat(ctx, a.ID, sourceagent.Heartbeat{AgentID: a.ID, Status: "ingest", Documents: result.Accepted})
|
||||
writeJSON(w, http.StatusAccepted, result)
|
||||
}
|
||||
@@ -61,6 +61,7 @@
|
||||
<button id="toggleEco" title="Optimierter Renderpfad für schwächere Systeme">ECO</button>
|
||||
<button id="resetView">Zentrieren</button>
|
||||
<button id="openSettings" title="Exakte KB-source-Filter und Laufzeitsteuerung">FILTER</button>
|
||||
<a class="analysis-dashboard-link" href="/source-agents.html" title="Verteilte Source-Agenten, Polling-Aufgaben und Source-Inbox">AGENTS</a>
|
||||
<a class="analysis-dashboard-link" href="/analysis.html" title="Technisches Analyse-Dashboard mit Lauf- und Graphhistorie">ANALYSE</a>
|
||||
<button id="enrichNow" class="think-action" title="Einen autonomen AI-THINK-Zyklus sofort starten"><span>AI-THINK</span><small>STARTEN</small></button>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
:root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,sans-serif;background:#040810;color:#dcecff}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 70% 0,#0b2637 0,#040810 42%);min-height:100vh}header{position:sticky;top:0;z-index:5;display:flex;justify-content:space-between;align-items:center;padding:18px 28px;background:#07111bd9;border-bottom:1px solid #173244;backdrop-filter:blur(18px)}header strong{display:block;letter-spacing:.16em;color:#66e3ff}header small{color:#7f9bad}nav{display:flex;gap:10px}a,button{color:#ccefff;background:#0a1a27;border:1px solid #285069;border-radius:9px;padding:9px 13px;text-decoration:none;cursor:pointer}button:hover,a:hover{border-color:#52dfff}main{width:min(1500px,96vw);margin:24px auto 60px;display:grid;gap:18px}.panel{background:#07121dcc;border:1px solid #17384b;border-radius:16px;padding:18px;box-shadow:0 20px 60px #0006}.panel h2{margin:0 0 8px;font-size:17px}.panel p{margin:0;color:#7896a9;font-size:13px}.auth{display:flex;gap:12px;align-items:center}.auth>div{margin-right:auto}.auth input{max-width:360px}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.stats article{padding:18px;border:1px solid #17384b;border-radius:14px;background:#07121dcc}.stats b{display:block;font-size:28px;color:#64e2ff}.stats span{color:#829dad}.grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}label{display:grid;gap:6px;margin-top:12px;color:#8eaabb;font-size:12px}input,select{width:100%;padding:10px 11px;border:1px solid #25465b;border-radius:8px;background:#030a10;color:#e5f5ff}.two{display:grid;grid-template-columns:1fr 1fr;gap:10px}.panel>button{margin-top:14px}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(310px,1fr));gap:12px;margin-top:14px}.card{border:1px solid #19384b;border-radius:12px;padding:14px;background:#050d15}.card-head{display:flex;justify-content:space-between;gap:12px}.card small,.muted{color:#7692a3}.status-online{color:#68f6b1}.status-offline{color:#ffb06b}.actions{display:flex;flex-wrap:wrap;gap:7px;margin-top:12px}.actions button{padding:6px 9px;font-size:11px}.task{margin-top:10px;padding:9px;border-left:2px solid #31a7cd;background:#081722}.task b{font-size:12px}.task span{display:block;color:#7994a5;font-size:11px;overflow-wrap:anywhere}.token-output{white-space:pre-wrap;word-break:break-all;background:#02070c;border:1px solid #2a5871;padding:12px;border-radius:10px;color:#9ff0ff}.hidden{display:none}.title-row{display:flex;justify-content:space-between;align-items:center;gap:12px}.title-row select{width:auto}.table-wrap{overflow:auto;margin-top:14px}table{width:100%;border-collapse:collapse;font-size:12px}th,td{padding:10px;border-bottom:1px solid #142b39;text-align:left;vertical-align:top}th{color:#79a6bb}td a{padding:0;border:0;background:none;color:#79dcff}.pill{display:inline-block;padding:3px 7px;border-radius:99px;background:#123246;color:#aeefff}.pill.candidate{background:#143c30;color:#8dffc5}.pill.archived{background:#2b2f36;color:#aab3bd}.pill.received,.pill.processing{background:#3a3215;color:#ffe59a}#toast{position:fixed;right:20px;bottom:20px;max-width:440px;padding:12px 16px;border-radius:10px;background:#102c3c;border:1px solid #3a718c;opacity:0;pointer-events:none;transition:.2s}#toast.show{opacity:1}@media(max-width:850px){.grid,.stats{grid-template-columns:1fr}.auth{align-items:stretch;flex-direction:column}.auth>div{margin:0}header{padding:14px}.title-row{align-items:flex-start;flex-direction:column}}
|
||||
|
||||
label.check{display:flex;grid-template-columns:auto 1fr;align-items:center;gap:8px}.check input{width:auto}
|
||||
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Source Agents · Neural Brain</title>
|
||||
<link rel="stylesheet" href="/source-agents.css">
|
||||
</head>
|
||||
<body>
|
||||
<header><div><strong>SOURCE AGENTS</strong><small>Verteilte Quellenbeobachtung · Inbox vor SearXNG</small></div><nav><a href="/">BRAIN</a><a href="/analysis.html">ANALYSE</a></nav></header>
|
||||
<main>
|
||||
<section class="panel auth"><div><h2>Administration</h2><p>Falls <code>BRAIN_API_KEY</code> gesetzt ist, wird er nur in dieser Browser-Session gehalten.</p></div><input id="apiKey" type="password" placeholder="BRAIN_API_KEY (optional)"><button id="saveKey">Übernehmen</button></section>
|
||||
<section class="stats" id="stats"><article><b id="agentCount">0</b><span>Agents</span></article><article><b id="taskCount">0</b><span>Tasks</span></article><article><b id="candidateCount">0</b><span>Inbox-Kandidaten</span></article><article><b id="receivedCount">0</b><span>Queue</span></article></section>
|
||||
<section class="grid">
|
||||
<article class="panel"><h2>Agent anlegen</h2><label>Name<input id="agentName" placeholder="Security News Frankfurt"></label><label>ID (optional)<input id="agentID" placeholder="security-frankfurt"></label><button id="createAgent">AGENT ERSTELLEN</button><pre id="tokenOutput" class="token-output hidden"></pre></article>
|
||||
<article class="panel"><h2>Polling-Aufgabe</h2><label>Agent<select id="taskAgent"></select></label><div class="two"><label>Typ<select id="taskType"><option>rss</option><option>atom</option><option>sitemap</option><option>web</option></select></label><label>Intervall<input id="taskInterval" value="4h"></label></div><label>Name<input id="taskName" placeholder="Microsoft Security Blog"></label><label>URL<input id="taskURL" placeholder="https://example.org/feed.xml"></label><label>Kategorien<input id="taskCategories" placeholder="Security, Microsoft"></label><label>Max. Elemente pro Lauf<input id="taskMaxItems" type="number" min="1" max="500" value="20"></label><label class="check"><input id="taskRefetchSeen" type="checkbox"> Bereits bekannte URLs erneut auf Inhaltsänderungen prüfen</label><button id="createTask">TASK SPEICHERN</button></article>
|
||||
</section>
|
||||
<section class="panel"><div class="title-row"><div><h2>Agents</h2><p>Tokenrechte: config · heartbeat · ingest. Kein Graph-/Adminzugriff.</p></div><button id="reload">AKTUALISIEREN</button></div><div id="agents" class="cards"></div></section>
|
||||
<section class="panel"><div class="title-row"><div><h2>Source Inbox</h2><p>Nur als <b>candidate</b> klassifizierte Dokumente werden vor SearXNG durchsucht. Graph-Nodes entstehen erst nach tatsächlichem Claim-Grounding.</p></div><select id="inboxFilter"><option value="">alle</option><option>received</option><option>processing</option><option>candidate</option><option>archived</option><option>used</option></select></div><div class="table-wrap"><table><thead><tr><th>Status</th><th>Titel / Quelle</th><th>Relevanz</th><th>Agent / Task</th><th>Eingang</th></tr></thead><tbody id="inbox"></tbody></table></div></section>
|
||||
</main>
|
||||
<div id="toast"></div>
|
||||
<script src="/source-agents.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
(() => {
|
||||
const $=id=>document.getElementById(id); let snapshot={agents:[],tasks:[],inbox:{}};
|
||||
$('apiKey').value=sessionStorage.getItem('brain_api_key')||'';
|
||||
$('saveKey').onclick=()=>{sessionStorage.setItem('brain_api_key',$('apiKey').value.trim());toast('API-Key für diese Session gespeichert.');loadAll()};
|
||||
function headers(){const h={'Content-Type':'application/json'},key=sessionStorage.getItem('brain_api_key')||'';if(key)h.Authorization=`Bearer ${key}`;return h}
|
||||
async function api(path,opt={}){const res=await fetch(path,{...opt,headers:{...headers(),...(opt.headers||{})}});const data=await res.json().catch(()=>({}));if(!res.ok)throw new Error(data.error||`HTTP ${res.status}`);return data}
|
||||
function toast(msg){const t=$('toast');t.textContent=msg;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),3500)}
|
||||
function esc(v){return String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
|
||||
function when(v){if(!v)return 'nie';const d=new Date(v);if(Number.isNaN(d.getTime()))return 'nie';return d.toLocaleString()}
|
||||
async function loadAll(){try{snapshot=await api('/api/source-agents');render();await loadInbox()}catch(e){toast(e.message)}}
|
||||
function render(){const {agents=[],tasks=[],inbox={}}=snapshot;$('agentCount').textContent=agents.length;$('taskCount').textContent=tasks.length;$('candidateCount').textContent=inbox.candidate||0;$('receivedCount').textContent=(inbox.received||0)+(inbox.processing||0);$('taskAgent').innerHTML=agents.map(a=>`<option value="${esc(a.id)}">${esc(a.name)} · ${esc(a.id)}</option>`).join('');$('agents').innerHTML=agents.length?agents.map(a=>{const own=tasks.filter(t=>t.agent_id===a.id),online=a.last_seen&&Date.now()-new Date(a.last_seen).getTime()<15*60*1000;return `<div class="card"><div class="card-head"><div><b>${esc(a.name)}</b><small>${esc(a.id)}</small></div><span class="${online?'status-online':'status-offline'}">● ${online?'online':'offline'}</span></div><small>Version ${esc(a.version||'—')} · letzter Kontakt ${esc(when(a.last_seen))}</small>${a.last_error?`<p>${esc(a.last_error)}</p>`:''}<div class="actions"><button data-rotate="${esc(a.id)}">TOKEN ROTIEREN</button><button data-toggle="${esc(a.id)}" data-enabled="${a.enabled}">${a.enabled?'DEAKTIVIEREN':'AKTIVIEREN'}</button><button data-delete-agent="${esc(a.id)}">LÖSCHEN</button></div>${own.map(t=>`<div class="task"><b>${esc(t.name)} · ${esc(t.type)} · ${esc(t.poll_interval)}</b><span>${esc(t.url)}</span><div class="actions"><button data-delete-task="${esc(t.id)}">TASK LÖSCHEN</button></div></div>`).join('')}</div>`}).join(''):'<p class="muted">Noch keine Agenten konfiguriert.</p>';wireActions()}
|
||||
function wireActions(){document.querySelectorAll('[data-rotate]').forEach(b=>b.onclick=async()=>{try{const d=await api(`/api/source-agents/${b.dataset.rotate}/rotate-token`,{method:'POST'});showToken(b.dataset.rotate,d.token);await loadAll()}catch(e){toast(e.message)}});document.querySelectorAll('[data-toggle]').forEach(b=>b.onclick=async()=>{try{await api(`/api/source-agents/${b.dataset.toggle}`,{method:'PATCH',body:JSON.stringify({enabled:b.dataset.enabled!=='true'})});await loadAll()}catch(e){toast(e.message)}});document.querySelectorAll('[data-delete-agent]').forEach(b=>b.onclick=async()=>{if(!confirm('Agent inklusive Tasks löschen?'))return;try{await api(`/api/source-agents/${b.dataset.deleteAgent}`,{method:'DELETE'});await loadAll()}catch(e){toast(e.message)}});document.querySelectorAll('[data-delete-task]').forEach(b=>b.onclick=async()=>{try{await api(`/api/source-tasks/${b.dataset.deleteTask}`,{method:'DELETE'});await loadAll()}catch(e){toast(e.message)}})}
|
||||
function showToken(id,token){const brain=location.origin;$('tokenOutput').textContent=`Token nur jetzt kopieren:\n${token}\n\nAgent-ENV:\nBRAIN_MODE=agent\nBRAIN_AGENT_BRAIN_URL=${brain}\nBRAIN_AGENT_ID=${id}\nBRAIN_AGENT_TOKEN=${token}`;$('tokenOutput').classList.remove('hidden')}
|
||||
$('createAgent').onclick=async()=>{try{const d=await api('/api/source-agents',{method:'POST',body:JSON.stringify({id:$('agentID').value.trim(),name:$('agentName').value.trim()})});showToken(d.agent.id,d.token);$('agentName').value='';$('agentID').value='';await loadAll()}catch(e){toast(e.message)}};
|
||||
$('createTask').onclick=async()=>{const agent=$('taskAgent').value;if(!agent)return toast('Zuerst einen Agent anlegen.');const task={name:$('taskName').value.trim(),type:$('taskType').value,url:$('taskURL').value.trim(),enabled:true,poll_interval:$('taskInterval').value.trim(),categories:$('taskCategories').value.split(',').map(x=>x.trim()).filter(Boolean),max_items:Number($('taskMaxItems').value)||20,config:{refetch_seen:$('taskRefetchSeen').checked?'true':'false'}};try{await api(`/api/source-agents/${encodeURIComponent(agent)}/tasks`,{method:'POST',body:JSON.stringify(task)});$('taskName').value='';$('taskURL').value='';await loadAll();toast('Polling-Aufgabe gespeichert.')}catch(e){toast(e.message)}};
|
||||
async function loadInbox(){try{const status=$('inboxFilter').value;const d=await api(`/api/source-inbox?limit=100${status?`&status=${encodeURIComponent(status)}`:''}`);$('inbox').innerHTML=(d.documents||[]).map(x=>`<tr><td><span class="pill ${esc(x.status)}">${esc(x.status)}</span></td><td><a href="${esc(x.document.canonical_url||x.document.url)}" target="_blank" rel="noreferrer">${esc(x.document.title)}</a><br><span class="muted">${esc(x.document.source_name||x.document.source_base_url||'')}</span></td><td>${(Number(x.relevance||0)*100).toFixed(0)}%</td><td>${esc(x.agent_id)}<br><span class="muted">${esc(x.task_id)}</span></td><td>${esc(when(x.received_at))}</td></tr>`).join('')}catch(e){toast(e.message)}}
|
||||
$('inboxFilter').onchange=loadInbox;$('reload').onclick=loadAll;loadAll();setInterval(loadAll,30000);
|
||||
})();
|
||||
Reference in New Issue
Block a user