mirror of
https://github.com/ntop/ntopng.git
synced 2026-04-28 06:59:33 +00:00
Initial chatbot UI commit (#10232)
* Initial chatbot UI commit * Update dist
This commit is contained in:
parent
2da8869186
commit
65772d3545
13 changed files with 2192 additions and 851 deletions
|
|
@ -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
|
||||
|
|
|
|||
469
http_src/vue/charts/line-chart.vue
Normal file
469
http_src/vue/charts/line-chart.vue
Normal 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
1654
http_src/vue/chatbot.vue
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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') }} ·
|
||||
<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>
|
||||
|
|
@ -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
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"] = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2259,6 +2259,9 @@ local known_parameters = {
|
|||
["prompt"] = validateUnquoted,
|
||||
["stream"] = validateBool,
|
||||
["content"] = validateUnchecked,
|
||||
["chatId"] = validateUUID,
|
||||
["sequence"] = validateNumber,
|
||||
["title"] = validateUnquoted,
|
||||
|
||||
-- VULNERABILITY SCAN
|
||||
["scan_type"] = validateSingleWord,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue