Spaces:
Runtime error
Runtime error
potato-pzy commited on
Commit Β·
02422e3
1
Parent(s): 0d599d9
feat: remove powered-by line and integrate sidecar with sentence-level streaming
Browse files- gemini_adapter.py +56 -16
- genai_app.py +38 -1
- llm_adapter.py +27 -20
- openai_adapter.py +54 -23
- requirements.txt +4 -0
- sidecar/__init__.py +1 -0
- sidecar/app.py +446 -0
- sidecar/config.py +53 -0
- sidecar/gate.py +209 -0
- sidecar/pipeline_events.py +171 -0
- sidecar/sentence_splitter.py +118 -0
- sidecar/stream_monitor.py +142 -0
- start_sidecar.sh +31 -0
- templates/dataflow.html +539 -0
- templates/genai.html +0 -1
- templates/sidecar.html +676 -0
gemini_adapter.py
CHANGED
|
@@ -1,25 +1,29 @@
|
|
| 1 |
"""
|
| 2 |
gemini_adapter.py β Concrete LLMAdapter for Google Gemini.
|
| 3 |
|
| 4 |
-
|
| 5 |
"""
|
| 6 |
|
| 7 |
import os
|
|
|
|
|
|
|
| 8 |
import google.generativeai as genai
|
| 9 |
-
|
| 10 |
from llm_adapter import LLMAdapter
|
| 11 |
|
| 12 |
|
| 13 |
class GeminiAdapter(LLMAdapter):
|
| 14 |
"""
|
| 15 |
Wraps Google Gemini API as an LLMAdapter.
|
|
|
|
|
|
|
| 16 |
"""
|
| 17 |
|
| 18 |
def __init__(
|
| 19 |
self,
|
| 20 |
-
api_key:
|
| 21 |
-
model_name:
|
| 22 |
-
system_prompt: Optional[str] = None
|
| 23 |
):
|
| 24 |
self.api_key = api_key or os.getenv("GEMINI_API_KEY")
|
| 25 |
if not self.api_key:
|
|
@@ -28,27 +32,63 @@ class GeminiAdapter(LLMAdapter):
|
|
| 28 |
"or pass api_key directly."
|
| 29 |
)
|
| 30 |
|
| 31 |
-
self.model_name
|
| 32 |
self.system_prompt = system_prompt or "You are a helpful AI assistant."
|
| 33 |
|
| 34 |
genai.configure(api_key=self.api_key)
|
| 35 |
-
self.
|
| 36 |
model_name=self.model_name,
|
| 37 |
-
system_instruction=self.system_prompt
|
| 38 |
)
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
def chat(self, prompt: str, system_prompt: Optional[str] = None) -> str:
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
if system_prompt and system_prompt != self.system_prompt:
|
| 45 |
-
|
| 46 |
model_name=self.model_name,
|
| 47 |
-
system_instruction=system_prompt
|
| 48 |
)
|
| 49 |
-
|
| 50 |
-
response = current_model.generate_content(prompt)
|
| 51 |
-
return response.text
|
| 52 |
|
| 53 |
def get_model_name(self) -> str:
|
| 54 |
return self.model_name
|
|
|
|
| 1 |
"""
|
| 2 |
gemini_adapter.py β Concrete LLMAdapter for Google Gemini.
|
| 3 |
|
| 4 |
+
V3 (Sidecar): adds stream_chat() for sentence-level streaming.
|
| 5 |
"""
|
| 6 |
|
| 7 |
import os
|
| 8 |
+
from typing import AsyncGenerator, Optional
|
| 9 |
+
|
| 10 |
import google.generativeai as genai
|
| 11 |
+
|
| 12 |
from llm_adapter import LLMAdapter
|
| 13 |
|
| 14 |
|
| 15 |
class GeminiAdapter(LLMAdapter):
|
| 16 |
"""
|
| 17 |
Wraps Google Gemini API as an LLMAdapter.
|
| 18 |
+
|
| 19 |
+
Supports both blocking `chat()` and streaming `stream_chat()`.
|
| 20 |
"""
|
| 21 |
|
| 22 |
def __init__(
|
| 23 |
self,
|
| 24 |
+
api_key: Optional[str] = None,
|
| 25 |
+
model_name: str = "gemini-2.0-flash-lite",
|
| 26 |
+
system_prompt: Optional[str] = None,
|
| 27 |
):
|
| 28 |
self.api_key = api_key or os.getenv("GEMINI_API_KEY")
|
| 29 |
if not self.api_key:
|
|
|
|
| 32 |
"or pass api_key directly."
|
| 33 |
)
|
| 34 |
|
| 35 |
+
self.model_name = model_name
|
| 36 |
self.system_prompt = system_prompt or "You are a helpful AI assistant."
|
| 37 |
|
| 38 |
genai.configure(api_key=self.api_key)
|
| 39 |
+
self._model = genai.GenerativeModel(
|
| 40 |
model_name=self.model_name,
|
| 41 |
+
system_instruction=self.system_prompt,
|
| 42 |
)
|
| 43 |
|
| 44 |
+
# ------------------------------------------------------------------
|
| 45 |
+
# Blocking interface (existing β unchanged)
|
| 46 |
+
# ------------------------------------------------------------------
|
| 47 |
+
|
| 48 |
def chat(self, prompt: str, system_prompt: Optional[str] = None) -> str:
|
| 49 |
+
"""Send prompt, return full response text (blocking)."""
|
| 50 |
+
model = self._get_model(system_prompt)
|
| 51 |
+
response = model.generate_content(prompt)
|
| 52 |
+
return response.text
|
| 53 |
+
|
| 54 |
+
# ------------------------------------------------------------------
|
| 55 |
+
# Streaming interface (NEW)
|
| 56 |
+
# ------------------------------------------------------------------
|
| 57 |
+
|
| 58 |
+
async def stream_chat(
|
| 59 |
+
self,
|
| 60 |
+
prompt: str,
|
| 61 |
+
system_prompt: Optional[str] = None,
|
| 62 |
+
) -> AsyncGenerator[str, None]:
|
| 63 |
+
"""
|
| 64 |
+
Yield token chunks from Gemini as they arrive.
|
| 65 |
+
|
| 66 |
+
This is a synchronous SDK call wrapped in an async generator β
|
| 67 |
+
Gemini's Python SDK streams synchronously, so we iterate the
|
| 68 |
+
response object directly and yield each text chunk.
|
| 69 |
+
"""
|
| 70 |
+
model = self._get_model(system_prompt)
|
| 71 |
+
|
| 72 |
+
# generate_content with stream=True returns a synchronous iterator
|
| 73 |
+
response = model.generate_content(prompt, stream=True)
|
| 74 |
+
|
| 75 |
+
for chunk in response:
|
| 76 |
+
text = getattr(chunk, "text", None)
|
| 77 |
+
if text:
|
| 78 |
+
yield text
|
| 79 |
+
|
| 80 |
+
# ------------------------------------------------------------------
|
| 81 |
+
# Helpers
|
| 82 |
+
# ------------------------------------------------------------------
|
| 83 |
+
|
| 84 |
+
def _get_model(self, system_prompt: Optional[str]):
|
| 85 |
+
"""Return model instance, recreating if system prompt differs."""
|
| 86 |
if system_prompt and system_prompt != self.system_prompt:
|
| 87 |
+
return genai.GenerativeModel(
|
| 88 |
model_name=self.model_name,
|
| 89 |
+
system_instruction=system_prompt,
|
| 90 |
)
|
| 91 |
+
return self._model
|
|
|
|
|
|
|
| 92 |
|
| 93 |
def get_model_name(self) -> str:
|
| 94 |
return self.model_name
|
genai_app.py
CHANGED
|
@@ -19,6 +19,7 @@ import os
|
|
| 19 |
import json
|
| 20 |
import time
|
| 21 |
import queue
|
|
|
|
| 22 |
from flask import Flask, request, jsonify, render_template, Response, stream_with_context
|
| 23 |
from flask_cors import CORS
|
| 24 |
|
|
@@ -76,6 +77,16 @@ def broadcast(event_type: str, data: dict):
|
|
| 76 |
def index():
|
| 77 |
return render_template("genai.html", model=ADAPTER.get_model_name())
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
@app.route("/genai-monitoring")
|
| 80 |
def monitoring():
|
| 81 |
return render_template("genai_monitoring.html", model=ADAPTER.get_model_name())
|
|
@@ -240,9 +251,35 @@ def guard_stats():
|
|
| 240 |
return jsonify(PG_ENGINE.stats())
|
| 241 |
|
| 242 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
if __name__ == "__main__":
|
| 244 |
port = int(os.getenv("GENAI_PORT", 5001))
|
| 245 |
print(f"GenAI Shield V2 starting on port {port}")
|
| 246 |
print(f"LLM Model: {ADAPTER.get_model_name()}")
|
| 247 |
print(f"Guard: Llama-Prompt-Guard-2-86M")
|
| 248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
import json
|
| 20 |
import time
|
| 21 |
import queue
|
| 22 |
+
import threading
|
| 23 |
from flask import Flask, request, jsonify, render_template, Response, stream_with_context
|
| 24 |
from flask_cors import CORS
|
| 25 |
|
|
|
|
| 77 |
def index():
|
| 78 |
return render_template("genai.html", model=ADAPTER.get_model_name())
|
| 79 |
|
| 80 |
+
@app.route("/sidecar")
|
| 81 |
+
def sidecar_ui():
|
| 82 |
+
"""Sidecar streaming chat UI."""
|
| 83 |
+
return render_template("sidecar.html")
|
| 84 |
+
|
| 85 |
+
@app.route("/dataflow")
|
| 86 |
+
def dataflow_ui():
|
| 87 |
+
"""Real-time data flow visualization dashboard."""
|
| 88 |
+
return render_template("dataflow.html")
|
| 89 |
+
|
| 90 |
@app.route("/genai-monitoring")
|
| 91 |
def monitoring():
|
| 92 |
return render_template("genai_monitoring.html", model=ADAPTER.get_model_name())
|
|
|
|
| 251 |
return jsonify(PG_ENGINE.stats())
|
| 252 |
|
| 253 |
|
| 254 |
+
def _start_sidecar_subprocess():
|
| 255 |
+
"""
|
| 256 |
+
Optionally launch the sidecar as a sub-process so both UIs run together.
|
| 257 |
+
Controlled via LAUNCH_SIDECAR=true env var.
|
| 258 |
+
"""
|
| 259 |
+
import subprocess, sys
|
| 260 |
+
sidecar_port = int(os.getenv("SIDECAR_PORT", 5050))
|
| 261 |
+
print(f"[GenAI Shield] Launching sidecar on :{sidecar_port}...")
|
| 262 |
+
proc = subprocess.Popen(
|
| 263 |
+
[sys.executable, "-m", "uvicorn", "sidecar.app:app",
|
| 264 |
+
"--host", "0.0.0.0", "--port", str(sidecar_port),
|
| 265 |
+
"--log-level", "warning"],
|
| 266 |
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
| 267 |
+
)
|
| 268 |
+
def _pipe():
|
| 269 |
+
for line in proc.stdout:
|
| 270 |
+
print("[sidecar]", line.decode(errors='replace').rstrip())
|
| 271 |
+
threading.Thread(target=_pipe, daemon=True).start()
|
| 272 |
+
return proc
|
| 273 |
+
|
| 274 |
+
|
| 275 |
if __name__ == "__main__":
|
| 276 |
port = int(os.getenv("GENAI_PORT", 5001))
|
| 277 |
print(f"GenAI Shield V2 starting on port {port}")
|
| 278 |
print(f"LLM Model: {ADAPTER.get_model_name()}")
|
| 279 |
print(f"Guard: Llama-Prompt-Guard-2-86M")
|
| 280 |
+
print(f"Sidecar UI: http://localhost:{port}/sidecar")
|
| 281 |
+
|
| 282 |
+
if os.getenv("LAUNCH_SIDECAR", "").lower() in ("1", "true", "yes"):
|
| 283 |
+
_start_sidecar_subprocess()
|
| 284 |
+
|
| 285 |
+
app.run(host="0.0.0.0", port=port, debug=False)
|
llm_adapter.py
CHANGED
|
@@ -1,40 +1,47 @@
|
|
| 1 |
"""
|
| 2 |
-
llm_adapter.py β Abstract base class for LLM adapters.
|
| 3 |
|
| 4 |
-
|
| 5 |
-
the TextMonitor by subclassing LLMAdapter and implementing its two methods.
|
| 6 |
"""
|
| 7 |
|
| 8 |
from abc import ABC, abstractmethod
|
| 9 |
-
from typing import Optional
|
| 10 |
|
| 11 |
|
| 12 |
class LLMAdapter(ABC):
|
| 13 |
"""
|
| 14 |
-
|
| 15 |
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
"""
|
| 18 |
|
| 19 |
@abstractmethod
|
| 20 |
-
def chat(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
self,
|
| 22 |
-
prompt:
|
| 23 |
system_prompt: Optional[str] = None,
|
| 24 |
-
) -> str:
|
| 25 |
-
"""
|
| 26 |
-
Send a prompt to the LLM and return the response text.
|
| 27 |
-
|
| 28 |
-
Args:
|
| 29 |
-
prompt: The user's message.
|
| 30 |
-
system_prompt: Optional system prompt to prepend.
|
| 31 |
-
|
| 32 |
-
Returns:
|
| 33 |
-
The model's response as a plain string.
|
| 34 |
-
"""
|
| 35 |
...
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
@abstractmethod
|
| 38 |
def get_model_name(self) -> str:
|
| 39 |
-
"""Return
|
| 40 |
...
|
|
|
|
| 1 |
"""
|
| 2 |
+
llm_adapter.py β Abstract base class for all LLM adapters.
|
| 3 |
|
| 4 |
+
V3 (Sidecar): adds stream_chat() abstract method.
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from abc import ABC, abstractmethod
|
| 8 |
+
from typing import AsyncGenerator, Optional
|
| 9 |
|
| 10 |
|
| 11 |
class LLMAdapter(ABC):
|
| 12 |
"""
|
| 13 |
+
Minimal interface that all LLM adapters must implement.
|
| 14 |
|
| 15 |
+
Methods
|
| 16 |
+
-------
|
| 17 |
+
chat(prompt, system_prompt) -> str
|
| 18 |
+
Blocking single-turn call. Returns full response text.
|
| 19 |
+
|
| 20 |
+
stream_chat(prompt, system_prompt) -> AsyncGenerator[str, None]
|
| 21 |
+
Async streaming call. Yields token chunks as they arrive.
|
| 22 |
+
|
| 23 |
+
get_model_name() -> str
|
| 24 |
+
Returns the model identifier string.
|
| 25 |
"""
|
| 26 |
|
| 27 |
@abstractmethod
|
| 28 |
+
def chat(self, prompt: str, system_prompt: Optional[str] = None) -> str:
|
| 29 |
+
"""Blocking call β return full response as a string."""
|
| 30 |
+
...
|
| 31 |
+
|
| 32 |
+
@abstractmethod
|
| 33 |
+
async def stream_chat(
|
| 34 |
self,
|
| 35 |
+
prompt: str,
|
| 36 |
system_prompt: Optional[str] = None,
|
| 37 |
+
) -> AsyncGenerator[str, None]:
|
| 38 |
+
"""Async generator β yield token chunks as they arrive."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
...
|
| 40 |
+
# Make this an async generator (required by ABC)
|
| 41 |
+
# Concrete classes must use `yield` in their implementation
|
| 42 |
+
yield # type: ignore[misc]
|
| 43 |
|
| 44 |
@abstractmethod
|
| 45 |
def get_model_name(self) -> str:
|
| 46 |
+
"""Return the model identifier (e.g. 'gemini-2.0-flash-lite')."""
|
| 47 |
...
|
openai_adapter.py
CHANGED
|
@@ -1,16 +1,19 @@
|
|
| 1 |
"""
|
| 2 |
openai_adapter.py β Concrete LLMAdapter for OpenAI / OpenRouter / Ollama.
|
| 3 |
|
| 4 |
-
|
| 5 |
|
|
|
|
| 6 |
OPENAI_API_KEY=sk-...
|
| 7 |
-
OPENAI_BASE_URL=https://openrouter.ai/api/v1
|
| 8 |
-
OPENAI_MODEL=
|
| 9 |
"""
|
| 10 |
|
| 11 |
import os
|
| 12 |
-
from typing import Optional
|
| 13 |
-
|
|
|
|
|
|
|
| 14 |
from llm_adapter import LLMAdapter
|
| 15 |
|
| 16 |
|
|
@@ -20,10 +23,8 @@ class OpenAIAdapter(LLMAdapter):
|
|
| 20 |
|
| 21 |
Args:
|
| 22 |
api_key: API key. Defaults to OPENAI_API_KEY env var.
|
| 23 |
-
base_url: API base URL. Defaults to OPENAI_BASE_URL env var
|
| 24 |
-
|
| 25 |
-
model: Model name. Defaults to OPENAI_MODEL env var,
|
| 26 |
-
or gpt-4o-mini if not set.
|
| 27 |
system_prompt: Default system prompt for all calls.
|
| 28 |
temperature: Sampling temperature (0 = deterministic).
|
| 29 |
max_tokens: Max tokens in response.
|
|
@@ -38,8 +39,8 @@ class OpenAIAdapter(LLMAdapter):
|
|
| 38 |
base_url: Optional[str] = None,
|
| 39 |
model: Optional[str] = None,
|
| 40 |
system_prompt: Optional[str] = None,
|
| 41 |
-
temperature: float
|
| 42 |
-
max_tokens: int
|
| 43 |
):
|
| 44 |
self._api_key = api_key or os.getenv("OPENAI_API_KEY", "")
|
| 45 |
self._base_url = base_url or os.getenv("OPENAI_BASE_URL", self.DEFAULT_BASE_URL)
|
|
@@ -51,13 +52,14 @@ class OpenAIAdapter(LLMAdapter):
|
|
| 51 |
self._temperature = temperature
|
| 52 |
self._max_tokens = max_tokens
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
)
|
|
|
|
| 58 |
|
| 59 |
# ------------------------------------------------------------------
|
| 60 |
-
#
|
| 61 |
# ------------------------------------------------------------------
|
| 62 |
|
| 63 |
def chat(
|
|
@@ -65,19 +67,48 @@ class OpenAIAdapter(LLMAdapter):
|
|
| 65 |
prompt: str,
|
| 66 |
system_prompt: Optional[str] = None,
|
| 67 |
) -> str:
|
| 68 |
-
"""Send prompt to LLM and return response text."""
|
| 69 |
-
sys_msg
|
| 70 |
-
|
| 71 |
response = self._client.chat.completions.create(
|
| 72 |
-
model
|
| 73 |
-
messages
|
| 74 |
-
{"role": "system",
|
| 75 |
-
{"role": "user",
|
| 76 |
],
|
| 77 |
temperature = self._temperature,
|
| 78 |
max_tokens = self._max_tokens,
|
| 79 |
)
|
| 80 |
return response.choices[0].message.content or ""
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
def get_model_name(self) -> str:
|
| 83 |
return self._model
|
|
|
|
| 1 |
"""
|
| 2 |
openai_adapter.py β Concrete LLMAdapter for OpenAI / OpenRouter / Ollama.
|
| 3 |
|
| 4 |
+
V3 (Sidecar): adds stream_chat() for sentence-level streaming.
|
| 5 |
|
| 6 |
+
Works with any OpenAI-compatible API. Configure via environment variables:
|
| 7 |
OPENAI_API_KEY=sk-...
|
| 8 |
+
OPENAI_BASE_URL=https://openrouter.ai/api/v1
|
| 9 |
+
OPENAI_MODEL=gpt-4o-mini
|
| 10 |
"""
|
| 11 |
|
| 12 |
import os
|
| 13 |
+
from typing import AsyncGenerator, Optional
|
| 14 |
+
|
| 15 |
+
from openai import AsyncOpenAI, OpenAI
|
| 16 |
+
|
| 17 |
from llm_adapter import LLMAdapter
|
| 18 |
|
| 19 |
|
|
|
|
| 23 |
|
| 24 |
Args:
|
| 25 |
api_key: API key. Defaults to OPENAI_API_KEY env var.
|
| 26 |
+
base_url: API base URL. Defaults to OPENAI_BASE_URL env var.
|
| 27 |
+
model: Model name. Defaults to OPENAI_MODEL env var.
|
|
|
|
|
|
|
| 28 |
system_prompt: Default system prompt for all calls.
|
| 29 |
temperature: Sampling temperature (0 = deterministic).
|
| 30 |
max_tokens: Max tokens in response.
|
|
|
|
| 39 |
base_url: Optional[str] = None,
|
| 40 |
model: Optional[str] = None,
|
| 41 |
system_prompt: Optional[str] = None,
|
| 42 |
+
temperature: float = 0.7,
|
| 43 |
+
max_tokens: int = 1024,
|
| 44 |
):
|
| 45 |
self._api_key = api_key or os.getenv("OPENAI_API_KEY", "")
|
| 46 |
self._base_url = base_url or os.getenv("OPENAI_BASE_URL", self.DEFAULT_BASE_URL)
|
|
|
|
| 52 |
self._temperature = temperature
|
| 53 |
self._max_tokens = max_tokens
|
| 54 |
|
| 55 |
+
# Synchronous client (existing blocking interface)
|
| 56 |
+
self._client = OpenAI(api_key=self._api_key, base_url=self._base_url)
|
| 57 |
+
|
| 58 |
+
# Async client (streaming interface)
|
| 59 |
+
self._async_client = AsyncOpenAI(api_key=self._api_key, base_url=self._base_url)
|
| 60 |
|
| 61 |
# ------------------------------------------------------------------
|
| 62 |
+
# Blocking interface (existing β unchanged)
|
| 63 |
# ------------------------------------------------------------------
|
| 64 |
|
| 65 |
def chat(
|
|
|
|
| 67 |
prompt: str,
|
| 68 |
system_prompt: Optional[str] = None,
|
| 69 |
) -> str:
|
| 70 |
+
"""Send prompt to LLM and return response text (blocking)."""
|
| 71 |
+
sys_msg = system_prompt or self._system_prompt
|
|
|
|
| 72 |
response = self._client.chat.completions.create(
|
| 73 |
+
model = self._model,
|
| 74 |
+
messages = [
|
| 75 |
+
{"role": "system", "content": sys_msg},
|
| 76 |
+
{"role": "user", "content": prompt},
|
| 77 |
],
|
| 78 |
temperature = self._temperature,
|
| 79 |
max_tokens = self._max_tokens,
|
| 80 |
)
|
| 81 |
return response.choices[0].message.content or ""
|
| 82 |
|
| 83 |
+
# ------------------------------------------------------------------
|
| 84 |
+
# Streaming interface (NEW)
|
| 85 |
+
# ------------------------------------------------------------------
|
| 86 |
+
|
| 87 |
+
async def stream_chat(
|
| 88 |
+
self,
|
| 89 |
+
prompt: str,
|
| 90 |
+
system_prompt: Optional[str] = None,
|
| 91 |
+
) -> AsyncGenerator[str, None]:
|
| 92 |
+
"""
|
| 93 |
+
Yield token chunks from OpenAI as they arrive (true async streaming).
|
| 94 |
+
"""
|
| 95 |
+
sys_msg = system_prompt or self._system_prompt
|
| 96 |
+
|
| 97 |
+
stream = await self._async_client.chat.completions.create(
|
| 98 |
+
model = self._model,
|
| 99 |
+
messages = [
|
| 100 |
+
{"role": "system", "content": sys_msg},
|
| 101 |
+
{"role": "user", "content": prompt},
|
| 102 |
+
],
|
| 103 |
+
temperature = self._temperature,
|
| 104 |
+
max_tokens = self._max_tokens,
|
| 105 |
+
stream = True,
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
async for chunk in stream:
|
| 109 |
+
delta = chunk.choices[0].delta
|
| 110 |
+
if delta and delta.content:
|
| 111 |
+
yield delta.content
|
| 112 |
+
|
| 113 |
def get_model_name(self) -> str:
|
| 114 |
return self._model
|
requirements.txt
CHANGED
|
@@ -5,3 +5,7 @@ transformers
|
|
| 5 |
google-generativeai
|
| 6 |
openai
|
| 7 |
huggingface-hub
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
google-generativeai
|
| 6 |
openai
|
| 7 |
huggingface-hub
|
| 8 |
+
fastapi>=0.111.0
|
| 9 |
+
uvicorn[standard]>=0.29.0
|
| 10 |
+
httpx>=0.27.0
|
| 11 |
+
python-multipart
|
sidecar/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# GenAI Shield V2 β Sidecar Package
|
sidecar/app.py
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
sidecar/app.py β GenAI Shield V2 Sidecar Proxy (FastAPI).
|
| 3 |
+
|
| 4 |
+
A language-agnostic sidecar that sits in front of any LLM API and provides:
|
| 5 |
+
β’ Pre-inference guard (Prompt Guard model + regex) β runs in parallel with LLM
|
| 6 |
+
β’ Sentence-level streaming β users see output word-by-word
|
| 7 |
+
β’ Post-inference monitoring β each sentence checked concurrently in background
|
| 8 |
+
β’ Block signal mid-stream β if output turns harmful, client is notified instantly
|
| 9 |
+
|
| 10 |
+
Endpoints
|
| 11 |
+
---------
|
| 12 |
+
POST /v1/chat β streaming or blocking chat with full shield
|
| 13 |
+
GET /v1/health β liveness probe
|
| 14 |
+
GET /v1/stats β guard model statistics
|
| 15 |
+
GET /v1/metrics β last-request latency breakdown
|
| 16 |
+
|
| 17 |
+
SSE Event Schema (stream=true)
|
| 18 |
+
-------------------------------
|
| 19 |
+
{ "type": "chunk", "text": "..." }
|
| 20 |
+
{ "type": "sentence", "text": "...", "sentence_id": 1 }
|
| 21 |
+
{ "type": "block_signal", "sentence_id": 3, "reason": "...", "threat_score": 85, "flags": [...] }
|
| 22 |
+
{ "type": "done", "threat_score": 5, "flags": [], "latency_ms": 420,
|
| 23 |
+
"guard_ms": 98, "sentences": 4 }
|
| 24 |
+
{ "type": "blocked", "reason": "...", "threat_score": 100, "flags": [...],
|
| 25 |
+
"pg_score": 0.97, "guard_ms": 102 }
|
| 26 |
+
|
| 27 |
+
Configure via environment variables (see sidecar/config.py).
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
import json
|
| 31 |
+
import logging
|
| 32 |
+
import os
|
| 33 |
+
import sys
|
| 34 |
+
import time
|
| 35 |
+
from pathlib import Path
|
| 36 |
+
from typing import AsyncGenerator, Optional
|
| 37 |
+
|
| 38 |
+
import uvicorn
|
| 39 |
+
from fastapi import FastAPI, HTTPException, Request
|
| 40 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 41 |
+
from fastapi.responses import FileResponse, StreamingResponse
|
| 42 |
+
from fastapi.staticfiles import StaticFiles
|
| 43 |
+
from pydantic import BaseModel
|
| 44 |
+
|
| 45 |
+
# ββ Path fix so imports work from project root ββββββββββββββββββββββββββββββββ
|
| 46 |
+
_ROOT = Path(__file__).parent.parent
|
| 47 |
+
if str(_ROOT) not in sys.path:
|
| 48 |
+
sys.path.insert(0, str(_ROOT))
|
| 49 |
+
|
| 50 |
+
from sidecar.config import (
|
| 51 |
+
GATE_GUARD_TIMEOUT_SEC,
|
| 52 |
+
GEMINI_API_KEY,
|
| 53 |
+
GEMINI_MODEL,
|
| 54 |
+
LLM_BACKEND,
|
| 55 |
+
LOG_LEVEL,
|
| 56 |
+
MONITOR_BLOCK_THRESHOLD,
|
| 57 |
+
MONITOR_WORKERS,
|
| 58 |
+
OPENAI_API_KEY,
|
| 59 |
+
OPENAI_BASE_URL,
|
| 60 |
+
OPENAI_MODEL,
|
| 61 |
+
PROMPT_GUARD_MODEL_DIR,
|
| 62 |
+
SENTENCE_MIN_CHARS,
|
| 63 |
+
SIDECAR_HOST,
|
| 64 |
+
SIDECAR_PORT,
|
| 65 |
+
SYSTEM_PROMPT,
|
| 66 |
+
)
|
| 67 |
+
from sidecar.gate import BlockEvent, ShieldGate, TokenEvent
|
| 68 |
+
from sidecar.sentence_splitter import SentenceEvent, SentenceSplitter
|
| 69 |
+
from sidecar.stream_monitor import BlockSignal, StreamMonitor
|
| 70 |
+
from sidecar.pipeline_events import RequestTrace, subscribe, unsubscribe
|
| 71 |
+
|
| 72 |
+
# Existing shield modules
|
| 73 |
+
from prompt_guard_engine import PromptGuardEngine
|
| 74 |
+
from prompt_guard_text_guard import PromptGuardTextGuard
|
| 75 |
+
from text_monitor import TextMonitor
|
| 76 |
+
|
| 77 |
+
# ββ Logging βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 78 |
+
logging.basicConfig(
|
| 79 |
+
level = getattr(logging, LOG_LEVEL.upper(), logging.INFO),
|
| 80 |
+
format = "[%(asctime)s] %(levelname)-8s %(name)s β %(message)s",
|
| 81 |
+
datefmt = "%H:%M:%S",
|
| 82 |
+
)
|
| 83 |
+
log = logging.getLogger("sidecar")
|
| 84 |
+
|
| 85 |
+
# ββ FastAPI app βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 86 |
+
app = FastAPI(
|
| 87 |
+
title = "GenAI Shield Sidecar",
|
| 88 |
+
description = "Transparent LLM proxy with pre/post-inference security screening",
|
| 89 |
+
version = "2.0.0",
|
| 90 |
+
)
|
| 91 |
+
app.add_middleware(
|
| 92 |
+
CORSMiddleware,
|
| 93 |
+
allow_origins = ["*"],
|
| 94 |
+
allow_methods = ["*"],
|
| 95 |
+
allow_headers = ["*"],
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
# Serve static files if the sidecar runs standalone
|
| 99 |
+
_STATIC_DIR = _ROOT / "static"
|
| 100 |
+
_TEMPLATES_DIR = _ROOT / "templates"
|
| 101 |
+
if _STATIC_DIR.exists():
|
| 102 |
+
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
|
| 103 |
+
|
| 104 |
+
# ββ Initialise shield components ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 105 |
+
log.info("Loading Prompt Guard engine...")
|
| 106 |
+
_PG_ENGINE = PromptGuardEngine(model_path=Path(PROMPT_GUARD_MODEL_DIR)).load()
|
| 107 |
+
_GUARD = PromptGuardTextGuard(_PG_ENGINE)
|
| 108 |
+
log.info("Prompt Guard ready.")
|
| 109 |
+
|
| 110 |
+
# ββ Initialise LLM adapter ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 111 |
+
if LLM_BACKEND == "openai":
|
| 112 |
+
from openai_adapter import OpenAIAdapter
|
| 113 |
+
_ADAPTER = OpenAIAdapter(
|
| 114 |
+
api_key = OPENAI_API_KEY,
|
| 115 |
+
base_url = OPENAI_BASE_URL,
|
| 116 |
+
model = OPENAI_MODEL,
|
| 117 |
+
system_prompt = SYSTEM_PROMPT,
|
| 118 |
+
)
|
| 119 |
+
else:
|
| 120 |
+
from gemini_adapter import GeminiAdapter
|
| 121 |
+
_ADAPTER = GeminiAdapter(
|
| 122 |
+
api_key = GEMINI_API_KEY,
|
| 123 |
+
model_name = GEMINI_MODEL,
|
| 124 |
+
system_prompt = SYSTEM_PROMPT,
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
log.info("LLM adapter: %s (%s)", LLM_BACKEND, _ADAPTER.get_model_name())
|
| 128 |
+
|
| 129 |
+
# ββ Shared monitor (stateful β tracks behavioural drift across requests) ββββββ
|
| 130 |
+
_TEXT_MONITOR = TextMonitor(_ADAPTER, system_prompt=SYSTEM_PROMPT)
|
| 131 |
+
_STREAM_MONITOR = StreamMonitor(_TEXT_MONITOR, block_threshold=MONITOR_BLOCK_THRESHOLD, max_workers=MONITOR_WORKERS)
|
| 132 |
+
|
| 133 |
+
# ββ Last-request metrics (lightweight, single-threaded access via asyncio) ββββ
|
| 134 |
+
_LAST_METRICS: dict = {}
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
# ββ Request schema ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 138 |
+
|
| 139 |
+
class ChatRequest(BaseModel):
|
| 140 |
+
prompt: str
|
| 141 |
+
stream: bool = True
|
| 142 |
+
system_prompt: Optional[str] = None
|
| 143 |
+
source: Optional[str] = "sidecar"
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# ββ Routes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 147 |
+
|
| 148 |
+
@app.get("/")
|
| 149 |
+
async def root():
|
| 150 |
+
"""Serve the sidecar streaming UI (standalone mode)."""
|
| 151 |
+
ui_file = _TEMPLATES_DIR / "sidecar.html"
|
| 152 |
+
if ui_file.exists():
|
| 153 |
+
return FileResponse(str(ui_file), media_type="text/html")
|
| 154 |
+
return {"message": "GenAI Shield Sidecar", "docs": "/docs"}
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
@app.get("/dataflow")
|
| 158 |
+
async def dataflow_ui():
|
| 159 |
+
"""Serve the real-time data flow visualization dashboard."""
|
| 160 |
+
ui_file = _TEMPLATES_DIR / "dataflow.html"
|
| 161 |
+
if ui_file.exists():
|
| 162 |
+
return FileResponse(str(ui_file), media_type="text/html")
|
| 163 |
+
return {"message": "dataflow.html not found"}
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
@app.get("/v1/pipeline-stream")
|
| 167 |
+
async def pipeline_stream():
|
| 168 |
+
"""
|
| 169 |
+
SSE stream of structured pipeline telemetry events.
|
| 170 |
+
The data flow dashboard subscribes here to get real-time stage data.
|
| 171 |
+
"""
|
| 172 |
+
async def _gen():
|
| 173 |
+
import asyncio
|
| 174 |
+
q = subscribe()
|
| 175 |
+
try:
|
| 176 |
+
while True:
|
| 177 |
+
try:
|
| 178 |
+
# Poll queue with a short timeout so we can yield keepalives
|
| 179 |
+
payload = q.get_nowait()
|
| 180 |
+
yield f"data: {json.dumps(payload)}\n\n"
|
| 181 |
+
except Exception:
|
| 182 |
+
# No event β send keepalive comment
|
| 183 |
+
yield ": keepalive\n\n"
|
| 184 |
+
await asyncio.sleep(0.5)
|
| 185 |
+
except asyncio.CancelledError:
|
| 186 |
+
pass
|
| 187 |
+
finally:
|
| 188 |
+
unsubscribe(q)
|
| 189 |
+
|
| 190 |
+
return StreamingResponse(
|
| 191 |
+
_gen(),
|
| 192 |
+
media_type = "text/event-stream",
|
| 193 |
+
headers = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
@app.get("/v1/health")
|
| 198 |
+
async def health():
|
| 199 |
+
return {
|
| 200 |
+
"status": "ok",
|
| 201 |
+
"guard_ready": _PG_ENGINE.ready,
|
| 202 |
+
"model": _ADAPTER.get_model_name(),
|
| 203 |
+
"backend": LLM_BACKEND,
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
@app.get("/v1/stats")
|
| 208 |
+
async def stats():
|
| 209 |
+
return _PG_ENGINE.stats()
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
@app.get("/v1/metrics")
|
| 213 |
+
async def metrics():
|
| 214 |
+
return _LAST_METRICS or {"message": "No requests processed yet"}
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
@app.post("/v1/chat")
|
| 218 |
+
async def chat(req: ChatRequest):
|
| 219 |
+
"""
|
| 220 |
+
Main chat endpoint.
|
| 221 |
+
|
| 222 |
+
- stream=true β Server-Sent Events (SSE) with sentence-level output
|
| 223 |
+
- stream=false β Blocking JSON response (legacy-compatible)
|
| 224 |
+
"""
|
| 225 |
+
if not req.prompt.strip():
|
| 226 |
+
raise HTTPException(status_code=400, detail="Empty prompt")
|
| 227 |
+
|
| 228 |
+
sys_prompt = req.system_prompt or SYSTEM_PROMPT
|
| 229 |
+
|
| 230 |
+
if req.stream:
|
| 231 |
+
return StreamingResponse(
|
| 232 |
+
_stream_handler(req.prompt, sys_prompt, req.source or "sidecar"),
|
| 233 |
+
media_type = "text/event-stream",
|
| 234 |
+
headers = {
|
| 235 |
+
"Cache-Control": "no-cache",
|
| 236 |
+
"X-Accel-Buffering": "no", # disable nginx buffering
|
| 237 |
+
},
|
| 238 |
+
)
|
| 239 |
+
else:
|
| 240 |
+
return await _blocking_handler(req.prompt, sys_prompt, req.source or "sidecar")
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
# ββ Streaming handler βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 244 |
+
|
| 245 |
+
async def _stream_handler(
|
| 246 |
+
prompt: str,
|
| 247 |
+
sys_prompt: str,
|
| 248 |
+
source: str,
|
| 249 |
+
) -> AsyncGenerator[str, None]:
|
| 250 |
+
"""
|
| 251 |
+
Full streaming pipeline:
|
| 252 |
+
Gate (guard β₯ LLM) β SentenceSplitter β StreamMonitor (background)
|
| 253 |
+
"""
|
| 254 |
+
t_total = time.perf_counter()
|
| 255 |
+
gate = ShieldGate(_GUARD, _ADAPTER, guard_timeout_sec=GATE_GUARD_TIMEOUT_SEC)
|
| 256 |
+
splitter = SentenceSplitter(min_chars=SENTENCE_MIN_CHARS)
|
| 257 |
+
_STREAM_MONITOR.reset()
|
| 258 |
+
|
| 259 |
+
# ββ Telemetry trace for this request ββββββββββββββββββββββββββββββββββ
|
| 260 |
+
trace = RequestTrace()
|
| 261 |
+
trace.on_request_in(prompt)
|
| 262 |
+
|
| 263 |
+
guard_ms_ref = 0.0
|
| 264 |
+
all_flags: list = []
|
| 265 |
+
threat_score = 0
|
| 266 |
+
block_fired = False
|
| 267 |
+
sentences_sent = 0
|
| 268 |
+
total_tokens = 0
|
| 269 |
+
|
| 270 |
+
def sse(event_dict: dict) -> str:
|
| 271 |
+
"""Format a dict as an SSE data line."""
|
| 272 |
+
return f"data: {json.dumps(event_dict)}\n\n"
|
| 273 |
+
|
| 274 |
+
async for gate_event in gate.run(prompt, sys_prompt, trace=trace):
|
| 275 |
+
|
| 276 |
+
# ββ Guard blocked the prompt βββββββββββββββββββββββββββββββββββββββ
|
| 277 |
+
if isinstance(gate_event, BlockEvent):
|
| 278 |
+
all_flags = gate_event.flags
|
| 279 |
+
threat_score = gate_event.threat_score
|
| 280 |
+
guard_ms_ref = gate_event.guard_ms
|
| 281 |
+
|
| 282 |
+
yield sse({
|
| 283 |
+
"type": "blocked",
|
| 284 |
+
"reason": gate_event.reason,
|
| 285 |
+
"threat_score": gate_event.threat_score,
|
| 286 |
+
"flags": gate_event.flags,
|
| 287 |
+
"pg_score": gate_event.pg_score,
|
| 288 |
+
"guard_ms": gate_event.guard_ms,
|
| 289 |
+
})
|
| 290 |
+
block_fired = True
|
| 291 |
+
break
|
| 292 |
+
|
| 293 |
+
# ββ LLM token received βββββββββββββββββββββββββββββββββββββββββββββ
|
| 294 |
+
if isinstance(gate_event, TokenEvent):
|
| 295 |
+
total_tokens += 1
|
| 296 |
+
splitter_events = splitter.feed(gate_event.text)
|
| 297 |
+
|
| 298 |
+
for ev in splitter_events:
|
| 299 |
+
if isinstance(ev, type(ev)) and ev.type == "chunk":
|
| 300 |
+
yield sse({"type": "chunk", "text": ev.text})
|
| 301 |
+
|
| 302 |
+
elif ev.type == "sentence":
|
| 303 |
+
sentences_sent += 1
|
| 304 |
+
trace.on_sentence_ready(ev.sentence_id, ev.text)
|
| 305 |
+
yield sse({
|
| 306 |
+
"type": "sentence",
|
| 307 |
+
"text": ev.text,
|
| 308 |
+
"sentence_id": ev.sentence_id,
|
| 309 |
+
})
|
| 310 |
+
# Submit to background monitor (non-blocking)
|
| 311 |
+
trace.on_monitor_start(ev.sentence_id)
|
| 312 |
+
await _STREAM_MONITOR.submit(ev.sentence_id, ev.text, prompt)
|
| 313 |
+
|
| 314 |
+
if block_fired:
|
| 315 |
+
total_ms = round((time.perf_counter() - t_total) * 1000, 2)
|
| 316 |
+
trace.on_request_done(threat_score, all_flags, blocked=True)
|
| 317 |
+
_update_metrics(threat_score, all_flags, guard_ms_ref, 0, 0, total_ms)
|
| 318 |
+
return
|
| 319 |
+
|
| 320 |
+
# ββ Stream ended β flush remaining buffer ββββββββββββββββββββββββββββββ
|
| 321 |
+
for ev in splitter.flush():
|
| 322 |
+
sentences_sent += 1
|
| 323 |
+
trace.on_sentence_ready(ev.sentence_id, ev.text)
|
| 324 |
+
yield sse({"type": "sentence", "text": ev.text, "sentence_id": ev.sentence_id})
|
| 325 |
+
trace.on_monitor_start(ev.sentence_id)
|
| 326 |
+
await _STREAM_MONITOR.submit(ev.sentence_id, ev.text, prompt)
|
| 327 |
+
|
| 328 |
+
trace.on_stream_done(total_tokens, sentences_sent)
|
| 329 |
+
|
| 330 |
+
# ββ Collect background monitor results βββββββββββββββββββββββββββββββββ
|
| 331 |
+
signals = await _STREAM_MONITOR.collect(timeout=1.5)
|
| 332 |
+
|
| 333 |
+
for sig in signals:
|
| 334 |
+
threat_score = max(threat_score, sig.threat_score)
|
| 335 |
+
all_flags.extend(sig.flags)
|
| 336 |
+
trace.on_monitor_result(sig.sentence_id, sig.threat_score, sig.flags, blocked=True)
|
| 337 |
+
yield sse({
|
| 338 |
+
"type": "block_signal",
|
| 339 |
+
"sentence_id": sig.sentence_id,
|
| 340 |
+
"reason": sig.reason,
|
| 341 |
+
"threat_score": sig.threat_score,
|
| 342 |
+
"flags": sig.flags,
|
| 343 |
+
})
|
| 344 |
+
|
| 345 |
+
total_ms = round((time.perf_counter() - t_total) * 1000, 2)
|
| 346 |
+
trace.on_request_done(threat_score, list(set(all_flags)), blocked=False)
|
| 347 |
+
|
| 348 |
+
yield sse({
|
| 349 |
+
"type": "done",
|
| 350 |
+
"threat_score": threat_score,
|
| 351 |
+
"flags": list(set(all_flags)),
|
| 352 |
+
"latency_ms": total_ms,
|
| 353 |
+
"sentences": sentences_sent,
|
| 354 |
+
})
|
| 355 |
+
|
| 356 |
+
_update_metrics(threat_score, all_flags, 0, 0, total_ms, total_ms)
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
# ββ Blocking handler (non-streaming, backward-compatible) βββββββββββββββββββββ
|
| 360 |
+
|
| 361 |
+
async def _blocking_handler(prompt: str, sys_prompt: str, source: str) -> dict:
|
| 362 |
+
"""
|
| 363 |
+
Non-streaming path β guard first, then full LLM call, then monitor.
|
| 364 |
+
Compatible with existing /genai-chat behaviour.
|
| 365 |
+
"""
|
| 366 |
+
import asyncio
|
| 367 |
+
|
| 368 |
+
t_start = time.perf_counter()
|
| 369 |
+
|
| 370 |
+
# Guard (in thread β synchronous)
|
| 371 |
+
loop = asyncio.get_event_loop()
|
| 372 |
+
guard_result = await loop.run_in_executor(None, _GUARD.screen, prompt)
|
| 373 |
+
guard_ms = round((time.perf_counter() - t_start) * 1000, 2)
|
| 374 |
+
|
| 375 |
+
pg_score = guard_result.get("checks", {}).get("prompt_guard", {}).get("malicious_score", 0.0)
|
| 376 |
+
|
| 377 |
+
if guard_result["blocked"]:
|
| 378 |
+
return {
|
| 379 |
+
"blocked": True,
|
| 380 |
+
"response": None,
|
| 381 |
+
"reason": guard_result["reason"],
|
| 382 |
+
"threat_score": guard_result["threat_score"],
|
| 383 |
+
"flags": guard_result["flags"],
|
| 384 |
+
"pg_score": pg_score,
|
| 385 |
+
"latency_breakdown": {"guard_ms": guard_ms, "model_ms": 0, "monitor_ms": 0},
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
# LLM call (blocking adapter)
|
| 389 |
+
t_model = time.perf_counter()
|
| 390 |
+
response = await loop.run_in_executor(None, _ADAPTER.chat, prompt, sys_prompt)
|
| 391 |
+
model_ms = round((time.perf_counter() - t_model) * 1000, 2)
|
| 392 |
+
|
| 393 |
+
# Post-monitor
|
| 394 |
+
t_monitor = time.perf_counter()
|
| 395 |
+
mon_result = await loop.run_in_executor(None, _TEXT_MONITOR.analyze, prompt, response)
|
| 396 |
+
monitor_ms = round((time.perf_counter() - t_monitor) * 1000, 2)
|
| 397 |
+
|
| 398 |
+
total_ms = round(guard_ms + model_ms + monitor_ms, 2)
|
| 399 |
+
threat_score = max(guard_result["threat_score"], mon_result["threat_score"])
|
| 400 |
+
all_flags = guard_result["flags"] + mon_result["flags"]
|
| 401 |
+
|
| 402 |
+
_update_metrics(threat_score, all_flags, guard_ms, model_ms, monitor_ms, total_ms)
|
| 403 |
+
|
| 404 |
+
return {
|
| 405 |
+
"blocked": False,
|
| 406 |
+
"response": response,
|
| 407 |
+
"threat_score": threat_score,
|
| 408 |
+
"flags": all_flags,
|
| 409 |
+
"pg_score": pg_score,
|
| 410 |
+
"latency_ms": total_ms,
|
| 411 |
+
"model": _ADAPTER.get_model_name(),
|
| 412 |
+
"latency_breakdown": {
|
| 413 |
+
"guard_ms": guard_ms,
|
| 414 |
+
"model_ms": model_ms,
|
| 415 |
+
"monitor_ms": monitor_ms,
|
| 416 |
+
},
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
# ββ Metrics helper βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 421 |
+
|
| 422 |
+
def _update_metrics(threat_score, flags, guard_ms, model_ms, monitor_ms, total_ms):
|
| 423 |
+
global _LAST_METRICS
|
| 424 |
+
_LAST_METRICS = {
|
| 425 |
+
"threat_score": threat_score,
|
| 426 |
+
"flags": flags,
|
| 427 |
+
"guard_ms": guard_ms,
|
| 428 |
+
"model_ms": model_ms,
|
| 429 |
+
"monitor_ms": monitor_ms,
|
| 430 |
+
"total_ms": total_ms,
|
| 431 |
+
"model": _ADAPTER.get_model_name(),
|
| 432 |
+
"timestamp": time.strftime("%H:%M:%S"),
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
# ββ Entry point βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 437 |
+
|
| 438 |
+
if __name__ == "__main__":
|
| 439 |
+
log.info("Starting GenAI Shield Sidecar on %s:%d", SIDECAR_HOST, SIDECAR_PORT)
|
| 440 |
+
uvicorn.run(
|
| 441 |
+
"sidecar.app:app",
|
| 442 |
+
host = SIDECAR_HOST,
|
| 443 |
+
port = SIDECAR_PORT,
|
| 444 |
+
log_level = LOG_LEVEL.lower(),
|
| 445 |
+
reload = False,
|
| 446 |
+
)
|
sidecar/config.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
sidecar/config.py β Centralised configuration for the GenAI Shield Sidecar.
|
| 3 |
+
|
| 4 |
+
All runtime behaviour is controlled via environment variables.
|
| 5 |
+
Defaults are safe for local development.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
|
| 10 |
+
# ββ Sidecar server ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 11 |
+
SIDECAR_HOST = os.getenv("SIDECAR_HOST", "0.0.0.0")
|
| 12 |
+
SIDECAR_PORT = int(os.getenv("SIDECAR_PORT", "5050"))
|
| 13 |
+
|
| 14 |
+
# ββ LLM backend βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 15 |
+
LLM_BACKEND = os.getenv("LLM_BACKEND", "gemini") # "gemini" | "openai"
|
| 16 |
+
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
|
| 17 |
+
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.0-flash-lite")
|
| 18 |
+
|
| 19 |
+
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
|
| 20 |
+
OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
| 21 |
+
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
| 22 |
+
|
| 23 |
+
# ββ System prompt βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 24 |
+
SYSTEM_PROMPT = os.getenv(
|
| 25 |
+
"GENAI_SYSTEM_PROMPT",
|
| 26 |
+
"You are a helpful AI assistant. Be concise, accurate, and professional.",
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
# ββ Shield thresholds βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 30 |
+
GUARD_BLOCK_THRESHOLD = float(os.getenv("GUARD_BLOCK_THRESHOLD", "0.85"))
|
| 31 |
+
GUARD_FLAG_THRESHOLD = float(os.getenv("GUARD_FLAG_THRESHOLD", "0.50"))
|
| 32 |
+
MONITOR_BLOCK_THRESHOLD = int(os.getenv("MONITOR_BLOCK_THRESHOLD", "40"))
|
| 33 |
+
|
| 34 |
+
# ββ Gate behaviour ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 35 |
+
# Max ms to wait for guard verdict before letting buffered tokens through anyway
|
| 36 |
+
# (safety valve β guard should always be <300ms in practice)
|
| 37 |
+
GATE_GUARD_TIMEOUT_SEC = float(os.getenv("GATE_GUARD_TIMEOUT_SEC", "3.0"))
|
| 38 |
+
|
| 39 |
+
# ββ Post-monitor thread pool ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
+
MONITOR_WORKERS = int(os.getenv("MONITOR_WORKERS", "4"))
|
| 41 |
+
|
| 42 |
+
# ββ Sentence splitter βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 43 |
+
# Minimum characters before a sentence boundary is declared
|
| 44 |
+
SENTENCE_MIN_CHARS = int(os.getenv("SENTENCE_MIN_CHARS", "20"))
|
| 45 |
+
|
| 46 |
+
# ββ Model path ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 47 |
+
PROMPT_GUARD_MODEL_DIR = os.getenv(
|
| 48 |
+
"PROMPT_GUARD_MODEL_DIR",
|
| 49 |
+
"models/Llama-Prompt-Guard-2-86M",
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
# ββ Logging βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 53 |
+
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
sidecar/gate.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
sidecar/gate.py β Concurrent Guard + LLM Fan-Out Gate.
|
| 3 |
+
|
| 4 |
+
The Gate is the heart of the sidecar. It starts the Prompt Guard check
|
| 5 |
+
and the LLM stream simultaneously, then:
|
| 6 |
+
|
| 7 |
+
β’ If Guard BLOCKS β cancel the LLM stream, yield a BlockEvent, stop.
|
| 8 |
+
β’ If Guard PASSES β open the gate; yield all buffered + live tokens.
|
| 9 |
+
|
| 10 |
+
This means the guard's latency (~50β150ms) is hidden inside the LLM's
|
| 11 |
+
time-to-first-token (~300β600ms). On the happy path the user effectively
|
| 12 |
+
sees ZERO added latency from the guard.
|
| 13 |
+
|
| 14 |
+
ββββββββββββββββββββββββββββββββββ
|
| 15 |
+
Client ββpromptβββΊβ Gate.run() β
|
| 16 |
+
β β
|
| 17 |
+
β Task A: guard.screen(prompt) β ~100ms
|
| 18 |
+
β Task B: adapter.stream_chat() β ~500ms TTFT
|
| 19 |
+
β β
|
| 20 |
+
β Guard finishes first? β
|
| 21 |
+
β blocked=True β cancel B ββββΊ BlockEvent
|
| 22 |
+
β blocked=False β open gate ββββΊ yield buffered tokens
|
| 23 |
+
β ββββΊ yield live tokensβ¦
|
| 24 |
+
ββββββββββββββββββββββββββββββββββ
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
import asyncio
|
| 28 |
+
import logging
|
| 29 |
+
import time
|
| 30 |
+
from dataclasses import dataclass, field
|
| 31 |
+
from typing import AsyncGenerator, List, Optional, Union
|
| 32 |
+
|
| 33 |
+
log = logging.getLogger(__name__)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ββ Gate events βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 37 |
+
|
| 38 |
+
@dataclass
|
| 39 |
+
class TokenEvent:
|
| 40 |
+
"""A raw token chunk from the LLM β pass through to client."""
|
| 41 |
+
text: str
|
| 42 |
+
type: str = field(default="token", init=False)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@dataclass
|
| 46 |
+
class BlockEvent:
|
| 47 |
+
"""Guard blocked the prompt β LLM never delivered output to client."""
|
| 48 |
+
reason: str
|
| 49 |
+
threat_score: int
|
| 50 |
+
flags: List[str]
|
| 51 |
+
pg_score: float
|
| 52 |
+
guard_ms: float
|
| 53 |
+
type: str = field(default="blocked", init=False)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
GateEvent = Union[TokenEvent, BlockEvent]
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# ββ The Gate ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 60 |
+
|
| 61 |
+
class ShieldGate:
|
| 62 |
+
"""
|
| 63 |
+
Orchestrates concurrent Guard check + LLM stream with a buffering gate.
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
guard: PromptGuardTextGuard instance.
|
| 67 |
+
adapter: LLMAdapter with stream_chat().
|
| 68 |
+
guard_timeout_sec: Max seconds to wait for guard before passing anyway.
|
| 69 |
+
(Safety valve β guard should always be < 300ms.)
|
| 70 |
+
"""
|
| 71 |
+
|
| 72 |
+
def __init__(self, guard, adapter, guard_timeout_sec: float = 3.0):
|
| 73 |
+
self._guard = guard
|
| 74 |
+
self._adapter = adapter
|
| 75 |
+
self._timeout = guard_timeout_sec
|
| 76 |
+
|
| 77 |
+
async def run(
|
| 78 |
+
self,
|
| 79 |
+
prompt: str,
|
| 80 |
+
system_prompt: str = "",
|
| 81 |
+
trace=None, # Optional[RequestTrace] β for pipeline telemetry
|
| 82 |
+
) -> AsyncGenerator[GateEvent, None]:
|
| 83 |
+
"""
|
| 84 |
+
Async generator β yields GateEvents to the SSE layer.
|
| 85 |
+
|
| 86 |
+
Yields either a single BlockEvent (if guard fires) or a stream of
|
| 87 |
+
TokenEvents followed by nothing (caller handles done/close).
|
| 88 |
+
"""
|
| 89 |
+
t_start = time.perf_counter()
|
| 90 |
+
|
| 91 |
+
# ββ Token buffer β accumulates LLM tokens while guard decides βββββ
|
| 92 |
+
token_buffer: List[str] = []
|
| 93 |
+
buffer_lock = asyncio.Lock()
|
| 94 |
+
first_token_seen = False
|
| 95 |
+
|
| 96 |
+
# ββ Task A: Guard (runs in thread pool β it's synchronous) ββββββββ
|
| 97 |
+
async def run_guard() -> dict:
|
| 98 |
+
if trace:
|
| 99 |
+
trace.on_guard_start()
|
| 100 |
+
loop = asyncio.get_event_loop()
|
| 101 |
+
result = await loop.run_in_executor(None, self._guard.screen, prompt)
|
| 102 |
+
return result
|
| 103 |
+
|
| 104 |
+
# ββ Task B: LLM stream β feeds token_buffer until gate opens ββββββ
|
| 105 |
+
async def run_stream():
|
| 106 |
+
nonlocal first_token_seen
|
| 107 |
+
if trace:
|
| 108 |
+
trace.on_llm_start()
|
| 109 |
+
try:
|
| 110 |
+
token_count = 0
|
| 111 |
+
total_chars = 0
|
| 112 |
+
async for token in self._adapter.stream_chat(prompt, system_prompt or None):
|
| 113 |
+
if not first_token_seen:
|
| 114 |
+
first_token_seen = True
|
| 115 |
+
if trace:
|
| 116 |
+
trace.on_first_token()
|
| 117 |
+
async with buffer_lock:
|
| 118 |
+
token_buffer.append(token)
|
| 119 |
+
token_count += 1
|
| 120 |
+
total_chars += len(token)
|
| 121 |
+
# Emit a token-flow tick every 10 tokens for the dashboard
|
| 122 |
+
if trace and token_count % 10 == 0:
|
| 123 |
+
trace.on_token_tick(token_count, total_chars)
|
| 124 |
+
except asyncio.CancelledError:
|
| 125 |
+
pass # Guard blocked β stream cancelled cleanly
|
| 126 |
+
|
| 127 |
+
# Start both tasks concurrently
|
| 128 |
+
guard_task = asyncio.create_task(run_guard(), name="shield-guard")
|
| 129 |
+
stream_task = asyncio.create_task(run_stream(), name="llm-stream")
|
| 130 |
+
|
| 131 |
+
# ββ Wait for guard verdict βββββββββββββββββββββββββββββββββββββββββ
|
| 132 |
+
try:
|
| 133 |
+
guard_result = await asyncio.wait_for(
|
| 134 |
+
asyncio.shield(guard_task),
|
| 135 |
+
timeout=self._timeout,
|
| 136 |
+
)
|
| 137 |
+
except asyncio.TimeoutError:
|
| 138 |
+
log.error("[Gate] Guard timed out after %.1fs β defaulting to PASS", self._timeout)
|
| 139 |
+
guard_result = {"blocked": False, "threat_score": 0, "flags": [], "checks": {}}
|
| 140 |
+
|
| 141 |
+
guard_ms = round((time.perf_counter() - t_start) * 1000, 2)
|
| 142 |
+
pg_score = guard_result.get("checks", {}).get("prompt_guard", {}).get("malicious_score", 0.0)
|
| 143 |
+
|
| 144 |
+
# ββ Guard says BLOCK βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 145 |
+
if guard_result.get("blocked"):
|
| 146 |
+
stream_task.cancel()
|
| 147 |
+
try:
|
| 148 |
+
await stream_task
|
| 149 |
+
except (asyncio.CancelledError, Exception):
|
| 150 |
+
pass
|
| 151 |
+
|
| 152 |
+
log.warning(
|
| 153 |
+
"[Gate] Prompt BLOCKED β score=%.3f flags=%s latency=%.0fms",
|
| 154 |
+
pg_score, guard_result.get("flags", []), guard_ms,
|
| 155 |
+
)
|
| 156 |
+
if trace:
|
| 157 |
+
trace.on_guard_block(
|
| 158 |
+
pg_score = pg_score,
|
| 159 |
+
threat_score = guard_result.get("threat_score", 100),
|
| 160 |
+
reason = guard_result.get("reason", "GUARD_BLOCKED"),
|
| 161 |
+
flags = guard_result.get("flags", []),
|
| 162 |
+
)
|
| 163 |
+
yield BlockEvent(
|
| 164 |
+
reason = guard_result.get("reason", "GUARD_BLOCKED"),
|
| 165 |
+
threat_score = guard_result.get("threat_score", 100),
|
| 166 |
+
flags = guard_result.get("flags", []),
|
| 167 |
+
pg_score = pg_score,
|
| 168 |
+
guard_ms = guard_ms,
|
| 169 |
+
)
|
| 170 |
+
return
|
| 171 |
+
|
| 172 |
+
# ββ Guard says PASS β drain buffer then yield live tokens ββββββββββ
|
| 173 |
+
log.info(
|
| 174 |
+
"[Gate] Prompt PASSED β score=%.3f latency=%.0fms β opening gate",
|
| 175 |
+
pg_score, guard_ms,
|
| 176 |
+
)
|
| 177 |
+
if trace:
|
| 178 |
+
trace.on_guard_pass(
|
| 179 |
+
pg_score = pg_score,
|
| 180 |
+
threat_score = guard_result.get("threat_score", 0),
|
| 181 |
+
flags = guard_result.get("flags", []),
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
# Drain anything buffered while guard was deciding
|
| 185 |
+
async with buffer_lock:
|
| 186 |
+
buffered = token_buffer.copy()
|
| 187 |
+
token_buffer.clear()
|
| 188 |
+
|
| 189 |
+
for token in buffered:
|
| 190 |
+
yield TokenEvent(text=token)
|
| 191 |
+
|
| 192 |
+
# Wait for stream task to complete, yielding live tokens as they arrive
|
| 193 |
+
# We poll the buffer so we can yield from the async generator context
|
| 194 |
+
while not stream_task.done():
|
| 195 |
+
await asyncio.sleep(0.005) # 5ms poll β tight enough for streaming
|
| 196 |
+
async with buffer_lock:
|
| 197 |
+
live = token_buffer.copy()
|
| 198 |
+
token_buffer.clear()
|
| 199 |
+
for token in live:
|
| 200 |
+
yield TokenEvent(text=token)
|
| 201 |
+
|
| 202 |
+
# Final drain after stream task completes
|
| 203 |
+
async with buffer_lock:
|
| 204 |
+
for token in token_buffer:
|
| 205 |
+
yield TokenEvent(text=token)
|
| 206 |
+
|
| 207 |
+
# Propagate any stream errors
|
| 208 |
+
if not stream_task.cancelled() and stream_task.exception():
|
| 209 |
+
log.error("[Gate] LLM stream error: %s", stream_task.exception())
|
sidecar/pipeline_events.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
sidecar/pipeline_events.py β Structured event bus for real-time data flow telemetry.
|
| 3 |
+
|
| 4 |
+
Every stage of the sidecar pipeline emits a PipelineEvent into a shared
|
| 5 |
+
in-process broadcast queue. The /v1/pipeline-stream SSE endpoint fans these
|
| 6 |
+
out to all connected dashboard clients in real time.
|
| 7 |
+
|
| 8 |
+
Event stages (in chronological order for a single request):
|
| 9 |
+
REQUEST_IN β client prompt received
|
| 10 |
+
GUARD_START β Prompt Guard model begins screening
|
| 11 |
+
LLM_START β LLM stream begins (parallel to GUARD_START)
|
| 12 |
+
GUARD_PASS β guard cleared the prompt
|
| 13 |
+
GUARD_BLOCK β guard rejected the prompt
|
| 14 |
+
TOKEN_FLOW β LLM tokens flowing (summary ticks, not every token)
|
| 15 |
+
SENTENCE_READY β a complete sentence was emitted
|
| 16 |
+
MONITOR_START β sentence submitted to background monitor
|
| 17 |
+
MONITOR_RESULT β monitor returned a verdict for a sentence
|
| 18 |
+
STREAM_DONE β LLM stream finished, all sentences emitted
|
| 19 |
+
REQUEST_DONE β full request completed, final metrics available
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
import queue
|
| 23 |
+
import time
|
| 24 |
+
import threading
|
| 25 |
+
import uuid
|
| 26 |
+
from dataclasses import dataclass, field, asdict
|
| 27 |
+
from typing import Any, Dict, List, Optional
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ββ Event types βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 31 |
+
|
| 32 |
+
@dataclass
|
| 33 |
+
class PipelineEvent:
|
| 34 |
+
stage: str # one of the constants above
|
| 35 |
+
request_id: str
|
| 36 |
+
ts: float = field(default_factory=time.perf_counter) # perf_counter seconds
|
| 37 |
+
wall: str = field(default_factory=lambda: time.strftime("%H:%M:%S"))
|
| 38 |
+
data: Dict = field(default_factory=dict)
|
| 39 |
+
|
| 40 |
+
def to_dict(self) -> Dict:
|
| 41 |
+
return {
|
| 42 |
+
"stage": self.stage,
|
| 43 |
+
"request_id": self.request_id,
|
| 44 |
+
"ts": round(self.ts * 1000, 2), # ms since process start
|
| 45 |
+
"wall": self.wall,
|
| 46 |
+
**self.data,
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ββ Global broadcast queue list βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 51 |
+
|
| 52 |
+
_subscribers: List[queue.Queue] = []
|
| 53 |
+
_lock = threading.Lock()
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def subscribe() -> queue.Queue:
|
| 57 |
+
"""Register a new SSE client. Returns its dedicated queue."""
|
| 58 |
+
q = queue.Queue(maxsize=200)
|
| 59 |
+
with _lock:
|
| 60 |
+
_subscribers.append(q)
|
| 61 |
+
return q
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def unsubscribe(q: queue.Queue) -> None:
|
| 65 |
+
"""Remove an SSE client queue."""
|
| 66 |
+
with _lock:
|
| 67 |
+
try:
|
| 68 |
+
_subscribers.remove(q)
|
| 69 |
+
except ValueError:
|
| 70 |
+
pass
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def emit(event: PipelineEvent) -> None:
|
| 74 |
+
"""Broadcast an event to all connected SSE clients."""
|
| 75 |
+
payload = event.to_dict()
|
| 76 |
+
with _lock:
|
| 77 |
+
dead = []
|
| 78 |
+
for q in _subscribers:
|
| 79 |
+
try:
|
| 80 |
+
q.put_nowait(payload)
|
| 81 |
+
except queue.Full:
|
| 82 |
+
dead.append(q)
|
| 83 |
+
for q in dead:
|
| 84 |
+
_subscribers.remove(q)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# ββ Request context helper ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 88 |
+
|
| 89 |
+
class RequestTrace:
|
| 90 |
+
"""
|
| 91 |
+
Tracks timing for a single request and emits PipelineEvents at each stage.
|
| 92 |
+
Used as a context object threaded through the pipeline.
|
| 93 |
+
"""
|
| 94 |
+
|
| 95 |
+
def __init__(self):
|
| 96 |
+
self.request_id: str = str(uuid.uuid4())[:8]
|
| 97 |
+
self._t0: float = time.perf_counter()
|
| 98 |
+
self._stage_start: Dict[str, float] = {}
|
| 99 |
+
|
| 100 |
+
def elapsed_ms(self) -> float:
|
| 101 |
+
return round((time.perf_counter() - self._t0) * 1000, 2)
|
| 102 |
+
|
| 103 |
+
def _since_stage(self, stage: str) -> float:
|
| 104 |
+
t = self._stage_start.get(stage)
|
| 105 |
+
if t is None:
|
| 106 |
+
return 0.0
|
| 107 |
+
return round((time.perf_counter() - t) * 1000, 2)
|
| 108 |
+
|
| 109 |
+
def _emit(self, stage: str, **kwargs):
|
| 110 |
+
self._stage_start.setdefault(stage, time.perf_counter())
|
| 111 |
+
emit(PipelineEvent(
|
| 112 |
+
stage = stage,
|
| 113 |
+
request_id = self.request_id,
|
| 114 |
+
data = {"elapsed_ms": self.elapsed_ms(), **kwargs},
|
| 115 |
+
))
|
| 116 |
+
|
| 117 |
+
# ββ Stage helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 118 |
+
|
| 119 |
+
def on_request_in(self, prompt_preview: str):
|
| 120 |
+
self._emit("REQUEST_IN", prompt_preview=prompt_preview[:80])
|
| 121 |
+
|
| 122 |
+
def on_guard_start(self):
|
| 123 |
+
self._stage_start["guard"] = time.perf_counter()
|
| 124 |
+
self._emit("GUARD_START")
|
| 125 |
+
|
| 126 |
+
def on_llm_start(self):
|
| 127 |
+
self._stage_start["llm"] = time.perf_counter()
|
| 128 |
+
self._emit("LLM_START")
|
| 129 |
+
|
| 130 |
+
def on_guard_pass(self, pg_score: float, threat_score: int, flags: list):
|
| 131 |
+
guard_ms = self._since_stage("guard")
|
| 132 |
+
self._emit("GUARD_PASS",
|
| 133 |
+
guard_ms=guard_ms, pg_score=pg_score,
|
| 134 |
+
threat_score=threat_score, flags=flags)
|
| 135 |
+
|
| 136 |
+
def on_guard_block(self, pg_score: float, threat_score: int, reason: str, flags: list):
|
| 137 |
+
guard_ms = self._since_stage("guard")
|
| 138 |
+
self._emit("GUARD_BLOCK",
|
| 139 |
+
guard_ms=guard_ms, pg_score=pg_score,
|
| 140 |
+
threat_score=threat_score, reason=reason, flags=flags)
|
| 141 |
+
|
| 142 |
+
def on_first_token(self):
|
| 143 |
+
ttft = self._since_stage("llm")
|
| 144 |
+
self._emit("FIRST_TOKEN", ttft_ms=ttft)
|
| 145 |
+
|
| 146 |
+
def on_token_tick(self, token_count: int, chars: int):
|
| 147 |
+
self._emit("TOKEN_FLOW", token_count=token_count, chars=chars)
|
| 148 |
+
|
| 149 |
+
def on_sentence_ready(self, sentence_id: int, sentence_preview: str):
|
| 150 |
+
self._emit("SENTENCE_READY",
|
| 151 |
+
sentence_id=sentence_id,
|
| 152 |
+
sentence_preview=sentence_preview[:60])
|
| 153 |
+
|
| 154 |
+
def on_monitor_start(self, sentence_id: int):
|
| 155 |
+
self._emit("MONITOR_START", sentence_id=sentence_id)
|
| 156 |
+
|
| 157 |
+
def on_monitor_result(self, sentence_id: int, threat_score: int, flags: list, blocked: bool):
|
| 158 |
+
self._emit("MONITOR_RESULT",
|
| 159 |
+
sentence_id=sentence_id, threat_score=threat_score,
|
| 160 |
+
flags=flags, blocked=blocked)
|
| 161 |
+
|
| 162 |
+
def on_stream_done(self, total_tokens: int, sentence_count: int):
|
| 163 |
+
stream_ms = self._since_stage("llm")
|
| 164 |
+
self._emit("STREAM_DONE",
|
| 165 |
+
stream_ms=stream_ms, total_tokens=total_tokens,
|
| 166 |
+
sentence_count=sentence_count)
|
| 167 |
+
|
| 168 |
+
def on_request_done(self, threat_score: int, flags: list, blocked: bool):
|
| 169 |
+
self._emit("REQUEST_DONE",
|
| 170 |
+
total_ms=self.elapsed_ms(),
|
| 171 |
+
threat_score=threat_score, flags=flags, blocked=blocked)
|
sidecar/sentence_splitter.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
sidecar/sentence_splitter.py β Streaming token accumulator with sentence boundary detection.
|
| 3 |
+
|
| 4 |
+
Accumulates raw token chunks from the LLM stream and detects sentence
|
| 5 |
+
boundaries. On each token it returns:
|
| 6 |
+
- A CHUNK event always (for immediate client rendering)
|
| 7 |
+
- A SENTENCE event when a sentence boundary is crossed (for shield monitoring)
|
| 8 |
+
|
| 9 |
+
Design notes:
|
| 10 |
+
- Boundaries are detected on sentence-ending punctuation followed by
|
| 11 |
+
whitespace, or on double newlines (paragraph breaks).
|
| 12 |
+
- A minimum character threshold prevents very short "sentences" from
|
| 13 |
+
creating excessive monitor submissions (e.g. "OK." or "Yes.").
|
| 14 |
+
- The final flush() call drains any remaining buffer as a sentence.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import re
|
| 18 |
+
from dataclasses import dataclass, field
|
| 19 |
+
from typing import List
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ββ Event types βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 23 |
+
|
| 24 |
+
@dataclass
|
| 25 |
+
class ChunkEvent:
|
| 26 |
+
"""A raw token chunk β emitted immediately to the client."""
|
| 27 |
+
text: str
|
| 28 |
+
type: str = field(default="chunk", init=False)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass
|
| 32 |
+
class SentenceEvent:
|
| 33 |
+
"""A complete sentence β emitted to the client AND submitted to the monitor."""
|
| 34 |
+
text: str
|
| 35 |
+
sentence_id: int
|
| 36 |
+
type: str = field(default="sentence", init=False)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ββ Sentence boundary pattern ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
+
#
|
| 41 |
+
# Matches end-of-sentence punctuation (. ! ?) followed by:
|
| 42 |
+
# a) whitespace (space / newline)
|
| 43 |
+
# b) end of string
|
| 44 |
+
# Also matches double newlines (paragraph breaks).
|
| 45 |
+
#
|
| 46 |
+
_SENTENCE_END_RE = re.compile(r'(?<=[.!?])\s+|(?<=[.!?])$|\n\n')
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class SentenceSplitter:
|
| 50 |
+
"""
|
| 51 |
+
Accumulates streaming tokens and fires sentence events at boundaries.
|
| 52 |
+
|
| 53 |
+
Args:
|
| 54 |
+
min_chars: Minimum buffer length before a boundary is declared.
|
| 55 |
+
Prevents "Yes.", "OK." from triggering monitor overhead.
|
| 56 |
+
"""
|
| 57 |
+
|
| 58 |
+
def __init__(self, min_chars: int = 20):
|
| 59 |
+
self._min_chars = min_chars
|
| 60 |
+
self._buffer: str = ""
|
| 61 |
+
self._sentence_id: int = 0
|
| 62 |
+
|
| 63 |
+
# ------------------------------------------------------------------
|
| 64 |
+
# Public API
|
| 65 |
+
# ------------------------------------------------------------------
|
| 66 |
+
|
| 67 |
+
def feed(self, token: str) -> List[ChunkEvent | SentenceEvent]:
|
| 68 |
+
"""
|
| 69 |
+
Feed a new token chunk. Returns a list of events to emit.
|
| 70 |
+
|
| 71 |
+
Always contains at least one ChunkEvent.
|
| 72 |
+
May contain a SentenceEvent if a boundary was crossed.
|
| 73 |
+
"""
|
| 74 |
+
self._buffer += token
|
| 75 |
+
events: List[ChunkEvent | SentenceEvent] = [ChunkEvent(text=token)]
|
| 76 |
+
|
| 77 |
+
# Scan for sentence boundaries in the current buffer
|
| 78 |
+
while True:
|
| 79 |
+
match = _SENTENCE_END_RE.search(self._buffer)
|
| 80 |
+
if not match:
|
| 81 |
+
break
|
| 82 |
+
|
| 83 |
+
end_pos = match.end()
|
| 84 |
+
sentence_text = self._buffer[:end_pos].strip()
|
| 85 |
+
|
| 86 |
+
# Only declare a sentence if it's long enough
|
| 87 |
+
if len(sentence_text) >= self._min_chars:
|
| 88 |
+
self._sentence_id += 1
|
| 89 |
+
events.append(SentenceEvent(
|
| 90 |
+
text=sentence_text,
|
| 91 |
+
sentence_id=self._sentence_id,
|
| 92 |
+
))
|
| 93 |
+
self._buffer = self._buffer[end_pos:]
|
| 94 |
+
else:
|
| 95 |
+
# Too short β don't split here, keep accumulating
|
| 96 |
+
break
|
| 97 |
+
|
| 98 |
+
return events
|
| 99 |
+
|
| 100 |
+
def flush(self) -> List[SentenceEvent]:
|
| 101 |
+
"""
|
| 102 |
+
Flush any remaining buffer as a final sentence.
|
| 103 |
+
Call this after the stream ends.
|
| 104 |
+
"""
|
| 105 |
+
events = []
|
| 106 |
+
remainder = self._buffer.strip()
|
| 107 |
+
if remainder:
|
| 108 |
+
self._sentence_id += 1
|
| 109 |
+
events.append(SentenceEvent(
|
| 110 |
+
text=remainder,
|
| 111 |
+
sentence_id=self._sentence_id,
|
| 112 |
+
))
|
| 113 |
+
self._buffer = ""
|
| 114 |
+
return events
|
| 115 |
+
|
| 116 |
+
@property
|
| 117 |
+
def sentence_count(self) -> int:
|
| 118 |
+
return self._sentence_id
|
sidecar/stream_monitor.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
sidecar/stream_monitor.py β Async sentence-level post-inference monitor.
|
| 3 |
+
|
| 4 |
+
Wraps the synchronous TextMonitor in an async ThreadPoolExecutor so that
|
| 5 |
+
per-sentence analysis runs concurrently in the background while the LLM
|
| 6 |
+
stream continues flowing to the client.
|
| 7 |
+
|
| 8 |
+
When a sentence crosses the threat threshold, a BlockSignal is returned
|
| 9 |
+
so the SSE layer can push a block event to the client immediately.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import asyncio
|
| 13 |
+
import logging
|
| 14 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 15 |
+
from dataclasses import dataclass, field
|
| 16 |
+
from typing import List, Optional
|
| 17 |
+
|
| 18 |
+
log = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ββ Event types βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class BlockSignal:
|
| 25 |
+
"""Emitted when the post-monitor flags a sentence as harmful."""
|
| 26 |
+
sentence_id: int
|
| 27 |
+
reason: str
|
| 28 |
+
threat_score: int
|
| 29 |
+
flags: List[str]
|
| 30 |
+
type: str = field(default="block_signal", init=False)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ββ Stream Monitor βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 34 |
+
|
| 35 |
+
class StreamMonitor:
|
| 36 |
+
"""
|
| 37 |
+
Async wrapper around TextMonitor for sentence-level background screening.
|
| 38 |
+
|
| 39 |
+
Usage
|
| 40 |
+
-----
|
| 41 |
+
monitor = StreamMonitor(text_monitor_instance, block_threshold=40)
|
| 42 |
+
|
| 43 |
+
# Submit sentences as they complete (non-blocking):
|
| 44 |
+
task = await monitor.submit(sentence_id=1, sentence="...", prompt="...")
|
| 45 |
+
|
| 46 |
+
# At stream end β collect any pending block signals:
|
| 47 |
+
signals = await monitor.collect(timeout=1.0)
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
text_monitor: Existing TextMonitor instance.
|
| 51 |
+
block_threshold: threat_score at or above which a BlockSignal is raised.
|
| 52 |
+
max_workers: Thread pool size for concurrent sentence analysis.
|
| 53 |
+
"""
|
| 54 |
+
|
| 55 |
+
def __init__(
|
| 56 |
+
self,
|
| 57 |
+
text_monitor,
|
| 58 |
+
block_threshold: int = 40,
|
| 59 |
+
max_workers: int = 4,
|
| 60 |
+
):
|
| 61 |
+
self._monitor = text_monitor
|
| 62 |
+
self._threshold = block_threshold
|
| 63 |
+
self._executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="shield-monitor")
|
| 64 |
+
self._tasks: List[asyncio.Future] = []
|
| 65 |
+
|
| 66 |
+
# ------------------------------------------------------------------
|
| 67 |
+
# Public API
|
| 68 |
+
# ------------------------------------------------------------------
|
| 69 |
+
|
| 70 |
+
async def submit(self, sentence_id: int, sentence: str, prompt: str) -> None:
|
| 71 |
+
"""
|
| 72 |
+
Non-blocking: submit a sentence for background analysis.
|
| 73 |
+
The result is stored internally; call collect() to retrieve signals.
|
| 74 |
+
"""
|
| 75 |
+
loop = asyncio.get_event_loop()
|
| 76 |
+
future = loop.run_in_executor(
|
| 77 |
+
self._executor,
|
| 78 |
+
self._analyze_sync,
|
| 79 |
+
sentence_id, sentence, prompt,
|
| 80 |
+
)
|
| 81 |
+
self._tasks.append(future)
|
| 82 |
+
|
| 83 |
+
async def collect(self, timeout: float = 1.5) -> List[BlockSignal]:
|
| 84 |
+
"""
|
| 85 |
+
Wait for all pending monitor tasks (up to timeout) and return
|
| 86 |
+
any BlockSignals found.
|
| 87 |
+
|
| 88 |
+
Called at stream end to finalise the threat assessment.
|
| 89 |
+
"""
|
| 90 |
+
if not self._tasks:
|
| 91 |
+
return []
|
| 92 |
+
|
| 93 |
+
signals: List[BlockSignal] = []
|
| 94 |
+
try:
|
| 95 |
+
results = await asyncio.wait_for(
|
| 96 |
+
asyncio.gather(*self._tasks, return_exceptions=True),
|
| 97 |
+
timeout=timeout,
|
| 98 |
+
)
|
| 99 |
+
for result in results:
|
| 100 |
+
if isinstance(result, BlockSignal):
|
| 101 |
+
signals.append(result)
|
| 102 |
+
elif isinstance(result, Exception):
|
| 103 |
+
log.warning("[StreamMonitor] Task error: %s", result)
|
| 104 |
+
except asyncio.TimeoutError:
|
| 105 |
+
log.warning("[StreamMonitor] collect() timed out after %.1fs β some sentences unscreened", timeout)
|
| 106 |
+
|
| 107 |
+
self._tasks.clear()
|
| 108 |
+
return signals
|
| 109 |
+
|
| 110 |
+
def reset(self) -> None:
|
| 111 |
+
"""Clear pending tasks (e.g. between requests)."""
|
| 112 |
+
self._tasks.clear()
|
| 113 |
+
|
| 114 |
+
def shutdown(self) -> None:
|
| 115 |
+
"""Gracefully shut down the thread pool."""
|
| 116 |
+
self._executor.shutdown(wait=False)
|
| 117 |
+
|
| 118 |
+
# ------------------------------------------------------------------
|
| 119 |
+
# Internal β runs in thread pool
|
| 120 |
+
# ------------------------------------------------------------------
|
| 121 |
+
|
| 122 |
+
def _analyze_sync(self, sentence_id: int, sentence: str, prompt: str) -> Optional[BlockSignal]:
|
| 123 |
+
"""
|
| 124 |
+
Synchronous analysis β called inside ThreadPoolExecutor.
|
| 125 |
+
Returns a BlockSignal if the sentence is flagged, else None.
|
| 126 |
+
"""
|
| 127 |
+
try:
|
| 128 |
+
result = self._monitor.analyze(prompt=prompt, response=sentence)
|
| 129 |
+
if result["threat_score"] >= self._threshold:
|
| 130 |
+
log.warning(
|
| 131 |
+
"[StreamMonitor] Sentence %d flagged: score=%d flags=%s",
|
| 132 |
+
sentence_id, result["threat_score"], result["flags"],
|
| 133 |
+
)
|
| 134 |
+
return BlockSignal(
|
| 135 |
+
sentence_id = sentence_id,
|
| 136 |
+
reason = result["reason"],
|
| 137 |
+
threat_score = result["threat_score"],
|
| 138 |
+
flags = result["flags"],
|
| 139 |
+
)
|
| 140 |
+
except Exception as exc:
|
| 141 |
+
log.error("[StreamMonitor] Analysis error on sentence %d: %s", sentence_id, exc)
|
| 142 |
+
return None
|
start_sidecar.sh
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# start_sidecar.sh β Launch the GenAI Shield Sidecar standalone
|
| 3 |
+
#
|
| 4 |
+
# Usage:
|
| 5 |
+
# ./start_sidecar.sh # default port 5050
|
| 6 |
+
# SIDECAR_PORT=8080 ./start_sidecar.sh # custom port
|
| 7 |
+
# GEMINI_API_KEY=... ./start_sidecar.sh # with API key inline
|
| 8 |
+
|
| 9 |
+
set -e
|
| 10 |
+
|
| 11 |
+
SIDECAR_PORT="${SIDECAR_PORT:-5050}"
|
| 12 |
+
LOG_LEVEL="${LOG_LEVEL:-info}"
|
| 13 |
+
|
| 14 |
+
echo "ββββββββββββββββββββββββββββββββββββββββββββββββ"
|
| 15 |
+
echo "β GenAI Shield Sidecar β Starting up β"
|
| 16 |
+
echo "ββββββββββββββββββββββββββββββββββββββββββββββββ"
|
| 17 |
+
echo ""
|
| 18 |
+
echo " Port: $SIDECAR_PORT"
|
| 19 |
+
echo " Backend: ${LLM_BACKEND:-gemini}"
|
| 20 |
+
echo " UI: http://localhost:$SIDECAR_PORT"
|
| 21 |
+
echo " API Docs: http://localhost:$SIDECAR_PORT/docs"
|
| 22 |
+
echo ""
|
| 23 |
+
|
| 24 |
+
# Run from project root so imports resolve correctly
|
| 25 |
+
cd "$(dirname "$0")"
|
| 26 |
+
|
| 27 |
+
python -m uvicorn sidecar.app:app \
|
| 28 |
+
--host 0.0.0.0 \
|
| 29 |
+
--port "$SIDECAR_PORT" \
|
| 30 |
+
--log-level "$LOG_LEVEL" \
|
| 31 |
+
--no-access-log
|
templates/dataflow.html
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 5 |
+
<title>GenAI Shield β Data Flow</title>
|
| 6 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
| 7 |
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
|
| 8 |
+
<style>
|
| 9 |
+
*{box-sizing:border-box;margin:0;padding:0}
|
| 10 |
+
:root{
|
| 11 |
+
--bg:#07070f;--s1:#0f0f1a;--s2:#161625;--b:#1f1f35;
|
| 12 |
+
--acc:#6c63ff;--grn:#00d4aa;--red:#ff4757;--ylw:#ffa502;
|
| 13 |
+
--txt:#e0e0f0;--muted:#5a5a7a;
|
| 14 |
+
--mono:'JetBrains Mono',monospace;--sans:'Inter',sans-serif;
|
| 15 |
+
}
|
| 16 |
+
body{font-family:var(--sans);background:var(--bg);color:var(--txt);height:100vh;display:flex;flex-direction:column;overflow:hidden}
|
| 17 |
+
|
| 18 |
+
/* Header */
|
| 19 |
+
.hdr{padding:.75rem 1.5rem;border-bottom:1px solid var(--b);background:var(--s1);display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
|
| 20 |
+
.hdr-l{display:flex;align-items:center;gap:.75rem}
|
| 21 |
+
.hdr-icon{width:34px;height:34px;border-radius:8px;background:linear-gradient(135deg,var(--acc),var(--grn));display:flex;align-items:center;justify-content:center;font-size:1rem}
|
| 22 |
+
.hdr h1{font-size:.95rem;font-weight:700}
|
| 23 |
+
.hdr-sub{font-family:var(--mono);font-size:.58rem;color:var(--muted);text-transform:uppercase}
|
| 24 |
+
.hdr-r{display:flex;align-items:center;gap:.75rem}
|
| 25 |
+
.dot{width:8px;height:8px;border-radius:50%;background:var(--grn);box-shadow:0 0 6px var(--grn);animation:pulse 2s ease-in-out infinite}
|
| 26 |
+
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
|
| 27 |
+
.nav-link{font-family:var(--mono);font-size:.6rem;padding:4px 10px;border:1px solid var(--b);border-radius:4px;color:var(--muted);text-decoration:none;transition:color .2s,border-color .2s}
|
| 28 |
+
.nav-link:hover{color:var(--acc);border-color:var(--acc)}
|
| 29 |
+
|
| 30 |
+
/* Layout */
|
| 31 |
+
.layout{flex:1;display:grid;grid-template-columns:1fr 320px;overflow:hidden}
|
| 32 |
+
|
| 33 |
+
/* Pipeline canvas area */
|
| 34 |
+
.canvas-wrap{padding:1.5rem;display:flex;flex-direction:column;gap:1rem;overflow:hidden}
|
| 35 |
+
.section-title{font-family:var(--mono);font-size:.6rem;text-transform:uppercase;color:var(--muted);letter-spacing:.08em;margin-bottom:.5rem}
|
| 36 |
+
|
| 37 |
+
/* Pipeline diagram */
|
| 38 |
+
.pipeline{background:var(--s1);border:1px solid var(--b);border-radius:12px;padding:1.5rem;flex-shrink:0}
|
| 39 |
+
.nodes{display:flex;align-items:center;gap:0;position:relative}
|
| 40 |
+
|
| 41 |
+
.node{display:flex;flex-direction:column;align-items:center;gap:.5rem;flex:1;position:relative;z-index:2}
|
| 42 |
+
.node-box{width:80px;height:80px;border-radius:12px;border:2px solid var(--b);background:var(--s2);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.25rem;cursor:default;transition:border-color .3s,box-shadow .3s;position:relative}
|
| 43 |
+
.node-icon{font-size:1.4rem}
|
| 44 |
+
.node-lbl{font-family:var(--mono);font-size:.55rem;text-align:center;color:var(--muted);text-transform:uppercase;line-height:1.3}
|
| 45 |
+
.node-timer{font-family:var(--mono);font-size:.65rem;font-weight:700;color:var(--grn);min-height:1em;text-align:center}
|
| 46 |
+
|
| 47 |
+
/* Node state classes */
|
| 48 |
+
.node-box.active{border-color:var(--acc);box-shadow:0 0 16px rgba(108,99,255,.4)}
|
| 49 |
+
.node-box.done{border-color:var(--grn);box-shadow:0 0 12px rgba(0,212,170,.25)}
|
| 50 |
+
.node-box.blocked{border-color:var(--red);box-shadow:0 0 16px rgba(255,71,87,.4)}
|
| 51 |
+
.node-box.warn{border-color:var(--ylw)}
|
| 52 |
+
|
| 53 |
+
/* Connectors */
|
| 54 |
+
.connector{flex:1;height:2px;background:var(--b);position:relative;z-index:1;align-self:center;margin-top:-40px}
|
| 55 |
+
.connector-inner{height:100%;width:0;background:linear-gradient(90deg,var(--acc),var(--grn));transition:width .4s ease}
|
| 56 |
+
.connector.active .connector-inner{width:100%}
|
| 57 |
+
.connector.blocked .connector-inner{background:var(--red)}
|
| 58 |
+
|
| 59 |
+
/* Packet animation */
|
| 60 |
+
.packet{position:absolute;top:50%;transform:translateY(-50%);width:10px;height:10px;border-radius:50%;background:var(--acc);box-shadow:0 0 8px var(--acc);animation:travel .6s ease forwards;opacity:0}
|
| 61 |
+
@keyframes travel{0%{left:0;opacity:1}100%{left:100%;opacity:0}}
|
| 62 |
+
.packet.red{background:var(--red);box-shadow:0 0 8px var(--red)}
|
| 63 |
+
.packet.grn{background:var(--grn);box-shadow:0 0 8px var(--grn)}
|
| 64 |
+
|
| 65 |
+
/* Parallel row label */
|
| 66 |
+
.parallel-note{font-family:var(--mono);font-size:.58rem;color:var(--acc);text-align:center;margin-top:.75rem}
|
| 67 |
+
|
| 68 |
+
/* Metrics grid */
|
| 69 |
+
.metrics-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:.75rem}
|
| 70 |
+
.metric-card{background:var(--s1);border:1px solid var(--b);border-radius:10px;padding:.875rem;display:flex;flex-direction:column;gap:.25rem}
|
| 71 |
+
.metric-card.acc{border-color:rgba(108,99,255,.4)}
|
| 72 |
+
.metric-card.grn{border-color:rgba(0,212,170,.4)}
|
| 73 |
+
.metric-card.red{border-color:rgba(255,71,87,.4)}
|
| 74 |
+
.metric-card.ylw{border-color:rgba(255,165,2,.4)}
|
| 75 |
+
.metric-lbl{font-family:var(--mono);font-size:.55rem;text-transform:uppercase;color:var(--muted)}
|
| 76 |
+
.metric-val{font-family:var(--mono);font-size:1.5rem;font-weight:700;line-height:1}
|
| 77 |
+
.metric-sub{font-family:var(--mono);font-size:.58rem;color:var(--muted)}
|
| 78 |
+
.metric-card.acc .metric-val{color:var(--acc)}
|
| 79 |
+
.metric-card.grn .metric-val{color:var(--grn)}
|
| 80 |
+
.metric-card.red .metric-val{color:var(--red)}
|
| 81 |
+
.metric-card.ylw .metric-val{color:var(--ylw)}
|
| 82 |
+
|
| 83 |
+
/* Charts row */
|
| 84 |
+
.charts-row{display:grid;grid-template-columns:1fr 1fr;gap:.75rem;flex:1;min-height:0}
|
| 85 |
+
.chart-card{background:var(--s1);border:1px solid var(--b);border-radius:10px;padding:1rem;display:flex;flex-direction:column;min-height:0}
|
| 86 |
+
.chart-card canvas{flex:1;min-height:0}
|
| 87 |
+
|
| 88 |
+
/* Right panel */
|
| 89 |
+
.right-panel{border-left:1px solid var(--b);display:flex;flex-direction:column;overflow:hidden}
|
| 90 |
+
.rp-header{padding:.875rem 1rem;border-bottom:1px solid var(--b);display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
|
| 91 |
+
.rp-title{font-family:var(--mono);font-size:.6rem;text-transform:uppercase;color:var(--muted)}
|
| 92 |
+
.rp-count{font-family:var(--mono);font-size:.6rem;color:var(--acc)}
|
| 93 |
+
.event-log{flex:1;overflow-y:auto;padding:.5rem}
|
| 94 |
+
.event-log::-webkit-scrollbar{width:3px}
|
| 95 |
+
.event-log::-webkit-scrollbar-thumb{background:var(--b);border-radius:2px}
|
| 96 |
+
|
| 97 |
+
.evt{padding:.5rem .75rem;border-left:2px solid var(--b);margin-bottom:.375rem;border-radius:0 6px 6px 0;background:var(--s2);cursor:pointer;transition:border-color .2s}
|
| 98 |
+
.evt:hover{border-left-color:var(--acc)}
|
| 99 |
+
.evt-stage{font-family:var(--mono);font-size:.58rem;font-weight:700;text-transform:uppercase}
|
| 100 |
+
.evt-meta{font-family:var(--mono);font-size:.55rem;color:var(--muted);margin-top:2px}
|
| 101 |
+
.evt-detail{font-size:.7rem;color:var(--txt);margin-top:3px;word-break:break-all}
|
| 102 |
+
|
| 103 |
+
.evt.REQUEST_IN {border-left-color:#6c63ff} .evt.REQUEST_IN .evt-stage{color:#6c63ff}
|
| 104 |
+
.evt.GUARD_START {border-left-color:#a78bfa} .evt.GUARD_START .evt-stage{color:#a78bfa}
|
| 105 |
+
.evt.LLM_START {border-left-color:#38bdf8} .evt.LLM_START .evt-stage{color:#38bdf8}
|
| 106 |
+
.evt.GUARD_PASS {border-left-color:#00d4aa} .evt.GUARD_PASS .evt-stage{color:#00d4aa}
|
| 107 |
+
.evt.GUARD_BLOCK {border-left-color:#ff4757} .evt.GUARD_BLOCK .evt-stage{color:#ff4757}
|
| 108 |
+
.evt.FIRST_TOKEN {border-left-color:#00d4aa} .evt.FIRST_TOKEN .evt-stage{color:#00d4aa}
|
| 109 |
+
.evt.TOKEN_FLOW {border-left-color:#22d3ee} .evt.TOKEN_FLOW .evt-stage{color:#22d3ee}
|
| 110 |
+
.evt.SENTENCE_READY{border-left-color:#a3e635} .evt.SENTENCE_READY .evt-stage{color:#a3e635}
|
| 111 |
+
.evt.MONITOR_START {border-left-color:#fbbf24} .evt.MONITOR_START .evt-stage{color:#fbbf24}
|
| 112 |
+
.evt.MONITOR_RESULT{border-left-color:#fb923c} .evt.MONITOR_RESULT .evt-stage{color:#fb923c}
|
| 113 |
+
.evt.STREAM_DONE {border-left-color:#00d4aa} .evt.STREAM_DONE .evt-stage{color:#00d4aa}
|
| 114 |
+
.evt.REQUEST_DONE {border-left-color:#6c63ff} .evt.REQUEST_DONE .evt-stage{color:#6c63ff}
|
| 115 |
+
|
| 116 |
+
/* Threat badge */
|
| 117 |
+
.tbadge{font-family:var(--mono);font-size:.52rem;font-weight:700;padding:1px 5px;border-radius:2px;display:inline-block}
|
| 118 |
+
.clean{background:rgba(0,212,170,.12);color:var(--grn);border:1px solid rgba(0,212,170,.3)}
|
| 119 |
+
.medium{background:rgba(255,165,2,.12);color:var(--ylw);border:1px solid rgba(255,165,2,.3)}
|
| 120 |
+
.high{background:rgba(255,71,87,.12);color:var(--red);border:1px solid rgba(255,71,87,.3)}
|
| 121 |
+
|
| 122 |
+
/* Empty state */
|
| 123 |
+
.empty-state{padding:2rem;text-align:center;color:var(--muted);font-family:var(--mono);font-size:.7rem}
|
| 124 |
+
</style>
|
| 125 |
+
</head>
|
| 126 |
+
<body>
|
| 127 |
+
|
| 128 |
+
<header class="hdr">
|
| 129 |
+
<div class="hdr-l">
|
| 130 |
+
<div class="hdr-icon">π</div>
|
| 131 |
+
<div>
|
| 132 |
+
<h1>Data Flow <span style="color:var(--acc)">Monitor</span></h1>
|
| 133 |
+
<div class="hdr-sub">Real-time pipeline telemetry Β· Sidecar v2</div>
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
<div class="hdr-r">
|
| 137 |
+
<a class="nav-link" href="/sidecar">β Chat</a>
|
| 138 |
+
<a class="nav-link" href="/genai-monitoring">SIEM</a>
|
| 139 |
+
<div class="dot" id="conn-dot" title="Connecting..."></div>
|
| 140 |
+
</div>
|
| 141 |
+
</header>
|
| 142 |
+
|
| 143 |
+
<div class="layout">
|
| 144 |
+
|
| 145 |
+
<!-- Left: pipeline + metrics -->
|
| 146 |
+
<div class="canvas-wrap">
|
| 147 |
+
|
| 148 |
+
<!-- Pipeline diagram -->
|
| 149 |
+
<div class="pipeline">
|
| 150 |
+
<div class="section-title">Request Pipeline Β· Live</div>
|
| 151 |
+
<div class="nodes" id="nodes">
|
| 152 |
+
|
| 153 |
+
<!-- Client -->
|
| 154 |
+
<div class="node">
|
| 155 |
+
<div class="node-box" id="n-client">
|
| 156 |
+
<div class="node-icon">π€</div>
|
| 157 |
+
<div class="node-lbl">Client</div>
|
| 158 |
+
</div>
|
| 159 |
+
<div class="node-timer" id="t-client">β</div>
|
| 160 |
+
</div>
|
| 161 |
+
|
| 162 |
+
<div class="connector" id="c-gate"><div class="connector-inner"></div></div>
|
| 163 |
+
|
| 164 |
+
<!-- Shield Gate -->
|
| 165 |
+
<div class="node">
|
| 166 |
+
<div class="node-box" id="n-gate">
|
| 167 |
+
<div class="node-icon">πͺ</div>
|
| 168 |
+
<div class="node-lbl">Shield Gate</div>
|
| 169 |
+
</div>
|
| 170 |
+
<div class="node-timer" id="t-gate">β</div>
|
| 171 |
+
</div>
|
| 172 |
+
|
| 173 |
+
<!-- Fork: guard + LLM (shown as two sub-connectors) -->
|
| 174 |
+
<div style="display:flex;flex-direction:column;gap:.25rem;flex:1.4">
|
| 175 |
+
<div style="display:flex;align-items:center;gap:0">
|
| 176 |
+
<div class="connector" id="c-guard" style="flex:1"><div class="connector-inner"></div></div>
|
| 177 |
+
<div class="node">
|
| 178 |
+
<div class="node-box" id="n-guard" style="width:68px;height:68px">
|
| 179 |
+
<div class="node-icon" style="font-size:1.1rem">π‘οΈ</div>
|
| 180 |
+
<div class="node-lbl">Prompt Guard</div>
|
| 181 |
+
</div>
|
| 182 |
+
<div class="node-timer" id="t-guard">β</div>
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
<div style="display:flex;align-items:center;gap:0">
|
| 186 |
+
<div class="connector" id="c-llm" style="flex:1"><div class="connector-inner"></div></div>
|
| 187 |
+
<div class="node">
|
| 188 |
+
<div class="node-box" id="n-llm" style="width:68px;height:68px">
|
| 189 |
+
<div class="node-icon" style="font-size:1.1rem">π€</div>
|
| 190 |
+
<div class="node-lbl">LLM API</div>
|
| 191 |
+
</div>
|
| 192 |
+
<div class="node-timer" id="t-llm">β</div>
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
<div class="parallel-note">β‘ parallel</div>
|
| 196 |
+
</div>
|
| 197 |
+
|
| 198 |
+
<div class="connector" id="c-monitor"><div class="connector-inner"></div></div>
|
| 199 |
+
|
| 200 |
+
<!-- Stream Monitor -->
|
| 201 |
+
<div class="node">
|
| 202 |
+
<div class="node-box" id="n-monitor">
|
| 203 |
+
<div class="node-icon">π</div>
|
| 204 |
+
<div class="node-lbl">Monitor</div>
|
| 205 |
+
</div>
|
| 206 |
+
<div class="node-timer" id="t-monitor">β</div>
|
| 207 |
+
</div>
|
| 208 |
+
|
| 209 |
+
<div class="connector" id="c-out"><div class="connector-inner"></div></div>
|
| 210 |
+
|
| 211 |
+
<!-- Output -->
|
| 212 |
+
<div class="node">
|
| 213 |
+
<div class="node-box" id="n-out">
|
| 214 |
+
<div class="node-icon">β
</div>
|
| 215 |
+
<div class="node-lbl">Output</div>
|
| 216 |
+
</div>
|
| 217 |
+
<div class="node-timer" id="t-out">β</div>
|
| 218 |
+
</div>
|
| 219 |
+
|
| 220 |
+
</div>
|
| 221 |
+
</div>
|
| 222 |
+
|
| 223 |
+
<!-- Metrics -->
|
| 224 |
+
<div>
|
| 225 |
+
<div class="section-title">Session Metrics</div>
|
| 226 |
+
<div class="metrics-grid">
|
| 227 |
+
<div class="metric-card acc">
|
| 228 |
+
<div class="metric-lbl">Total Requests</div>
|
| 229 |
+
<div class="metric-val" id="m-total">0</div>
|
| 230 |
+
<div class="metric-sub">since page load</div>
|
| 231 |
+
</div>
|
| 232 |
+
<div class="metric-card grn">
|
| 233 |
+
<div class="metric-lbl">Avg Guard ms</div>
|
| 234 |
+
<div class="metric-val" id="m-guard-avg">β</div>
|
| 235 |
+
<div class="metric-sub">Prompt Guard latency</div>
|
| 236 |
+
</div>
|
| 237 |
+
<div class="metric-card ylw">
|
| 238 |
+
<div class="metric-lbl">Avg TTFT ms</div>
|
| 239 |
+
<div class="metric-val" id="m-ttft-avg">β</div>
|
| 240 |
+
<div class="metric-sub">time to first token</div>
|
| 241 |
+
</div>
|
| 242 |
+
<div class="metric-card red">
|
| 243 |
+
<div class="metric-lbl">Blocked</div>
|
| 244 |
+
<div class="metric-val" id="m-blocked">0</div>
|
| 245 |
+
<div class="metric-sub">pre/post inference</div>
|
| 246 |
+
</div>
|
| 247 |
+
</div>
|
| 248 |
+
</div>
|
| 249 |
+
|
| 250 |
+
<!-- Charts -->
|
| 251 |
+
<div class="charts-row">
|
| 252 |
+
<div class="chart-card">
|
| 253 |
+
<div class="section-title">Guard Latency (ms) Β· last 20</div>
|
| 254 |
+
<canvas id="chart-guard"></canvas>
|
| 255 |
+
</div>
|
| 256 |
+
<div class="chart-card">
|
| 257 |
+
<div class="section-title">Total Request Time (ms) Β· last 20</div>
|
| 258 |
+
<canvas id="chart-total"></canvas>
|
| 259 |
+
</div>
|
| 260 |
+
</div>
|
| 261 |
+
|
| 262 |
+
</div>
|
| 263 |
+
|
| 264 |
+
<!-- Right: event log -->
|
| 265 |
+
<div class="right-panel">
|
| 266 |
+
<div class="rp-header">
|
| 267 |
+
<span class="rp-title">Pipeline Event Log</span>
|
| 268 |
+
<span class="rp-count" id="evt-count">0 events</span>
|
| 269 |
+
</div>
|
| 270 |
+
<div class="event-log" id="event-log">
|
| 271 |
+
<div class="empty-state" id="empty-msg">Waiting for requestsβ¦</div>
|
| 272 |
+
</div>
|
| 273 |
+
</div>
|
| 274 |
+
|
| 275 |
+
</div>
|
| 276 |
+
|
| 277 |
+
<script>
|
| 278 |
+
// ββ State ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 279 |
+
const state = {
|
| 280 |
+
total: 0, blocked: 0,
|
| 281 |
+
guardTimes: [], ttftTimes: [], totalTimes: [],
|
| 282 |
+
reqStart: {}, guardStart: {}, llmStart: {},
|
| 283 |
+
};
|
| 284 |
+
|
| 285 |
+
// ββ Chart setup ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 286 |
+
const chartOpts = (color) => ({
|
| 287 |
+
type: 'line',
|
| 288 |
+
data: {
|
| 289 |
+
labels: Array(20).fill(''),
|
| 290 |
+
datasets: [{
|
| 291 |
+
data: Array(20).fill(null),
|
| 292 |
+
borderColor: color,
|
| 293 |
+
backgroundColor: color + '18',
|
| 294 |
+
borderWidth: 1.5,
|
| 295 |
+
fill: true,
|
| 296 |
+
tension: 0.4,
|
| 297 |
+
pointRadius: 2,
|
| 298 |
+
pointBackgroundColor: color,
|
| 299 |
+
}]
|
| 300 |
+
},
|
| 301 |
+
options: {
|
| 302 |
+
responsive: true, maintainAspectRatio: false, animation: false,
|
| 303 |
+
scales: {
|
| 304 |
+
x: { display: false },
|
| 305 |
+
y: { min: 0, grid: { color: '#1f1f35' }, ticks: { color: '#5a5a7a', font: { family: 'JetBrains Mono', size: 9 } } }
|
| 306 |
+
},
|
| 307 |
+
plugins: { legend: { display: false } }
|
| 308 |
+
}
|
| 309 |
+
});
|
| 310 |
+
|
| 311 |
+
const cGuard = new Chart(document.getElementById('chart-guard').getContext('2d'), chartOpts('#6c63ff'));
|
| 312 |
+
const cTotal = new Chart(document.getElementById('chart-total').getContext('2d'), chartOpts('#00d4aa'));
|
| 313 |
+
|
| 314 |
+
function pushChart(chart, val) {
|
| 315 |
+
chart.data.datasets[0].data.push(val);
|
| 316 |
+
chart.data.datasets[0].data.shift();
|
| 317 |
+
chart.data.labels.push('');
|
| 318 |
+
chart.data.labels.shift();
|
| 319 |
+
chart.update('none');
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
// ββ Node helpers ββββββββββββοΏ½οΏ½οΏ½ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 323 |
+
function nodeState(id, cls) {
|
| 324 |
+
const el = document.getElementById('n-' + id);
|
| 325 |
+
if (!el) return;
|
| 326 |
+
el.className = 'node-box ' + (cls || '');
|
| 327 |
+
}
|
| 328 |
+
function nodeTime(id, ms) {
|
| 329 |
+
const el = document.getElementById('t-' + id);
|
| 330 |
+
if (el) el.textContent = ms != null ? ms + 'ms' : 'β';
|
| 331 |
+
}
|
| 332 |
+
function connActive(id, cls) {
|
| 333 |
+
const el = document.getElementById('c-' + id);
|
| 334 |
+
if (el) el.className = 'connector active ' + (cls || '');
|
| 335 |
+
}
|
| 336 |
+
function firePacket(connId, cls) {
|
| 337 |
+
const conn = document.getElementById('c-' + connId);
|
| 338 |
+
if (!conn) return;
|
| 339 |
+
const p = document.createElement('div');
|
| 340 |
+
p.className = 'packet ' + (cls || '');
|
| 341 |
+
conn.style.position = 'relative';
|
| 342 |
+
conn.appendChild(p);
|
| 343 |
+
setTimeout(() => p.remove(), 700);
|
| 344 |
+
}
|
| 345 |
+
function resetPipeline() {
|
| 346 |
+
['client','gate','guard','llm','monitor','out'].forEach(id => nodeState(id, ''));
|
| 347 |
+
['client','gate','guard','llm','monitor','out'].forEach(id => nodeTime(id, null));
|
| 348 |
+
['gate','guard','llm','monitor','out'].forEach(id => {
|
| 349 |
+
const el = document.getElementById('c-' + id);
|
| 350 |
+
if (el) el.className = 'connector';
|
| 351 |
+
});
|
| 352 |
+
document.getElementById('n-out').querySelector('.node-icon').textContent = 'β
';
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
// ββ Metrics update βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 356 |
+
function updateMetrics() {
|
| 357 |
+
document.getElementById('m-total').textContent = state.total;
|
| 358 |
+
document.getElementById('m-blocked').textContent = state.blocked;
|
| 359 |
+
const avg = arr => arr.length ? Math.round(arr.slice(-20).reduce((a,b)=>a+b,0)/Math.min(arr.length,20)) : 'β';
|
| 360 |
+
document.getElementById('m-guard-avg').textContent = avg(state.guardTimes);
|
| 361 |
+
document.getElementById('m-ttft-avg').textContent = avg(state.ttftTimes);
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
// ββ Event log ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 365 |
+
let evtCount = 0;
|
| 366 |
+
function logEvent(ev) {
|
| 367 |
+
const log = document.getElementById('event-log');
|
| 368 |
+
const empty = document.getElementById('empty-msg');
|
| 369 |
+
if (empty) empty.remove();
|
| 370 |
+
|
| 371 |
+
evtCount++;
|
| 372 |
+
document.getElementById('evt-count').textContent = evtCount + ' events';
|
| 373 |
+
|
| 374 |
+
const d = document.createElement('div');
|
| 375 |
+
d.className = 'evt ' + ev.stage;
|
| 376 |
+
|
| 377 |
+
let detail = '';
|
| 378 |
+
if (ev.prompt_preview) detail = `"${ev.prompt_preview.slice(0,50)}"`;
|
| 379 |
+
else if (ev.guard_ms) detail = `Guard: ${ev.guard_ms}ms Β· PG: ${(ev.pg_score||0).toFixed(3)}`;
|
| 380 |
+
else if (ev.ttft_ms) detail = `TTFT: ${ev.ttft_ms}ms`;
|
| 381 |
+
else if (ev.stream_ms) detail = `Stream: ${ev.stream_ms}ms Β· ${ev.total_tokens||0} tokens`;
|
| 382 |
+
else if (ev.total_ms) detail = `Total: ${ev.total_ms}ms`;
|
| 383 |
+
else if (ev.sentence_preview) detail = `"${ev.sentence_preview.slice(0,40)}"`;
|
| 384 |
+
else if (ev.token_count) detail = `${ev.token_count} tokens`;
|
| 385 |
+
|
| 386 |
+
const score = ev.threat_score;
|
| 387 |
+
const badge = score >= 60 ? `<span class="tbadge high">HIGH ${score}</span>`
|
| 388 |
+
: score >= 30 ? `<span class="tbadge medium">WARN ${score}</span>`
|
| 389 |
+
: score > 0 ? `<span class="tbadge clean">CLEAN ${score}</span>` : '';
|
| 390 |
+
|
| 391 |
+
d.innerHTML = `<div class="evt-stage">${ev.stage.replace(/_/g,' ')}</div>
|
| 392 |
+
<div class="evt-meta">${ev.wall} Β· req ${ev.request_id} Β· +${ev.elapsed_ms}ms ${badge}</div>
|
| 393 |
+
${detail ? `<div class="evt-detail">${detail}</div>` : ''}`;
|
| 394 |
+
|
| 395 |
+
log.prepend(d);
|
| 396 |
+
if (log.children.length > 80) log.lastChild.remove();
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
// ββ Pipeline stage handler βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 400 |
+
function handleStage(ev) {
|
| 401 |
+
logEvent(ev);
|
| 402 |
+
const rid = ev.request_id;
|
| 403 |
+
|
| 404 |
+
switch(ev.stage) {
|
| 405 |
+
case 'REQUEST_IN':
|
| 406 |
+
resetPipeline();
|
| 407 |
+
state.reqStart[rid] = ev.elapsed_ms;
|
| 408 |
+
nodeState('client', 'active');
|
| 409 |
+
connActive('gate');
|
| 410 |
+
firePacket('gate');
|
| 411 |
+
nodeState('gate', 'active');
|
| 412 |
+
break;
|
| 413 |
+
|
| 414 |
+
case 'GUARD_START':
|
| 415 |
+
state.guardStart[rid] = ev.elapsed_ms;
|
| 416 |
+
nodeState('guard', 'active');
|
| 417 |
+
connActive('guard');
|
| 418 |
+
firePacket('guard');
|
| 419 |
+
break;
|
| 420 |
+
|
| 421 |
+
case 'LLM_START':
|
| 422 |
+
state.llmStart[rid] = ev.elapsed_ms;
|
| 423 |
+
nodeState('llm', 'active');
|
| 424 |
+
connActive('llm');
|
| 425 |
+
firePacket('llm');
|
| 426 |
+
break;
|
| 427 |
+
|
| 428 |
+
case 'GUARD_PASS':
|
| 429 |
+
nodeState('guard', 'done');
|
| 430 |
+
nodeTime('guard', ev.guard_ms);
|
| 431 |
+
state.guardTimes.push(ev.guard_ms);
|
| 432 |
+
pushChart(cGuard, ev.guard_ms);
|
| 433 |
+
updateMetrics();
|
| 434 |
+
break;
|
| 435 |
+
|
| 436 |
+
case 'GUARD_BLOCK':
|
| 437 |
+
state.total++;
|
| 438 |
+
state.blocked++;
|
| 439 |
+
nodeState('guard', 'blocked');
|
| 440 |
+
nodeTime('guard', ev.guard_ms);
|
| 441 |
+
nodeState('gate', 'blocked');
|
| 442 |
+
nodeState('llm', 'blocked');
|
| 443 |
+
nodeState('out', 'blocked');
|
| 444 |
+
document.getElementById('n-out').querySelector('.node-icon').textContent = 'π«';
|
| 445 |
+
state.guardTimes.push(ev.guard_ms);
|
| 446 |
+
pushChart(cGuard, ev.guard_ms);
|
| 447 |
+
updateMetrics();
|
| 448 |
+
break;
|
| 449 |
+
|
| 450 |
+
case 'FIRST_TOKEN':
|
| 451 |
+
nodeState('llm', 'done');
|
| 452 |
+
nodeTime('llm', ev.ttft_ms);
|
| 453 |
+
state.ttftTimes.push(ev.ttft_ms);
|
| 454 |
+
updateMetrics();
|
| 455 |
+
break;
|
| 456 |
+
|
| 457 |
+
case 'TOKEN_FLOW':
|
| 458 |
+
firePacket('monitor', 'grn');
|
| 459 |
+
break;
|
| 460 |
+
|
| 461 |
+
case 'SENTENCE_READY':
|
| 462 |
+
connActive('monitor');
|
| 463 |
+
firePacket('monitor', 'grn');
|
| 464 |
+
nodeState('monitor', 'active');
|
| 465 |
+
break;
|
| 466 |
+
|
| 467 |
+
case 'MONITOR_START':
|
| 468 |
+
nodeState('monitor', 'active');
|
| 469 |
+
break;
|
| 470 |
+
|
| 471 |
+
case 'MONITOR_RESULT':
|
| 472 |
+
if (ev.blocked) {
|
| 473 |
+
nodeState('monitor', 'blocked');
|
| 474 |
+
nodeState('out', 'blocked');
|
| 475 |
+
document.getElementById('n-out').querySelector('.node-icon').textContent = 'π«';
|
| 476 |
+
} else {
|
| 477 |
+
nodeState('monitor', 'done');
|
| 478 |
+
}
|
| 479 |
+
break;
|
| 480 |
+
|
| 481 |
+
case 'STREAM_DONE':
|
| 482 |
+
nodeState('llm', 'done');
|
| 483 |
+
nodeTime('llm', ev.stream_ms);
|
| 484 |
+
break;
|
| 485 |
+
|
| 486 |
+
case 'REQUEST_DONE':
|
| 487 |
+
state.total++;
|
| 488 |
+
if (ev.blocked) state.blocked++;
|
| 489 |
+
nodeState('client', 'done');
|
| 490 |
+
nodeState('gate', 'done');
|
| 491 |
+
if (!ev.blocked) {
|
| 492 |
+
nodeState('monitor', 'done');
|
| 493 |
+
nodeState('out', 'done');
|
| 494 |
+
connActive('out', 'grn');
|
| 495 |
+
firePacket('out', 'grn');
|
| 496 |
+
}
|
| 497 |
+
nodeTime('out', ev.total_ms);
|
| 498 |
+
nodeTime('client', ev.total_ms);
|
| 499 |
+
state.totalTimes.push(ev.total_ms);
|
| 500 |
+
pushChart(cTotal, ev.total_ms);
|
| 501 |
+
updateMetrics();
|
| 502 |
+
break;
|
| 503 |
+
}
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
// ββ SSE connection βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 507 |
+
function connect() {
|
| 508 |
+
const dot = document.getElementById('conn-dot');
|
| 509 |
+
dot.style.background = 'var(--ylw)';
|
| 510 |
+
dot.style.boxShadow = '0 0 6px var(--ylw)';
|
| 511 |
+
|
| 512 |
+
// Try sidecar direct first, fall back to current host
|
| 513 |
+
const url = '/v1/pipeline-stream';
|
| 514 |
+
const es = new EventSource(url);
|
| 515 |
+
|
| 516 |
+
es.onopen = () => {
|
| 517 |
+
dot.style.background = 'var(--grn)';
|
| 518 |
+
dot.style.boxShadow = '0 0 6px var(--grn)';
|
| 519 |
+
dot.title = 'Connected';
|
| 520 |
+
};
|
| 521 |
+
|
| 522 |
+
es.onmessage = e => {
|
| 523 |
+
if (!e.data || e.data.startsWith(':')) return;
|
| 524 |
+
try { handleStage(JSON.parse(e.data)); } catch {}
|
| 525 |
+
};
|
| 526 |
+
|
| 527 |
+
es.onerror = () => {
|
| 528 |
+
dot.style.background = 'var(--red)';
|
| 529 |
+
dot.style.boxShadow = '0 0 6px var(--red)';
|
| 530 |
+
dot.title = 'Disconnected β retrying';
|
| 531 |
+
es.close();
|
| 532 |
+
setTimeout(connect, 3000);
|
| 533 |
+
};
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
connect();
|
| 537 |
+
</script>
|
| 538 |
+
</body>
|
| 539 |
+
</html>
|
templates/genai.html
CHANGED
|
@@ -33,7 +33,6 @@
|
|
| 33 |
<div class="container" style="padding: 0; max-width: none; display: flex; justify-content: space-between; align-items: center;">
|
| 34 |
<div>
|
| 35 |
<h1 style="font-size: 1.5rem; margin: 0;">Risknox <span style="text-decoration: underline;">GenAI Shield V2</span></h1>
|
| 36 |
-
<p style="font-size: 0.65rem; font-weight: 800; text-transform: uppercase; color: var(--muted);">Powered by Llama Prompt Guard 2 Β· {{ model }}</p>
|
| 37 |
</div>
|
| 38 |
<a href="/genai-monitoring" class="btn secondary" style="font-size: 0.75rem;">Dashboard β</a>
|
| 39 |
</div>
|
|
|
|
| 33 |
<div class="container" style="padding: 0; max-width: none; display: flex; justify-content: space-between; align-items: center;">
|
| 34 |
<div>
|
| 35 |
<h1 style="font-size: 1.5rem; margin: 0;">Risknox <span style="text-decoration: underline;">GenAI Shield V2</span></h1>
|
|
|
|
| 36 |
</div>
|
| 37 |
<a href="/genai-monitoring" class="btn secondary" style="font-size: 0.75rem;">Dashboard β</a>
|
| 38 |
</div>
|
templates/sidecar.html
ADDED
|
@@ -0,0 +1,676 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Risknox | GenAI Shield β Sidecar</title>
|
| 7 |
+
<meta name="description" content="GenAI Shield sidecar proxy with real-time streaming and sentence-level threat detection">
|
| 8 |
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
|
| 9 |
+
<style>
|
| 10 |
+
:root {
|
| 11 |
+
--bg: #0a0a0f;
|
| 12 |
+
--surface: #12121a;
|
| 13 |
+
--surface2: #1a1a26;
|
| 14 |
+
--border: #2a2a3d;
|
| 15 |
+
--accent: #6c63ff;
|
| 16 |
+
--accent2: #00d4aa;
|
| 17 |
+
--danger: #ff4757;
|
| 18 |
+
--warn: #ffa502;
|
| 19 |
+
--text: #e8e8f0;
|
| 20 |
+
--muted: #6b6b8a;
|
| 21 |
+
--mono: 'JetBrains Mono', monospace;
|
| 22 |
+
--sans: 'Inter', system-ui, sans-serif;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 26 |
+
|
| 27 |
+
body {
|
| 28 |
+
font-family: var(--sans);
|
| 29 |
+
background: var(--bg);
|
| 30 |
+
color: var(--text);
|
| 31 |
+
height: 100vh;
|
| 32 |
+
display: flex;
|
| 33 |
+
flex-direction: column;
|
| 34 |
+
overflow: hidden;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/* ββ Header βββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 38 |
+
.header {
|
| 39 |
+
padding: 0.875rem 1.5rem;
|
| 40 |
+
border-bottom: 1px solid var(--border);
|
| 41 |
+
background: var(--surface);
|
| 42 |
+
display: flex;
|
| 43 |
+
align-items: center;
|
| 44 |
+
justify-content: space-between;
|
| 45 |
+
flex-shrink: 0;
|
| 46 |
+
}
|
| 47 |
+
.header-brand { display: flex; align-items: center; gap: 0.75rem; }
|
| 48 |
+
.shield-icon {
|
| 49 |
+
width: 36px; height: 36px;
|
| 50 |
+
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
| 51 |
+
border-radius: 8px;
|
| 52 |
+
display: flex; align-items: center; justify-content: center;
|
| 53 |
+
font-size: 1.1rem;
|
| 54 |
+
}
|
| 55 |
+
.header h1 { font-size: 1rem; font-weight: 700; }
|
| 56 |
+
.header-sub {
|
| 57 |
+
font-family: var(--mono);
|
| 58 |
+
font-size: 0.6rem;
|
| 59 |
+
color: var(--muted);
|
| 60 |
+
text-transform: uppercase;
|
| 61 |
+
letter-spacing: 0.08em;
|
| 62 |
+
}
|
| 63 |
+
.header-actions { display: flex; align-items: center; gap: 0.75rem; }
|
| 64 |
+
.mode-badge {
|
| 65 |
+
font-family: var(--mono);
|
| 66 |
+
font-size: 0.6rem;
|
| 67 |
+
font-weight: 700;
|
| 68 |
+
padding: 3px 8px;
|
| 69 |
+
border-radius: 3px;
|
| 70 |
+
background: rgba(108, 99, 255, 0.15);
|
| 71 |
+
border: 1px solid rgba(108, 99, 255, 0.4);
|
| 72 |
+
color: var(--accent);
|
| 73 |
+
text-transform: uppercase;
|
| 74 |
+
}
|
| 75 |
+
.status-dot {
|
| 76 |
+
width: 8px; height: 8px;
|
| 77 |
+
border-radius: 50%;
|
| 78 |
+
background: var(--accent2);
|
| 79 |
+
box-shadow: 0 0 6px var(--accent2);
|
| 80 |
+
animation: pulse 2s ease-in-out infinite;
|
| 81 |
+
}
|
| 82 |
+
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.4; } }
|
| 83 |
+
|
| 84 |
+
/* ββ Chat area ββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 85 |
+
.chat-area {
|
| 86 |
+
flex: 1;
|
| 87 |
+
overflow-y: auto;
|
| 88 |
+
padding: 1.5rem;
|
| 89 |
+
display: flex;
|
| 90 |
+
flex-direction: column;
|
| 91 |
+
gap: 1.5rem;
|
| 92 |
+
}
|
| 93 |
+
.chat-area::-webkit-scrollbar { width: 4px; }
|
| 94 |
+
.chat-area::-webkit-scrollbar-track { background: transparent; }
|
| 95 |
+
.chat-area::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
| 96 |
+
|
| 97 |
+
/* ββ Messages βββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 98 |
+
.msg { display: flex; gap: 0.75rem; max-width: 760px; width: 100%; }
|
| 99 |
+
.msg.user { align-self: flex-end; flex-direction: row-reverse; }
|
| 100 |
+
.msg.assistant { align-self: flex-start; }
|
| 101 |
+
|
| 102 |
+
.avatar {
|
| 103 |
+
width: 32px; height: 32px;
|
| 104 |
+
border-radius: 8px;
|
| 105 |
+
display: flex; align-items: center; justify-content: center;
|
| 106 |
+
font-weight: 700; font-size: 0.65rem;
|
| 107 |
+
flex-shrink: 0;
|
| 108 |
+
font-family: var(--mono);
|
| 109 |
+
}
|
| 110 |
+
.msg.user .avatar { background: var(--accent); color: #fff; }
|
| 111 |
+
.msg.assistant .avatar { background: var(--surface2); color: var(--accent2); border: 1px solid var(--border); }
|
| 112 |
+
|
| 113 |
+
.bubble {
|
| 114 |
+
padding: 0.875rem 1.125rem;
|
| 115 |
+
border-radius: 12px;
|
| 116 |
+
line-height: 1.65;
|
| 117 |
+
font-size: 0.9375rem;
|
| 118 |
+
max-width: calc(100% - 44px);
|
| 119 |
+
}
|
| 120 |
+
.msg.user .bubble {
|
| 121 |
+
background: var(--accent);
|
| 122 |
+
color: #fff;
|
| 123 |
+
border-bottom-right-radius: 3px;
|
| 124 |
+
}
|
| 125 |
+
.msg.assistant .bubble {
|
| 126 |
+
background: var(--surface);
|
| 127 |
+
border: 1px solid var(--border);
|
| 128 |
+
border-bottom-left-radius: 3px;
|
| 129 |
+
color: var(--text);
|
| 130 |
+
position: relative;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
/* ββ Streaming cursor βββββββββββββββββββββββββββββββββββββββ */
|
| 134 |
+
.stream-cursor {
|
| 135 |
+
display: inline-block;
|
| 136 |
+
width: 2px;
|
| 137 |
+
height: 1em;
|
| 138 |
+
background: var(--accent2);
|
| 139 |
+
margin-left: 2px;
|
| 140 |
+
vertical-align: text-bottom;
|
| 141 |
+
border-radius: 1px;
|
| 142 |
+
animation: blink 0.8s step-end infinite;
|
| 143 |
+
}
|
| 144 |
+
@keyframes blink { 0%,100% { opacity:1; } 50% { opacity:0; } }
|
| 145 |
+
|
| 146 |
+
/* ββ Shield monitoring bar ββββββββββββββββββββββββββββββββββ */
|
| 147 |
+
.shield-bar {
|
| 148 |
+
display: flex;
|
| 149 |
+
align-items: center;
|
| 150 |
+
gap: 0.5rem;
|
| 151 |
+
margin-top: 0.625rem;
|
| 152 |
+
font-family: var(--mono);
|
| 153 |
+
font-size: 0.6rem;
|
| 154 |
+
color: var(--muted);
|
| 155 |
+
}
|
| 156 |
+
.shield-bar .spinner {
|
| 157 |
+
width: 10px; height: 10px;
|
| 158 |
+
border: 1.5px solid var(--accent2);
|
| 159 |
+
border-top-color: transparent;
|
| 160 |
+
border-radius: 50%;
|
| 161 |
+
animation: spin 0.8s linear infinite;
|
| 162 |
+
}
|
| 163 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 164 |
+
|
| 165 |
+
/* ββ Threat meta row ββββββββββββββββββββββββββββββββββββββββ */
|
| 166 |
+
.msg-meta {
|
| 167 |
+
display: flex;
|
| 168 |
+
align-items: center;
|
| 169 |
+
gap: 0.5rem;
|
| 170 |
+
margin-top: 0.5rem;
|
| 171 |
+
flex-wrap: wrap;
|
| 172 |
+
}
|
| 173 |
+
.threat-badge {
|
| 174 |
+
font-family: var(--mono);
|
| 175 |
+
font-size: 0.6rem;
|
| 176 |
+
font-weight: 700;
|
| 177 |
+
padding: 2px 7px;
|
| 178 |
+
border-radius: 3px;
|
| 179 |
+
text-transform: uppercase;
|
| 180 |
+
}
|
| 181 |
+
.threat-clean { background: rgba(0,212,170,0.12); color: var(--accent2); border: 1px solid rgba(0,212,170,0.3); }
|
| 182 |
+
.threat-medium { background: rgba(255,165,2,0.12); color: var(--warn); border: 1px solid rgba(255,165,2,0.3); }
|
| 183 |
+
.threat-high { background: rgba(255,71,87,0.12); color: var(--danger); border: 1px solid rgba(255,71,87,0.3); }
|
| 184 |
+
.meta-chip {
|
| 185 |
+
font-family: var(--mono);
|
| 186 |
+
font-size: 0.57rem;
|
| 187 |
+
color: var(--muted);
|
| 188 |
+
}
|
| 189 |
+
.flag-chip {
|
| 190 |
+
font-family: var(--mono);
|
| 191 |
+
font-size: 0.57rem;
|
| 192 |
+
padding: 1px 5px;
|
| 193 |
+
border: 1px solid rgba(255,71,87,0.4);
|
| 194 |
+
color: var(--danger);
|
| 195 |
+
border-radius: 2px;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
/* ββ Block signal overlay βββββββββββββββββββββββββββββββββββ */
|
| 199 |
+
.block-signal-overlay {
|
| 200 |
+
margin-top: 0.625rem;
|
| 201 |
+
padding: 0.75rem 1rem;
|
| 202 |
+
background: rgba(255,71,87,0.08);
|
| 203 |
+
border: 1px solid rgba(255,71,87,0.4);
|
| 204 |
+
border-radius: 8px;
|
| 205 |
+
font-size: 0.8rem;
|
| 206 |
+
}
|
| 207 |
+
.block-signal-overlay strong { color: var(--danger); font-family: var(--mono); font-size: 0.7rem; }
|
| 208 |
+
|
| 209 |
+
/* Tainted text fade */
|
| 210 |
+
.tainted { opacity: 0.3; text-decoration: line-through; color: var(--danger); }
|
| 211 |
+
|
| 212 |
+
/* ββ Blocked message ββββββββββββββββββββββββββββββββββββββββ */
|
| 213 |
+
.blocked-card {
|
| 214 |
+
max-width: 760px;
|
| 215 |
+
padding: 1rem 1.25rem;
|
| 216 |
+
background: rgba(255,71,87,0.07);
|
| 217 |
+
border: 1px solid rgba(255,71,87,0.4);
|
| 218 |
+
border-radius: 12px;
|
| 219 |
+
align-self: flex-end;
|
| 220 |
+
}
|
| 221 |
+
.blocked-card-title {
|
| 222 |
+
font-weight: 700;
|
| 223 |
+
font-size: 0.875rem;
|
| 224 |
+
color: var(--danger);
|
| 225 |
+
margin-bottom: 0.375rem;
|
| 226 |
+
display: flex; align-items: center; gap: 0.5rem;
|
| 227 |
+
}
|
| 228 |
+
.blocked-card-reason {
|
| 229 |
+
font-family: var(--mono);
|
| 230 |
+
font-size: 0.7rem;
|
| 231 |
+
color: var(--muted);
|
| 232 |
+
margin-bottom: 0.5rem;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
/* ββ Input area βββββββββββββββββββββββββββββββββββββββββββββ */
|
| 236 |
+
.input-area {
|
| 237 |
+
padding: 1rem 1.5rem;
|
| 238 |
+
border-top: 1px solid var(--border);
|
| 239 |
+
background: var(--surface);
|
| 240 |
+
flex-shrink: 0;
|
| 241 |
+
}
|
| 242 |
+
.input-row {
|
| 243 |
+
max-width: 760px;
|
| 244 |
+
margin: 0 auto;
|
| 245 |
+
display: flex;
|
| 246 |
+
gap: 0.75rem;
|
| 247 |
+
align-items: flex-end;
|
| 248 |
+
}
|
| 249 |
+
.input-wrap {
|
| 250 |
+
flex: 1;
|
| 251 |
+
background: var(--surface2);
|
| 252 |
+
border: 1px solid var(--border);
|
| 253 |
+
border-radius: 10px;
|
| 254 |
+
padding: 0.75rem 1rem;
|
| 255 |
+
transition: border-color 0.2s;
|
| 256 |
+
}
|
| 257 |
+
.input-wrap:focus-within { border-color: var(--accent); }
|
| 258 |
+
textarea {
|
| 259 |
+
width: 100%;
|
| 260 |
+
background: transparent;
|
| 261 |
+
border: none;
|
| 262 |
+
outline: none;
|
| 263 |
+
font-family: var(--sans);
|
| 264 |
+
font-size: 0.9375rem;
|
| 265 |
+
color: var(--text);
|
| 266 |
+
resize: none;
|
| 267 |
+
min-height: 24px;
|
| 268 |
+
max-height: 160px;
|
| 269 |
+
line-height: 1.5;
|
| 270 |
+
}
|
| 271 |
+
textarea::placeholder { color: var(--muted); }
|
| 272 |
+
.send-btn {
|
| 273 |
+
width: 44px; height: 44px;
|
| 274 |
+
border-radius: 10px;
|
| 275 |
+
border: none;
|
| 276 |
+
background: var(--accent);
|
| 277 |
+
color: #fff;
|
| 278 |
+
cursor: pointer;
|
| 279 |
+
display: flex; align-items: center; justify-content: center;
|
| 280 |
+
transition: background 0.2s, transform 0.1s;
|
| 281 |
+
flex-shrink: 0;
|
| 282 |
+
}
|
| 283 |
+
.send-btn:hover { background: #7c73ff; }
|
| 284 |
+
.send-btn:active { transform: scale(0.94); }
|
| 285 |
+
.send-btn:disabled { background: var(--border); cursor: not-allowed; }
|
| 286 |
+
|
| 287 |
+
/* ββ Latency bar (below input) ββββββββββββββββββββββββββββββ */
|
| 288 |
+
.latency-row {
|
| 289 |
+
max-width: 760px;
|
| 290 |
+
margin: 0.5rem auto 0;
|
| 291 |
+
display: flex;
|
| 292 |
+
gap: 1rem;
|
| 293 |
+
font-family: var(--mono);
|
| 294 |
+
font-size: 0.58rem;
|
| 295 |
+
color: var(--muted);
|
| 296 |
+
}
|
| 297 |
+
.latency-seg { display: flex; align-items: center; gap: 0.3rem; }
|
| 298 |
+
.latency-dot { width: 6px; height: 6px; border-radius: 50%; }
|
| 299 |
+
.dot-guard { background: var(--accent); }
|
| 300 |
+
.dot-model { background: var(--accent2); }
|
| 301 |
+
.dot-monitor { background: var(--warn); }
|
| 302 |
+
|
| 303 |
+
/* ββ Welcome state ββββββββββββββββββββββββββββββββββββββββββ */
|
| 304 |
+
.welcome {
|
| 305 |
+
flex: 1;
|
| 306 |
+
display: flex;
|
| 307 |
+
flex-direction: column;
|
| 308 |
+
align-items: center;
|
| 309 |
+
justify-content: center;
|
| 310 |
+
text-align: center;
|
| 311 |
+
gap: 1rem;
|
| 312 |
+
color: var(--muted);
|
| 313 |
+
padding: 2rem;
|
| 314 |
+
}
|
| 315 |
+
.welcome-icon { font-size: 3rem; }
|
| 316 |
+
.welcome h2 { font-size: 1.25rem; color: var(--text); }
|
| 317 |
+
.welcome p { font-size: 0.875rem; max-width: 400px; line-height: 1.6; }
|
| 318 |
+
.pill-row { display: flex; gap: 0.5rem; flex-wrap: wrap; justify-content: center; }
|
| 319 |
+
.pill {
|
| 320 |
+
font-family: var(--mono);
|
| 321 |
+
font-size: 0.65rem;
|
| 322 |
+
padding: 4px 10px;
|
| 323 |
+
border-radius: 99px;
|
| 324 |
+
background: var(--surface2);
|
| 325 |
+
border: 1px solid var(--border);
|
| 326 |
+
}
|
| 327 |
+
</style>
|
| 328 |
+
</head>
|
| 329 |
+
<body>
|
| 330 |
+
|
| 331 |
+
<header class="header">
|
| 332 |
+
<div class="header-brand">
|
| 333 |
+
<div class="shield-icon">π‘οΈ</div>
|
| 334 |
+
<div>
|
| 335 |
+
<h1>GenAI Shield <span style="color:var(--accent)">Sidecar</span></h1>
|
| 336 |
+
<div class="header-sub">Parallel Guard Β· Sentence Streaming Β· Background Monitor</div>
|
| 337 |
+
</div>
|
| 338 |
+
</div>
|
| 339 |
+
<div class="header-actions">
|
| 340 |
+
<div class="mode-badge">Sidecar v2</div>
|
| 341 |
+
<div class="status-dot" id="status-dot" title="Guard ready"></div>
|
| 342 |
+
</div>
|
| 343 |
+
</header>
|
| 344 |
+
|
| 345 |
+
<div class="chat-area" id="chat-area">
|
| 346 |
+
<div class="welcome" id="welcome-screen">
|
| 347 |
+
<div class="welcome-icon">π‘οΈ</div>
|
| 348 |
+
<h2>Sidecar Proxy Active</h2>
|
| 349 |
+
<p>Your prompt is guarded <em>before</em> the LLM sees it, and every sentence of the response is monitored <em>as it streams</em> β with zero added latency on the happy path.</p>
|
| 350 |
+
<div class="pill-row">
|
| 351 |
+
<span class="pill">β‘ Parallel guard + LLM</span>
|
| 352 |
+
<span class="pill">π‘ Sentence-level SSE</span>
|
| 353 |
+
<span class="pill">π Background monitor</span>
|
| 354 |
+
<span class="pill">π« Mid-stream block signal</span>
|
| 355 |
+
</div>
|
| 356 |
+
</div>
|
| 357 |
+
</div>
|
| 358 |
+
|
| 359 |
+
<div class="input-area">
|
| 360 |
+
<div class="input-row">
|
| 361 |
+
<div class="input-wrap">
|
| 362 |
+
<textarea
|
| 363 |
+
id="prompt-input"
|
| 364 |
+
rows="1"
|
| 365 |
+
placeholder="Enter a prompt β the shield and the LLM start together..."
|
| 366 |
+
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMessage();}"
|
| 367 |
+
oninput="autoResize(this)"
|
| 368 |
+
></textarea>
|
| 369 |
+
</div>
|
| 370 |
+
<button class="send-btn" id="send-btn" onclick="sendMessage()" title="Send">
|
| 371 |
+
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
|
| 372 |
+
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
|
| 373 |
+
</svg>
|
| 374 |
+
</button>
|
| 375 |
+
</div>
|
| 376 |
+
<div class="latency-row" id="latency-row" style="display:none">
|
| 377 |
+
<span class="latency-seg"><span class="latency-dot dot-guard"></span><span id="lat-guard">Guard: β</span></span>
|
| 378 |
+
<span class="latency-seg"><span class="latency-dot dot-model"></span><span id="lat-model">Stream: β</span></span>
|
| 379 |
+
<span class="latency-seg"><span class="latency-dot dot-monitor"></span><span id="lat-monitor">Monitor: background</span></span>
|
| 380 |
+
</div>
|
| 381 |
+
</div>
|
| 382 |
+
|
| 383 |
+
<script>
|
| 384 |
+
// ββ Config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 385 |
+
const SIDECAR_URL = '/v1/chat'; // proxied through Flask or direct to :5050
|
| 386 |
+
|
| 387 |
+
// ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 388 |
+
const chatArea = document.getElementById('chat-area');
|
| 389 |
+
const input = document.getElementById('prompt-input');
|
| 390 |
+
const sendBtn = document.getElementById('send-btn');
|
| 391 |
+
const latRow = document.getElementById('latency-row');
|
| 392 |
+
|
| 393 |
+
function esc(s) {
|
| 394 |
+
return String(s)
|
| 395 |
+
.replace(/&/g,'&')
|
| 396 |
+
.replace(/</g,'<')
|
| 397 |
+
.replace(/>/g,'>')
|
| 398 |
+
.replace(/\n/g,'<br>');
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
function autoResize(el) {
|
| 402 |
+
el.style.height = 'auto';
|
| 403 |
+
el.style.height = Math.min(el.scrollHeight, 160) + 'px';
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
function scrollBottom() {
|
| 407 |
+
chatArea.scrollTop = chatArea.scrollHeight;
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
function hideWelcome() {
|
| 411 |
+
const w = document.getElementById('welcome-screen');
|
| 412 |
+
if (w) w.remove();
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
function setLatency(guard, total) {
|
| 416 |
+
latRow.style.display = 'flex';
|
| 417 |
+
document.getElementById('lat-guard').textContent = `Guard: ${guard || 'β'}ms`;
|
| 418 |
+
document.getElementById('lat-model').textContent = `Total: ${total || 'β'}ms`;
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
// ββ Append user bubble βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 422 |
+
function appendUser(text) {
|
| 423 |
+
const div = document.createElement('div');
|
| 424 |
+
div.className = 'msg user';
|
| 425 |
+
div.innerHTML = `
|
| 426 |
+
<div class="avatar">U</div>
|
| 427 |
+
<div class="bubble">${esc(text)}</div>
|
| 428 |
+
`;
|
| 429 |
+
chatArea.appendChild(div);
|
| 430 |
+
scrollBottom();
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
// ββ Append assistant streaming bubble βββββββββββββββββββββββββββββββββββββββββ
|
| 434 |
+
function createAssistantBubble() {
|
| 435 |
+
const div = document.createElement('div');
|
| 436 |
+
div.className = 'msg assistant';
|
| 437 |
+
|
| 438 |
+
const bubbleId = 'bubble-' + Date.now();
|
| 439 |
+
const metaId = 'meta-' + Date.now();
|
| 440 |
+
const shieldId = 'shield-' + Date.now();
|
| 441 |
+
|
| 442 |
+
div.innerHTML = `
|
| 443 |
+
<div class="avatar">AI</div>
|
| 444 |
+
<div>
|
| 445 |
+
<div class="bubble" id="${bubbleId}">
|
| 446 |
+
<span class="stream-cursor"></span>
|
| 447 |
+
</div>
|
| 448 |
+
<div class="shield-bar" id="${shieldId}">
|
| 449 |
+
<div class="spinner"></div>
|
| 450 |
+
<span>Shield monitoring outputβ¦</span>
|
| 451 |
+
</div>
|
| 452 |
+
<div class="msg-meta" id="${metaId}" style="display:none"></div>
|
| 453 |
+
</div>
|
| 454 |
+
`;
|
| 455 |
+
chatArea.appendChild(div);
|
| 456 |
+
scrollBottom();
|
| 457 |
+
|
| 458 |
+
return {
|
| 459 |
+
getBubble: () => document.getElementById(bubbleId),
|
| 460 |
+
getMeta: () => document.getElementById(metaId),
|
| 461 |
+
getShield: () => document.getElementById(shieldId),
|
| 462 |
+
bubbleId,
|
| 463 |
+
metaId,
|
| 464 |
+
shieldId,
|
| 465 |
+
|
| 466 |
+
// Sentence tracking for block signals
|
| 467 |
+
sentences: {}, // sentence_id β { start, end } char positions not tracked easily,
|
| 468 |
+
// so we track spans instead
|
| 469 |
+
fullText: '',
|
| 470 |
+
};
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
// ββ Block signal: taint sentences from sentence_id onward βββββββββββββββββββββ
|
| 474 |
+
function applyBlockSignal(bubbleEl, bubbleState, signalData) {
|
| 475 |
+
const sid = signalData.sentence_id;
|
| 476 |
+
|
| 477 |
+
// Mark all sentence spans from sid onward as tainted
|
| 478 |
+
const spans = bubbleEl.querySelectorAll(`[data-sid]`);
|
| 479 |
+
spans.forEach(span => {
|
| 480 |
+
const spanSid = parseInt(span.dataset.sid, 10);
|
| 481 |
+
if (spanSid >= sid) span.classList.add('tainted');
|
| 482 |
+
});
|
| 483 |
+
|
| 484 |
+
// Append block signal banner
|
| 485 |
+
const overlay = document.createElement('div');
|
| 486 |
+
overlay.className = 'block-signal-overlay';
|
| 487 |
+
overlay.innerHTML = `
|
| 488 |
+
<strong>π« OUTPUT BLOCKED AT SENTENCE ${sid}</strong>
|
| 489 |
+
<div style="margin-top:4px;font-size:0.75rem;color:var(--muted)">${esc(signalData.reason)}</div>
|
| 490 |
+
<div style="margin-top:6px">${(signalData.flags||[]).map(f => `<span class="flag-chip">${f}</span>`).join(' ')}</div>
|
| 491 |
+
`;
|
| 492 |
+
bubbleEl.parentElement.insertAdjacentElement('beforeend', overlay);
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
// ββ Render done meta ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 496 |
+
function renderDone(metaEl, shieldEl, doneData) {
|
| 497 |
+
// Hide shield spinner
|
| 498 |
+
shieldEl.style.display = 'none';
|
| 499 |
+
|
| 500 |
+
const score = doneData.threat_score || 0;
|
| 501 |
+
const cls = score >= 60 ? 'threat-high' : score >= 30 ? 'threat-medium' : 'threat-clean';
|
| 502 |
+
const label = score >= 60 ? 'HIGH RISK' : score >= 30 ? 'SUSPICIOUS' : 'CLEAN';
|
| 503 |
+
|
| 504 |
+
let html = `<span class="threat-badge ${cls}">${label} Β· ${score}</span>`;
|
| 505 |
+
html += `<span class="meta-chip">${doneData.latency_ms || 0}ms total Β· ${doneData.sentences || 0} sentences</span>`;
|
| 506 |
+
(doneData.flags || []).forEach(f => { html += `<span class="flag-chip">${f}</span>`; });
|
| 507 |
+
|
| 508 |
+
metaEl.innerHTML = html;
|
| 509 |
+
metaEl.style.display = 'flex';
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
// ββ Append blocked card βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 513 |
+
function appendBlocked(data) {
|
| 514 |
+
const div = document.createElement('div');
|
| 515 |
+
div.className = 'blocked-card';
|
| 516 |
+
div.innerHTML = `
|
| 517 |
+
<div class="blocked-card-title">π« Input Rejected by Prompt Guard</div>
|
| 518 |
+
<div class="blocked-card-reason">${esc(data.reason || 'GUARD_BLOCKED')}</div>
|
| 519 |
+
<div style="margin-top:4px">
|
| 520 |
+
${(data.flags||[]).map(f => `<span class="flag-chip">${f}</span>`).join(' ')}
|
| 521 |
+
<span class="meta-chip" style="margin-left:8px">PG score: ${(data.pg_score||0).toFixed(3)} Β· Guard: ${data.guard_ms||0}ms</span>
|
| 522 |
+
</div>
|
| 523 |
+
`;
|
| 524 |
+
chatArea.appendChild(div);
|
| 525 |
+
scrollBottom();
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
// ββ Main send logic βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 529 |
+
async function sendMessage() {
|
| 530 |
+
const prompt = input.value.trim();
|
| 531 |
+
if (!prompt) return;
|
| 532 |
+
|
| 533 |
+
hideWelcome();
|
| 534 |
+
input.value = '';
|
| 535 |
+
input.style.height = 'auto';
|
| 536 |
+
sendBtn.disabled = true;
|
| 537 |
+
|
| 538 |
+
appendUser(prompt);
|
| 539 |
+
|
| 540 |
+
const state = createAssistantBubble();
|
| 541 |
+
const bubble = state.getBubble();
|
| 542 |
+
const meta = state.getMeta();
|
| 543 |
+
const shield = state.getShield();
|
| 544 |
+
|
| 545 |
+
const tStart = performance.now();
|
| 546 |
+
let currentSentenceId = 0;
|
| 547 |
+
let blocked = false;
|
| 548 |
+
let guardMs = 0;
|
| 549 |
+
|
| 550 |
+
try {
|
| 551 |
+
const res = await fetch(SIDECAR_URL, {
|
| 552 |
+
method: 'POST',
|
| 553 |
+
headers: { 'Content-Type': 'application/json' },
|
| 554 |
+
body: JSON.stringify({ prompt, stream: true }),
|
| 555 |
+
});
|
| 556 |
+
|
| 557 |
+
if (!res.ok) {
|
| 558 |
+
const err = await res.json().catch(() => ({}));
|
| 559 |
+
shield.style.display = 'none';
|
| 560 |
+
bubble.innerHTML = `<span style="color:var(--danger)">Error: ${esc(err.detail || res.statusText)}</span>`;
|
| 561 |
+
sendBtn.disabled = false;
|
| 562 |
+
return;
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
const reader = res.body.getReader();
|
| 566 |
+
const decoder = new TextDecoder();
|
| 567 |
+
let rawBuf = '';
|
| 568 |
+
|
| 569 |
+
// Remove blinking cursor initially placed
|
| 570 |
+
bubble.innerHTML = '';
|
| 571 |
+
|
| 572 |
+
while (true) {
|
| 573 |
+
const { done, value } = await reader.read();
|
| 574 |
+
if (done) break;
|
| 575 |
+
|
| 576 |
+
rawBuf += decoder.decode(value, { stream: true });
|
| 577 |
+
|
| 578 |
+
// Parse SSE lines
|
| 579 |
+
const lines = rawBuf.split('\n');
|
| 580 |
+
rawBuf = lines.pop(); // keep incomplete line
|
| 581 |
+
|
| 582 |
+
for (const line of lines) {
|
| 583 |
+
if (!line.startsWith('data: ')) continue;
|
| 584 |
+
let ev;
|
| 585 |
+
try { ev = JSON.parse(line.slice(6)); } catch { continue; }
|
| 586 |
+
|
| 587 |
+
switch (ev.type) {
|
| 588 |
+
|
| 589 |
+
// ββ Raw token chunk β append immediately ββββββββββββββ
|
| 590 |
+
case 'chunk': {
|
| 591 |
+
// We actually render sentence spans, not raw chunks
|
| 592 |
+
// Chunks are for the browser but we track sentences for guard
|
| 593 |
+
// Simple approach: append text nodes directly
|
| 594 |
+
bubble.appendChild(document.createTextNode(ev.text));
|
| 595 |
+
scrollBottom();
|
| 596 |
+
break;
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
// ββ Complete sentence β wrap in span with data-sid ββββ
|
| 600 |
+
case 'sentence': {
|
| 601 |
+
// Replace plain text accumulated in bubble with a tracked span
|
| 602 |
+
// Strategy: clear bubble's text nodes, re-render as spans
|
| 603 |
+
currentSentenceId = ev.sentence_id;
|
| 604 |
+
|
| 605 |
+
// Find last text node and replace with span
|
| 606 |
+
const lastChild = bubble.lastChild;
|
| 607 |
+
if (lastChild && lastChild.nodeType === Node.TEXT_NODE) {
|
| 608 |
+
bubble.removeChild(lastChild);
|
| 609 |
+
}
|
| 610 |
+
const span = document.createElement('span');
|
| 611 |
+
span.dataset.sid = ev.sentence_id;
|
| 612 |
+
span.textContent = ev.text + ' ';
|
| 613 |
+
bubble.appendChild(span);
|
| 614 |
+
scrollBottom();
|
| 615 |
+
break;
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
// ββ Guard blocked the prompt (pre-inference) βββοΏ½οΏ½οΏ½ββββββ
|
| 619 |
+
case 'blocked': {
|
| 620 |
+
blocked = true;
|
| 621 |
+
guardMs = ev.guard_ms || 0;
|
| 622 |
+
|
| 623 |
+
// Remove the assistant bubble we created
|
| 624 |
+
bubble.closest('.msg').remove();
|
| 625 |
+
appendBlocked(ev);
|
| 626 |
+
setLatency(ev.guard_ms, ev.guard_ms);
|
| 627 |
+
break;
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
// ββ Mid-stream block signal (post-monitor) ββββββββββββ
|
| 631 |
+
case 'block_signal': {
|
| 632 |
+
applyBlockSignal(bubble, state, ev);
|
| 633 |
+
break;
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
+
// ββ Stream done βββββββββββββββββββββββββββββββββββββββ
|
| 637 |
+
case 'done': {
|
| 638 |
+
// Remove trailing cursor if present
|
| 639 |
+
const cursor = bubble.querySelector('.stream-cursor');
|
| 640 |
+
if (cursor) cursor.remove();
|
| 641 |
+
|
| 642 |
+
renderDone(meta, shield, ev);
|
| 643 |
+
setLatency(null, ev.latency_ms);
|
| 644 |
+
break;
|
| 645 |
+
}
|
| 646 |
+
}
|
| 647 |
+
}
|
| 648 |
+
}
|
| 649 |
+
|
| 650 |
+
} catch (err) {
|
| 651 |
+
shield.style.display = 'none';
|
| 652 |
+
bubble.innerHTML = `<span style="color:var(--danger)">Connection error: ${esc(err.message)}</span>`;
|
| 653 |
+
}
|
| 654 |
+
|
| 655 |
+
sendBtn.disabled = false;
|
| 656 |
+
input.focus();
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
// ββ Health check on load ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 660 |
+
(async () => {
|
| 661 |
+
try {
|
| 662 |
+
const r = await fetch('/v1/health');
|
| 663 |
+
const d = await r.json();
|
| 664 |
+
if (!d.guard_ready) {
|
| 665 |
+
document.getElementById('status-dot').style.background = 'var(--warn)';
|
| 666 |
+
document.getElementById('status-dot').style.boxShadow = '0 0 6px var(--warn)';
|
| 667 |
+
document.getElementById('status-dot').title = 'Guard not ready';
|
| 668 |
+
}
|
| 669 |
+
} catch {
|
| 670 |
+
document.getElementById('status-dot').style.background = 'var(--danger)';
|
| 671 |
+
document.getElementById('status-dot').title = 'Sidecar unreachable';
|
| 672 |
+
}
|
| 673 |
+
})();
|
| 674 |
+
</script>
|
| 675 |
+
</body>
|
| 676 |
+
</html>
|