Initial chatbot UI commit (#10232)

* Initial chatbot UI commit

* Update dist
This commit is contained in:
GabrieleDeri 2026-03-28 15:05:01 +01:00 committed by GitHub
parent 2da8869186
commit 65772d3545
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 2192 additions and 851 deletions

View file

@ -10,7 +10,7 @@ window.$ = $
//import moment from 'moment'
import moment from 'moment-timezone'
import ApexCharts from 'apexcharts'
import "bootstrap-icons/font/bootstrap-icons.css";
window.moment = moment
window.ApexCharts = ApexCharts

View file

@ -0,0 +1,469 @@
<!--
(C) 2026 - ntop.org
-->
<template>
<div ref="container" class="line-container">
<div v-if="chart.title" class="line-title"><strong>{{ chart.title }}</strong></div>
<Loading v-if="!props.hideLoading" :isLoading="loading" />
<NoData :show="no_data"></NoData>
<div class="line-body">
<div ref="wrapper" class="line-wrapper" v-show="!loading && !no_data"></div>
<div v-if="!loading && tooltip.visible" class="line-tooltip"
:style="{ top: tooltip.y + 'px', left: tooltip.x + 'px' }">
<span v-if="tooltip.series" class="tt-series">{{ tooltip.series }}</span>
<span v-if="tooltip.series" class="tt-sep">·</span>
<span class="tt-val">{{ tooltip.value }}</span>
</div>
</div>
<div v-if="!loading && series.length && !no_data" class="line-legend">
<div v-for="(s, i) in series" :key="i" class="legend-item"
:class="{ dimmed: hiddenSeries.has(s.label) }" @click="toggleSeries(s.label)">
<svg class="legend-line-icon" viewBox="0 0 24 12">
<line x1="0" y1="6" x2="24" y2="6" :stroke="s.color" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="12" cy="6" r="3" :fill="s.color"/>
</svg>
<span class="legend-name form-control-sm" :title="s.label">{{ s.label }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onBeforeUnmount, nextTick, watch } from "vue";
import { default as Loading } from "../loading.vue";
import colorUtils from "../../utilities/color-utils.js";
import formatterUtils from "../../utilities/formatter-utils.js";
import NoData from '../components/no-data.vue';
const d3 = d3v7;
const props = defineProps({ chart: { type: Object, required: true }, hideLoading: Boolean });
const { refresh, unit, label, custom_fetch } = props.chart;
const formatted_label = label ? (i18n(label) || label) : null;
const container = ref(null);
const wrapper = ref(null);
const loading = ref(false);
const no_data = ref(false);
const series = ref([]);
const has_loaded = ref(false);
const hiddenSeries = ref(new Set());
const emit = defineEmits(["chart-updated", "update-requested"]);
const tooltip = reactive({ visible: false, x: 0, y: 0, series: "", value: "" });
const M = { top: 16, right: 20, bottom: 52, left: 56 };
let svgEl = null;
let gChart = null;
let xScale = null;
let yScale = null;
let clipId = null;
let currentData = null;
let refreshTimer = null;
let isUnixTs = false;
let isDateAxis = false;
let parseX = null;
let iW_last = 0;
let iH_last = 0;
let fmtDate_last = null;
let hoverLine = null;
let hoverDots = [];
let hoverXLabel = null;
let resizeObs = null;
/* ── Lifecycle ── */
onMounted(async () => {
await nextTick();
resizeObs = new ResizeObserver(() => { if (currentData) renderChart(currentData); });
resizeObs.observe(wrapper.value);
buildSVG();
await load();
if (refresh > 0) refreshTimer = setInterval(load, refresh);
});
onBeforeUnmount(() => {
clearInterval(refreshTimer);
resizeObs?.disconnect();
});
/* ── SVG scaffold ── */
function buildSVG() {
if (!wrapper.value) return;
wrapper.value.replaceChildren();
clipId = `clip-${Math.random().toString(36).slice(2)}`;
svgEl = d3.select(wrapper.value)
.append("svg")
.attr("width", "100%")
.attr("height", "100%")
.style("display", "block");
svgEl.append("defs").append("clipPath").attr("id", clipId).append("rect");
svgEl.append("g").attr("class", "grid-y");
gChart = svgEl.append("g").attr("class", "chart-area").attr("clip-path", `url(#${clipId})`);
svgEl.append("g").attr("class", "axis-x");
svgEl.append("g").attr("class", "axis-y");
/* Overlay on top — captures mouse, same transform as gChart */
svgEl.append("rect").attr("class", "mouse-overlay")
.attr("fill", "transparent")
.attr("stroke", "none");
}
/* ── Data fetch ── */
async function load() {
if (!has_loaded.value) loading.value = true;
const { update_url, url_params, custom_fetch } = props.chart;
emit("update-requested");
try {
let data;
if (custom_fetch) {
data = await custom_fetch(update_url, url_params);
} else {
const url = url_params && Object.keys(url_params).length
? `${update_url}?${new URLSearchParams(url_params)}`
: update_url;
const res = await ntopng_utility.http_request(url, null, null, true);
data = res?.rsp?.data || res?.rsp;
}
if (!data || (Array.isArray(data) && !data.length)) {
if (!has_loaded.value) no_data.value = true; return;
}
const norm = normaliseData(data);
if (!norm.length) { if (!has_loaded.value) no_data.value = true; return; }
no_data.value = false;
has_loaded.value = true;
currentData = norm;
renderChart(norm);
} catch (e) {
console.error(`lineChart-${props.chart.name}:`, e);
if (!has_loaded.value) no_data.value = true;
} finally {
loading.value = false;
emit("chart-updated");
}
}
function normaliseData(raw) {
if (raw.length && raw[0].data && Array.isArray(raw[0].data)) return raw;
if (raw.length && "y" in raw[0]) return [{ label: formatted_label || "", data: raw }];
return [];
}
/* ── Render ── */
function renderChart(data) {
if (!wrapper.value || !svgEl) return;
const W = wrapper.value.clientWidth || 400;
const H = wrapper.value.clientHeight || 220;
const iW = W - M.left - M.right;
const iH = H - M.top - M.bottom;
if (iW <= 0 || iH <= 0) return;
iW_last = iW;
iH_last = iH;
/* Colours */
const PALETTE = colorUtils.assignRoundRobinColors(data.map(s => s.label));
series.value = data.map((s, i) => ({
label: s.label, color: s.color || PALETTE[i % PALETTE.length], url: s.url || null,
}));
const getColor = (s, i) => series.value[i]?.color || PALETTE[i % PALETTE.length];
const vis = data.filter(s => !hiddenSeries.value.has(s.label));
const allX = vis.flatMap(s => s.data.map(d => d.x));
const allY = vis.flatMap(s => s.data.map(d => d.y));
if (!allX.length) return;
/* X type detection */
isUnixTs = typeof allX[0] === "number" && allX[0] > 1_000_000_000;
isDateAxis = isUnixTs || allX[0] instanceof Date
|| (typeof allX[0] === "string" && !isNaN(Date.parse(allX[0])));
const isOrd = !isDateAxis && typeof allX[0] === "string";
parseX = isUnixTs ? v => new Date(+v * 1000)
: isDateAxis ? v => (v instanceof Date ? v : new Date(v))
: isOrd ? v => v
: v => +v;
const xVals = allX.map(parseX);
xScale = isOrd ? d3.scalePoint().domain([...new Set(xVals)]).range([0, iW]).padding(0.5)
: isDateAxis ? d3.scaleTime().domain(d3.extent(xVals)).range([0, iW]).nice()
: d3.scaleLinear().domain(d3.extent(xVals)).range([0, iW]).nice();
const yMin = Math.min(0, d3.min(allY));
const yMax = d3.max(allY);
yScale = d3.scaleLinear().domain([yMin, yMax]).range([iH, 0]).nice();
/* SVG size */
svgEl.attr("viewBox", `0 0 ${W} ${H}`);
svgEl.select(`#${clipId} rect`).attr("width", iW).attr("height", iH + 4);
/* All groups share the same (M.left, M.top) offset */
const tx = `translate(${M.left},${M.top})`;
gChart.attr("transform", tx);
svgEl.select(".grid-y").attr("transform", tx);
/* Axes are shifted: x-axis goes to bottom of chart area */
svgEl.select(".axis-x").attr("transform", `translate(${M.left},${M.top + iH})`);
svgEl.select(".axis-y").attr("transform", `translate(${M.left},${M.top})`);
/* Overlay exactly covers the chart area */
svgEl.select(".mouse-overlay")
.attr("x", M.left).attr("y", M.top)
.attr("width", iW).attr("height", iH);
/* Date formatter — expects ms */
fmtDate_last = v => {
const ms = v instanceof Date ? v.getTime() : (isUnixTs ? +v * 1000 : +v);
return formatterUtils.getFormatter('date')(ms);
};
/* ── X Axis ── */
const nXTicks = Math.max(2, Math.min(6, Math.floor(iW / 90)));
const xAxisFn = isOrd
? d3.axisBottom(xScale).tickSizeOuter(0).tickSize(5)
: isDateAxis
? d3.axisBottom(xScale).ticks(nXTicks).tickSizeOuter(0).tickSize(5).tickFormat(fmtDate_last)
: d3.axisBottom(xScale).ticks(nXTicks).tickSizeOuter(0).tickSize(5);
const axX = svgEl.select(".axis-x");
axX.call(xAxisFn);
/* Remove D3 default stroke:none on domain by forcing attr (not style) */
axX.select("path.domain")
.attr("stroke", "var(--color-border-secondary)")
.attr("stroke-width", "1")
.attr("fill", "none");
axX.selectAll(".tick line")
.attr("stroke", "var(--color-border-secondary)")
.attr("stroke-width", "1");
axX.selectAll(".tick text")
.attr("fill", "var(--color-text-secondary)")
.attr("font-size", "11px");
/* ── Y Axis ── */
const nYTicks = Math.max(2, Math.min(5, Math.floor(iH / 40)));
const valFmt = unit ? formatterUtils.getFormatter(unit, null, null, formatted_label) : d3.format("~s");
const yAxisFn = d3.axisLeft(yScale).ticks(nYTicks).tickFormat(valFmt).tickSizeOuter(0).tickSize(5);
const axY = svgEl.select(".axis-y");
axY.call(yAxisFn);
axY.select("path.domain")
.attr("stroke", "var(--color-border-secondary)")
.attr("stroke-width", "1")
.attr("fill", "none");
axY.selectAll(".tick line")
.attr("stroke", "var(--color-border-secondary)")
.attr("stroke-width", "1");
axY.selectAll(".tick text")
.attr("fill", "var(--color-text-secondary)")
.attr("font-size", "11px");
/* ── Grid lines ── */
const gridData = yScale.ticks(nYTicks);
const gridSel = svgEl.select(".grid-y").selectAll("line.gl").data(gridData);
gridSel.enter().append("line").attr("class", "gl").merge(gridSel)
.attr("x1", 0).attr("x2", iW)
.attr("y1", d => yScale(d)).attr("y2", d => yScale(d))
.attr("stroke", "var(--color-border-tertiary)")
.attr("stroke-width", "0.5");
gridSel.exit().remove();
/* ── Line / area generators ── */
const lineGen = d3.line()
.x(d => xScale(parseX(d.x))).y(d => yScale(d.y))
.defined(d => d.y != null && !isNaN(d.y)).curve(d3.curveMonotoneX);
const areaGen = d3.area()
.x(d => xScale(parseX(d.x))).y0(yScale(Math.max(yMin, 0))).y1(d => yScale(d.y))
.defined(d => d.y != null && !isNaN(d.y)).curve(d3.curveMonotoneX);
/* Areas */
const areas = gChart.selectAll("path.area-fill").data(data, d => d.label);
areas.enter().append("path").attr("class", "area-fill").attr("stroke", "none")
.merge(areas)
.attr("d", s => areaGen(s.data))
.attr("fill", (s, i) => getColor(s, i))
.attr("opacity", s => hiddenSeries.value.has(s.label) ? 0 : 0.08);
areas.exit().remove();
/* Lines */
const lines = gChart.selectAll("path.line-path").data(data, d => d.label);
lines.enter().append("path").attr("class", "line-path").attr("fill", "none")
.attr("stroke-linejoin", "round").attr("stroke-linecap", "round")
.merge(lines)
.attr("d", s => lineGen(s.data))
.attr("stroke", (s, i) => getColor(s, i))
.attr("stroke-width", "2")
.attr("opacity", s => hiddenSeries.value.has(s.label) ? 0.12 : 1);
lines.exit().remove();
/* Static dots */
const DOT_THRESH = 30;
const dg = gChart.selectAll("g.dg").data(data, d => d.label);
dg.enter().append("g").attr("class", "dg").merge(dg).each(function(s, si) {
const g = d3.select(this);
const color = getColor(s, si);
const hidden = hiddenSeries.value.has(s.label);
const valid = s.data.filter(d => d.y != null && !isNaN(d.y));
const pts = valid.length <= DOT_THRESH ? valid : (valid.length ? [valid[valid.length - 1]] : []);
const c = g.selectAll("circle.sd").data(pts, d => d.x);
c.enter().append("circle").attr("class", "sd").merge(c)
.attr("r", valid.length <= DOT_THRESH ? 3.5 : 4)
.attr("cx", d => xScale(parseX(d.x)))
.attr("cy", d => yScale(d.y))
.attr("fill", color)
.attr("stroke", "var(--color-background-primary)")
.attr("stroke-width", "1.5")
.attr("opacity", hidden ? 0 : 1);
c.exit().remove();
});
dg.exit().remove();
/* Mouse handler */
svgEl.select(".mouse-overlay")
.on("mousemove", function(ev) { onMove(ev, data, iH); })
.on("mouseleave", onLeave);
}
/* ── Mouse ── */
function clearHover() {
hoverLine?.remove(); hoverLine = null;
hoverXLabel?.remove(); hoverXLabel = null;
hoverDots.forEach(d => d.remove()); hoverDots = [];
}
function onLeave() { tooltip.visible = false; clearHover(); }
function onMove(ev, data, iH) {
/* Use the SVG element as reference for d3.pointer.
This gives coords in SVG viewBox space.
Subtract M.left/M.top to get chart-area (gChart) space. */
const svgNode = svgEl.node();
const [svgX, svgY] = d3.pointer(ev, svgNode);
const cx = svgX - M.left; /* x in chart space */
const cy = svgY - M.top; /* y in chart space */
if (cx < 0 || cx > iW_last || cy < 0 || cy > iH) return;
const vis = data.filter(s => !hiddenSeries.value.has(s.label));
if (!vis.length) return;
/* Nearest data point by X pixel distance */
let bestDx = Infinity, snapPx = null, snapRaw = null;
vis.forEach(s => {
s.data.forEach(d => {
const px = xScale(parseX(d.x));
const dx = Math.abs(px - cx);
if (dx < bestDx) { bestDx = dx; snapPx = px; snapRaw = d.x; }
});
});
if (snapPx === null) return;
clearHover();
/* Vertical line at MOUSE x (cx), in gChart space */
hoverLine = gChart.append("line")
.attr("x1", cx).attr("x2", cx).attr("y1", 0).attr("y2", iH)
.attr("stroke", "var(--color-border-primary)")
.attr("stroke-width", "1")
.attr("stroke-dasharray", "3 3")
.attr("pointer-events", "none");
/* X axis label at snapped point — appended to axis-x group (coords relative to that group) */
const labelTxt = isDateAxis ? fmtDate_last(snapRaw) : String(snapRaw);
hoverXLabel = svgEl.select(".axis-x").append("g").attr("class", "hxl");
hoverXLabel.append("line")
.attr("x1", snapPx).attr("x2", snapPx).attr("y1", 0).attr("y2", 6)
.attr("stroke", "var(--color-text-primary)").attr("stroke-width", "1.5");
hoverXLabel.append("text")
.attr("x", snapPx).attr("y", 20)
.attr("text-anchor", "middle")
.attr("fill", "var(--color-text-primary)")
.attr("font-size", "11").attr("font-weight", "700")
.text(labelTxt);
/* Dots at snapped data point, in gChart space */
vis.forEach(s => {
const pt = s.data.find(d => String(d.x) === String(snapRaw));
if (!pt || pt.y == null || isNaN(pt.y)) return;
const color = series.value.find(sv => sv.label === s.label)?.color || "#888";
hoverDots.push(
gChart.append("circle")
.attr("cx", snapPx) /* snap to data point x */
.attr("cy", yScale(pt.y)) /* exact y from scale */
.attr("r", 5)
.attr("fill", color)
.attr("stroke", "var(--color-background-primary)")
.attr("stroke-width", "2")
.attr("pointer-events", "none")
);
});
/* Tooltip — series closest in Y to cursor */
let closeS = null, closePt = null, minDY = Infinity;
vis.forEach(s => {
const pt = s.data.find(d => String(d.x) === String(snapRaw));
if (!pt || pt.y == null) return;
const dy = Math.abs(yScale(pt.y) - cy);
if (dy < minDY) { minDY = dy; closeS = s; closePt = pt; }
});
if (!closePt) return;
const valFmt = unit ? formatterUtils.getFormatter(unit, null, null, formatted_label) : v => v;
const rect = container.value.getBoundingClientRect();
Object.assign(tooltip, {
visible: true,
x: ev.clientX - rect.left + 14,
y: ev.clientY - rect.top - 12,
series: closeS.label,
value: valFmt(closePt.y),
});
}
/* ── Toggle / expose / watch ── */
function toggleSeries(label) {
const n = new Set(hiddenSeries.value);
n.has(label) ? n.delete(label) : n.add(label);
hiddenSeries.value = n;
if (currentData) renderChart(currentData);
}
defineExpose({ update: async () => { await load(); } });
watch(() => props.chart.url_params, () => { load(); }, { deep: true });
</script>
<style scoped>
.line-container {
position: relative; display: flex; flex-direction: column;
align-items: stretch; width: 100%; height: 100%;
min-width: 0; box-sizing: border-box;
}
.line-title {
flex-shrink: 0; margin-bottom: 4px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.line-body {
display: flex; flex-direction: column; align-items: stretch;
flex: 1 1 auto; min-height: 0; width: 100%; height: 100%; position: relative;
}
.line-wrapper { flex: 1 1 auto; min-height: 0; width: 100%; height: 100%; overflow: hidden; }
.line-legend {
flex-shrink: 0; display: flex; flex-direction: row; flex-wrap: wrap;
align-items: center; gap: 4px 12px; padding: 6px 0 2px; overflow: hidden;
}
.legend-item {
display: flex; align-items: center; gap: 5px; min-width: 0;
cursor: pointer; user-select: none; transition: opacity 0.25s ease;
}
.legend-item.dimmed { opacity: 0.35; }
.legend-line-icon { width: 24px; height: 12px; flex-shrink: 0; }
.legend-name { flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.line-tooltip {
position: absolute; pointer-events: none; display: flex; align-items: center; gap: 6px;
background: rgba(10,10,10,0.85); color: #fff; padding: 5px 10px 5px 8px;
border-radius: 6px; white-space: nowrap; box-shadow: 0 2px 10px rgba(0,0,0,0.4);
z-index: 100; backdrop-filter: blur(4px);
}
.tt-series { font-weight: 500; }
.tt-sep { opacity: 0.45; }
.tt-val { font-weight: 700; }
</style>

1654
http_src/vue/chatbot.vue Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,824 +0,0 @@
<template>
<div class="llm-chat-page d-flex flex-column" style="height: calc(100vh - 120px); min-height: 500px;">
<!-- Header bar: provider selector + status -->
<div class="chat-header d-flex align-items-center gap-3 px-3 py-2 flex-shrink-0">
<span class="chat-header-label fw-semibold small text-uppercase ls-1">
<i class="fas fa-microchip me-1 opacity-75"></i>
{{ _i18n('llm.provider') }}
</span>
<div v-if="loadingProviders" class="d-flex align-items-center gap-2 small chat-muted-text">
<span class="spinner-border spinner-border-sm" role="status"></span>
{{ _i18n('llm.loading_providers') }}
</div>
<div v-else-if="providers.length === 0" class="text-warning small d-flex align-items-center gap-1">
<i class="fas fa-exclamation-triangle"></i>
{{ _i18n('llm.no_providers') }}
</div>
<select
v-else
v-model="selectedProvider"
class="chat-select form-select form-select-sm w-auto"
:disabled="sending"
>
<option v-for="p in providers" :key="p.provider" :value="p.provider">
{{ p.provider }} {{ p.model }}
</option>
</select>
<!-- Model badge -->
<span v-if="selectedProvider && !loadingProviders && providers.length > 0" class="model-badge">
<i class="fas fa-circle-dot me-1" style="font-size:0.55rem;vertical-align:middle;"></i>
{{ providers.find(p => p.provider === selectedProvider)?.model ?? selectedProvider }}
</span>
<!-- Spacer + clear button -->
<div class="ms-auto d-flex align-items-center gap-2">
<!-- History depth indicator -->
<span v-if="history.length > 0" class="history-badge">
<i class="fas fa-layer-group me-1"></i>{{ history.length / 2 }} turns
</span>
<!-- Clear conversation -->
<button
v-if="messages.length > 0"
class="btn-clear"
:disabled="sending"
@click="clearChat"
:title="_i18n('llm.clear_chat')"
>
<i class="fas fa-trash-alt me-1"></i>
<span class="d-none d-sm-inline">{{ _i18n('llm.clear_chat') }}</span>
</button>
</div>
</div>
<!-- Message list -->
<div
ref="messageList"
class="chat-messages flex-grow-1 overflow-auto px-3 py-4 d-flex flex-column gap-3"
>
<!-- Empty state -->
<div
v-if="messages.length === 0"
class="m-auto text-center py-5"
>
<div class="empty-state-icon mx-auto mb-4">
<i class="fas fa-comments"></i>
</div>
<div class="fw-semibold chat-text-color" style="font-size:1rem;">{{ _i18n('llm.empty_state_title') }}</div>
</div>
<!-- Messages -->
<div
v-for="(msg, idx) in messages"
:key="idx"
class="d-flex"
:class="msg.role === 'user' ? 'justify-content-end' : 'justify-content-start'"
>
<!-- Assistant avatar -->
<div v-if="msg.role === 'assistant'" class="flex-shrink-0 me-2 mt-1">
<span class="chat-avatar assistant-avatar">
<i class="fas fa-robot"></i>
</span>
</div>
<!-- Bubble -->
<div
class="chat-bubble"
:class="msg.role === 'user'
? 'user-bubble'
: msg.error
? 'error-bubble'
: 'assistant-bubble'"
style="max-width: min(72%, 640px);"
>
<!-- Error icon -->
<div v-if="msg.error" class="d-flex align-items-center gap-2 mb-1 small fw-semibold error-label">
<i class="fas fa-exclamation-circle"></i>
{{ _i18n('llm.error_label') }}
</div>
<!-- Content -->
<div
v-if="msg.role === 'user'"
class="chat-content"
style="white-space: pre-wrap; word-break: break-word; font-size: 0.9rem; line-height: 1.55;"
>{{ msg.content }}</div>
<div
v-else
class="chat-content markdown-body"
style="word-break: break-word; font-size: 0.9rem; line-height: 1.55;"
v-html="renderMarkdown(msg.content)"
></div>
<!-- Timestamp + stats -->
<div
class="mt-1 d-flex align-items-center gap-2 flex-wrap"
:class="msg.role === 'user' ? 'bubble-meta-user' : 'bubble-meta-assistant'"
style="font-size:0.7rem;"
>
<span>{{ msg.time }}</span>
<template v-if="msg.role === 'assistant' && msg.stats && msg.stats.completion_time_s != null">
<span class="opacity-40">·</span>
<span>{{ msg.stats.completion_time_s }}s</span>
<template v-if="msg.stats.generation_tokens_per_second != null">
<span class="opacity-40">·</span>
<span>{{ msg.stats.generation_tokens_per_second }} tok/s</span>
</template>
</template>
</div>
</div>
<!-- User avatar -->
<div v-if="msg.role === 'user'" class="flex-shrink-0 ms-2 mt-1">
<span class="chat-avatar user-avatar">
<i class="fas fa-user"></i>
</span>
</div>
</div>
<!-- Typing indicator -->
<div v-if="sending" class="d-flex justify-content-start">
<span class="chat-avatar assistant-avatar me-2 mt-1 flex-shrink-0">
<i class="fas fa-robot"></i>
</span>
<div class="assistant-bubble chat-bubble d-flex align-items-center gap-1" style="height:40px; padding: 0 1rem;">
<span class="typing-dot"></span>
<span class="typing-dot"></span>
<span class="typing-dot"></span>
</div>
</div>
</div>
<!-- Input bar -->
<div class="chat-footer px-3 py-2 flex-shrink-0">
<!-- Timeout warning strip -->
<div v-if="timedOut" class="timeout-alert d-flex align-items-center gap-2 mb-2 small">
<i class="fas fa-clock"></i>
{{ _i18n('llm.timeout_warning') }}
<button class="btn btn-sm btn-link p-0 ms-auto timeout-dismiss" @click="timedOut = false">
<i class="fas fa-times"></i>
</button>
</div>
<div class="d-flex gap-2 align-items-end">
<textarea
ref="promptInput"
v-model="prompt"
class="chat-input form-control"
:placeholder="_i18n('llm.input_placeholder')"
rows="1"
style="resize: none; max-height: 120px; overflow-y: auto;"
:disabled="sending || providers.length === 0"
@keydown.enter.exact.prevent="send"
@input="autoResize"
></textarea>
<button
class="btn-send d-flex align-items-center gap-2 flex-shrink-0"
style="height: 38px;"
:disabled="!canSend"
@click="send"
>
<span v-if="sending" class="spinner-border spinner-border-sm" role="status"></span>
<i v-else class="fas fa-paper-plane"></i>
<span class="d-none d-sm-inline">{{ sending ? _i18n('llm.sending') : _i18n('llm.send') }}</span>
</button>
</div>
<div class="chat-hint small mt-1">
<i class="fas fa-keyboard me-1 opacity-50"></i>
<span class="opacity-50">Enter</span> {{ _i18n('llm.send') }} &nbsp;·&nbsp;
<span class="opacity-50">Shift+Enter</span> new line
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick } from "vue";
import { ntopng_utility } from "../services/context/ntopng_globals_services";
import MarkdownIt from "markdown-it";
import hljs from "highlight.js";
import DOMPurify from "dompurify";
// Markdown renderer
const md = new MarkdownIt({
html: false,
linkify: true,
breaks: true,
highlight(str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return `<pre class="code-block"><code class="hljs">${hljs.highlight(str, { language: lang }).value}</code></pre>`;
} catch (_) {}
}
return `<pre class="code-block"><code class="hljs">${hljs.highlightAuto(str).value}</code></pre>`;
},
});
function renderMarkdown(content) {
return DOMPurify.sanitize(md.render(content || ""));
}
// i18n
const _i18n = (t) => i18n(t);
// Props
const props = defineProps({
context: {
type: Object,
default: () => ({}),
},
});
// State
const providers = ref([]);
const selectedProvider = ref(null);
const loadingProviders = ref(true);
// UI messages carry display metadata (timestamps, error flags, stats)
const messages = ref([]);
// API history pure { role, content } pairs sent to the LLM each turn
const history = ref([]);
const prompt = ref("");
const sending = ref(false);
const timedOut = ref(false);
const messageList = ref(null);
const promptInput = ref(null);
const MAX_HISTORY = 40; // max individual messages kept (20 user + 20 assistant)
const timeoutSec = 120;
// Computed
const canSend = computed(() =>
!sending.value &&
prompt.value.trim().length > 0 &&
selectedProvider.value !== null
);
// Helpers
function nowTime() {
return new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function pushMessage(role, content, error = false, stats = null) {
messages.value.push({ role, content, time: nowTime(), error, stats });
nextTick(scrollBottom);
}
function scrollBottom() {
if (messageList.value) {
messageList.value.scrollTop = messageList.value.scrollHeight;
}
}
function autoResize(e) {
const el = e.target;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 120) + "px";
}
function resetTextarea() {
if (promptInput.value) {
promptInput.value.style.height = "auto";
}
}
function clearChat() {
messages.value = [];
history.value = [];
}
// Providers
async function loadProviders() {
loadingProviders.value = true;
try {
const url = `${http_prefix}/lua/pro/rest/v2/get/llm/providers.lua`;
const list = await ntopng_utility.http_request(url) ?? [];
providers.value = Array.isArray(list) ? list : [];
if (providers.value.length > 0) selectedProvider.value = providers.value[0].provider;
} catch (err) {
console.error("llm providers fetch failed:", err);
providers.value = [];
} finally {
loadingProviders.value = false;
}
}
// Send
async function send() {
if (!canSend.value) return;
const text = prompt.value.trim();
prompt.value = "";
resetTextarea();
timedOut.value = false;
// 1. Show user bubble immediately
pushMessage("user", text);
// 2. Append user turn to history
history.value.push({ role: "user", content: text });
// 3. Trim history if it exceeds the cap (always drop oldest user+assistant pair)
while (history.value.length > MAX_HISTORY) {
history.value.splice(0, 2);
}
sending.value = true;
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort();
timedOut.value = true;
}, timeoutSec * 1000);
try {
const csrf = props.context.csrf;
const url = `${http_prefix}/lua/pro/rest/v2/post/llm/completion.lua`;
// Send the full conversation history so the backend can forward it
// to the provider's /v1/chat/completions endpoint as-is.
const body = JSON.stringify({
provider: selectedProvider.value,
messages: history.value, // full history instead of single prompt
stream: false,
csrf,
});
const rsp = await ntopng_utility.http_request(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
signal: controller.signal,
}, /* throw_exception */ true);
clearTimeout(timer);
const reply = rsp?.reply ?? null;
if (!reply) throw new Error(_i18n("llm.empty_response_error"));
// 4. Append assistant turn to history so next request includes it
history.value.push({ role: "assistant", content: reply });
pushMessage("assistant", reply, false, rsp?.stats ?? null);
} catch (err) {
clearTimeout(timer);
// 5. Roll back the user turn from history on failure
// so a retry won't duplicate it
history.value.pop();
if (err.name === "AbortError") {
pushMessage("assistant", _i18n("llm.timeout_error_message"), true);
} else {
pushMessage("assistant", `${_i18n("llm.request_error")}: ${err.message}`, true);
}
} finally {
sending.value = false;
nextTick(() => promptInput.value?.focus());
}
}
// Lifecycle
onMounted(() => {
loadProviders();
});
</script>
<style>
@import "highlight.js/styles/github.css";
/* strip hljs bg — we control backgrounds ourselves */
.hljs { background: transparent !important; }
/* ── Component-level theme tokens ─────────────────────────────────────── */
.llm-chat-page {
--chat-header-bg: var(--navbar-tab-container-bg, #f1f3f5);
--chat-footer-bg: var(--navbar-tab-container-bg, #f1f3f5);
--chat-border: rgba(0, 0, 0, 0.10);
--chat-text: var(--ntop-text-color, #111111);
--chat-muted: var(--ntop-muted-text-color, #37474F);
--chat-icon: var(--icon-color, #363943);
/* user bubble: ntop orange */
--user-bubble-bg: var(--ntop-orange, #FF8F00);
--user-bubble-shadow: rgba(255, 143, 0, 0.30);
/* assistant bubble */
--assistant-bubble-bg: #ffffff;
--assistant-bubble-border: rgba(0, 0, 0, 0.10);
--assistant-bubble-shadow: rgba(0, 0, 0, 0.06);
/* error bubble */
--error-bubble-bg: #fff3f3;
--error-bubble-border: rgba(220, 53, 69, 0.25);
--error-bubble-text: #b91c1c;
/* avatars */
--assistant-avatar-bg: var(--ntop-orange, #FF8F00);
--user-avatar-bg: var(--ntop-blue-light, #62717B);
/* inputs */
--input-bg: #ffffff;
--input-border: rgba(0, 0, 0, 0.15);
--input-focus-border: var(--ntop-orange, #FF8F00);
--input-focus-shadow: rgba(255, 143, 0, 0.18);
--input-text: var(--ntop-text-color, #111111);
--input-placeholder: rgba(55, 71, 79, 0.55);
/* code blocks */
--code-bg: #f6f8fa;
--code-border: rgba(0, 0, 0, 0.10);
--code-text: #24292e;
/* inline code */
--inline-code-bg: rgba(175, 184, 193, 0.22);
/* misc */
--send-btn-bg: var(--ntop-orange, #FF8F00);
--send-btn-hover: var(--ntop-orange-dark, #C56000);
--send-btn-shadow: rgba(255, 143, 0, 0.35);
--model-badge-bg: rgba(255, 143, 0, 0.12);
--model-badge-text: var(--ntop-orange-dark, #C56000);
--empty-icon-bg: rgba(255, 143, 0, 0.10);
--empty-icon-color: var(--ntop-orange, #FF8F00);
--hint-color: var(--ntop-muted-text-color, #37474F);
--timeout-bg: #fffbeb;
--timeout-border: rgba(245, 158, 11, 0.35);
--timeout-text: #92400e;
--scrollbar-thumb: rgba(0, 0, 0, 0.15);
/* clear button */
--clear-btn-bg: transparent;
--clear-btn-border: rgba(0, 0, 0, 0.15);
--clear-btn-text: var(--ntop-muted-text-color, #37474F);
--clear-btn-hover-bg: rgba(220, 53, 69, 0.08);
--clear-btn-hover-border: rgba(220, 53, 69, 0.35);
--clear-btn-hover-text: #b91c1c;
/* history badge */
--history-badge-bg: rgba(0, 0, 0, 0.06);
--history-badge-text: var(--ntop-muted-text-color, #37474F);
}
/* ── Dark-mode overrides ──────────────────────────────────────────────── */
:root[data-theme='dark'] .llm-chat-page {
--chat-border: rgba(255, 255, 255, 0.08);
--assistant-bubble-bg: #1e2d36;
--assistant-bubble-border: rgba(255, 255, 255, 0.08);
--assistant-bubble-shadow: rgba(0, 0, 0, 0.20);
--error-bubble-bg: rgba(185, 28, 28, 0.15);
--error-bubble-border: rgba(239, 68, 68, 0.30);
--error-bubble-text: #fca5a5;
--input-bg: #162028;
--input-border: rgba(255, 255, 255, 0.10);
--input-text: var(--ntop-text-color, #E2E2E2);
--input-placeholder: rgba(167, 166, 166, 0.55);
--code-bg: #0d1b22;
--code-border: rgba(255, 255, 255, 0.10);
--code-text: #e2e8f0;
--inline-code-bg: rgba(255, 255, 255, 0.10);
--model-badge-bg: rgba(255, 143, 0, 0.15);
--model-badge-text: var(--ntop-orange-light, #FFC046);
--empty-icon-bg: rgba(255, 143, 0, 0.12);
--hint-color: var(--ntop-muted-text-color, #A7A6A6);
--timeout-bg: rgba(180, 120, 10, 0.15);
--timeout-border: rgba(251, 191, 36, 0.25);
--timeout-text: #fde68a;
--scrollbar-thumb: rgba(255, 255, 255, 0.12);
--clear-btn-border: rgba(255, 255, 255, 0.12);
--clear-btn-text: var(--ntop-muted-text-color, #A7A6A6);
--clear-btn-hover-bg: rgba(239, 68, 68, 0.12);
--clear-btn-hover-border: rgba(239, 68, 68, 0.35);
--clear-btn-hover-text: #fca5a5;
--history-badge-bg: rgba(255, 255, 255, 0.07);
--history-badge-text: var(--ntop-muted-text-color, #A7A6A6);
}
/* ── Dark-mode hljs overrides ─────────────────────────────────────────── */
:root[data-theme='dark'] .hljs { color: #e2e8f0; }
:root[data-theme='dark'] .hljs-comment,
:root[data-theme='dark'] .hljs-quote { color: #8b949e; font-style: italic; }
:root[data-theme='dark'] .hljs-keyword,
:root[data-theme='dark'] .hljs-selector-tag,
:root[data-theme='dark'] .hljs-deletion { color: #ff7b72; }
:root[data-theme='dark'] .hljs-string,
:root[data-theme='dark'] .hljs-addition { color: #a5d6ff; }
:root[data-theme='dark'] .hljs-title,
:root[data-theme='dark'] .hljs-section { color: #d2a8ff; }
:root[data-theme='dark'] .hljs-number,
:root[data-theme='dark'] .hljs-literal { color: #f2cc60; }
:root[data-theme='dark'] .hljs-built_in,
:root[data-theme='dark'] .hljs-type { color: #ffa657; }
:root[data-theme='dark'] .hljs-attr,
:root[data-theme='dark'] .hljs-attribute { color: #7ee787; }
:root[data-theme='dark'] .hljs-variable,
:root[data-theme='dark'] .hljs-template-variable { color: #e3b341; }
:root[data-theme='dark'] .hljs-punctuation { color: #c9d1d9; }
/* ── Markdown body ────────────────────────────────────────────────────── */
.markdown-body p:last-child { margin-bottom: 0; }
.markdown-body pre.code-block {
background: var(--code-bg);
border: 1px solid var(--code-border);
border-radius: 8px;
padding: 0.75rem 1rem;
overflow-x: auto;
margin: 0.5rem 0;
}
.markdown-body pre.code-block code {
background: none;
padding: 0;
font-size: 0.82em;
color: var(--code-text);
}
.markdown-body code:not(pre code) {
background: var(--inline-code-bg);
color: var(--chat-text);
border-radius: 4px;
padding: 0.1em 0.4em;
font-size: 0.83em;
}
.markdown-body ul,
.markdown-body ol { padding-left: 1.4rem; margin-bottom: 0.5rem; }
.markdown-body blockquote {
border-left: 3px solid var(--ntop-orange, #FF8F00);
padding-left: 0.75rem;
color: var(--chat-muted);
margin: 0.5rem 0;
opacity: 0.85;
}
.markdown-body table { border-collapse: collapse; width: 100%; margin: 0.5rem 0; font-size: 0.85em; }
.markdown-body th,
.markdown-body td { border: 1px solid var(--chat-border); padding: 0.35rem 0.65rem; }
.markdown-body th { background: var(--inline-code-bg); color: var(--chat-text); font-weight: 600; }
.markdown-body td { color: var(--chat-text); }
.markdown-body a { color: var(--ntop-orange, #FF8F00); }
.markdown-body h1, .markdown-body h2, .markdown-body h3,
.markdown-body h4, .markdown-body h5, .markdown-body h6 {
color: var(--chat-text);
margin-top: 0.75rem;
margin-bottom: 0.35rem;
font-weight: 600;
}
.markdown-body hr {
border: none;
border-top: 1px solid var(--chat-border);
margin: 0.75rem 0;
}
</style>
<style scoped>
.ls-1 { letter-spacing: 0.05em; }
/* ── Layout ─────────────────────────────────────────────────────────── */
.llm-chat-page {
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--chat-border);
}
/* ── Header ─────────────────────────────────────────────────────────── */
.chat-header {
background: var(--chat-header-bg);
border-bottom: 1px solid var(--chat-border);
}
.chat-header-label {
color: var(--chat-muted);
font-size: 0.7rem;
}
.model-badge {
font-size: 0.7rem;
color: var(--model-badge-text);
background: var(--model-badge-bg);
padding: 0.2rem 0.55rem;
border-radius: 20px;
font-weight: 500;
letter-spacing: 0.02em;
}
/* ── History badge ───────────────────────────────────────────────────── */
.history-badge {
font-size: 0.68rem;
color: var(--history-badge-text);
background: var(--history-badge-bg);
padding: 0.18rem 0.5rem;
border-radius: 20px;
font-weight: 500;
white-space: nowrap;
}
/* ── Clear button ────────────────────────────────────────────────────── */
.btn-clear {
font-size: 0.75rem;
color: var(--clear-btn-text);
background: var(--clear-btn-bg);
border: 1px solid var(--clear-btn-border);
border-radius: 8px;
padding: 0.2rem 0.6rem;
cursor: pointer;
transition: background .15s, border-color .15s, color .15s;
white-space: nowrap;
line-height: 1.6;
}
.btn-clear:hover:not(:disabled) {
background: var(--clear-btn-hover-bg);
border-color: var(--clear-btn-hover-border);
color: var(--clear-btn-hover-text);
}
.btn-clear:disabled { opacity: 0.4; cursor: not-allowed; }
/* ── Chat select ─────────────────────────────────────────────────────── */
.chat-select {
background-color: var(--input-bg) !important;
border-color: var(--input-border) !important;
color: var(--input-text) !important;
font-size: 0.8rem;
}
.chat-select:focus {
border-color: var(--input-focus-border) !important;
box-shadow: 0 0 0 3px var(--input-focus-shadow) !important;
}
/* ── Message area ────────────────────────────────────────────────────── */
.chat-messages { background: transparent; }
/* ── Empty state ─────────────────────────────────────────────────────── */
.empty-state-icon {
width: 56px;
height: 56px;
border-radius: 16px;
background: var(--empty-icon-bg);
color: var(--empty-icon-color);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
}
/* ── Avatars ─────────────────────────────────────────────────────────── */
.chat-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
font-size: 13px;
color: #fff;
flex-shrink: 0;
}
.assistant-avatar { background: var(--assistant-avatar-bg); box-shadow: 0 2px 6px var(--user-bubble-shadow); }
.user-avatar { background: var(--user-avatar-bg); }
/* ── Chat bubbles ────────────────────────────────────────────────────── */
.chat-bubble {
padding: 0.55rem 0.85rem;
border-radius: 16px;
animation: fadeUp .18s ease-out;
}
.user-bubble {
background: var(--user-bubble-bg);
color: #fff;
border-radius: 16px 16px 4px 16px;
box-shadow: 0 2px 8px var(--user-bubble-shadow);
}
.bubble-meta-user { color: rgba(255,255,255,0.65); }
.assistant-bubble {
background: var(--assistant-bubble-bg);
border: 1px solid var(--assistant-bubble-border);
color: var(--chat-text);
border-radius: 16px 16px 16px 4px;
box-shadow: 0 2px 8px var(--assistant-bubble-shadow);
}
.bubble-meta-assistant { color: var(--chat-muted); }
.error-bubble {
background: var(--error-bubble-bg);
border: 1px solid var(--error-bubble-border);
color: var(--error-bubble-text);
border-radius: 16px 16px 16px 4px;
}
.error-label { color: var(--error-bubble-text); }
/* ── Text helpers ────────────────────────────────────────────────────── */
.chat-text-color { color: var(--chat-text); }
.chat-muted-text { color: var(--chat-muted); }
/* ── Footer / input ──────────────────────────────────────────────────── */
.chat-footer {
background: var(--chat-footer-bg);
border-top: 1px solid var(--chat-border);
}
.chat-input {
background-color: var(--input-bg) !important;
border-color: var(--input-border) !important;
color: var(--input-text) !important;
border-radius: 10px !important;
font-size: 0.9rem;
transition: border-color .15s, box-shadow .15s;
line-height: 1.5;
padding: 0.45rem 0.75rem;
}
.chat-input::placeholder { color: var(--input-placeholder) !important; }
.chat-input:focus {
border-color: var(--input-focus-border) !important;
box-shadow: 0 0 0 3px var(--input-focus-shadow) !important;
outline: none;
}
.chat-input:disabled { opacity: 0.5; }
/* ── Send button ─────────────────────────────────────────────────────── */
.btn-send {
background: var(--send-btn-bg);
color: #fff;
border: none;
border-radius: 10px;
padding: 0 1rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background .15s, box-shadow .15s, transform .1s;
white-space: nowrap;
}
.btn-send:hover:not(:disabled) {
background: var(--send-btn-hover);
box-shadow: 0 4px 12px var(--send-btn-shadow);
transform: translateY(-1px);
}
.btn-send:active:not(:disabled) { transform: translateY(0); }
.btn-send:disabled { opacity: 0.45; cursor: not-allowed; }
/* ── Hint line ───────────────────────────────────────────────────────── */
.chat-hint { color: var(--hint-color); font-size: 0.7rem; }
/* ── Timeout alert ───────────────────────────────────────────────────── */
.timeout-alert {
background: var(--timeout-bg);
border: 1px solid var(--timeout-border);
color: var(--timeout-text);
border-radius: 8px;
padding: 0.35rem 0.75rem;
}
.timeout-dismiss {
color: var(--timeout-text) !important;
opacity: 0.7;
text-decoration: none;
}
.timeout-dismiss:hover { opacity: 1; }
/* ── Typing dots ─────────────────────────────────────────────────────── */
.typing-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--ntop-orange, #FF8F00);
animation: blink 1.2s infinite;
opacity: 0.6;
}
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes blink {
0%, 80%, 100% { opacity: 0.2; transform: scale(0.85); }
40% { opacity: 0.8; transform: scale(1); }
}
/* ── Animations ──────────────────────────────────────────────────────── */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Scrollbar ───────────────────────────────────────────────────────── */
.overflow-auto::-webkit-scrollbar { width: 5px; }
.overflow-auto::-webkit-scrollbar-track { background: transparent; }
.overflow-auto::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 4px;
}
</style>

View file

@ -181,17 +181,19 @@ import { default as ModalDeleteAllACLRule } from "./modal-delete-all-acl-rules.v
/* Charts */
import { default as MultiPieChart } from "./charts/multi-pie-chart.vue";
import { default as PieChart } from "./charts/pie-chart.vue";
import { default as LineChart } from "./charts/line-chart.vue";
import { default as PeityChart } from "./charts/peity.vue";
//import { default as LLMTest } from "./llm_test.vue";
import { default as Chatbot } from "./chatbot.vue";
let ntopVue = {
//LLMTest: LLMTest,
Chatbot: Chatbot,
// graphs
MultiPieChart: MultiPieChart,
PieChart: PieChart,
PeityChart: PeityChart,
LineChart: LineChart,
// pages
PageAlertStats: PageAlertStats,

@ -1 +1 @@
Subproject commit 3cf65c89abb9d53b5e73dcc75bb4041dd3642e12
Subproject commit 8ca9fab1acfbba2857cbb9bbf48d7d18f37d53d5

View file

@ -1128,3 +1128,21 @@ CREATE TABLE IF NOT EXISTS `hourly_asn` (
COMMENT 'Hourly aggregated traffic statistics per source/destination ASN pair. Used for autonomous-system level traffic analysis and BGP peer analytics. Partitioned by day on FIRST_SEEN.';
@
ALTER TABLE `hourly_asn` ADD COLUMN IF NOT EXISTS TOTAL_BYTES UInt64;
@
CREATE TABLE IF NOT EXISTS ai_chat_history (
chat_id UUID COMMENT 'Unique identifier for a chat session',
sequence UInt32 COMMENT 'Seq number to preserve message order within a chat',
created_at DateTime COMMENT 'Message creation timestamp',
username String COMMENT 'Identifier of the user who created the chat',
message_role UInt8 COMMENT 'Role of message sender (user = 1 or assistant = 2 )',
message_content String COMMENT 'Raw message content (user input or assistant response)',
provider String COMMENT 'LLM provider used (local llm, anthropic, openAI)',
model String COMMENT 'Model name used for generation',
completion_time_sec UInt32 COMMENT 'Time taken to generate the assistant response (seconds)',
tokens_per_second UInt32 COMMENT 'Generation speed in tokens per second',
artifact_json String DEFAULT '' COMMENT 'JSON-encoded artifact spec (chart, ping, etc.) for assistant messages; empty for user messages',
evidence_json String DEFAULT '' COMMENT 'JSON audit trail of how the answer was produced: tool calls with inputs and result metadata',
) ENGINE = MergeTree() PARTITION BY toYYYYMMDD(created_at) ORDER BY (chat_id, sequence)
COMMENT 'Chat history table storing user and assistant messages for conversations';

View file

@ -60,6 +60,7 @@
"@yaireo/tagify": "^4.35.3",
"apexcharts": "^3.33.1",
"bootstrap": "^5.3.0",
"bootstrap-icons": "^1.13.1",
"d3": "^3.5.17",
"d3-array": "^3.1.1",
"d3-chord": "^3.0.1",
@ -72,13 +73,16 @@
"datatables.net-dt": "^1.11.5",
"datatables.net-responsive-dt": "^2.4.0",
"dc": "^4.2.7",
"dompurify": "^3.3.3",
"dygraphs": "^2.2.1",
"flatpickr": "^4.6.11",
"highlight.js": "^11.11.1",
"jquery": "^3.6.0",
"jquery-ui": "^1.13.2",
"jquery.are-you-sure": "^1.9.0",
"lucide": "^0.482.0",
"madge": "^5.0.1",
"markdown-it": "^14.1.1",
"moment": "^2.29.1",
"moment-timezone": "^0.5.34",
"peity": "^3.3.0",
@ -88,6 +92,7 @@
"sortablejs": "^1.15.1",
"store-js": "^2.0.4",
"topojson-client": "^2.0.1",
"uuid": "^11.1.0",
"vis-network": "^9.1.6",
"vite": "^6.3.5",
"vue": "3.2.37",

View file

@ -16,6 +16,7 @@ local lang = {
["hop_exporter"] = "Hop / Exporter",
["return_path"] = "Return Path",
["historical_flows"] = "Historical Flows",
["nanalyst"] = "nAnalyst",
["active_inactive"] = "Active/Inactive Hosts",
["active_monitoring"] = "Active Monitoring",
["current_hosts"] = "Current Hosts",
@ -614,7 +615,7 @@ local lang = {
["memory"] = "Memory",
["middle_endian"] = "Month/Day/Year",
["mirrored_traffic"] = "Mirrored Traffic",
["missing_x_parameter"] = "Missing \"%{param}\" parameter",
["missing_x_parameter"] = "Missing %{param} parameter",
["mitre_id"] = "Mitre Att&ck",
["model"] = "Model",
["modify_flowdev_alias"] = "Modify Flow Device Alias",
@ -7123,11 +7124,21 @@ local lang = {
["no_providers"] = "No LLM Provider Available",
["timeout"] = "Timeout",
["timeout_warning"] = "Request Timeout",
["empty_state_title"] = "Ask us a question!",
["ask_a_question"] = "Ask nAnalyst a question",
["error_label"] = "Error",
["input_placeholder"] = "Ask a question",
["sending"] = "Generating...",
["send"] = "Generate"
["analyzing"] = "Analyzing...",
["investigating"] = "Investigating...",
["inspecting"] = "Inspecting...",
["correlating"] = "Correlating...",
["send"] = "Investigate",
["history"] = "History",
["new_chat"] = "New Chat",
["no_conversations_yet"] = "No Conversations Yet",
["rename_chat"] = "Rename Chat",
["ai_can_make_mistakes"] = "nAnalyst can make mistakes. Always verify critical information independently",
["show_evidence"] = "Show Evidence",
["hide_evidence"] = "Hide Evidence",
},
["notification_endpoint"] = {
["discord"] = {

View file

@ -374,15 +374,16 @@ else
})
-- ##############################################
-- chatbot, hide if viewed interface or system or not enterprise xl
--[[
page_utils.add_menubar_section({
section = page_utils.menu_sections.chatbot,
hidden = is_system_interface or is_viewed,
entries = {{
entry = page_utils.menu_entries.chatbot,
url = '/lua/chatbot.lua'
}}})
]] --
page_utils.add_menubar_section({
section = page_utils.menu_sections.nanalyst,
hidden = is_system_interface or is_viewed or not ntop.isEnterpriseXL() or not ntop.isClickHouseEnabled(),
entries = {{
entry = page_utils.menu_entries.nanalyst,
url = '/lua/nanalyst.lua'
}}})
]]
-- ##############################################
-- Views menu entry for ASN Mode

View file

@ -2259,6 +2259,9 @@ local known_parameters = {
["prompt"] = validateUnquoted,
["stream"] = validateBool,
["content"] = validateUnchecked,
["chatId"] = validateUUID,
["sequence"] = validateNumber,
["title"] = validateUnquoted,
-- VULNERABILITY SCAN
["scan_type"] = validateSingleWord,

View file

@ -107,10 +107,12 @@ page_utils.menu_sections = {
i18n_title = "help",
icon = "fas fa-life-ring"
},
chatbot = {
key = "chatbot",
i18n_title = "chatbot",
icon = "fa-solid fa-headset"
nanalyst = {
key = "nanalyst",
i18n_title = "nanalyst",
-- icon = "fa-solid fa-headset"
icon = "fa-solid fa-magnifying-glass-chart"
-- icon = "fa-solid fa-brain"
},
health = {
key = "health",
@ -305,11 +307,11 @@ page_utils.menu_entries = {
section = "hosts"
},
-- Chatbot
chatbot = {
key = "chatbot",
i18n_title = "chatbot",
section = "chatbot"
-- Chatbot (nAnalyst)
nanalyst = {
key = "nanalyst",
i18n_title = "nanalyst",
section = "nanalyst"
},
-- Interface

View file

@ -13,7 +13,7 @@ local template_utils = require("template_utils")
sendHTTPContentTypeHeader('text/html')
page_utils.print_header_and_set_active_menu_entry(page_utils.menu_entries.geo_map)
page_utils.print_header_and_set_active_menu_entry(page_utils.menu_entries.nanalyst)
dofile(dirs.installdir .. "/scripts/lua/inc/menu.lua")
@ -26,7 +26,7 @@ local context = {
local json_context = json.encode(context)
template_utils.render("pages/vue_page.template", {
vue_page_name = "LLMTest",
vue_page_name = "Chatbot",
page_context = json_context
})