Dr-Swopt 6 dagen geleden
bovenliggende
commit
af504807c9

+ 9 - 57
src/app/components/chatbot/chatbot.component.html

@@ -1,54 +1,7 @@
 <div class="container chat-wrapper">
 
-  <!-- ── Setup Required Gate ─────────────────────────────────────────────────
-       Shown when no webhook URL is saved in localStorage.
-       User must configure before the chat UI is accessible.            ──── -->
-  @if (setupRequired()) {
-    <div class="setup-gate glass-panel">
-      <div class="setup-gate__icon">🔗</div>
-      <h2 class="setup-gate__title">Intelligence Setup Required</h2>
-      <p class="setup-gate__desc">
-        Paste your n8n RAG webhook URL below to connect the Intelligence tab
-        to your local pipeline. Saved to <code>localStorage</code> — survives refresh.
-      </p>
-      <div class="setup-gate__row">
-        <input
-          class="setup-gate__input"
-          type="text"
-          [(ngModel)]="webhookInputDraft"
-          placeholder="http://localhost:5678/webhook/rag-query"
-          (keydown.enter)="saveWebhookUrl()"
-        />
-        <button
-          class="btn btn-primary setup-gate__save"
-          (click)="saveWebhookUrl()"
-          [disabled]="!webhookInputDraft.trim()">
-          Connect
-        </button>
-      </div>
-      <div class="setup-gate__status-row">
-        <span class="webhook-config__status-dot"
-              [class.dot-green]="surveillance.n8nStatus() === 'online'"
-              [class.dot-yellow]="surveillance.n8nStatus() === 'untested'"
-              [class.dot-red]="surveillance.n8nStatus() === 'offline'">
-        </span>
-        <span class="setup-gate__status-label"
-              [class.state-online]="surveillance.n8nStatus() === 'online'"
-              [class.state-warn]="surveillance.n8nStatus() === 'untested'"
-              [class.state-offline]="surveillance.n8nStatus() === 'offline'">
-          n8n: {{ surveillance.n8nStatus() === 'online' ? 'Reachable'
-                : surveillance.n8nStatus() === 'offline' ? 'Unreachable'
-                : 'Untested — click Connect to verify' }}
-        </span>
-      </div>
-      <div class="setup-gate__hint">Lego 06 · Webhook URL · n8n → Ollama → Angular</div>
-    </div>
-  }
-
-  <!-- ── Chat Interface ──────────────────────────────────────────────────────
-       Only rendered once setup is complete.                            ──── -->
-  @if (!setupRequired()) {
-    <div class="chat-layout">
+  <!-- ── Chat Interface ────────────────────────────────────────────────────── -->
+  <div class="chat-layout">
 
       <!-- Sidebar info -->
       <aside class="chat-sidebar glass-panel">
@@ -168,10 +121,10 @@
           }
         </div>
 
-        <!-- n8n offline banner -->
-        @if (surveillance.n8nStatus() === 'offline') {
+        <!-- NestJS offline banner — socket proxy unavailable -->
+        @if (surveillance.nestStatus() === 'OFFLINE') {
           <div class="n8n-offline-banner">
-            🔴 n8n unreachable — messaging disabled. Use "Test Connection" in the sidebar to retry.
+            🔴 NestJS offline — chat proxy unavailable. Restore the backend to send messages.
           </div>
         }
 
@@ -183,20 +136,19 @@
             (keydown)="onEnter($event)"
             placeholder="Ask about palm oil ripeness, site data, operational reports..."
             rows="2"
-            [disabled]="loading() || surveillance.n8nStatus() === 'offline'">
+            [disabled]="loading() || chatSocket.sending() || surveillance.nestStatus() === 'OFFLINE'">
           </textarea>
           <button
             class="btn btn-primary send-btn"
             (click)="sendMessage()"
-            [disabled]="!inputText.trim() || loading() || surveillance.n8nStatus() === 'offline'">
-            {{ loading() ? '...' : 'Send' }}
+            [disabled]="!inputText.trim() || loading() || chatSocket.sending() || surveillance.nestStatus() === 'OFFLINE'">
+            {{ chatSocket.sending() ? '...' : 'Send' }}
           </button>
         </div>
 
-        <div class="chat-hint">Enter to send · Shift+Enter for new line · HTTP POST → n8n webhook</div>
+        <div class="chat-hint">Enter to send · Shift+Enter for new line · Socket → NestJS → n8n</div>
       </div>
 
     </div>
-  }
 
 </div>

+ 35 - 58
src/app/components/chatbot/chatbot.component.ts

@@ -1,15 +1,13 @@
 /**
  * Lego 03 / Lego 06 / Lego 14 — n8n RAG Chatbot Tab (Intelligence)
  *
- * Sends user prompts to n8n via HTTP POST to the RAG webhook.
- * Displays conversation history with streamed or batch responses.
+ * Chat messages are sent to NestJS via Socket.io (chat:send).
+ * NestJS proxies them server-to-server to n8n — no CORS constraint.
+ * The n8n webhook URL lives in NestJS .env (N8N_WEBHOOK_URL).
  *
- * Lego 06: Webhook URL is NOT hardcoded. It is read from localStorage
- * so the user can point it at any running n8n instance. A settings
- * panel inside this tab writes the URL back to localStorage on save.
- *
- * The n8n webhook internally triggers: LanceDB search → Ollama.
- * This component is the Angular entry point to that chain.
+ * Lego 06: The optional webhook URL in localStorage is kept only for the
+ * SurveillanceService pulse-check ("Test Connection"). It is no longer
+ * required for sending messages — the proxy handles routing.
  */
 
 import {
@@ -21,18 +19,11 @@ import {
 } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
-import { HttpClient } from '@angular/common/http';
 import { SurveillanceService } from '../../services/surveillance.service';
-import { environment } from '../../../environments/environment';
+import { ChatSocketService } from '../../services/chat-socket.service';
 
 const WEBHOOK_STORAGE_KEY = 'n8n_webhook_url';
 
-/** True when the user has never saved a webhook URL to localStorage */
-function hasStoredWebhook(): boolean {
-  const v = localStorage.getItem(WEBHOOK_STORAGE_KEY);
-  return !!v && v.trim().length > 0;
-}
-
 export interface ChatMessage {
   role: 'user' | 'bot' | 'error';
   text: string;
@@ -61,12 +52,8 @@ export class ChatbotComponent implements AfterViewChecked {
   inputText = '';
   loading = signal<boolean>(false);
 
-  // Lego 06 — Webhook URL from localStorage only; no env fallback (forces setup)
-  webhookUrl = signal<string>(
-    localStorage.getItem(WEBHOOK_STORAGE_KEY) ?? ''
-  );
-  /** True until the user saves a URL — shows the Setup Required gate screen */
-  setupRequired = signal<boolean>(!hasStoredWebhook());
+  // Lego 06 — Optional URL stored locally for pulse-check only (not used for sending)
+  webhookUrl = signal<string>(localStorage.getItem(WEBHOOK_STORAGE_KEY) ?? '');
   webhookInputDraft = '';
   showWebhookConfig = signal<boolean>(false);
 
@@ -76,7 +63,7 @@ export class ChatbotComponent implements AfterViewChecked {
   testingConnection = signal<boolean>(false);
 
   constructor(
-    private http: HttpClient,
+    public chatSocket: ChatSocketService,
     public surveillance: SurveillanceService,
   ) {
     this.webhookInputDraft = this.webhookUrl();
@@ -98,48 +85,39 @@ export class ChatbotComponent implements AfterViewChecked {
     this.testingConnection.set(false);
   }
 
-  sendMessage(): void {
+  async sendMessage(): Promise<void> {
     const text = this.inputText.trim();
-    if (!text || this.loading() || !this.webhookUrl()) return;
-    if (this.surveillance.n8nStatus() === 'offline') return;
+    if (!text || this.loading() || this.chatSocket.sending()) return;
 
-    // Push user message
+    // Push user message immediately
     this.pushMessage({ role: 'user', text, timestamp: new Date() });
     this.inputText = '';
     this.loading.set(true);
 
     const start = Date.now();
 
-    this.http
-      .post<any>(this.webhookUrl(), { query: text, prompt: text })
-      .subscribe({
-        next: (response) => {
-          const durationMs = Date.now() - start;
-          // n8n may return { output }, { answer }, { response }, or plain string
-          const botText =
-            response?.output ??
-            response?.answer ??
-            response?.response ??
-            response?.text ??
-            (typeof response === 'string' ? response : JSON.stringify(response));
-
-          this.pushMessage({
-            role: 'bot',
-            text: botText,
-            timestamp: new Date(),
-            durationMs,
-          });
-          this.loading.set(false);
-        },
-        error: (err) => {
-          this.pushMessage({
-            role: 'error',
-            text: `n8n unreachable: ${err.message ?? 'Unknown error'}. Is the workflow active on port 5678?`,
-            timestamp: new Date(),
-          });
-          this.loading.set(false);
-        },
+    try {
+      const response = await this.chatSocket.send(text);
+      const durationMs = Date.now() - start;
+
+      // n8n may return { output }, { answer }, { response }, { text }, or a raw string
+      const botText =
+        response?.output ??
+        response?.answer ??
+        response?.response ??
+        response?.text ??
+        (typeof response === 'string' ? response : JSON.stringify(response));
+
+      this.pushMessage({ role: 'bot', text: botText, timestamp: new Date(), durationMs });
+    } catch (err: any) {
+      this.pushMessage({
+        role: 'error',
+        text: `Proxy error: ${err.message ?? 'Unknown error'}. Check NestJS → n8n connection.`,
+        timestamp: new Date(),
       });
+    } finally {
+      this.loading.set(false);
+    }
   }
 
   onEnter(event: KeyboardEvent): void {
@@ -170,9 +148,8 @@ export class ChatbotComponent implements AfterViewChecked {
     if (!url) return;
     localStorage.setItem(WEBHOOK_STORAGE_KEY, url);
     this.webhookUrl.set(url);
-    this.setupRequired.set(false);
     this.showWebhookConfig.set(false);
-    // Immediately pulse-check the newly saved URL
+    // Immediately pulse-check the saved URL
     this.surveillance.checkN8nStatus(url);
   }
 

+ 86 - 0
src/app/services/chat-socket.service.ts

@@ -0,0 +1,86 @@
+/**
+ * Lego 03 / Lego 06 — RAG Chat via NestJS Socket Proxy
+ *
+ * Sends chat messages to NestJS over the existing /vision Socket.io connection.
+ * NestJS forwards the message server-to-server to n8n (no CORS constraint),
+ * then emits chat:result back here.
+ *
+ * This service does NOT open a second socket — it shares the /vision namespace
+ * socket with VisionSocketService by injecting VisionSocketService and calling
+ * its exposed socket methods. Instead, we open our own /vision socket here
+ * specifically for chat so lifecycle is independently managed.
+ *
+ * Events:
+ *   emit  → chat:send   { message: string }
+ *   on    ← chat:result  <n8n response object>
+ *   on    ← chat:error   { message: string }
+ */
+
+import { Injectable, signal, OnDestroy } from '@angular/core';
+import { io, Socket } from 'socket.io-client';
+import { environment } from '../../environments/environment';
+
+export interface ChatResult {
+  output?: string;
+  answer?: string;
+  response?: string;
+  text?: string;
+  [key: string]: any;
+}
+
+@Injectable({ providedIn: 'root' })
+export class ChatSocketService implements OnDestroy {
+
+  readonly sending = signal<boolean>(false);
+  readonly lastError = signal<string | null>(null);
+
+  private socket: Socket;
+
+  constructor() {
+    this.socket = io(`${environment.nestWsUrl}/vision`, {
+      transports: ['websocket'],
+      reconnection: true,
+      reconnectionDelay: 1000,
+    });
+  }
+
+  /**
+   * Send a chat message to NestJS. NestJS proxies it to n8n server-to-server.
+   * Returns a Promise that resolves with the n8n response or rejects on error.
+   */
+  send(message: string): Promise<ChatResult> {
+    return new Promise((resolve, reject) => {
+      this.sending.set(true);
+      this.lastError.set(null);
+
+      // One-shot listeners — removed immediately after first fire
+      const onResult = (data: ChatResult) => {
+        cleanup();
+        this.sending.set(false);
+        resolve(data);
+      };
+
+      const onError = (err: { message: string }) => {
+        cleanup();
+        this.sending.set(false);
+        const msg = err?.message ?? 'n8n proxy error';
+        this.lastError.set(msg);
+        reject(new Error(msg));
+      };
+
+      const cleanup = () => {
+        this.socket.off('chat:result', onResult);
+        this.socket.off('chat:error', onError);
+      };
+
+      this.socket.once('chat:result', onResult);
+      this.socket.once('chat:error', onError);
+
+      this.socket.emit('chat:send', { message });
+    });
+  }
+
+  ngOnDestroy(): void {
+    this.socket.disconnect();
+  }
+}