Merge pull request #91 from SpecterOps/llm_chat_over_data

Llm chat over data
This commit is contained in:
Will Schroeder
2025-11-07 13:48:45 -08:00
committed by GitHub
17 changed files with 1807 additions and 6 deletions
+3
View File
@@ -1049,6 +1049,9 @@ services:
- NEMESIS_MONITORING=${NEMESIS_MONITORING:-disabled}
- OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://otel-collector:4317
- OTEL_EXPORTER_OTLP_TRACES_ENDPOINT_INSECURE=true
# Chatbot configuration
- CHATBOT_DB_PASSWORD=${CHATBOT_DB_PASSWORD:-chatbot_pass_change_me}
- MCP_MAX_RESULTS=${MCP_MAX_RESULTS:-1000}
logging: *logging-config
depends_on:
postgres: { condition: service_healthy }
+13
View File
@@ -65,3 +65,16 @@ NEMESIS_URL="https://localhost:7443/"
# Phoenix UI will be available at http://localhost:6006
# Example:
# PHOENIX_ENABLED=true
# (Optional) Chatbot database configuration.
# Password for the read-only database user used by the chatbot.
# Example:
# CHATBOT_DB_PASSWORD="chatbot_secure_password"
CHATBOT_DB_PASSWORD="chatbot_pass_change_me"
# (Optional) Maximum number of results returned by chatbot tools
# Helps prevent expensive queries and context window issues.
# Example:
# MCP_MAX_RESULTS=500
MCP_MAX_RESULTS=1000
+33 -1
View File
@@ -837,4 +837,36 @@ CREATE OR REPLACE TRIGGER update_dpapi_domain_backup_keys_updated_at
CREATE OR REPLACE TRIGGER update_dpapi_system_credentials_updated_at
BEFORE UPDATE ON dpapi.system_credentials
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
EXECUTE FUNCTION update_updated_at_column();
-----------------------
-- CHATBOT READ-ONLY USER
-----------------------
-- Create read-only user for chatbot queries with restricted table access
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'chatbot_readonly') THEN
CREATE USER chatbot_readonly WITH PASSWORD 'chatbot_pass_change_me';
END IF;
END
$$;
-- Grant connection and schema usage
GRANT CONNECT ON DATABASE enrichment TO chatbot_readonly;
GRANT USAGE ON SCHEMA public TO chatbot_readonly;
GRANT USAGE ON SCHEMA chromium TO chatbot_readonly;
-- Grant SELECT on specific tables only (chatbot-accessible tables)
GRANT SELECT ON files_enriched TO chatbot_readonly;
GRANT SELECT ON enrichments TO chatbot_readonly;
GRANT SELECT ON findings TO chatbot_readonly;
GRANT SELECT ON file_linkings TO chatbot_readonly;
GRANT SELECT ON chromium.cookies TO chatbot_readonly;
GRANT SELECT ON chromium.logins TO chatbot_readonly;
-- Explicitly revoke write permissions to ensure read-only access
REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON ALL TABLES IN SCHEMA public FROM chatbot_readonly;
REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON ALL TABLES IN SCHEMA chromium FROM chatbot_readonly;
REVOKE CREATE ON SCHEMA public FROM chatbot_readonly;
REVOKE CREATE ON SCHEMA chromium FROM chatbot_readonly;
+16 -1
View File
@@ -7,7 +7,17 @@ RUN apt-get update && \
apt-get install -y libpq5 \
gcc libc6-dev curl wget libicu-dev && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
rm -rf /var/lib/apt/lists/* && \
ARCH=$(dpkg --print-architecture) && \
wget https://go.dev/dl/go1.25.4.linux-${ARCH}.tar.gz && \
tar -C /usr/local -xzf go1.25.4.linux-${ARCH}.tar.gz && \
rm go1.25.4.linux-${ARCH}.tar.gz
# Install genai-toolbox for chatbot MCP functionality
ENV GOPATH=/go \
PATH=/usr/local/go/bin:/go/bin:$PATH
RUN go install github.com/googleapis/genai-toolbox@v0.18.0
# Install .NET Runtime
@@ -82,6 +92,11 @@ ENV DOTNET_ROOT=/usr/local/dotnet \
PATH=/usr/local/dotnet:$PATH
COPY --from=base /usr/local/dotnet /usr/local/dotnet
# Copy genai-toolbox binary from base stage
ENV GOPATH=/go \
PATH=/go/bin:$PATH
COPY --from=base /go/bin/genai-toolbox /go/bin/genai-toolbox
COPY --from=bundle /venv /venv
+17
View File
@@ -677,6 +677,23 @@ def run_report_generator(request: dict):
return {"success": False, "error": str(e)}
@app.post("/agents/chatbot/stream")
async def chatbot_stream_endpoint(request: dict):
"""Stream chatbot responses for interactive querying."""
try:
from agents.tasks.chatbot import ChatbotRequest, chatbot_stream
# Parse and validate request
chatbot_request = ChatbotRequest(**request)
# Stream the response
return await chatbot_stream(chatbot_request)
except Exception as e:
logger.exception(message="Error in chatbot streaming")
return {"success": False, "error": str(e)}
@app.api_route("/healthz", methods=["GET", "HEAD"])
async def health_check():
"""Health check endpoint."""
+506
View File
@@ -0,0 +1,506 @@
# genai-toolbox configuration for Nemesis Chatbot
# Database source definition
sources:
chatbot-db:
kind: postgres
host: ${POSTGRES_HOST:postgres}
port: ${POSTGRES_PORT:5432}
database: ${POSTGRES_DB:enrichment}
user: chatbot_readonly
password: ${CHATBOT_DB_PASSWORD}
# Custom SQL tools for querying Nemesis data
tools:
# FILES_ENRICHED queries
count-files:
kind: postgres-sql
source: chatbot-db
description: Count total files, optionally filtered by project, agent, source, or extension
parameters:
- name: project
type: string
required: false
description: Filter by project name (optional)
- name: agent_id
type: string
required: false
description: Filter by agent ID (optional)
- name: source
type: string
required: false
description: Filter by source (case-insensitive pattern match, optional)
- name: extension
type: string
required: false
description: Filter by file extension (optional)
statement: |
SELECT COUNT(*) as file_count
FROM files_enriched
WHERE ($1::text IS NULL OR project = $1)
AND ($2::text IS NULL OR agent_id = $2)
AND ($3::text IS NULL OR LOWER(source) LIKE LOWER('%' || $3 || '%'))
AND ($4::text IS NULL OR extension = $4)
search-files:
kind: postgres-sql
source: chatbot-db
description: Search for files by name, path, or extension with optional filters
parameters:
- name: filename_pattern
type: string
required: false
description: Search pattern for filename (case-insensitive, optional)
- name: path_pattern
type: string
required: false
description: Search pattern for file path (case-insensitive, optional)
- name: extension
type: string
required: false
description: Filter by file extension (optional)
- name: project
type: string
required: false
description: Filter by project name (optional)
- name: source
type: string
required: false
description: Filter by source (case-insensitive pattern match, optional)
- name: limit
type: integer
required: false
description: Maximum number of results (default 100, max 1000)
statement: |
SELECT object_id::text, file_name, path, extension, size, magic_type, mime_type,
source, agent_id, project, timestamp, originating_object_id::text
FROM files_enriched
WHERE ($1::text IS NULL OR LOWER(file_name) LIKE LOWER('%' || $1 || '%'))
AND ($2::text IS NULL OR LOWER(path) LIKE LOWER('%' || $2 || '%'))
AND ($3::text IS NULL OR extension = $3)
AND ($4::text IS NULL OR project = $4)
AND ($5::text IS NULL OR LOWER(source) LIKE LOWER('%' || $5 || '%'))
ORDER BY timestamp DESC
LIMIT LEAST(COALESCE($6, 100), 1000)
get-file-details:
kind: postgres-sql
source: chatbot-db
description: Get detailed information about a specific file by object_id
parameters:
- name: object_id
type: string
description: The UUID of the file to retrieve
statement: |
SELECT object_id::text, agent_id, source, project, timestamp, path, file_name,
extension, size, magic_type, mime_type, originating_object_id::text
FROM files_enriched
WHERE object_id = $1::uuid
# ENRICHMENTS queries
list-enrichment-modules:
kind: postgres-sql
source: chatbot-db
description: List all unique enrichment module names with their usage counts
statement: |
SELECT module_name, COUNT(*) as usage_count
FROM enrichments
GROUP BY module_name
ORDER BY usage_count DESC
get-file-enrichments:
kind: postgres-sql
source: chatbot-db
description: Get all enrichment results for a specific file
parameters:
- name: object_id
type: string
description: The UUID of the file
statement: |
SELECT e.enrichment_id, e.object_id::text, e.module_name, e.result_data, e.created_at,
f.file_name, f.path
FROM enrichments e
JOIN files_enriched f ON e.object_id = f.object_id
WHERE e.object_id = $1::uuid
ORDER BY e.created_at DESC
search-enrichments-by-module:
kind: postgres-sql
source: chatbot-db
description: Search enrichment results from a specific module
parameters:
- name: module_name
type: string
description: Name of the enrichment module
- name: limit
type: integer
required: false
description: Maximum number of results (default 100, max 1000)
statement: |
SELECT e.object_id::text, e.module_name, e.result_data, e.created_at,
f.file_name, f.path, f.source
FROM enrichments e
JOIN files_enriched f ON e.object_id = f.object_id
WHERE e.module_name = $1
ORDER BY e.created_at DESC
LIMIT LEAST(COALESCE($2, 100), 1000)
# FINDINGS queries
get-unique-findings-categories:
kind: postgres-sql
source: chatbot-db
description: List all unique finding categories with their counts and severity statistics
statement: |
SELECT category, COUNT(*) as count,
AVG(severity) as avg_severity,
MAX(severity) as max_severity
FROM findings
GROUP BY category
ORDER BY count DESC
count-findings:
kind: postgres-sql
source: chatbot-db
description: Count findings, optionally filtered by severity, category, or source
parameters:
- name: min_severity
type: integer
required: false
description: Minimum severity level (0-10, optional)
- name: category
type: string
required: false
description: Filter by finding category (optional)
- name: source
type: string
required: false
description: Filter by source (case-insensitive pattern match, optional)
statement: |
SELECT COUNT(*) as finding_count
FROM findings f
JOIN files_enriched fe ON f.object_id = fe.object_id
WHERE ($1::integer IS NULL OR f.severity >= $1)
AND ($2::text IS NULL OR $2 = '' OR f.category = $2)
AND ($3::text IS NULL OR $3 = '' OR LOWER(fe.source) LIKE LOWER('%' || $3 || '%'))
search-findings:
kind: postgres-sql
source: chatbot-db
description: Search findings with filters for severity, category, and source
parameters:
- name: min_severity
type: integer
required: false
description: Minimum severity level (0-10, optional)
- name: category
type: string
required: false
description: Filter by finding category (optional)
- name: source
type: string
required: false
description: Filter by source (case-insensitive pattern match, optional)
- name: limit
type: integer
required: false
description: Maximum number of results (default 100, max 1000)
statement: |
SELECT f.finding_id, f.finding_name, f.category, f.severity, f.origin_type,
f.origin_name, f.data, f.created_at,
fe.file_name, fe.path, fe.source, fe.agent_id, fe.project
FROM findings f
JOIN files_enriched fe ON f.object_id = fe.object_id
WHERE ($1::integer IS NULL OR f.severity >= $1)
AND ($2::text IS NULL OR f.category = $2)
AND ($3::text IS NULL OR LOWER(fe.source) LIKE LOWER('%' || $3 || '%'))
ORDER BY f.severity DESC, f.created_at DESC
LIMIT LEAST(COALESCE($4, 100), 1000)
get-file-findings:
kind: postgres-sql
source: chatbot-db
description: Get all findings for a specific file by object_id
parameters:
- name: object_id
type: string
description: The UUID of the file
statement: |
SELECT f.finding_id, f.finding_name, f.category, f.severity,
f.origin_type, f.origin_name, f.data, f.created_at,
fe.file_name, fe.path, fe.source, fe.agent_id, fe.project
FROM findings f
JOIN files_enriched fe ON f.object_id = fe.object_id
WHERE f.object_id = $1::uuid
ORDER BY f.severity DESC, f.created_at DESC
get-findings-by-category:
kind: postgres-sql
source: chatbot-db
description: Get aggregated count of findings grouped by category
parameters:
- name: source
type: string
required: false
description: Filter by source (case-insensitive pattern match, optional)
statement: |
SELECT f.category, COUNT(*) as count,
AVG(f.severity) as avg_severity,
MAX(f.severity) as max_severity
FROM findings f
JOIN files_enriched fe ON f.object_id = fe.object_id
WHERE ($1::text IS NULL OR LOWER(fe.source) LIKE LOWER('%' || $1 || '%'))
GROUP BY f.category
ORDER BY count DESC
get-triaged-findings:
kind: postgres-sql
source: chatbot-db
description: Get findings with triage status (true positive, false positive, etc)
parameters:
- name: triage_value
type: string
required: false
description: Filter by triage value (e.g., true_positive, false_positive, optional)
- name: source
type: string
required: false
description: Filter by source (case-insensitive pattern match, optional)
- name: limit
type: integer
required: false
description: Maximum number of results (default 100, max 1000)
statement: |
SELECT f.finding_id, f.finding_name, f.category, f.severity,
fth.value as triage_value, fth.explanation, fth.timestamp as triage_timestamp,
fe.file_name, fe.path, fe.source
FROM findings f
JOIN findings_triage_history fth ON f.triage_id = fth.id
JOIN files_enriched fe ON f.object_id = fe.object_id
WHERE ($1::text IS NULL OR fth.value = $1)
AND ($2::text IS NULL OR LOWER(fe.source) LIKE LOWER('%' || $2 || '%'))
ORDER BY fth.timestamp DESC
LIMIT LEAST(COALESCE($3, 100), 1000)
search-credential-findings-by-host:
kind: postgres-sql
source: chatbot-db
description: Search for credential findings related to a specific hostname or system. Use this to find credentials that may provide access to a target system.
parameters:
- name: hostname
type: string
description: Target hostname or system name to search for in credential findings (case-insensitive partial match)
- name: source
type: string
required: false
description: Filter by source (case-insensitive pattern match, optional)
- name: limit
type: integer
required: false
description: Maximum number of results (default 100, max 1000)
statement: |
SELECT f.finding_id, f.finding_name, f.category, f.severity,
f.origin_type, f.origin_name, f.data, f.created_at,
fe.file_name, fe.path, fe.source, fe.agent_id, fe.project
FROM findings f
JOIN files_enriched fe ON f.object_id = fe.object_id
WHERE f.category = 'credential'
AND LOWER(f.data::text) LIKE LOWER('%' || $1 || '%')
AND ($2::text IS NULL OR LOWER(fe.source) LIKE LOWER('%' || $2 || '%'))
ORDER BY f.severity DESC, f.created_at DESC
LIMIT LEAST(COALESCE($3, 100), 1000)
search-logins-by-host:
kind: postgres-sql
source: chatbot-db
description: Search for decrypted browser credentials related to a specific hostname or URL. Use this to find saved browser passwords for accessing a target system.
parameters:
- name: hostname
type: string
description: Target hostname or URL to search for in saved browser credentials (case-insensitive partial match)
- name: source
type: string
required: false
description: Filter by source (case-insensitive pattern match, optional)
- name: limit
type: integer
required: false
description: Maximum number of results (default 100, max 1000)
statement: |
SELECT origin_url, username_value, password_value_dec, signon_realm,
date_created, date_last_used, times_used,
source, username, browser, agent_id, project
FROM chromium.logins
WHERE is_decrypted = true
AND (LOWER(origin_url) LIKE LOWER('%' || $1 || '%')
OR LOWER(signon_realm) LIKE LOWER('%' || $1 || '%'))
AND ($2::text IS NULL OR LOWER(source) LIKE LOWER('%' || $2 || '%'))
ORDER BY date_last_used DESC NULLS LAST, times_used DESC
LIMIT LEAST(COALESCE($3, 100), 1000)
# CHROMIUM.COOKIES queries
count-cookies:
kind: postgres-sql
source: chatbot-db
description: Count browser cookies, optionally filtered by host or source
parameters:
- name: host_pattern
type: string
required: false
description: Filter by host_key pattern (case-insensitive, optional)
- name: source
type: string
required: false
description: Filter by source (case-insensitive pattern match, optional)
statement: |
SELECT COUNT(*) as cookie_count
FROM chromium.cookies
WHERE ($1::text IS NULL OR LOWER(host_key) LIKE LOWER('%' || $1 || '%'))
AND ($2::text IS NULL OR LOWER(source) LIKE LOWER('%' || $2 || '%'))
search-cookies:
kind: postgres-sql
source: chatbot-db
description: Search browser cookies by host, name, or source
parameters:
- name: host_pattern
type: string
required: false
description: Filter by host_key pattern (case-insensitive, optional)
- name: name_pattern
type: string
required: false
description: Filter by cookie name pattern (case-insensitive, optional)
- name: source
type: string
required: false
description: Filter by source (case-insensitive pattern match, optional)
- name: is_decrypted
type: boolean
required: false
description: Filter by decryption status (optional)
- name: limit
type: integer
required: false
description: Maximum number of results (default 100, max 1000)
statement: |
SELECT host_key, name, path, value_dec, is_decrypted, is_secure, is_httponly,
expires_utc, source, username, browser, agent_id, project, originating_object_id::text
FROM chromium.cookies
WHERE ($1::text IS NULL OR LOWER(host_key) LIKE LOWER('%' || $1 || '%'))
AND ($2::text IS NULL OR LOWER(name) LIKE LOWER('%' || $2 || '%'))
AND ($3::text IS NULL OR LOWER(source) LIKE LOWER('%' || $3 || '%'))
AND ($4::boolean IS NULL OR is_decrypted = $4)
ORDER BY expires_utc DESC NULLS LAST
LIMIT LEAST(COALESCE($5, 100), 1000)
# CHROMIUM.LOGINS queries
count-logins:
kind: postgres-sql
source: chatbot-db
description: Count saved browser credentials, optionally filtered by URL or source
parameters:
- name: url_pattern
type: string
required: false
description: Filter by origin_url pattern (case-insensitive, optional)
- name: source
type: string
required: false
description: Filter by source (case-insensitive pattern match, optional)
- name: is_decrypted
type: boolean
required: false
description: Filter by decryption status (optional)
statement: |
SELECT COUNT(*) as login_count
FROM chromium.logins
WHERE ($1::text IS NULL OR LOWER(origin_url) LIKE LOWER('%' || $1 || '%'))
AND ($2::text IS NULL OR LOWER(source) LIKE LOWER('%' || $2 || '%'))
AND ($3::boolean IS NULL OR is_decrypted = $3)
search-logins:
kind: postgres-sql
source: chatbot-db
description: Search browser saved credentials by URL, username, or source
parameters:
- name: url_pattern
type: string
required: false
description: Filter by origin_url pattern (case-insensitive, optional)
- name: username_pattern
type: string
required: false
description: Filter by username_value pattern (case-insensitive, optional)
- name: source
type: string
required: false
description: Filter by source (case-insensitive pattern match, optional)
- name: is_decrypted
type: boolean
required: false
description: Filter by decryption status (optional)
- name: limit
type: integer
required: false
description: Maximum number of results (default 100, max 1000)
statement: |
SELECT origin_url, username_value, password_value_dec, signon_realm,
is_decrypted, date_created, date_last_used, times_used,
source, username, browser, agent_id, project, originating_object_id::text
FROM chromium.logins
WHERE ($1::text IS NULL OR LOWER(origin_url) LIKE LOWER('%' || $1 || '%'))
AND ($2::text IS NULL OR LOWER(username_value) LIKE LOWER('%' || $2 || '%'))
AND ($3::text IS NULL OR LOWER(source) LIKE LOWER('%' || $3 || '%'))
AND ($4::boolean IS NULL OR is_decrypted = $4)
ORDER BY date_last_used DESC NULLS LAST
LIMIT LEAST(COALESCE($5, 100), 1000)
# PLAINTEXT CONTENT searches
search-document-content:
kind: postgres-sql
source: chatbot-db
description: Full-text search through plaintext content chunks of files. Returns the first matching chunk from each file.
parameters:
- name: search_query
type: string
description: Text to search for in document content (full-text search)
- name: path_pattern
type: string
required: false
description: Filter by file path pattern using LIKE (e.g., '%folder%', optional)
- name: agent_pattern
type: string
required: false
description: Filter by agent ID pattern using LIKE (optional)
- name: project_name
type: string
required: false
description: Filter by exact project name (optional)
- name: start_date
type: string
required: false
description: Filter files from this date onwards in ISO format (e.g., '2024-01-01', optional)
- name: end_date
type: string
required: false
description: Filter files up to this date in ISO format (e.g., '2024-12-31', optional)
- name: max_results
type: integer
required: false
description: Maximum number of results (default 100, max 1000)
- name: source_pattern
type: string
required: false
description: Filter by source pattern using LIKE (optional)
statement: |
SELECT object_id::text, chunk_number, content, file_name, path, extension,
project, agent_id, source, timestamp
FROM public.search_documents(
$1,
$2,
$3,
$4,
$5::timestamp with time zone,
$6::timestamp with time zone,
COALESCE($7, 100),
$8
)
+355
View File
@@ -0,0 +1,355 @@
"""Chatbot agent for interactive querying of Nemesis data."""
import asyncio
import os
import subprocess
from pathlib import Path
from typing import AsyncGenerator
import structlog
from agents.base_agent import BaseAgent
from agents.logger import set_agent_metadata
from agents.model_manager import ModelManager
from agents.prompt_manager import PromptManager
from common.db import get_postgres_connection_str
from fastapi import HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from pydantic_ai import Agent
from pydantic_ai.mcp import MCPServerStreamableHTTP
from pydantic_ai.settings import ModelSettings
logger = structlog.get_logger(__name__)
class ChatMessage(BaseModel):
"""A single chat message."""
role: str # "user" or "assistant"
content: str
class ChatbotRequest(BaseModel):
"""Request model for chatbot queries."""
message: str
history: list[ChatMessage] = []
use_history: bool = True
temperature: float = 0.7
class ChatbotAgent(BaseAgent):
"""Agent for interactive data querying via natural language."""
def __init__(self):
super().__init__()
self.prompt_manager = PromptManager(get_postgres_connection_str())
self.name = "chatbot"
self.description = "Interactive chatbot for querying Nemesis data"
self.agent_type = "llm_based"
self.has_prompt = True
self.llm_temperature = 0.7 # Default, can be overridden per request
# Get max rows from environment
max_results = int(os.getenv("MCP_MAX_RESULTS", "1000"))
# System prompt - will be saved to DB on first use
self.system_prompt = f"""You are a data query assistant for Nemesis, an offensive security data platform.
Your role is to retrieve and report data from the database. Do NOT provide recommendations, analysis, or suggestions - only report the requested data. You have access to MCP tools to query the Nemesis PostgreSQL database.
When answering questions:
1. Query the database using the appropriate tools
2. Report ONLY the data retrieved unless explicitly instructed otherwise - i.e., no analysis or recommendations unless a user explicitly asks for it
3. Present results clearly and concisely
4. For large result sets, summarize counts and key details
5. Use case-insensitive pattern matching for host/source filters
6. Be brief - users want facts, not explanations unless they explicitly request them
7. Don't return the "project" field to users
Query Guidelines:
- Use a `limit` of {max_results} for maximum results
- Filter by severity, category, or source to narrow results
- Use search-document-content when users ask to search "for" or "containing" specific text but otherwise restrict your usage of "search-document-content" since it can return a lot of results
- a `originating_object_id` field points to the `object_id` the finding/file originated from
Searching for Credentials to Access Systems:
When users ask about accessing a specific system or finding credentials for a hostname, follow this order:
1. First, use search-credential-findings-by-host to find credential findings related to the target hostname
2. Second, use search-logins-by-host to find decrypted browser credentials for the target hostname
3. Only as a last resort, if the above return no results, use search-document-content with the hostname to search file contents
This order ensures you check the most relevant credential sources first before falling back to broader document searches.
"""
self.mcp_process = None
def get_prompt(self) -> str:
"""Get the chatbot prompt from database or use default."""
try:
prompt_data = self.prompt_manager.get_prompt(self.name)
if prompt_data:
return prompt_data["prompt"]
else:
logger.info("No prompt found in database, initializing with default", agent_name=self.name)
success = self.prompt_manager.save_prompt(self.name, self.system_prompt, self.description)
if success:
logger.info("Default prompt saved to database", agent_name=self.name)
else:
logger.debug(
"Could not save default prompt to database (likely during startup)", agent_name=self.name
)
return self.system_prompt
except Exception as e:
logger.warning("Error managing prompt, using default", agent_name=self.name, error=str(e))
return self.system_prompt
def execute(self, ctx, activity_input: dict) -> dict:
"""
Execute method required by BaseAgent.
Note: ChatbotAgent is designed for interactive HTTP streaming,
not workflow-based execution. Use the chatbot_stream endpoint instead.
"""
logger.warning("ChatbotAgent.execute called but this agent is designed for HTTP streaming only")
return {
"success": False,
"error": "ChatbotAgent does not support workflow execution. Use /agents/chatbot/stream endpoint instead."
}
def _get_chatbot_connection_string(self) -> str:
"""Get PostgreSQL connection string for chatbot read-only user."""
chatbot_password = os.getenv("CHATBOT_DB_PASSWORD", "chatbot_pass_change_me")
postgres_host = os.getenv("POSTGRES_HOST", "postgres")
postgres_port = os.getenv("POSTGRES_PORT", "5432")
postgres_db = os.getenv("POSTGRES_DB", "enrichment")
postgres_params = os.getenv("POSTGRES_PARAMETERS", "sslmode=disable")
return f"postgresql://chatbot_readonly:{chatbot_password}@{postgres_host}:{postgres_port}/{postgres_db}?{postgres_params}"
async def start_mcp_server(self):
"""Start the genai-toolbox MCP server as a subprocess listening on HTTP."""
# Check if process is already running
if self.mcp_process and self.mcp_process.poll() is None:
logger.debug("MCP server already running")
return
try:
tools_file = Path(__file__).parent.parent / "mcp" / "tools.yaml"
if not tools_file.exists():
raise FileNotFoundError(f"tools.yaml not found at {tools_file}")
# Get database connection string for chatbot readonly user
db_url = self._get_chatbot_connection_string()
# Start genai-toolbox HTTP server (default port 5000)
logger.info("Starting genai-toolbox MCP HTTP server", tools_file=str(tools_file))
self.mcp_process = subprocess.Popen(
["genai-toolbox", "--tools-file", str(tools_file)],
env={**os.environ, "DATABASE_URL": db_url},
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
# Give it a moment to start
await asyncio.sleep(2)
if self.mcp_process.poll() is not None:
stderr = self.mcp_process.stderr.read().decode() if self.mcp_process.stderr else ""
# If it failed due to address in use, that's actually okay
if "address already in use" in stderr.lower():
logger.info("MCP server already running (address in use)")
self.mcp_process = None
return
raise RuntimeError(f"MCP server failed to start: {stderr}")
logger.info("MCP HTTP server started successfully on http://127.0.0.1:5000/mcp")
except Exception as e:
logger.error("Failed to start MCP server", error=str(e))
raise
async def stop_mcp_server(self):
"""Stop the MCP server subprocess."""
if self.mcp_process and self.mcp_process.poll() is None:
logger.info("Stopping MCP server")
self.mcp_process.terminate()
try:
self.mcp_process.wait(timeout=5)
except subprocess.TimeoutExpired:
logger.warning("MCP server didn't stop gracefully, killing")
self.mcp_process.kill()
self.mcp_process = None
async def stream_chat_response(self, request: ChatbotRequest) -> AsyncGenerator[str, None]:
"""
Stream chatbot responses token-by-token.
Args:
request: ChatbotRequest with message, history, and settings
Yields:
Chunks of the response as they're generated
"""
model = ModelManager.get_model()
if not model:
logger.warning("No model available from ModelManager")
raise HTTPException(status_code=503, detail="AI model not available")
try:
# Set metadata for Phoenix tracing
set_agent_metadata(
agent_name="chatbot",
message_length=len(request.message),
has_history=len(request.history) > 0 if request.use_history else False,
tags=["chatbot", "interactive_query"],
)
# Get current prompt from database
current_prompt = self.get_prompt()
# Build conversation history if enabled
conversation = ""
if request.use_history and request.history:
for msg in request.history:
role_label = "User" if msg.role == "user" else "Assistant"
conversation += f"{role_label}: {msg.content}\n\n"
# Add current message
conversation += f"User: {request.message}\n\nAssistant:"
# Connect to MCP HTTP server (genai-toolbox running on http://127.0.0.1:5000/mcp)
mcp_server = MCPServerStreamableHTTP(url='http://127.0.0.1:5000/mcp')
# Create agent with MCP tools
agent = Agent(
model=model,
system_prompt=current_prompt,
toolsets=[mcp_server],
instrument=ModelManager.is_instrumentation_enabled(),
retries=5, # Increased from 2 to handle transient MCP tool failures
model_settings=ModelSettings(temperature=request.temperature),
)
logger.info("Starting chatbot stream", message=request.message, temperature=request.temperature)
# When tools are involved, streaming doesn't work as expected
# Get the complete result and send it
result = await agent.run(conversation)
# Log tool calls and their results with full details
tool_calls = []
tool_errors = []
if hasattr(result, 'all_messages'):
for msg_idx, msg in enumerate(result.all_messages()):
logger.debug(f"Message {msg_idx}: type={type(msg).__name__}, role={getattr(msg, 'role', 'unknown')}")
if hasattr(msg, 'parts'):
for part_idx, part in enumerate(msg.parts):
part_type = type(part).__name__
logger.debug(f" Part {part_idx}: type={part_type}")
# Log tool calls (requests)
if hasattr(part, 'tool_name'):
tool_info = {
'tool': part.tool_name,
'args': getattr(part, 'args', {})
}
logger.info(f"TOOL CALL: {part.tool_name}", args=tool_info['args'])
tool_calls.append(tool_info)
# Log tool returns (responses)
if hasattr(part, 'tool_name') and hasattr(part, 'content'):
full_content = str(part.content)
logger.debug(
f"TOOL RESPONSE: {part.tool_name}",
content_length=len(full_content),
full_content=full_content # Log FULL content for debugging
)
# Log errors
if hasattr(part, 'error'):
error_info = {
'tool': getattr(part, 'tool_name', 'unknown'),
'error': str(part.error)
}
logger.error("TOOL ERROR", error_info=error_info)
tool_errors.append(error_info)
if tool_calls:
logger.info("MCP tools called", count=len(tool_calls), tools=[t['tool'] for t in tool_calls])
else:
logger.warning("No MCP tools were called by the LLM")
if tool_errors:
logger.error("Tool errors occurred", error_count=len(tool_errors))
# Extract just the text output from the result
if hasattr(result, 'data'):
final_text = str(result.data)
elif hasattr(result, 'output'):
final_text = str(result.output)
else:
final_text = str(result)
logger.info(f"Got complete response, {len(final_text)} chars")
# Debug: Log final response to check for UUID corruption
if 'object_id' in final_text.lower() or 'uuid' in final_text.lower():
logger.warning(
"FINAL RESPONSE contains object_id/UUID",
final_response=final_text[:2000] # Log first 2000 chars
)
# Send the complete response
if final_text:
yield final_text
else:
logger.warning("No text in final result")
# Log completion metrics
logger.info(
"Chatbot response completed",
total_tokens=result.usage().total_tokens if hasattr(result, "usage") else None,
)
except Exception as e:
logger.error("Chatbot streaming failed", error=str(e))
yield f"\n\n[Error: {str(e)}]"
# Global chatbot agent instance
_chatbot_agent: ChatbotAgent | None = None
def get_chatbot_agent() -> ChatbotAgent:
"""Get or create the global chatbot agent instance."""
global _chatbot_agent
if _chatbot_agent is None:
_chatbot_agent = ChatbotAgent()
return _chatbot_agent
async def chatbot_stream(request: ChatbotRequest) -> StreamingResponse:
"""
FastAPI endpoint handler for streaming chatbot responses.
Args:
request: ChatbotRequest with message and settings
Returns:
StreamingResponse with text/event-stream content
"""
agent = get_chatbot_agent()
# Ensure MCP HTTP server is running
await agent.start_mcp_server()
# Stream the response (connects to MCP server via HTTP)
return StreamingResponse(
agent.stream_chat_response(request),
media_type="text/plain",
)
+1
View File
@@ -43,6 +43,7 @@ pillow = "^11.3.0"
opentelemetry-api = "^1.38.0"
opentelemetry-sdk = "^1.38.0"
opentelemetry-exporter-otlp-proto-grpc = "^1.38.0"
presidio-analyzer = "^2.2.360"
[tool.poetry.group.dev.dependencies]
ruff = "^0.9.2"
+9 -2
View File
@@ -12,6 +12,7 @@ import {
HelpCircle,
Key,
LayoutDashboard,
MessageSquare,
Search,
Settings,
Siren,
@@ -41,6 +42,7 @@ import ThemeToggle from './components/ThemeToggle';
import YaraRulesManager from './components/Yara/YaraManager';
import Containers from './components/Containers/Containers';
import AgentsPage from './components/Agents/AgentsPage';
import ChatbotPage from './components/Chatbot/ChatbotPage';
import FileBrowser from './components/FileBrowser/FileBrowser';
import Chromium from './components/Chromium/Chromium';
import Dpapi from './components/Dpapi/Dpapi';
@@ -197,9 +199,13 @@ const Sidebar = ({ onCollapse }) => {
{ id: 'reporting', label: 'Reporting', icon: BarChart2, path: '/reporting' }
];
// Add Agents tab if LiteLLM is available
// Add Chatbot and Agents tabs if LiteLLM is available
const navigationItems = litellmAvailable
? [...baseNavigationItems, { id: 'agents', label: 'Agents', icon: Bot, path: '/agents' }]
? [
...baseNavigationItems,
{ id: 'chatbot', label: 'Chatbot', icon: MessageSquare, path: '/chatbot' },
{ id: 'agents', label: 'Agents', icon: Bot, path: '/agents' }
]
: baseNavigationItems;
const utilityItems = [
@@ -369,6 +375,7 @@ const App = () => {
<Route path="/dpapi" element={<Dpapi />} />
<Route path="/yara-rules" element={<YaraRulesManager />} />
<Route path="/containers" element={<Containers />} />
<Route path="/chatbot" element={<ChatbotPage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/reporting" element={<ReportingPage />} />
<Route path="/reporting/source/:sourceName" element={<SourceReportPage />} />
@@ -0,0 +1,520 @@
import { AlertCircle, Bot, Send, Settings as SettingsIcon, Trash2, X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import ExampleQueries from './ExampleQueries';
import MessageBubble from './MessageBubble';
const MESSAGES_STORAGE_KEY = 'chatbot_messages';
const TOKEN_WARNING_THRESHOLD = 50000;
// Rough token estimation: ~4 characters per token
const estimateTokens = (text) => {
return Math.ceil(text.length / 4);
};
const ChatbotPage = () => {
const [messages, setMessages] = useState(() => {
// Load messages from localStorage on mount
try {
const saved = localStorage.getItem(MESSAGES_STORAGE_KEY);
return saved ? JSON.parse(saved) : [];
} catch {
return [];
}
});
const [currentMessage, setCurrentMessage] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [useHistory, setUseHistory] = useState(true);
const [temperature, setTemperature] = useState(0.7);
const [error, setError] = useState(null);
const [showSettings, setShowSettings] = useState(false);
const [systemPrompt, setSystemPrompt] = useState('');
const [originalPrompt, setOriginalPrompt] = useState('');
const [savingPrompt, setSavingPrompt] = useState(false);
const [promptError, setPromptError] = useState(null);
const [spendData, setSpendData] = useState(null);
const [showTokenWarning, setShowTokenWarning] = useState(false);
const [estimatedTokens, setEstimatedTokens] = useState(0);
const messagesEndRef = useRef(null);
const abortControllerRef = useRef(null);
const textareaRef = useRef(null);
// Auto-scroll to bottom when messages change
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
// Save messages to localStorage whenever they change
useEffect(() => {
try {
localStorage.setItem(MESSAGES_STORAGE_KEY, JSON.stringify(messages));
} catch (err) {
console.error('Failed to save messages to localStorage:', err);
}
}, [messages]);
// Refocus input after response completes
useEffect(() => {
if (!isStreaming && messages.length > 0) {
// Small delay to ensure DOM has updated
setTimeout(() => {
textareaRef.current?.focus();
}, 100);
}
}, [isStreaming, messages.length]);
// Fetch system prompt when settings are opened
useEffect(() => {
if (showSettings && !systemPrompt) {
fetchSystemPrompt();
}
}, [showSettings]);
// Fetch spend data on mount
useEffect(() => {
fetchSpendData();
}, []);
// Calculate token usage and show warning if needed
useEffect(() => {
const totalText = messages.map(m => m.content).join(' ');
const tokens = estimateTokens(totalText);
setEstimatedTokens(tokens);
if (tokens > TOKEN_WARNING_THRESHOLD && !showTokenWarning) {
setShowTokenWarning(true);
}
}, [messages]);
const fetchSpendData = async () => {
try {
const response = await fetch('/api/agents/spend-data');
if (!response.ok) throw new Error('Network response error');
const result = await response.json();
setSpendData({
spend: result.total_spend,
total_tokens: result.total_tokens
});
} catch (err) {
console.error('Error fetching spend data:', err);
// Don't set error state - gracefully degrade
}
};
const fetchSystemPrompt = async () => {
try {
const query = {
query: `
query GetChatbotPrompt {
agent_prompts_by_pk(name: "chatbot") {
name
prompt
description
}
}
`
};
const response = await fetch('/hasura/v1/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-hasura-admin-secret': window.ENV.HASURA_ADMIN_SECRET,
},
body: JSON.stringify(query)
});
if (!response.ok) throw new Error('Network response error');
const result = await response.json();
if (result.errors) throw new Error(result.errors[0].message);
const prompt = result.data.agent_prompts_by_pk?.prompt || '';
setSystemPrompt(prompt);
setOriginalPrompt(prompt);
} catch (err) {
console.error('Error fetching system prompt:', err);
setPromptError('Failed to load system prompt');
}
};
const saveSystemPrompt = async () => {
if (systemPrompt === originalPrompt) return;
setSavingPrompt(true);
setPromptError(null);
try {
const mutation = {
query: `
mutation UpsertChatbotPrompt($prompt: String!) {
insert_agent_prompts_one(
object: {
name: "chatbot",
prompt: $prompt,
description: "Interactive chatbot for querying Nemesis data"
},
on_conflict: {
constraint: agent_prompts_pkey,
update_columns: [prompt]
}
) {
name
prompt
}
}
`,
variables: { prompt: systemPrompt }
};
const response = await fetch('/hasura/v1/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-hasura-admin-secret': window.ENV.HASURA_ADMIN_SECRET,
},
body: JSON.stringify(mutation)
});
if (!response.ok) throw new Error('Network response error');
const result = await response.json();
if (result.errors) throw new Error(result.errors[0].message);
setOriginalPrompt(systemPrompt);
alert('System prompt saved successfully!');
} catch (err) {
console.error('Error saving system prompt:', err);
setPromptError('Failed to save system prompt');
} finally {
setSavingPrompt(false);
}
};
const cancelPromptEdit = () => {
setSystemPrompt(originalPrompt);
setPromptError(null);
};
const sendMessage = async (messageText) => {
if (!messageText.trim() || isStreaming) return;
const userMessage = { role: 'user', content: messageText.trim() };
setMessages(prev => [...prev, userMessage]);
setCurrentMessage('');
setIsStreaming(true);
setError(null);
// Create abort controller for this request
abortControllerRef.current = new AbortController();
try {
const response = await fetch('/api/chatbot/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: messageText.trim(),
history: useHistory ? messages : [],
use_history: useHistory,
temperature: temperature
}),
signal: abortControllerRef.current.signal
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let assistantMessage = '';
// Add empty assistant message that we'll update
setMessages(prev => [...prev, { role: 'assistant', content: '' }]);
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
assistantMessage += chunk;
// Update the last message (assistant's response) in real-time
setMessages(prev => {
const updated = [...prev];
updated[updated.length - 1] = {
role: 'assistant',
content: assistantMessage
};
return updated;
});
}
} catch (err) {
if (err.name === 'AbortError') {
console.log('Request aborted');
} else {
console.error('Streaming error:', err);
setError(err.message || 'Failed to get response from chatbot');
// Remove the empty assistant message if there was an error
setMessages(prev => prev.filter(msg => msg.content !== ''));
}
} finally {
setIsStreaming(false);
abortControllerRef.current = null;
// Refresh spend data after response completes
fetchSpendData();
}
};
const handleSubmit = (e) => {
e.preventDefault();
sendMessage(currentMessage);
};
const handleExampleClick = (example) => {
setCurrentMessage(example);
};
const clearHistory = () => {
if (window.confirm('Clear all conversation history?')) {
setMessages([]);
setError(null);
setShowTokenWarning(false);
// Clear from localStorage as well
try {
localStorage.removeItem(MESSAGES_STORAGE_KEY);
} catch (err) {
console.error('Failed to clear messages from localStorage:', err);
}
}
};
const handleKeyPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
};
return (
<div className="flex flex-col h-[calc(100vh-1rem)] max-w-6xl mx-auto px-4 pt-4 pb-2">
{/* Header */}
<div className="flex items-center justify-between mb-4 pb-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center space-x-3">
<Bot className="w-8 h-8 text-blue-500" />
<div>
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Chatbot</h1>
<p className="text-sm text-gray-600 dark:text-gray-400">
Query Nemesis data with natural language
</p>
</div>
</div>
{/* LLM Usage Stats */}
{spendData && (
<div className="flex items-center space-x-4 text-sm text-white">
<div className="text-center">
<div className="font-semibold">
${spendData.spend ? spendData.spend.toFixed(4) : '0.0000'}
</div>
<div className="text-xs opacity-80">Total Spend</div>
</div>
<div className="text-center">
<div className="font-semibold">
{spendData.total_tokens ? spendData.total_tokens.toLocaleString() : '0'}
</div>
<div className="text-xs opacity-80">Total Tokens</div>
</div>
</div>
)}
<div className="flex items-center space-x-3">
{/* Settings Toggle */}
<button
onClick={() => setShowSettings(!showSettings)}
className={`p-2 rounded-lg transition-colors ${showSettings
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300'
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400'
}`}
title="Settings"
>
<SettingsIcon className="w-5 h-5" />
</button>
{/* Clear History */}
<button
onClick={clearHistory}
disabled={messages.length === 0}
className="flex items-center space-x-1 px-3 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
title="Clear conversation history"
>
<Trash2 className="w-4 h-4" />
<span>Clear History</span>
</button>
</div>
</div>
{/* Settings Panel */}
{showSettings && (
<div className="mb-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 max-h-96 overflow-y-auto flex-shrink-0">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Settings</h3>
<div className="space-y-4">
{/* Temperature Control */}
<div>
<label className="flex justify-between text-sm text-gray-600 dark:text-gray-400 mb-1">
<span>Temperature: {temperature.toFixed(1)}</span>
<span className="text-xs text-gray-500">
{temperature < 0.3 ? 'Focused' : temperature < 0.7 ? 'Balanced' : 'Creative'}
</span>
</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={temperature}
onChange={(e) => setTemperature(parseFloat(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
</div>
{/* Use History Toggle */}
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600 dark:text-gray-400">Use Conversation History</span>
<button
onClick={() => setUseHistory(!useHistory)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${useHistory ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${useHistory ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* System Prompt Editor */}
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">System Prompt</h4>
{promptError && (
<div className="mb-3 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-sm text-red-600 dark:text-red-400">
{promptError}
</div>
)}
<textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
className="w-full h-64 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono"
placeholder="Loading system prompt..."
/>
<div className="flex space-x-2 mt-3">
<button
onClick={saveSystemPrompt}
disabled={savingPrompt || systemPrompt === originalPrompt}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{savingPrompt ? 'Saving...' : 'Save Prompt'}
</button>
<button
onClick={cancelPromptEdit}
disabled={savingPrompt || systemPrompt === originalPrompt}
className="px-4 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
</div>
)}
{/* Scrollable Content Area */}
<div className="flex-1 overflow-y-auto space-y-4 min-h-0">
{/* Token Warning */}
{showTokenWarning && (
<div className="p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg flex items-start space-x-2">
<AlertCircle className="w-5 h-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm text-yellow-600 dark:text-yellow-400">
<strong>High token usage detected:</strong> Your conversation history is getting long (~{estimatedTokens.toLocaleString()} tokens)
and consuming significant LLM resources. Consider clicking "Clear History" to reset and reduce costs.
</p>
</div>
<button
onClick={() => setShowTokenWarning(false)}
className="text-yellow-600 dark:text-yellow-400 hover:text-yellow-800 dark:hover:text-yellow-200"
title="Dismiss warning"
>
<X className="w-5 h-5" />
</button>
</div>
)}
{/* Error Display */}
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-start space-x-2">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
</div>
)}
{/* Example Queries (show when no messages) */}
{messages.length === 0 && (
<ExampleQueries onExampleClick={handleExampleClick} />
)}
{/* Messages */}
<div className="space-y-4 px-2">
{messages.map((msg, idx) => (
<MessageBubble
key={idx}
message={msg}
isStreaming={isStreaming && idx === messages.length - 1}
/>
))}
<div ref={messagesEndRef} />
</div>
</div>
{/* Input Form */}
<form onSubmit={handleSubmit} className="flex-shrink-0 border-t border-gray-200 dark:border-gray-700 pt-4 mt-4 pb-4">
<div className="flex space-x-2">
<textarea
ref={textareaRef}
value={currentMessage}
onChange={(e) => setCurrentMessage(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Ask a question about your data..."
disabled={isStreaming}
className="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 disabled:bg-gray-100 dark:disabled:bg-gray-800 disabled:cursor-not-allowed"
rows={2}
/>
<button
type="submit"
disabled={!currentMessage.trim() || isStreaming}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
>
<Send className="w-5 h-5" />
<span>{isStreaming ? 'Sending...' : 'Send'}</span>
</button>
</div>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Press Enter to send, Shift+Enter for new line
</p>
</form>
</div>
);
};
export default ChatbotPage;
@@ -0,0 +1,52 @@
import { Lightbulb } from 'lucide-react';
const ExampleQueries = ({ onExampleClick }) => {
const examples = [
{
title: 'High Severity Findings',
query: 'How many findings have severity greater than 7?',
description: 'Count critical security findings',
},
{
title: 'Exposed Credentials',
query: 'What decrypted passwords are available from host://WORKSTATION01?',
description: 'Find credentials from a specific source',
},
{
title: 'Lateral Movement',
query: 'How can I access example.com?',
description: 'Identify lateral movement opportunities',
},
];
return (
<div className="mb-6 space-y-4">
<div className="flex items-center space-x-2 text-gray-600 dark:text-gray-400">
<Lightbulb className="w-5 h-5" />
<h3 className="text-sm font-semibold">Try these example queries:</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{examples.map((example, idx) => (
<button
key={idx}
onClick={() => onExampleClick(example.query)}
className="text-left p-4 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-blue-500 dark:hover:border-blue-400 hover:shadow-md transition-all group"
>
<h4 className="font-semibold text-sm text-gray-800 dark:text-gray-200 mb-1 group-hover:text-blue-600 dark:group-hover:text-blue-400">
{example.title}
</h4>
<p className="text-xs text-gray-600 dark:text-gray-400 mb-2">
{example.description}
</p>
<p className="text-xs text-gray-500 dark:text-gray-500 italic line-clamp-2">
"{example.query}"
</p>
</button>
))}
</div>
</div>
);
};
export default ExampleQueries;
@@ -0,0 +1,91 @@
import React from 'react';
import { Bot, User } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
const TypingIndicator = () => (
<div className="flex space-x-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></div>
</div>
);
const MessageBubble = ({ message, isStreaming }) => {
const isUser = message.role === 'user';
return (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
<div className={`flex max-w-3xl ${isUser ? 'flex-row-reverse' : 'flex-row'} items-start space-x-3`}>
{/* Avatar */}
<div
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
isUser
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
}`}
>
{isUser ? <User className="w-5 h-5" /> : <Bot className="w-5 h-5" />}
</div>
{/* Message Content */}
<div
className={`flex-1 px-4 py-3 rounded-lg ${
isUser
? 'bg-blue-600 text-white ml-3'
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 mr-3'
}`}
>
{isUser ? (
// User messages are plain text
<p className="text-sm whitespace-pre-wrap break-words">{message.content}</p>
) : (
// Assistant messages support markdown
<>
{message.content ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown
components={{
// Customize code blocks
code({ node, inline, className, children, ...props }) {
return inline ? (
<code
className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs"
{...props}
>
{children}
</code>
) : (
<pre className="bg-gray-200 dark:bg-gray-900 p-3 rounded-lg overflow-x-auto">
<code className="text-xs" {...props}>
{children}
</code>
</pre>
);
},
// Customize tables
table({ children }) {
return (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-300 dark:divide-gray-600">
{children}
</table>
</div>
);
},
}}
>
{message.content}
</ReactMarkdown>
</div>
) : isStreaming ? (
<TypingIndicator />
) : null}
</>
)}
</div>
</div>
</div>
);
};
export default MessageBubble;
@@ -0,0 +1,117 @@
import React, { useState } from 'react';
import { ChevronDown, ChevronRight, Copy, Check } from 'lucide-react';
const QueryModal = ({ queries }) => {
const [expandedQueries, setExpandedQueries] = useState({});
const [copiedIndex, setCopiedIndex] = useState(null);
const toggleQuery = (index) => {
setExpandedQueries(prev => ({
...prev,
[index]: !prev[index]
}));
};
const copyToClipboard = async (text, index) => {
try {
await navigator.clipboard.writeText(text);
setCopiedIndex(index);
setTimeout(() => setCopiedIndex(null), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
if (!queries || queries.length === 0) return null;
return (
<div className="mb-4 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 shadow-sm">
<div className="p-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
SQL Queries Executed
</h3>
</div>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{queries.map((query, index) => {
const isExpanded = expandedQueries[index];
const isCopied = copiedIndex === index;
return (
<div key={index} className="transition-colors">
{/* Query Header - Collapsible */}
<button
onClick={() => toggleQuery(index)}
className="w-full flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
<div className="flex items-center space-x-2 flex-1 min-w-0">
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-gray-500 flex-shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-gray-500 flex-shrink-0" />
)}
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
{query.name || `Query ${index + 1}`}
</span>
{query.timestamp && (
<span className="text-xs text-gray-500 dark:text-gray-400">
{new Date(query.timestamp).toLocaleTimeString()}
</span>
)}
</div>
{query.rowCount !== undefined && (
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">
{query.rowCount} rows
</span>
)}
</button>
{/* Query Content - Expandable */}
{isExpanded && (
<div className="px-3 pb-3">
<div className="relative">
{/* Copy Button */}
<button
onClick={() => copyToClipboard(query.sql, index)}
className="absolute top-2 right-2 p-1.5 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors"
title="Copy to clipboard"
>
{isCopied ? (
<Check className="w-4 h-4 text-green-600 dark:text-green-400" />
) : (
<Copy className="w-4 h-4 text-gray-600 dark:text-gray-400" />
)}
</button>
{/* SQL Code Block */}
<pre className="bg-gray-50 dark:bg-gray-900 p-3 pr-12 rounded-lg overflow-x-auto text-xs font-mono">
<code className="text-gray-800 dark:text-gray-200">
{query.sql}
</code>
</pre>
</div>
{/* Query Metadata */}
{(query.executionTime || query.error) && (
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
{query.executionTime && (
<span>Execution time: {query.executionTime}ms</span>
)}
{query.error && (
<span className="text-red-600 dark:text-red-400">
Error: {query.error}
</span>
)}
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
);
};
export default QueryModal;
+1 -1
View File
@@ -3809,4 +3809,4 @@ propcache = ">=0.2.1"
[metadata]
lock-version = "2.1"
python-versions = ">=3.12,<3.14"
content-hash = "24ef2f823348ff25c13ffdc741e07ad735a7c6d9706faf37c69ba93843c21c42"
content-hash = "f8e33433b5ee89307bdbea073cb80ce5b7c45eb4b872888479f10bf7e6ef6539"
+1
View File
@@ -26,6 +26,7 @@ watchdog = "^6.0.0"
pyyaml = "^6.0.2"
jinja2 = "^3.1.6"
markdown = "^3.9"
httpx = "^0.28.1"
[tool.poetry.group.dev.dependencies]
pytest = "^8.4.2"
+56 -1
View File
@@ -11,6 +11,7 @@ from pathlib import Path as PathLib
from typing import Annotated
from urllib.parse import urlparse
import httpx
import psycopg
import requests
from common.db import get_postgres_connection_str
@@ -45,7 +46,7 @@ from psycopg_pool import ConnectionPool
from pydantic import ValidationError
from web_api.container_monitor import get_monitor, start_monitor, stop_monitor
from web_api.large_containers import LargeContainerProcessor
from web_api.models.requests import CleanupRequest, EnrichmentRequest
from web_api.models.requests import ChatbotRequest, CleanupRequest, EnrichmentRequest
from web_api.models.responses import (
ContainerStatusResponse,
ContainerSubmissionResponse,
@@ -1421,6 +1422,60 @@ async def run_translation(
raise HTTPException(status_code=500, detail=str(e)) from e
@app.post(
"/chatbot/stream",
tags=["chatbot"],
summary="Stream chatbot responses",
description="Stream interactive chatbot responses for querying Nemesis data",
)
async def chatbot_stream(
request: ChatbotRequest = Body(..., description="Chatbot request with message and conversation history"),
):
"""
Stream chatbot responses via Dapr to agents service.
Uses HTTP streaming to provide real-time token-by-token responses.
"""
try:
url = f"http://localhost:{DAPR_PORT}/v1.0/invoke/agents/method/agents/chatbot/stream"
logger.debug("Proxying chatbot request to agents service", message_length=len(request.message))
async def stream_proxy():
"""Generator function that streams chunks from agents service."""
try:
async with httpx.AsyncClient() as client:
async with client.stream(
"POST",
url,
json=request.model_dump(),
timeout=120.0,
) as response:
response.raise_for_status()
async for chunk in response.aiter_bytes():
if chunk:
yield chunk
except httpx.HTTPStatusError as e:
logger.error("HTTP error from agents service", status_code=e.response.status_code)
error_msg = f"\n\n[Error: Agents service returned {e.response.status_code}]"
yield error_msg.encode()
except httpx.TimeoutException:
logger.error("Timeout streaming from agents service")
yield b"\n\n[Error: Request timeout]"
except Exception as e:
logger.exception("Error in stream proxy")
error_msg = f"\n\n[Error: {str(e)}]"
yield error_msg.encode()
return StreamingResponse(
stream_proxy(),
media_type="text/plain",
)
except Exception as e:
logger.exception(message="Error initiating chatbot stream")
raise HTTPException(status_code=500, detail=str(e)) from e
@app.get(
"/system/available-services",
tags=["system"],
@@ -7,3 +7,19 @@ class EnrichmentRequest(BaseModel):
class CleanupRequest(BaseModel):
expiration: str | None = None # ISO datetime or "all"
class ChatbotMessage(BaseModel):
"""A single chat message."""
role: str # "user" or "assistant"
content: str
class ChatbotRequest(BaseModel):
"""Request model for chatbot queries."""
message: str
history: list[ChatbotMessage] = []
use_history: bool = True
temperature: float = 0.7