Update Ollama integration
All checks were successful
release-tag / release-image (push) Successful in 1m32s

This commit is contained in:
2026-07-29 09:43:02 +02:00
parent afa519b72a
commit a33ff09c41
16 changed files with 1238 additions and 31 deletions

View File

@@ -1,6 +1,7 @@
package main
import (
"context"
"encoding/json"
"errors"
"io"
@@ -10,20 +11,25 @@ import (
"strconv"
"strings"
"kb-editor/internal/aifallback"
"kb-editor/internal/store"
)
type appConfig struct {
Mode string `json:"mode"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Writable bool `json:"writable"`
Mode string `json:"mode"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Writable bool `json:"writable"`
AIFallbackEnabled bool `json:"ai_fallback_enabled"`
AIFallbackTimeoutSeconds int `json:"ai_fallback_timeout_seconds,omitempty"`
AIFallbackModel string `json:"ai_fallback_model,omitempty"`
}
type app struct {
store *store.Store
web fs.FS
config appConfig
ai *aifallback.Service
}
func newApp(s *store.Store, web fs.FS, configs ...appConfig) *app {
@@ -34,6 +40,11 @@ func newApp(s *store.Store, web fs.FS, configs ...appConfig) *app {
return &app{store: s, web: web, config: cfg}
}
func (a *app) withAI(service *aifallback.Service) *app {
a.ai = service
return a
}
func (a *app) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/health", a.handleHealth)
@@ -42,6 +53,8 @@ func (a *app) routes() http.Handler {
mux.HandleFunc("GET /api/search", a.handleSearch)
mux.HandleFunc("GET /api/facets", a.handleFacets)
mux.HandleFunc("GET /api/items/{key}", a.handleGet)
mux.HandleFunc("POST /api/ai/fallback", a.handleAIFallback)
mux.HandleFunc("GET /api/staging/{key}", a.handleStagingGet)
if a.config.Writable {
mux.HandleFunc("PUT /api/items/{key}", a.handlePut)
@@ -71,11 +84,12 @@ func securityHeaders(next http.Handler) http.Handler {
func (a *app) handleHealth(w http.ResponseWriter, r *http.Request) {
payload := map[string]any{
"ok": true,
"count": a.store.Count(),
"data_dir": a.store.DataDir(),
"mode": a.config.Mode,
"writable": a.config.Writable,
"ok": true,
"count": a.store.Count(),
"data_dir": a.store.DataDir(),
"mode": a.config.Mode,
"writable": a.config.Writable,
"ai_fallback_enabled": a.config.AIFallbackEnabled && a.ai != nil,
}
if a.config.Writable {
payload["backup_dir"] = a.store.BackupDir()
@@ -130,6 +144,66 @@ func (a *app) handleGet(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"document": doc, "meta": meta})
}
type aiFallbackRequest struct {
Query string `json:"query"`
}
func (a *app) handleAIFallback(w http.ResponseWriter, r *http.Request) {
if !a.config.AIFallbackEnabled || a.ai == nil {
writeError(w, http.StatusNotFound, "KI-Fallback ist auf dieser Instanz deaktiviert")
return
}
if !mustJSONContentType(w, r) {
return
}
var req aiFallbackRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage: "+err.Error())
return
}
query := strings.TrimSpace(req.Query)
if len([]rune(query)) < 3 {
writeError(w, http.StatusBadRequest, "Suchanfrage ist für den KI-Fallback zu kurz")
return
}
// Server-side guard: AI generation is only permitted when the regular KB has zero hits.
check := a.store.Search(store.Query{Q: query, Page: 1, PageSize: 1})
if check.Total > 0 {
writeJSON(w, http.StatusConflict, map[string]any{
"error": "Die Wissensbasis enthält inzwischen passende Treffer; KI-Fallback wurde nicht gestartet",
"total": check.Total,
})
return
}
result, err := a.ai.Generate(r.Context(), query)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(r.Context().Err(), context.DeadlineExceeded) {
writeError(w, http.StatusGatewayTimeout, "KI-Fallback hat das Zeitlimit überschritten")
return
}
writeError(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusCreated, result)
}
func (a *app) handleStagingGet(w http.ResponseWriter, r *http.Request) {
if !a.config.AIFallbackEnabled || a.ai == nil {
writeError(w, http.StatusNotFound, "Staging-Viewer ist auf dieser Instanz deaktiviert")
return
}
result, err := a.ai.GetStaging(r.PathValue("key"))
if errors.Is(err, os.ErrNotExist) {
writeError(w, http.StatusNotFound, "Staging-Eintrag nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (a *app) handleReadOnly(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusForbidden, "Diese Instanz läuft im Google-/Viewer-Modus und ist schreibgeschützt")
}

View File

@@ -9,7 +9,10 @@ import (
"os"
"path/filepath"
"testing"
"time"
"kb-editor/internal/aifallback"
"kb-editor/internal/staging"
"kb-editor/internal/store"
)
@@ -127,3 +130,75 @@ func TestSearchEndpointReturnsRankedHits(t *testing.T) {
t.Fatalf("exact ID should rank first: %+v", result.Items)
}
}
func TestAIFallbackOnlyRunsForZeroResultsAndReturnsStagingArticle(t *testing.T) {
knowledge := t.TempDir()
b, _ := json.Marshal(map[string]any{"id": "KB-KNOWN", "title": "Bekannter Fehler", "answer": "Bekannte Lösung"})
if err := os.WriteFile(filepath.Join(knowledge, "known.json"), b, 0o644); err != nil {
t.Fatal(err)
}
s, err := store.New(knowledge)
if err != nil {
t.Fatal(err)
}
calls := 0
ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"message": map[string]any{"content": `{"title":"KI-Entwurf","text":"Symptom","answer":"1. Diagnose","categories":["Windows"],"keywords":["unbekannt"]}`},
"done": true,
})
}))
defer ollama.Close()
st, err := staging.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
ai, err := aifallback.New(aifallback.Config{BaseURL: ollama.URL, Model: "test-model", Timeout: time.Second, MaxConcurrent: 1, MinScore: 0.78}, st)
if err != nil {
t.Fatal(err)
}
web, err := fs.Sub(webFS, "viewer")
if err != nil {
t.Fatal(err)
}
h := newApp(s, web, appConfig{Mode: "google", Title: "Helpdesk", Writable: false, AIFallbackEnabled: true}).withAI(ai).routes()
// Existing results must block the AI path before Ollama is called.
req := httptest.NewRequest(http.MethodPost, "/api/ai/fallback", bytes.NewBufferString(`{"query":"Bekannter Fehler"}`))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusConflict {
t.Fatalf("known query status=%d body=%s", rr.Code, rr.Body.String())
}
if calls != 0 {
t.Fatalf("Ollama should not be called when KB has hits, calls=%d", calls)
}
// Unknown query is generated and stored in staging.
req = httptest.NewRequest(http.MethodPost, "/api/ai/fallback", bytes.NewBufferString(`{"query":"0xDEADBEEF völlig unbekannt"}`))
req.Header.Set("Content-Type", "application/json")
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("unknown query status=%d body=%s", rr.Code, rr.Body.String())
}
var generated aifallback.Result
if err := json.Unmarshal(rr.Body.Bytes(), &generated); err != nil {
t.Fatal(err)
}
if calls != 1 || generated.Key == "" {
t.Fatalf("calls=%d result=%+v", calls, generated)
}
get := httptest.NewRequest(http.MethodGet, "/api/staging/"+generated.Key, nil)
getRR := httptest.NewRecorder()
h.ServeHTTP(getRR, get)
if getRR.Code != http.StatusOK {
t.Fatalf("staging get status=%d body=%s", getRR.Code, getRR.Body.String())
}
}

View File

@@ -9,9 +9,13 @@ import (
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"kb-editor/internal/aifallback"
"kb-editor/internal/staging"
"kb-editor/internal/store"
)
@@ -35,6 +39,16 @@ func main() {
log.Fatalf("initialize store: %v", err)
}
aiService, aiTimeout, err := aiServiceFromEnv(cfg.Mode, s.DataDir())
if err != nil {
log.Fatal(err)
}
if aiService != nil {
cfg.AIFallbackEnabled = true
cfg.AIFallbackTimeoutSeconds = int(aiTimeout.Seconds())
cfg.AIFallbackModel = aiService.Model()
}
reloadInterval, err := autoReloadInterval(cfg.Mode)
if err != nil {
log.Fatal(err)
@@ -48,15 +62,19 @@ func main() {
log.Fatal(err)
}
app := newApp(s, sub, cfg)
app := newApp(s, sub, cfg).withAI(aiService)
handler := requestLogger(optionalBasicAuth(app.routes()))
writeTimeout := 60 * time.Second
if aiService != nil && aiTimeout+30*time.Second > writeTimeout {
writeTimeout = aiTimeout + 30*time.Second
}
srv := &http.Server{
Addr: listen,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
WriteTimeout: writeTimeout,
IdleTimeout: 90 * time.Second,
}
@@ -66,6 +84,9 @@ func main() {
if reloadInterval > 0 {
log.Printf("Automatic index reload: %s", reloadInterval)
}
if aiService != nil {
log.Printf("AI fallback enabled: model=%q timeout=%s staging=%s", aiService.Model(), aiTimeout, aiService.StagingDir())
}
if u := os.Getenv("BASIC_AUTH_USER"); u != "" {
log.Printf("Basic authentication enabled for user %q", u)
}
@@ -127,6 +148,89 @@ func startAutoReload(s *store.Store, interval time.Duration) {
}
}
func aiServiceFromEnv(mode, dataDir string) (*aifallback.Service, time.Duration, error) {
enabled, err := envBool("AI_FALLBACK_ENABLED", false)
if err != nil {
return nil, 0, err
}
if !enabled {
return nil, 0, nil
}
if mode != "google" {
return nil, 0, fmt.Errorf("AI_FALLBACK_ENABLED is only supported with APP_MODE=google")
}
timeout, err := time.ParseDuration(envOr("OLLAMA_TIMEOUT", "10m"))
if err != nil || timeout < time.Second {
return nil, 0, fmt.Errorf("invalid OLLAMA_TIMEOUT: expected a duration such as 10m")
}
maxConcurrent, err := strconv.Atoi(envOr("OLLAMA_MAX_CONCURRENT", "1"))
if err != nil || maxConcurrent < 1 || maxConcurrent > 16 {
return nil, 0, fmt.Errorf("OLLAMA_MAX_CONCURRENT must be an integer between 1 and 16")
}
autoReply, err := envBool("OLLAMA_STAGING_AUTO_REPLY", false)
if err != nil {
return nil, 0, err
}
minScore, err := strconv.ParseFloat(envOr("OLLAMA_STAGING_MIN_SCORE", "0.78"), 64)
if err != nil || minScore < 0 || minScore > 1 {
return nil, 0, fmt.Errorf("OLLAMA_STAGING_MIN_SCORE must be between 0 and 1")
}
stagingDir := strings.TrimSpace(os.Getenv("STAGING_DIR"))
if stagingDir == "" {
stagingDir = filepath.Join(filepath.Dir(dataDir), "staging")
}
stagingAbs, err := filepath.Abs(stagingDir)
if err != nil {
return nil, 0, err
}
dataAbs, err := filepath.Abs(dataDir)
if err != nil {
return nil, 0, err
}
if pathContains(dataAbs, stagingAbs) || pathContains(stagingAbs, dataAbs) {
return nil, 0, fmt.Errorf("STAGING_DIR (%s) must be separate from DATA_DIR (%s)", stagingAbs, dataAbs)
}
st, err := staging.New(stagingAbs)
if err != nil {
return nil, 0, err
}
svc, err := aifallback.New(aifallback.Config{
BaseURL: envOr("OLLAMA_BASE_URL", "http://ollama:11434"),
Model: strings.TrimSpace(os.Getenv("OLLAMA_MODEL")),
Timeout: timeout,
MaxConcurrent: maxConcurrent,
AutoReply: autoReply,
MinScore: minScore,
}, st)
if err != nil {
return nil, 0, err
}
return svc, timeout, nil
}
func pathContains(parent, child string) bool {
rel, err := filepath.Rel(parent, child)
if err != nil {
return false
}
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
}
func envBool(key string, fallback bool) (bool, error) {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return fallback, nil
}
value, err := strconv.ParseBool(raw)
if err != nil {
return false, fmt.Errorf("invalid %s %q: expected true or false", key, raw)
}
return value, nil
}
func envOr(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v

View File

@@ -12,15 +12,24 @@
config: null,
currentKey: null,
currentDoc: null,
currentStaging: false,
aiResultKey: null,
aiRunning: false,
aiController: null,
aiTimerHandle: null,
aiStartedAt: 0,
aiRunToken: 0,
};
const els = {
brandTitle: $('#brandTitle'), brandSubtitle: $('#brandSubtitle'), countBadge: $('#countBadge'),
brandTitle: $('#brandTitle'), brandSubtitle: $('#brandSubtitle'), countBadge: $('#countBadge'), modeBadge: $('#modeBadge'),
hero: $('#hero'), heroSearchForm: $('#heroSearchForm'), heroSearch: $('#heroSearch'), quickLinks: $('#quickLinks'),
resultsView: $('#resultsView'), topSearchForm: $('#topSearchForm'), topSearch: $('#topSearch'),
resultCount: $('#resultCount'), resultHint: $('#resultHint'), clearSearch: $('#clearSearch'), sideFacets: $('#sideFacets'),
loading: $('#loading'), noResults: $('#noResults'), resultList: $('#resultList'), pagination: $('#pagination'),
articleDialog: $('#articleDialog'), closeArticle: $('#closeArticle'), articleEyebrow: $('#articleEyebrow'),
loading: $('#loading'), noResults: $('#noResults'), noResultsHint: $('#noResultsHint'), resultList: $('#resultList'), pagination: $('#pagination'),
aiFallbackPanel: $('#aiFallbackPanel'), aiTitle: $('#aiTitle'), aiStatus: $('#aiStatus'), aiProgress: $('#aiProgress'),
aiTimer: $('#aiTimer'), aiNote: $('#aiNote'), openAIResult: $('#openAIResult'), retryAI: $('#retryAI'),
articleDialog: $('#articleDialog'), closeArticle: $('#closeArticle'), articleEyebrow: $('#articleEyebrow'), stagingBadge: $('#stagingBadge'),
articleTitle: $('#articleTitle'), articleMeta: $('#articleMeta'), problemSection: $('#problemSection'),
articleProblem: $('#articleProblem'), answerSection: $('#answerSection'), articleAnswer: $('#articleAnswer'),
tagsSection: $('#tagsSection'), articleTags: $('#articleTags'), sourceSection: $('#sourceSection'),
@@ -28,13 +37,28 @@
articlePath: $('#articlePath'), copyAnswer: $('#copyAnswer'), copyLink: $('#copyLink'), toastHost: $('#toastHost'),
};
async function api(url) {
const response = await fetch(url, {headers: {'Accept': 'application/json'}});
async function api(url, options = {}) {
const headers = {'Accept': 'application/json', ...(options.headers || {})};
const response = await fetch(url, {...options, headers});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error || `${response.status} ${response.statusText}`);
if (!response.ok) {
const error = new Error(body.error || `${response.status} ${response.statusText}`);
error.status = response.status;
error.body = body;
throw error;
}
return body;
}
async function postJSON(url, data, options = {}) {
return api(url, {
method: 'POST',
body: JSON.stringify(data),
...options,
headers: {'Content-Type': 'application/json', ...(options.headers || {})},
});
}
function escapeHTML(value) {
return String(value ?? '').replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
}
@@ -64,7 +88,7 @@
const params = new URLSearchParams();
if (state.query) params.set('q', state.query);
if (state.page > 1) params.set('page', String(state.page));
if (state.currentKey) params.set('doc', state.currentKey);
if (state.currentKey) params.set(state.currentStaging ? 'staging' : 'doc', state.currentKey);
const url = `${location.pathname}${params.toString() ? `?${params}` : ''}`;
history[replace ? 'replaceState' : 'pushState']({}, '', url);
}
@@ -80,6 +104,7 @@
els.brandTitle.textContent = config.title || 'Helpdesk Search';
els.brandSubtitle.textContent = config.subtitle || 'Interne Wissenssuche für den Helpdesk';
els.countBadge.textContent = `${Number(health.count || 0).toLocaleString('de-DE')} Wissenseinträge`;
els.modeBadge.textContent = config.ai_fallback_enabled ? 'Nur lesen · KI-Fallback' : 'Nur lesen';
renderFacets();
} catch (error) {
els.countBadge.textContent = 'Wissensbasis nicht erreichbar';
@@ -108,7 +133,7 @@
});
}
async function runSearch() {
async function runSearch({allowAI = true} = {}) {
const query = state.query.trim();
if (!query) {
showHome();
@@ -116,6 +141,7 @@
}
showResults();
resetAIPanel({cancel: false});
els.loading.classList.remove('hidden');
els.noResults.classList.add('hidden');
els.resultList.innerHTML = '';
@@ -123,14 +149,25 @@
try {
const data = await api(`/api/search?${qs({q: query, page: state.page, page_size: state.pageSize})}`);
if (query !== state.query.trim()) return;
state.page = data.page || 1;
state.total = data.total || 0;
state.totalPages = data.total_pages || 0;
renderResults(data.items || []);
renderPagination();
els.resultCount.textContent = `${state.total.toLocaleString('de-DE')} ${state.total === 1 ? 'Treffer' : 'Treffer'}`;
els.resultCount.textContent = `${state.total.toLocaleString('de-DE')} Treffer`;
els.resultHint.textContent = `für „${query}`;
if (!state.total) els.noResults.classList.remove('hidden');
if (!state.total) {
if (allowAI && state.config?.ai_fallback_enabled) {
await runAIFallback(query);
} else {
els.noResults.classList.remove('hidden');
els.noResultsHint.textContent = state.config?.ai_fallback_enabled
? 'Für diese URL wurde kein neuer KI-Entwurf gestartet. Ein vorhandener Staging-Entwurf kann direkt geöffnet werden.'
: 'Versuche einen kürzeren Fehlercode, einen Produktnamen oder einzelne Wörter aus der Fehlermeldung.';
}
}
} catch (error) {
els.resultCount.textContent = 'Suche fehlgeschlagen';
els.resultHint.textContent = '';
@@ -140,6 +177,117 @@
}
}
async function runAIFallback(query) {
if (!state.config?.ai_fallback_enabled || state.aiRunning) return;
const runToken = ++state.aiRunToken;
state.aiRunning = true;
state.aiResultKey = null;
state.aiController?.abort();
state.aiController = new AbortController();
showAIPending();
startAITimer();
try {
const generated = await postJSON('/api/ai/fallback', {query}, {signal: state.aiController.signal});
if (runToken !== state.aiRunToken || query !== state.query.trim()) return;
state.aiResultKey = generated.key;
showAISuccess(generated);
await openStaging(generated.key);
} catch (error) {
if (error.name === 'AbortError') return;
if (runToken !== state.aiRunToken || query !== state.query.trim()) return;
if (error.status === 409) {
toast('Während der KI-Anfrage ist ein KB-Treffer verfügbar geworden. Die Suche wird aktualisiert.', 'success');
await runSearch({allowAI: false});
return;
}
showAIError(error.message);
} finally {
if (runToken === state.aiRunToken) {
state.aiRunning = false;
stopAITimer();
}
}
}
function showAIPending() {
els.noResults.classList.add('hidden');
els.aiFallbackPanel.classList.remove('hidden', 'ai-success', 'ai-error');
els.aiFallbackPanel.classList.add('ai-pending');
els.aiTitle.textContent = 'KI erstellt einen Helpdesk-Entwurf';
const model = state.config?.ai_fallback_model ? ` (${state.config.ai_fallback_model})` : '';
els.aiStatus.textContent = `Die interne Wissensbasis hat keinen Treffer. Ollama${model} erzeugt jetzt einen strukturierten Entwurf.`;
els.aiNote.textContent = 'Der Entwurf wird getrennt von der produktiven KB gespeichert und muss geprüft werden.';
els.aiProgress.classList.remove('hidden');
els.openAIResult.classList.add('hidden');
els.retryAI.classList.add('hidden');
}
function showAISuccess(generated) {
els.aiFallbackPanel.classList.remove('ai-pending', 'ai-error');
els.aiFallbackPanel.classList.add('ai-success');
els.aiTitle.textContent = 'KI-Entwurf im Staging gespeichert';
const seconds = Math.max(0, Number(generated.duration_ms || 0) / 1000);
els.aiStatus.textContent = `Der Entwurf wurde nach ${seconds.toLocaleString('de-DE', {maximumFractionDigits: 1})} Sekunden erzeugt und als ${generated.key} abgelegt.`;
els.aiNote.textContent = 'AI-Staging ist ungeprüft und bleibt von der produktiven Wissensbasis getrennt.';
els.aiProgress.classList.add('hidden');
els.openAIResult.classList.remove('hidden');
els.retryAI.classList.add('hidden');
}
function showAIError(message) {
els.aiFallbackPanel.classList.remove('ai-pending', 'ai-success');
els.aiFallbackPanel.classList.add('ai-error');
els.aiTitle.textContent = 'KI-Fallback konnte keinen Entwurf liefern';
els.aiStatus.textContent = message || 'Unbekannter Fehler bei der Ollama-Anfrage.';
els.aiNote.textContent = 'Die normale Wissensbasis wurde nicht verändert.';
els.aiProgress.classList.add('hidden');
els.openAIResult.classList.add('hidden');
els.retryAI.classList.remove('hidden');
els.noResults.classList.remove('hidden');
}
function startAITimer() {
stopAITimer();
state.aiStartedAt = Date.now();
updateAITimer();
state.aiTimerHandle = setInterval(updateAITimer, 1000);
}
function updateAITimer() {
const elapsed = Math.floor((Date.now() - state.aiStartedAt) / 1000);
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
const max = Number(state.config?.ai_fallback_timeout_seconds || 600);
els.aiTimer.textContent = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')} / ${formatDuration(max)}`;
}
function formatDuration(seconds) {
const minutes = Math.floor(seconds / 60);
const rest = Math.floor(seconds % 60);
return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`;
}
function stopAITimer() {
if (state.aiTimerHandle) clearInterval(state.aiTimerHandle);
state.aiTimerHandle = null;
}
function resetAIPanel({cancel = true} = {}) {
if (cancel && state.aiController) state.aiController.abort();
if (cancel) state.aiRunToken++;
state.aiRunning = false;
state.aiController = null;
stopAITimer();
els.aiFallbackPanel.classList.add('hidden');
els.aiFallbackPanel.classList.remove('ai-pending', 'ai-success', 'ai-error');
els.aiProgress.classList.remove('hidden');
els.openAIResult.classList.add('hidden');
els.retryAI.classList.add('hidden');
els.aiTimer.textContent = '00:00';
}
function renderResults(items) {
els.resultList.innerHTML = '';
for (const item of items) {
@@ -210,6 +358,7 @@
if (page < 1 || page > state.totalPages || page === state.page) return;
state.page = page;
state.currentKey = null;
state.currentStaging = false;
updateURL();
runSearch();
window.scrollTo({top: 0, behavior: 'smooth'});
@@ -218,9 +367,12 @@
function submitSearch(value) {
const query = String(value ?? '').trim();
if (!query) return;
resetAIPanel({cancel: true});
state.query = query;
state.page = 1;
state.currentKey = null;
state.currentStaging = false;
state.aiResultKey = null;
els.heroSearch.value = query;
els.topSearch.value = query;
updateURL();
@@ -228,9 +380,12 @@
}
function showHome() {
resetAIPanel({cancel: true});
state.query = '';
state.page = 1;
state.currentKey = null;
state.currentStaging = false;
state.aiResultKey = null;
els.hero.classList.remove('hidden');
els.resultsView.classList.add('hidden');
els.heroSearch.value = '';
@@ -248,6 +403,7 @@
try {
const data = await api(`/api/items/${encodeURIComponent(key)}`);
state.currentKey = key;
state.currentStaging = false;
state.currentDoc = data.document || {};
renderArticle(state.currentDoc, data.meta || {});
if (updateHistory) updateURL();
@@ -257,7 +413,24 @@
}
}
async function openStaging(key, {updateHistory = true} = {}) {
try {
const data = await api(`/api/staging/${encodeURIComponent(key)}`);
state.aiResultKey = key;
state.currentKey = key;
state.currentStaging = true;
state.currentDoc = data.document || {};
renderArticle(state.currentDoc, data.meta || {staging: true});
if (updateHistory) updateURL();
if (!els.articleDialog.open) els.articleDialog.showModal();
} catch (error) {
toast(error.message, 'error');
}
}
function renderArticle(doc, meta) {
const isStaging = Boolean(meta.staging);
els.stagingBadge.classList.toggle('hidden', !isStaging);
els.articleEyebrow.textContent = doc.id || meta.rel_path || 'Wissensartikel';
els.articleTitle.textContent = doc.title || '(ohne Titel)';
els.articleProblem.textContent = doc.text || '';
@@ -266,6 +439,7 @@
els.answerSection.classList.toggle('hidden', !doc.answer);
const metaParts = [];
if (isStaging) metaParts.push('AI-STAGING / ungeprüft');
if (doc.language) metaParts.push(doc.language);
if (doc.communication_style) metaParts.push(doc.communication_style);
if (typeof doc.auto_reply === 'boolean') metaParts.push(`auto_reply: ${doc.auto_reply}`);
@@ -303,6 +477,7 @@
if (els.articleDialog.open) els.articleDialog.close();
state.currentKey = null;
state.currentDoc = null;
state.currentStaging = false;
if (updateHistory) updateURL({replace: true});
}
@@ -320,7 +495,7 @@
el.className = `toast ${type}`;
el.textContent = message;
els.toastHost.appendChild(el);
setTimeout(() => el.remove(), 3200);
setTimeout(() => el.remove(), 4200);
}
function bindEvents() {
@@ -343,6 +518,10 @@
});
els.copyAnswer.addEventListener('click', () => copyText(String(state.currentDoc?.answer || ''), 'Antwort kopiert.'));
els.copyLink.addEventListener('click', () => copyText(location.href, 'Artikellink kopiert.'));
els.openAIResult.addEventListener('click', () => {
if (state.aiResultKey) openStaging(state.aiResultKey);
});
els.retryAI.addEventListener('click', () => runAIFallback(state.query.trim()));
document.addEventListener('keydown', event => {
if (event.key === '/' && !['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName)) {
@@ -355,20 +534,23 @@
}
async function hydrateFromURL({historyNavigation = false} = {}) {
resetAIPanel({cancel: true});
const params = new URLSearchParams(location.search);
state.query = (params.get('q') || '').trim();
state.page = Math.max(1, Number.parseInt(params.get('page') || '1', 10) || 1);
const docKey = params.get('doc') || '';
const stagingKey = params.get('staging') || '';
if (state.query) {
els.heroSearch.value = state.query;
els.topSearch.value = state.query;
await runSearch();
await runSearch({allowAI: !stagingKey});
} else {
showHome();
}
if (docKey) await openArticle(docKey, {updateHistory: false});
if (stagingKey) await openStaging(stagingKey, {updateHistory: false});
else if (docKey) await openArticle(docKey, {updateHistory: false});
else if (historyNavigation && els.articleDialog.open) closeArticle({updateHistory: false});
}

View File

@@ -17,7 +17,7 @@
</span>
</a>
<div class="top-meta">
<span class="mode-badge">Nur lesen</span>
<span id="modeBadge" class="mode-badge">Nur lesen</span>
<span id="countBadge" class="count-badge">Wissensbasis lädt …</span>
</div>
</header>
@@ -74,8 +74,27 @@
<div id="noResults" class="no-results hidden">
<div class="no-results-icon"></div>
<h2>Keine passenden Einträge gefunden</h2>
<p>Versuche einen kürzeren Fehlercode, einen Produktnamen oder einzelne Wörter aus der Fehlermeldung.</p>
<p id="noResultsHint">Versuche einen kürzeren Fehlercode, einen Produktnamen oder einzelne Wörter aus der Fehlermeldung.</p>
</div>
<section id="aiFallbackPanel" class="ai-fallback hidden" aria-live="polite">
<div class="ai-glow" aria-hidden="true"></div>
<div class="ai-head">
<div class="ai-mark">AI</div>
<div>
<div class="section-kicker">OLLAMA · STAGING</div>
<h2 id="aiTitle">KI erstellt einen Helpdesk-Entwurf</h2>
</div>
<span id="aiTimer" class="ai-timer">00:00</span>
</div>
<p id="aiStatus">Die interne Wissensbasis hat keinen Treffer. Die Anfrage wird an das konfigurierte Ollama-Modell übergeben.</p>
<div id="aiProgress" class="ai-progress"><span></span></div>
<div class="ai-actions">
<span id="aiNote">Der Entwurf wird getrennt von der produktiven KB gespeichert und muss geprüft werden.</span>
<button id="openAIResult" class="copy-btn hidden" type="button">Entwurf öffnen</button>
<button id="retryAI" class="copy-btn hidden" type="button">Erneut versuchen</button>
</div>
</section>
<div id="resultList" class="result-list" aria-live="polite"></div>
<nav id="pagination" class="pagination hidden" aria-label="Suchergebnisse"></nav>
</div>
@@ -87,7 +106,7 @@
<article class="article-shell">
<header class="article-head">
<div>
<div id="articleEyebrow" class="article-eyebrow"></div>
<div class="article-eyebrow-row"><div id="articleEyebrow" class="article-eyebrow"></div><span id="stagingBadge" class="staging-badge hidden">AI-STAGING · UNGEPRÜFT</span></div>
<h2 id="articleTitle"></h2>
</div>
<button id="closeArticle" class="close-btn" type="button" aria-label="Artikel schließen">×</button>

View File

@@ -229,3 +229,42 @@ mark { color: #ddecff; background: rgba(121,167,255,.16); border-radius: 3px; pa
.answer-head, .source-line, .article-foot { align-items: flex-start; flex-direction: column; }
.article-foot { gap: 4px; }
}
/* Optional Ollama fallback / staging viewer */
.ai-fallback {
position: relative;
overflow: hidden;
margin: 0 0 16px;
padding: 22px;
border: 1px solid rgba(121,167,255,.27);
border-radius: var(--radius);
background:
linear-gradient(135deg, rgba(121,167,255,.10), rgba(143,124,255,.055) 48%, rgba(13,23,41,.86)),
rgba(13,23,41,.9);
box-shadow: 0 16px 50px rgba(0,0,0,.14), inset 0 1px rgba(255,255,255,.035);
}
.ai-fallback.ai-success { border-color: rgba(100,217,173,.30); background: linear-gradient(135deg, rgba(100,217,173,.08), rgba(121,167,255,.045), rgba(13,23,41,.9)); }
.ai-fallback.ai-error { border-color: rgba(255,132,144,.28); background: linear-gradient(135deg, rgba(255,132,144,.07), rgba(13,23,41,.9)); }
.ai-glow { position: absolute; width: 240px; height: 240px; border-radius: 50%; right: -100px; top: -150px; background: radial-gradient(circle, rgba(121,167,255,.2), transparent 67%); pointer-events: none; }
.ai-head { position: relative; display: grid; grid-template-columns: 42px minmax(0,1fr) auto; gap: 13px; align-items: center; }
.ai-mark { width: 42px; height: 42px; display: grid; place-items: center; border-radius: 13px; border: 1px solid rgba(121,167,255,.32); background: linear-gradient(135deg, rgba(121,167,255,.2), rgba(143,124,255,.16)); color: #d9e6ff; font-size: 11px; font-weight: 850; letter-spacing: .08em; }
.ai-head h2 { margin: 5px 0 0; font-size: 16px; letter-spacing: -.015em; }
.ai-timer { color: #8da8d4; font: 650 10px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; padding: 6px 8px; border-radius: 999px; border: 1px solid var(--line); background: rgba(4,10,20,.25); white-space: nowrap; }
.ai-fallback > p { position: relative; margin: 15px 0 14px 55px; color: #a9bad2; font-size: 12px; line-height: 1.65; max-width: 760px; }
.ai-progress { position: relative; height: 3px; margin: 0 0 17px 55px; border-radius: 999px; background: rgba(121,167,255,.09); overflow: hidden; }
.ai-progress span { position: absolute; inset: 0 auto 0 -38%; width: 38%; border-radius: inherit; background: linear-gradient(90deg, transparent, #79a7ff, #8f7cff, transparent); animation: ai-sweep 1.65s infinite ease-in-out; }
@keyframes ai-sweep { 0% { transform: translateX(0); } 100% { transform: translateX(365%); } }
.ai-actions { position: relative; margin-left: 55px; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.ai-actions > span { color: var(--faint); font-size: 10px; line-height: 1.5; }
.ai-actions button { flex: 0 0 auto; }
.article-eyebrow-row { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
.staging-badge { color: #ffd99a; border: 1px solid rgba(255,197,100,.24); background: rgba(255,197,100,.07); border-radius: 999px; padding: 4px 7px; font-size: 8px; font-weight: 800; letter-spacing: .08em; }
@media (max-width: 620px) {
.ai-fallback { padding: 18px; }
.ai-head { grid-template-columns: 38px minmax(0,1fr); }
.ai-mark { width: 38px; height: 38px; }
.ai-timer { grid-column: 2; justify-self: start; }
.ai-fallback > p, .ai-progress, .ai-actions { margin-left: 0; }
.ai-actions { align-items: flex-start; flex-direction: column; }
}