@cryptotaxi247 / CoPilot / commits / be72efc0

Refactor ai analyst (#804)

* refactor(ai-analyst): simplify tabs layout * refactor(ui): improve version banner * refactor(ai-analyst): refine overview styles * refactor(ui): improve version banner layout * refactor(ai-analyst): animate status tags * refactor(ai-analyst): relocate view and update tabs * refactor(ai-analyst): extract chat query component * refactor(ai-analyst): extract chat subcomponents * refactor(ai-analyst): improve chat bubble UX * refactor(ai-analyst): fix chat height * refactor(ai-analyst): convert overview to tabs * refactor(ai-analyst): refine UI and error handling * refactor(ai-analyst): refine report UI layout * refactor(auth): autofocus TOTP input on mount * refactor(ai-analyst): refine UI and components * refactor(ai-analyst): enhance jobs list UI * refactor(version): update current version to 0.1.55 --------- Co-authored-by: Davide Di Modica <webmaster.ddm@gmail.com>

taylor_socfortress committed Apr 22, 2026 at 11:09 UTC be72efc0b045fce3c633e6626386453bc2d0ab34
20 files changed +665 -563
backend/app/version/services/version.py
+1 -1
@@ -7,7 +7,7 @@ from loguru import logger
7 from packaging.version import Version
8
9 # Current version - update this with each release
10 -CURRENT_VERSION = "0.1.54"
10 +CURRENT_VERSION = "0.1.55"
11 VERSION_CHECK_URL = "https://api.github.com/repos/socfortress/CoPilot/releases/latest"
12
13
frontend/src/components/aiAnalyst/AlertReportDetails.vue
+15 -14
@@ -2,7 +2,7 @@
2 <n-spin :show="loading" class="flex grow flex-col" content-class="flex grow flex-col">
3 <n-tabs type="line" animated :tabs-padding="24" class="grow" pane-wrapper-class="flex grow flex-col">
4 <n-tab-pane name="Summary" tab="Summary" display-directive="show:lazy" class="flex grow flex-col">
5 - <div class="p-7 pt-4">
5 + <div class="p-6 pt-3">
6 <div class="flex flex-col gap-4">
7 <div v-if="report?.severity_assessment" class="flex items-center gap-2">
8 <Badge type="splitted" bright :color="severityColor">
@@ -10,33 +10,33 @@
10 <template #value>{{ report.severity_assessment }}</template>
11 </Badge>
12 </div>
13 - <div v-if="report?.summary">
14 - <h4 class="mb-2 text-sm font-semibold opacity-70">Summary</h4>
15 - <p class="whitespace-pre-wrap">{{ report.summary }}</p>
16 - </div>
17 - <div v-if="report?.recommended_actions">
18 - <h4 class="mb-2 text-sm font-semibold opacity-70">Recommended Actions</h4>
19 - <p class="whitespace-pre-wrap">{{ report.recommended_actions }}</p>
20 - </div>
13 + <CardKV v-if="report?.summary">
14 + <template #key>Summary</template>
15 + <template #value>{{ report.summary }}</template>
16 + </CardKV>
17 + <CardKV v-if="report?.recommended_actions">
18 + <template #key>Recommended Actions</template>
19 + <template #value>{{ report.recommended_actions }}</template>
20 + </CardKV>
21 <div v-if="!report?.summary && !report?.recommended_actions">
22 - <n-empty description="No summary available" />
22 + <n-empty description="No summary available" class="h-40" />
23 </div>
24 </div>
25 </div>
26 </n-tab-pane>
27 <n-tab-pane name="Report" tab="Full Report" display-directive="show:lazy">
28 - <div class="p-7 pt-4">
28 + <div class="p-6 pt-3">
29 <Markdown v-if="report?.report_markdown" :source="report.report_markdown" breaks />
30 - <n-empty v-else description="No report content available" />
30 + <n-empty v-else description="No report content available" class="h-40" />
31 </div>
32 </n-tab-pane>
33 <n-tab-pane name="IOCs" tab="IOCs" display-directive="show:lazy">
34 - <div class="p-7 pt-4">
34 + <div class="p-6 pt-3">
35 <AlertReportIocsList :alert-id="alert.alert_id" />
36 </div>
37 </n-tab-pane>
38 <n-tab-pane name="Jobs" tab="Jobs" display-directive="show:lazy">
39 - <div class="p-7 pt-4">
39 + <div class="p-6 pt-3">
40 <AlertReportJobsList :alert-id="alert.alert_id" />
41 </div>
42 </n-tab-pane>
@@ -49,6 +49,7 @@ import type { AlertWithReport } from "@/types/aiAnalyst.d"
49 import { NEmpty, NSpin, NTabPane, NTabs } from "naive-ui"
50 import { computed, defineAsyncComponent, ref, toRefs } from "vue"
51 import Badge from "@/components/common/Badge.vue"
52 +import CardKV from "@/components/common/cards/CardKV.vue"
53 import Markdown from "@/components/common/Markdown.vue"
54
55 const props = defineProps<{
frontend/src/components/aiAnalyst/AlertReportIocsList.vue
+28 -24
@@ -1,41 +1,45 @@
1 <template>
2 - <n-spin :show="loading">
2 + <n-spin :show="loading" class="min-h-40">
3 <div class="flex flex-col gap-2">
4 <template v-if="iocs.length">
5 - <n-card v-for="ioc of iocs" :key="ioc.id" size="small" embedded>
6 - <div class="flex flex-wrap items-center gap-3">
7 - <Badge type="splitted" bright>
8 - <template #label>Type</template>
9 - <template #value>{{ ioc.ioc_type }}</template>
10 - </Badge>
11 - <Badge type="splitted" bright :color="verdictColor(ioc.vt_verdict)">
12 - <template #label>VT Verdict</template>
13 - <template #value>{{ ioc.vt_verdict }}</template>
14 - </Badge>
15 - <Badge v-if="ioc.vt_score" type="splitted">
16 - <template #label>VT Score</template>
17 - <template #value>{{ ioc.vt_score }}</template>
18 - </Badge>
19 - </div>
20 - <div class="mt-2">
21 - <code class="text-sm break-all">{{ ioc.ioc_value }}</code>
22 - </div>
23 - <div v-if="ioc.details" class="mt-2 text-sm opacity-70">
5 + <CardEntity v-for="ioc of iocs" :key="ioc.id" size="small" embedded>
6 + <template #default>
7 + <CodeSource :code="ioc.ioc_value" />
8 + </template>
9 + <template v-if="ioc.details" #mainExtra>
10 {{ ioc.details }}
25 - </div>
26 - </n-card>
11 + </template>
12 + <template #footer>
13 + <div class="flex flex-wrap items-center gap-3">
14 + <Badge type="splitted" bright>
15 + <template #label>Type</template>
16 + <template #value>{{ ioc.ioc_type }}</template>
17 + </Badge>
18 + <Badge type="splitted" bright :color="verdictColor(ioc.vt_verdict)">
19 + <template #label>VT Verdict</template>
20 + <template #value>{{ ioc.vt_verdict }}</template>
21 + </Badge>
22 + <Badge v-if="ioc.vt_score" type="splitted">
23 + <template #label>VT Score</template>
24 + <template #value>{{ ioc.vt_score }}</template>
25 + </Badge>
26 + </div>
27 + </template>
28 + </CardEntity>
29 </template>
28 - <n-empty v-else-if="!loading" description="No IOCs found for this alert" />
30 + <n-empty v-else-if="!loading" description="No IOCs found for this alert" class="min-h-40 justify-center" />
31 </div>
32 </n-spin>
33 </template>
34
35 <script setup lang="ts">
36 import type { AiAnalystIoc } from "@/types/aiAnalyst.d"
35 -import { NCard, NEmpty, NSpin, useMessage } from "naive-ui"
37 +import { NEmpty, NSpin, useMessage } from "naive-ui"
38 import { onBeforeMount, ref, toRefs } from "vue"
39 import Api from "@/api"
40 import Badge from "@/components/common/Badge.vue"
41 +import CardEntity from "@/components/common/cards/CardEntity.vue"
42 +import CodeSource from "@/components/common/CodeSource.vue"
43
44 const props = defineProps<{
45 alertId: number
frontend/src/components/aiAnalyst/AlertReportItem.vue
+50 -58
@@ -1,12 +1,7 @@
1 <template>
2 <div>
3 <CardEntity hoverable clickable @click="openDetails()">
4 - <template v-if="alert" #headerMain>
5 - <div class="flex items-center gap-2">
6 - <span>#{{ alert.alert_id }} - {{ alert.source }}</span>
7 - <Icon :name="InfoIcon" :size="16" />
8 - </div>
9 - </template>
4 + <template v-if="alert" #headerMain>#{{ alert.alert_id }} - {{ alert.source }}</template>
5
6 <template v-if="alert?.alert_creation_time" #headerExtra>
7 <div class="flex items-center gap-2">
@@ -16,52 +11,55 @@
11 </template>
12
13 <template v-if="alert" #default>
19 - {{ alert.alert_name }}
20 - </template>
21 -
22 - <template v-if="alert" #mainExtra>
23 - <div class="flex flex-wrap items-center gap-3">
24 - <Badge
25 - type="splitted"
26 - bright
27 - :color="
28 - alert.status === 'OPEN' ? 'danger' : alert.status === 'IN_PROGRESS' ? 'warning' : 'success'
29 - "
30 - >
31 - <template #iconLeft>
32 - <Icon :name="StatusIcon" :size="14" />
33 - </template>
34 - <template #label>Status</template>
35 - <template #value>{{ alert.status || "n/d" }}</template>
36 - </Badge>
14 + <div class="flex flex-col gap-2">
15 + <div>{{ alert.alert_name }}</div>
16 + <div class="flex flex-wrap items-center gap-3">
17 + <Badge
18 + type="splitted"
19 + bright
20 + :color="
21 + alert.status === 'OPEN'
22 + ? 'danger'
23 + : alert.status === 'IN_PROGRESS'
24 + ? 'warning'
25 + : 'success'
26 + "
27 + >
28 + <template #iconLeft>
29 + <Icon :name="StatusIcon" :size="14" />
30 + </template>
31 + <template #label>Status</template>
32 + <template #value>{{ alert.status || "n/d" }}</template>
33 + </Badge>
34
38 - <Badge v-if="alert.report.severity_assessment" type="splitted" bright :color="severityColor">
39 - <template #iconLeft>
40 - <Icon :name="SeverityIcon" :size="14" />
41 - </template>
42 - <template #label>Severity</template>
43 - <template #value>{{ alert.report.severity_assessment }}</template>
44 - </Badge>
35 + <Badge v-if="alert.report.severity_assessment" type="splitted" bright :color="severityColor">
36 + <template #iconLeft>
37 + <Icon :name="SeverityIcon" :size="14" />
38 + </template>
39 + <template #label>Severity</template>
40 + <template #value>{{ alert.report.severity_assessment }}</template>
41 + </Badge>
42
46 - <Badge v-if="alert.assigned_to" type="splitted" bright color="success">
47 - <template #iconLeft>
48 - <Icon :name="AssigneeIcon" :size="14" />
49 - </template>
50 - <template #label>Assignee</template>
51 - <template #value>{{ alert.assigned_to }}</template>
52 - </Badge>
43 + <Badge v-if="alert.assigned_to" type="splitted" bright color="success">
44 + <template #iconLeft>
45 + <Icon :name="AssigneeIcon" :size="14" />
46 + </template>
47 + <template #label>Assignee</template>
48 + <template #value>{{ alert.assigned_to }}</template>
49 + </Badge>
50
54 - <Badge type="splitted">
55 - <template #label>Customer</template>
56 - <template #value>
57 - <code class="text-primary leading-none">#{{ alert.customer_code }}</code>
58 - </template>
59 - </Badge>
51 + <Badge type="splitted">
52 + <template #label>Customer</template>
53 + <template #value>
54 + <code class="text-primary leading-none">#{{ alert.customer_code }}</code>
55 + </template>
56 + </Badge>
57 + </div>
58 </div>
59 </template>
60
61 <template v-if="alert" #footerMain>
64 - <div class="flex items-center gap-2 text-sm opacity-70">
62 + <div class="text-secondary flex items-center gap-1 text-sm">
63 <Icon :name="ReportIcon" :size="14" />
64 <span>Report generated {{ formatDate(alert.report.created_at, dFormats.datetime) }}</span>
65 </div>
@@ -71,19 +69,14 @@
69 <n-modal
70 v-model:show="showDetails"
71 :style="{ maxWidth: 'min(850px, 90vw)', minHeight: 'min(540px, 90vh)', overflow: 'hidden' }"
72 + content-class="p-0!"
73 display-directive="show"
74 + preset="card"
75 + :title="alertNameTruncated"
76 + :bordered="false"
77 + segmented
78 >
76 - <n-card
77 - content-class="flex flex-col p-0!"
78 - :title="alertNameTruncated"
79 - closable
80 - :bordered="false"
81 - segmented
82 - role="modal"
83 - @close="showDetails = false"
84 - >
85 - <AlertReportDetails v-if="alert" :alert />
86 - </n-card>
79 + <AlertReportDetails v-if="alert" :alert />
80 </n-modal>
81 </div>
82 </template>
@@ -91,7 +84,7 @@
84 <script setup lang="ts">
85 import type { AlertWithReport } from "@/types/aiAnalyst.d"
86 import _truncate from "lodash/truncate"
94 -import { NCard, NModal } from "naive-ui"
87 +import { NModal } from "naive-ui"
88 import { computed, ref, toRefs } from "vue"
89 import Badge from "@/components/common/Badge.vue"
90 import CardEntity from "@/components/common/cards/CardEntity.vue"
@@ -106,7 +99,6 @@ const props = defineProps<{
99
100 const { alertData } = toRefs(props)
101
109 -const InfoIcon = "carbon:information"
102 const TimeIcon = "carbon:time"
103 const StatusIcon = "carbon:circle-dash"
104 const SeverityIcon = "carbon:warning-alt"
frontend/src/components/aiAnalyst/AlertReportJobsList.vue
+51 -39
@@ -1,42 +1,53 @@
1 <template>
2 - <n-spin :show="loading">
3 - <div class="flex flex-col gap-2">
2 + <n-spin :show="loading" class="min-h-40">
3 + <div ref="jobsListRef" class="flex flex-col gap-2">
4 <template v-if="jobs.length">
5 - <n-card v-for="job of jobs" :key="job.id" size="small" embedded>
6 - <div class="flex flex-wrap items-center gap-3">
7 - <Badge type="splitted" bright :color="statusColor(job.status)">
8 - <template #label>Status</template>
9 - <template #value>{{ job.status }}</template>
10 - </Badge>
11 - <Badge type="splitted">
12 - <template #label>Triggered By</template>
13 - <template #value>{{ job.triggered_by }}</template>
14 - </Badge>
15 - <Badge v-if="job.alert_type" type="splitted">
16 - <template #label>Alert Type</template>
17 - <template #value>{{ job.alert_type }}</template>
18 - </Badge>
19 - <Badge v-if="job.template_used" type="splitted">
20 - <template #label>Template</template>
21 - <template #value>{{ job.template_used }}</template>
22 - </Badge>
23 - </div>
24 - <div class="mt-2 flex flex-wrap items-center gap-3 text-sm opacity-70">
25 - <span>
26 - <Icon :name="TimeIcon" :size="12" class="relative top-0.5" />
27 - Created: {{ formatDate(job.created_at, dFormats.datetime) }}
28 - </span>
29 - <span v-if="job.started_at">Started: {{ formatDate(job.started_at, dFormats.datetime) }}</span>
30 - <span v-if="job.completed_at">
31 - Completed: {{ formatDate(job.completed_at, dFormats.datetime) }}
32 - </span>
33 - </div>
34 - <div v-if="job.error_message" class="mt-2">
5 + <CardEntity v-for="job of jobs" :key="job.id" size="small" embedded>
6 + <template #default>
7 + <div class="flex flex-wrap items-center gap-3">
8 + <Badge type="splitted" bright :color="statusColor(job.status)">
9 + <template #label>Status</template>
10 + <template #value>{{ job.status }}</template>
11 + </Badge>
12 + <Badge type="splitted">
13 + <template #label>Triggered By</template>
14 + <template #value>{{ job.triggered_by }}</template>
15 + </Badge>
16 + <Badge v-if="job.alert_type" type="splitted">
17 + <template #label>Alert Type</template>
18 + <template #value>{{ job.alert_type }}</template>
19 + </Badge>
20 + <Badge v-if="job.template_used" type="splitted">
21 + <template #label>Template</template>
22 + <template #value>{{ job.template_used }}</template>
23 + </Badge>
24 + </div>
25 + </template>
26 + <template v-if="job.error_message" #mainExtra>
27 <n-alert type="error" :bordered="false" size="small">
28 {{ job.error_message }}
29 </n-alert>
38 - </div>
39 - </n-card>
30 + </template>
31 + <template #footer>
32 + <n-timeline :horizontal="horizontalMode">
33 + <n-timeline-item title="Created">
34 + <div class="text-secondary font-mono text-sm">
35 + {{ formatDate(job.created_at, dFormats.datetime).toString() }}
36 + </div>
37 + </n-timeline-item>
38 + <n-timeline-item v-if="job.started_at" title="Started" type="warning">
39 + <div class="text-secondary font-mono text-sm">
40 + {{ formatDate(job.started_at, dFormats.datetime).toString() }}
41 + </div>
42 + </n-timeline-item>
43 + <n-timeline-item v-if="job.completed_at" title="Completed" type="success">
44 + <div class="text-secondary font-mono text-sm">
45 + {{ formatDate(job.completed_at, dFormats.datetime).toString() }}
46 + </div>
47 + </n-timeline-item>
48 + </n-timeline>
49 + </template>
50 + </CardEntity>
51 </template>
52 <n-empty v-else-if="!loading" description="No jobs found for this alert" />
53 </div>
@@ -45,11 +56,12 @@
56
57 <script setup lang="ts">
58 import type { AiAnalystJob } from "@/types/aiAnalyst.d"
48 -import { NAlert, NCard, NEmpty, NSpin, useMessage } from "naive-ui"
49 -import { onBeforeMount, ref, toRefs } from "vue"
59 +import { useElementSize } from "@vueuse/core"
60 +import { NAlert, NEmpty, NSpin, NTimeline, NTimelineItem, useMessage } from "naive-ui"
61 +import { computed, onBeforeMount, ref, toRefs, useTemplateRef } from "vue"
62 import Api from "@/api"
63 import Badge from "@/components/common/Badge.vue"
52 -import Icon from "@/components/common/Icon.vue"
64 +import CardEntity from "@/components/common/cards/CardEntity.vue"
65 import { useSettingsStore } from "@/stores/settings"
66 import { formatDate } from "@/utils/format"
67
@@ -59,8 +71,8 @@ const props = defineProps<{
71
72 const { alertId } = toRefs(props)
73
62 -const TimeIcon = "carbon:time"
63 -
74 +const { width: jobsListWidthRef } = useElementSize(useTemplateRef("jobsListRef"))
75 +const horizontalMode = computed(() => jobsListWidthRef.value > 550)
76 const message = useMessage()
77 const dFormats = useSettingsStore().dateFormat
78 const loading = ref(false)
frontend/src/components/aiAnalyst/AlertsReportsList.vue
+3 -9
@@ -55,17 +55,16 @@
55
56 <script setup lang="ts">
57 import type { AlertWithReport } from "@/types/aiAnalyst.d"
58 -import axios from "axios"
58 import { NEmpty, NSelect, NSpin, useMessage } from "naive-ui"
59 import { computed, onBeforeMount, ref } from "vue"
60 import Api from "@/api"
61 +import { getApiErrorMessage } from "@/utils"
62 import AlertReportItem from "./AlertReportItem.vue"
63
64 const message = useMessage()
65 const loading = ref(false)
66 const alertsList = ref<AlertWithReport[]>([])
67 const customerFilter = ref<string | null>(null)
68 -let abortController: AbortController | null = null
68
69 const sort = ref<"desc" | "asc">("desc")
70 const sortOptions = [
@@ -90,9 +89,6 @@ const customerOptions = computed(() => {
89 })
90
91 function getData() {
93 - abortController?.abort()
94 - abortController = new AbortController()
95 -
92 loading.value = true
93
94 Api.aiAnalyst
@@ -105,10 +101,8 @@ function getData() {
101 }
102 })
103 .catch(err => {
108 - if (!axios.isCancel(err)) {
109 - alertsList.value = []
110 - message.error(err.response?.data?.message || "An error occurred. Please try again later.")
111 - }
104 + alertsList.value = []
105 + message.error(getApiErrorMessage(err) || "An error occurred. Please try again later.")
106 })
107 .finally(() => {
108 loading.value = false
frontend/src/components/aiAnalyst/TalonChat.vue deleted
-244
@@ -1,244 +0,0 @@
1 -<template>
2 - <div class="flex h-full flex-col overflow-hidden">
3 - <n-scrollbar ref="scrollbar" class="grow">
4 - <div v-if="messages.length" class="flex flex-col gap-6 p-4 pb-20">
5 - <div
6 - v-for="msg of messages"
7 - :key="msg.id"
8 - class="group flex flex-col gap-0.5"
9 - :class="{ 'items-end text-right': msg.sender === 'user' }"
10 - >
11 - <template v-if="msg.sender === 'server'">
12 - <div class="text-secondary inline-flex items-center gap-1 text-sm font-semibold">
13 - <span>Talon:</span>
14 - <div
15 - v-if="isCopySupported"
16 - class="pointer-events-none ml-1 flex items-center opacity-0 transition-opacity duration-300 group-hover:pointer-events-auto group-hover:opacity-100"
17 - >
18 - <n-tooltip>
19 - <template #trigger>
20 - <n-button size="tiny" text @click="copyText(msg.body)">
21 - <template #icon>
22 - <Icon name="carbon:copy" :size="13" />
23 - </template>
24 - </n-button>
25 - </template>
26 - <div class="text-xs">Copy</div>
27 - </n-tooltip>
28 - </div>
29 - </div>
30 - <div class="**:text-default **:text-sm [&_*:last-child]:mb-0!">
31 - <Markdown :source="msg.body" class="overflow-hidden" />
32 - </div>
33 - </template>
34 -
35 - <template v-if="msg.sender === 'user'">
36 - <div class="flex items-end justify-end gap-3 pl-6">
37 - <div
38 - class="bg-secondary **:text-default rounded-lg px-2 py-1 text-sm [&_*:last-child]:mb-0!"
39 - >
40 - <Markdown :source="msg.body" class="overflow-hidden" />
41 - </div>
42 - </div>
43 - </template>
44 -
45 - <div class="text-tertiary mt-1 text-xs">
46 - {{ formatDate(msg.datetime, dFormats.datetime) }}
47 - </div>
48 - </div>
49 -
50 - <div v-if="streaming" class="group flex flex-col gap-0.5">
51 - <div class="text-secondary inline-flex items-center gap-1 text-sm font-semibold">
52 - <span>Talon:</span>
53 - </div>
54 - <div v-if="streamBuffer" class="**:text-default **:text-sm [&_*:last-child]:mb-0!">
55 - <Markdown :source="streamBuffer" class="overflow-hidden" />
56 - </div>
57 - <div v-else class="animate-fade flex items-center gap-2">
58 - <Icon name="svg-spinners:pulse-rings-3" :size="20" />
59 - <span class="text-tertiary text-sm">Thinking...</span>
60 - </div>
61 - </div>
62 - </div>
63 - <div v-else class="text-secondary flex flex-col items-center justify-center py-12 text-center">
64 - <Icon name="carbon:chat-bot" :size="40" class="mb-3 opacity-50" />
65 - <p class="text-lg font-semibold">Welcome to Talon AI</p>
66 - <p class="mt-1 max-w-md text-sm opacity-70">
67 - Your AI-powered security analyst. Ask questions about alerts, threats, or your environment and Talon
68 - will investigate and respond.
69 - </p>
70 - <div class="mt-6 flex flex-col gap-2">
71 - <button
72 - v-for="example of exampleMessages"
73 - :key="example"
74 - class="bg-secondary hover:bg-hover rounded-lg px-4 py-2.5 text-left text-sm transition-colors"
75 - @click="useExample(example)"
76 - >
77 - {{ example }}
78 - </button>
79 - </div>
80 - </div>
81 - </n-scrollbar>
82 -
83 - <div class="border-color flex items-end gap-2 border-t p-3">
84 - <n-tooltip v-if="messages.length && !streaming">
85 - <template #trigger>
86 - <n-button text @click="clearChat()">
87 - <template #icon>
88 - <Icon name="carbon:trash-can" :size="16" />
89 - </template>
90 - </n-button>
91 - </template>
92 - <div class="text-xs">Clear chat</div>
93 - </n-tooltip>
94 - <n-input
95 - v-model:value="input"
96 - type="textarea"
97 - :autosize="{ minRows: 1, maxRows: 6 }"
98 - placeholder="Ask Talon..."
99 - :disabled="streaming"
100 - @keydown.enter.exact.prevent="sendMessage()"
101 - />
102 - <n-button v-if="!streaming" type="primary" :disabled="!input?.trim()" @click="sendMessage()">
103 - <template #icon>
104 - <Icon name="carbon:send-filled" :size="16" />
105 - </template>
106 - </n-button>
107 - <n-button v-else type="error" @click="stopStream()">
108 - <template #icon>
109 - <Icon name="carbon:stop-filled" :size="16" />
110 - </template>
111 - </n-button>
112 - </div>
113 - </div>
114 -</template>
115 -
116 -<script setup lang="ts">
117 -import type { ScrollbarInst } from "naive-ui"
118 -import { useClipboard, useStorage } from "@vueuse/core"
119 -import { NButton, NInput, NScrollbar, NTooltip, useMessage } from "naive-ui"
120 -import { nanoid } from "nanoid"
121 -import { nextTick, ref } from "vue"
122 -import Api from "@/api"
123 -import Icon from "@/components/common/Icon.vue"
124 -import Markdown from "@/components/common/Markdown.vue"
125 -import { useSettingsStore } from "@/stores/settings"
126 -import { formatDate } from "@/utils/format"
127 -import { secureLocalStorage } from "@/utils/secure-storage"
128 -
129 -interface TalonMessage {
130 - id: string
131 - datetime: Date
132 - body: string
133 - sender: "user" | "server"
134 -}
135 -
136 -const message = useMessage()
137 -const dFormats = useSettingsStore().dateFormat
138 -const { copy: copyText, isSupported: isCopySupported } = useClipboard()
139 -
140 -const messages = useStorage<TalonMessage[]>("talon-chat-messages", [], secureLocalStorage({ session: true }))
141 -const input = ref("")
142 -const streaming = ref(false)
143 -const streamBuffer = ref("")
144 -const scrollbar = ref<ScrollbarInst | null>(null)
145 -
146 -const exampleMessages = [
147 - "Summarize the most critical alerts from today",
148 - "What MITRE ATT&CK techniques have been seen this week?",
149 - "Explain what a brute force attack looks like in our logs",
150 - "What should I investigate first as a SOC analyst?"
151 -]
152 -
153 -let abortController: AbortController | null = null
154 -
155 -function useExample(example: string) {
156 - input.value = example
157 - sendMessage()
158 -}
159 -
160 -function clearChat() {
161 - messages.value = []
162 -}
163 -
164 -function scrollChat() {
165 - nextTick(() => {
166 - scrollbar.value?.scrollTo({ top: 99999999999999, behavior: "smooth" })
167 - })
168 -}
169 -
170 -async function sendMessage() {
171 - const text = input.value?.trim()
172 - if (!text || streaming.value) return
173 -
174 - messages.value.push({
175 - id: nanoid(),
176 - datetime: new Date(),
177 - body: text,
178 - sender: "user"
179 - })
180 -
181 - input.value = ""
182 - streaming.value = true
183 - streamBuffer.value = ""
184 - scrollChat()
185 -
186 - abortController = new AbortController()
187 -
188 - try {
189 - await Api.talon.streamMessage(
190 - text,
191 - (chunk: string) => {
192 - streamBuffer.value += chunk
193 - scrollChat()
194 - },
195 - () => {
196 - if (streamBuffer.value) {
197 - messages.value.push({
198 - id: nanoid(),
199 - datetime: new Date(),
200 - body: streamBuffer.value,
201 - sender: "server"
202 - })
203 - }
204 - streaming.value = false
205 - streamBuffer.value = ""
206 - scrollChat()
207 - },
208 - (err: string) => {
209 - if (err !== "AbortError") {
210 - message.error("An error occurred. Please try again.")
211 - }
212 - streaming.value = false
213 - streamBuffer.value = ""
214 - scrollChat()
215 - },
216 - abortController.signal
217 - )
218 - } catch {
219 - streaming.value = false
220 - streamBuffer.value = ""
221 - }
222 -}
223 -
224 -function stopStream() {
225 - abortController?.abort()
226 - if (streamBuffer.value) {
227 - messages.value.push({
228 - id: nanoid(),
229 - datetime: new Date(),
230 - body: streamBuffer.value,
231 - sender: "server"
232 - })
233 - }
234 - streaming.value = false
235 - streamBuffer.value = ""
236 - scrollChat()
237 -}
238 -
239 -defineExpose({
240 - clearHistory() {
241 - messages.value = []
242 - }
243 -})
244 -</script>
frontend/src/components/aiAnalyst/TalonOverview.vue
+90 -106
@@ -1,5 +1,5 @@
1 <template>
2 - <div class="flex flex-col gap-8 p-2">
2 + <div class="flex flex-col gap-8">
3 <!-- Hero -->
4 <div class="flex flex-col items-center gap-3 py-6 text-center">
5 <div class="bg-primary/10 flex h-16 w-16 items-center justify-center rounded-2xl">
@@ -13,20 +13,28 @@
13 <div class="mt-1 flex items-center gap-2">
14 <n-tag size="small" round type="success">
15 <template #icon>
16 - <Icon name="carbon:checkmark-filled" :size="12" />
16 + <Icon name="carbon:checkmark-filled" />
17 </template>
18 Integrated
19 </n-tag>
20 - <n-tag v-if="status" size="small" round :type="status === 'healthy' ? 'success' : 'warning'">
20 + <n-tag
21 + v-if="status"
22 + size="small"
23 + round
24 + :type="status === 'healthy' ? 'success' : 'warning'"
25 + class="animate-fade"
26 + >
27 {{ status === "healthy" ? "Online" : status }}
28 </n-tag>
23 - <n-tag v-else-if="statusChecked" size="small" round type="error">Unreachable</n-tag>
29 + <n-tag v-else-if="statusChecked" size="small" round type="error" class="animate-fade">
30 + Unreachable
31 + </n-tag>
32 </div>
33 <a
34 href="https://github.com/taylorwalton/talon"
35 target="_blank"
36 rel="noopener noreferrer"
29 - class="text-primary mt-1 inline-flex items-center gap-1.5 text-xs hover:underline"
37 + class="text-primary mt-1 inline-flex items-center gap-1.5 text-xs no-underline hover:underline"
38 >
39 <Icon name="carbon:logo-github" :size="14" />
40 <span>github.com/taylorwalton/talon</span>
@@ -34,78 +42,54 @@
42 </a>
43 </div>
44
37 - <!-- How It Works -->
38 - <section>
39 - <SectionHeader title="How It Works" icon="carbon:flow" />
40 - <div class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
41 - <StepCard
42 - v-for="item in investigationSteps"
43 - :key="item.step"
44 - :step="item.step"
45 - :title="item.title"
46 - :description="item.description"
47 - :icon="item.icon"
48 - />
49 - </div>
50 - </section>
51 -
52 - <!-- Capabilities -->
53 - <section>
54 - <SectionHeader title="Capabilities" icon="carbon:catalog" />
55 - <div class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
56 - <FeatureCard
57 - v-for="feature in capabilities"
58 - :key="feature.title"
59 - :title="feature.title"
60 - :description="feature.description"
61 - :icon="feature.icon"
62 - />
63 - </div>
64 - </section>
65 -
66 - <!-- Architecture -->
67 - <section>
68 - <SectionHeader title="Architecture" icon="carbon:network-4" />
69 - <div class="bg-secondary mt-3 overflow-x-auto rounded-lg p-5">
45 + <n-tabs type="card" placement="left" animated>
46 + <n-tab-pane name="How It Works" tab="How It Works">
47 + <div class="grid grid-cols-1 gap-3">
48 + <StepCard
49 + v-for="item in investigationSteps"
50 + :key="item.step"
51 + :step="item.step"
52 + :title="item.title"
53 + :description="item.description"
54 + :icon="item.icon"
55 + />
56 + </div>
57 + </n-tab-pane>
58 + <n-tab-pane name="Capabilities" tab="Capabilities">
59 + <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
60 + <FeatureCard
61 + v-for="feature in capabilities"
62 + :key="feature.title"
63 + :title="feature.title"
64 + :description="feature.description"
65 + :icon="feature.icon"
66 + />
67 + </div>
68 + </n-tab-pane>
69 + <n-tab-pane name="Architecture" tab="Architecture">
70 <pre class="text-default text-xs leading-relaxed"><code>{{ architectureDiagram }}</code></pre>
71 - </div>
72 - </section>
73 -
74 - <!-- Integration Points -->
75 - <section>
76 - <SectionHeader title="CoPilot Integration" icon="carbon:connect" />
77 - <div class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
78 - <FeatureCard
79 - v-for="point in integrationPoints"
80 - :key="point.title"
81 - :title="point.title"
82 - :description="point.description"
83 - :icon="point.icon"
84 - />
85 - </div>
86 - </section>
71 + </n-tab-pane>
72 + <n-tab-pane name="CoPilot Integration" tab="CoPilot Integration">
73 + <div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
74 + <FeatureCard
75 + v-for="point in integrationPoints"
76 + :key="point.title"
77 + :title="point.title"
78 + :description="point.description"
79 + :icon="point.icon"
80 + />
81 + </div>
82 + </n-tab-pane>
83 + </n-tabs>
84 </div>
85 </template>
86
87 <script setup lang="ts">
91 -import { NTag } from "naive-ui"
88 +import { NTabPane, NTabs, NTag } from "naive-ui"
89 import { defineComponent, h, onMounted, ref } from "vue"
90 import Api from "@/api"
91 import Icon from "@/components/common/Icon.vue"
92
96 -// --- Inline sub-components ---
97 -
98 -const SectionHeader = defineComponent({
99 - props: { title: String, icon: String },
100 - setup(props) {
101 - return () =>
102 - h("div", { class: "flex items-center gap-2" }, [
103 - h(Icon, { name: props.icon, size: 18, class: "text-primary" }),
104 - h("h2", { class: "text-default text-lg font-semibold" }, props.title)
105 - ])
106 - }
107 -})
108 -
93 const StepCard = defineComponent({
94 props: { step: Number, title: String, description: String, icon: String },
95 setup(props) {
@@ -260,44 +244,44 @@ const integrationPoints = [
244 }
245 ]
246
263 -const architectureDiagram = `┌──────────────────────────────────────────────────────┐
247 +const architectureDiagram = `┌───────────────────────────────────────────────────────┐
248 │ CoPilot (FastAPI) │
265 -│ │
266 -│ Alert created → POST /investigate ──────────────┐ │
267 -│ GET /status, GET /jobs/:alertId ← Talon HTTP API│ │
268 -│ │ │
269 -│ Write-back API (MCP tools): │ │
270 -│ POST /api/ai_analyst/jobs ←───────────┘ │
271 -│ POST /api/ai_analyst/reports │
272 -│ POST /api/ai_analyst/iocs │
273 -│ MySQL: ai_analyst_job / report / ioc │
274 -└───────────────────────┬──────────────────────────────┘
275 - │ read-only MCP ▲ REST write-back
276 - ▼ │
277 -┌──────────────────────────────────────────────────────┐
249 +│ │
250 +│ Alert created → POST /investigate ───────────────┐ │
251 +│ GET /status, GET /jobs/:alertId ← Talon HTTP API │ │
252 +│ │ │
253 +│ Write-back API (MCP tools): │ │
254 +│ POST /api/ai_analyst/jobs ←────────────┘ │
255 +│ POST /api/ai_analyst/reports │
256 +│ POST /api/ai_analyst/iocs │
257 +│ MySQL: ai_analyst_job / report / ioc │
258 +└───────────┬───────────────────────────────────────────┘
259 + │ ▲ REST write-back
260 + ▼ read-only MCP │
261 +┌────────────────────────────────┴──────────────────────┐
262 │ Talon (Node.js) │
279 -│ │
280 -│ HTTP channel (port 3100) │
281 -│ POST /investigate ← CoPilot triggers this │
282 -│ POST /message ← ad-hoc analyst prompts │
283 -│ GET /status ← queue + job overview │
284 -│ │
285 -│ Scheduled task (every 15 min) │
286 -│ Queries MySQL for OPEN alerts with no job row │
287 -│ Runs full investigation per alert │
288 -│ │
289 -│ SOC agent (containerized) │
290 -│ groups/copilot/CLAUDE.md ← investigation flow │
291 -│ groups/copilot/prompts/ ← per-alert templates │
292 -└──────────────────────────────────────────────────────┘
293 - │ MCP tools (read-only)
294 - ▼
295 -┌──────────────────────────────────────────────────────┐
296 -│ opensearch-mcp — raw SIEM queries │
297 -│ opensearch_anon — anonymizing proxy (PII→tokens) │
298 -│ mysql-mcp — CoPilot DB (alerts, assets) │
299 -│ copilot-mcp — CoPilot REST API write-back │
300 -│ ollama (optional) — local LLM for sensitive data │
301 -│ mempalace — persistent investigation memory │
302 -└──────────────────────────────────────────────────────┘`
263 +│ │
264 +│ HTTP channel (port 3100) │
265 +│ POST /investigate ← CoPilot triggers this │
266 +│ POST /message ← ad-hoc analyst prompts │
267 +│ GET /status ← queue + job overview │
268 +│ │
269 +│ Scheduled task (every 15 min) │
270 +│ Queries MySQL for OPEN alerts with no job row │
271 +│ Runs full investigation per alert │
272 +│ │
273 +│ SOC agent (containerized) │
274 +│ groups/copilot/CLAUDE.md ← investigation flow │
275 +│ groups/copilot/prompts/ ← per-alert templates │
276 +└───────────────┬───────────────────────────────────────┘
277 + │
278 + ▼ MCP tools (read-only)
279 +┌───────────────────────────────────────────────────────┐
280 +│ opensearch-mcp — raw SIEM queries │
281 +│ opensearch_anon — anonymizing proxy (PII→tokens) │
282 +│ mysql-mcp — CoPilot DB (alerts, assets) │
283 +│ copilot-mcp — CoPilot REST API write-back │
284 +│ ollama (optional) — local LLM for sensitive data │
285 +│ mempalace — persistent investigation memory │
286 +└───────────────────────────────────────────────────────┘`
287 </script>
frontend/src/components/aiChatbot/ChatQuery.vue
-1
@@ -93,7 +93,6 @@
93 </template>
94
95 <script setup lang="ts">
96 -// TODO-FE: refactor
96 import type { RemovableRef } from "@vueuse/core"
97 import type { SelectOption } from "naive-ui"
98 import type { VNode } from "vue"
frontend/src/components/auth/TotpForm.vue
+8 -1
@@ -5,6 +5,7 @@
5
6 <n-collapse-transition :show="!showBackupInput">
7 <n-input-otp
8 + ref="twoFaCodeRef"
9 v-model:value="twoFaCode"
10 block
11 size="large"
@@ -46,9 +47,10 @@
47 </template>
48
49 <script lang="ts" setup>
50 +import type { InputOtpInst } from "naive-ui"
51 import type { TOTPValidateRequest } from "@/api/endpoints/totp"
52 import { NButton, NCollapseTransition, NInput, NInputOtp, useMessage } from "naive-ui"
51 -import { computed, ref, watch } from "vue"
53 +import { computed, nextTick, onMounted, ref, watch } from "vue"
54 import { useRouter } from "vue-router"
55 import { useAuthStore } from "@/stores/auth"
56
@@ -60,6 +62,7 @@ const twoFaTempToken = defineModel<string>("twoFaTempToken", { default: "" })
62
63 const router = useRouter()
64 const message = useMessage()
65 +const twoFaCodeRef = ref<InputOtpInst | null>(null)
66 const authStore = useAuthStore()
67
68 const twoFaCode = ref<string[]>([])
@@ -108,4 +111,8 @@ async function verify2fa(params?: { useBackupCode?: boolean }) {
111 twoFaLoading.value = false
112 })
113 }
114 +
115 +onMounted(() => {
116 + twoFaCodeRef.value?.focusOnChar(0)
117 +})
118 </script>
frontend/src/components/common/CodeSource.vue
+1 -1
@@ -1,5 +1,5 @@
1 <template>
2 - <div class="flex flex-col gap-2">
2 + <div class="flex flex-col gap-0.5">
3 <n-card v-if="!showSource" content-class="p-0!" embedded class="overflow-hidden">
4 <div v-shiki="{ lang, decode }" class="scrollbar-styled code-bg-transparent overflow-hidden">
5 <pre v-html="source"></pre>
frontend/src/components/common/VersionUpdateBanner.vue
+27 -45
@@ -1,22 +1,20 @@
1 <template>
2 - <n-alert v-if="showBanner && versionInfo?.is_outdated" type="info" closable class="mb-4" @close="dismissBanner">
2 + <n-alert v-if="showBanner" title="New version available!" type="info" closable @close="dismissBanner">
3 <template #icon>
4 - <Icon :name="UpdateIcon" />
4 + <Icon :name="UpdateIcon" :size="18" />
5 </template>
6 - <div class="flex flex-col gap-3">
6 + <template #header>New version available!</template>
7 + <div v-if="versionInfo" class="flex flex-col">
8 <div class="flex items-center justify-between gap-4">
8 - <div>
9 - <strong>New version available!</strong>
10 - <div class="mt-1 text-sm">
11 - You're running v{{ versionInfo.current_version }}, v{{ versionInfo.latest_version }} is now
12 - available.
13 - <span v-if="versionInfo.published_at" class="opacity-75">
14 - (Released {{ formatDate(versionInfo.published_at) }})
15 - </span>
16 - </div>
9 + <div class="text-sm">
10 + You're running v{{ versionInfo.current_version }}, v{{ versionInfo.latest_version }} is now
11 + available.
12 + <span v-if="versionInfo.published_at" class="opacity-75">
13 + (Released {{ formatDate(versionInfo.published_at, dFormats.datetime) }})
14 + </span>
15 </div>
16 <div class="flex gap-2">
19 - <n-button v-if="showReleaseNotes" size="small" @click="toggleNotes">
17 + <n-button v-if="showReleaseNotes" size="small" secondary @click="toggleNotes">
18 {{ expandedNotes ? "Hide" : "Show" }} Release Notes
19 </n-button>
20 <n-button
@@ -32,57 +30,56 @@
30 </div>
31 </div>
32 <n-collapse-transition :show="expandedNotes">
35 - <div v-if="versionInfo.release_notes" class="release-notes text-sm">
36 - <n-divider class="my-2!" />
37 - <pre class="whitespace-pre-wrap">{{ versionInfo.release_notes }}</pre>
38 - </div>
33 + <n-card v-if="versionInfo.release_notes" class="mt-4" content-class="pr-2!">
34 + <n-scrollbar class="max-h-50 pr-4">
35 + <Markdown :source="versionInfo.release_notes" breaks />
36 + </n-scrollbar>
37 + </n-card>
38 </n-collapse-transition>
39 </div>
40 </n-alert>
41 </template>
42
43 <script setup lang="ts">
45 -// TODO-FE: refactor
44 import type { VersionCheckResponse } from "@/types/version.d"
47 -import { NAlert, NButton, NCollapseTransition, NDivider } from "naive-ui"
45 +import { useStorage } from "@vueuse/core"
46 +import { NAlert, NButton, NCard, NCollapseTransition, NScrollbar } from "naive-ui"
47 import { computed, onBeforeMount, ref } from "vue"
48 import Api from "@/api"
49 import Icon from "@/components/common/Icon.vue"
51 -import dayjs from "@/utils/dayjs"
50 +import Markdown from "@/components/common/Markdown.vue"
51 +import { useSettingsStore } from "@/stores/settings"
52 +import { formatDate } from "@/utils/format"
53
54 const UpdateIcon = "carbon:update-now"
55 const versionInfo = ref<VersionCheckResponse | null>(null)
56 const showBanner = ref(false)
57 const expandedNotes = ref(false)
58
59 +const dFormats = useSettingsStore().dateFormat
60 const DISMISS_KEY = "version_banner_dismissed"
61 const DISMISS_DURATION = 24 * 60 * 60 * 1000 // 24 hours
62
63 +const dismissedAt = useStorage<number | null>(DISMISS_KEY, null)
64 +
65 const showReleaseNotes = computed(() => {
66 return versionInfo.value?.release_notes && versionInfo.value.release_notes.trim().length > 0
67 })
68
65 -function formatDate(dateString: string): string {
66 - return dayjs(dateString).format("MMM D, YYYY")
67 -}
68 -
69 function toggleNotes() {
70 expandedNotes.value = !expandedNotes.value
71 }
72
73 function checkIfDismissed() {
74 - const dismissed = localStorage.getItem(DISMISS_KEY)
75 - if (dismissed) {
76 - const dismissedTime = Number.parseInt(dismissed)
77 - const now = Date.now()
74 + if (dismissedAt.value) {
75 // Show again after 24 hours
79 - return now - dismissedTime < DISMISS_DURATION
76 + return Date.now() - dismissedAt.value < DISMISS_DURATION
77 }
78 return false
79 }
80
81 function dismissBanner() {
85 - localStorage.setItem(DISMISS_KEY, Date.now().toString())
82 + dismissedAt.value = Date.now()
83 showBanner.value = false
84 }
85
@@ -108,18 +105,3 @@ onBeforeMount(() => {
105 checkVersion()
106 })
107 </script>
111 -
112 -<style lang="scss" scoped>
113 -.release-notes {
114 - max-height: 400px;
115 - overflow-y: auto;
116 - padding: 0.5rem;
117 - border-radius: 4px;
118 - background-color: rgba(0, 0, 0, 0.05);
119 -
120 - pre {
121 - font-family: inherit;
122 - margin: 0;
123 - }
124 -}
125 -</style>
frontend/src/components/talonChat/TalonChatBubble.vue new
+62
@@ -0,0 +1,62 @@
1 +<template>
2 + <div class="group flex flex-col gap-0.5" :class="{ 'items-end text-right': msg.sender === 'user' }">
3 + <template v-if="msg.sender === 'server'">
4 + <div class="text-secondary inline-flex items-center gap-1 text-sm font-semibold">
5 + <span>Talon:</span>
6 + <div
7 + v-if="isCopySupported"
8 + class="pointer-events-none ml-1 flex items-center opacity-0 transition-opacity duration-300 group-hover:pointer-events-auto group-hover:opacity-100"
9 + >
10 + <n-tooltip>
11 + <template #trigger>
12 + <n-button size="tiny" text @click="copyText(msg.body)">
13 + <template #icon>
14 + <Icon name="carbon:copy" :size="13" />
15 + </template>
16 + </n-button>
17 + </template>
18 + <div class="text-xs">{{ showCopyTooltip ? "Copied!" : "Copy" }}</div>
19 + </n-tooltip>
20 + </div>
21 + </div>
22 + <div class="**:text-default **:text-sm [&_*:last-child]:mb-0!">
23 + <Markdown :source="msg.body" class="overflow-hidden" />
24 + </div>
25 + </template>
26 +
27 + <template v-if="msg.sender === 'user'">
28 + <div class="flex items-end justify-end gap-3 pl-6">
29 + <div class="bg-secondary **:text-default rounded-lg px-2 py-1 text-sm [&_*:last-child]:mb-0!">
30 + <Markdown :source="msg.body" class="overflow-hidden" />
31 + </div>
32 + </div>
33 + </template>
34 +
35 + <div class="text-tertiary mt-1 text-xs">
36 + {{ formatDate(msg.datetime, dFormats.datetime) }}
37 + </div>
38 + </div>
39 +</template>
40 +
41 +<script setup lang="ts">
42 +import { useClipboard } from "@vueuse/core"
43 +import { NButton, NTooltip } from "naive-ui"
44 +import Icon from "@/components/common/Icon.vue"
45 +import Markdown from "@/components/common/Markdown.vue"
46 +import { useSettingsStore } from "@/stores/settings"
47 +import { formatDate } from "@/utils/format"
48 +
49 +export interface TalonMessage {
50 + id: string
51 + datetime: Date
52 + body: string
53 + sender: "user" | "server"
54 +}
55 +
56 +defineProps<{
57 + msg: TalonMessage
58 +}>()
59 +
60 +const dFormats = useSettingsStore().dateFormat
61 +const { copy: copyText, copied: showCopyTooltip, isSupported: isCopySupported } = useClipboard()
62 +</script>
frontend/src/components/talonChat/TalonChatContainer.vue new
+158
@@ -0,0 +1,158 @@
1 +<template>
2 + <div class="flex h-full flex-col overflow-hidden">
3 + <n-scrollbar ref="scrollbar" class="grow">
4 + <div v-if="messages.length" class="flex flex-col gap-6 pb-20">
5 + <TalonChatBubble v-for="msg of messages" :key="msg.id" :msg class="animate-fade" />
6 +
7 + <div v-if="streaming" class="group animate-fade flex flex-col gap-0.5">
8 + <div class="text-secondary inline-flex items-center gap-1 text-sm font-semibold">
9 + <span>Talon:</span>
10 + </div>
11 + <div v-if="streamBuffer" class="**:text-default **:text-sm [&_*:last-child]:mb-0!">
12 + <Markdown :source="streamBuffer" class="overflow-hidden" />
13 + </div>
14 + <div v-else class="animate-fade flex items-center gap-2">
15 + <Icon name="svg-spinners:pulse-rings-3" :size="20" />
16 + <span class="text-tertiary text-sm">Thinking...</span>
17 + </div>
18 + </div>
19 + </div>
20 + <div v-else class="animate-fade flex flex-col items-center justify-center py-12">
21 + <TalonChatExample @example="useExample" />
22 + </div>
23 + </n-scrollbar>
24 +
25 + <TalonChatQuery
26 + v-model:input="input"
27 + :loading="streaming"
28 + @message="sendMessage"
29 + @stop="stopStream()"
30 + @clear-chat="clearChat()"
31 + />
32 + </div>
33 +</template>
34 +
35 +<script setup lang="ts">
36 +import type { ScrollbarInst } from "naive-ui"
37 +import type { Message } from "./TalonChatQuery.vue"
38 +import { useStorage } from "@vueuse/core"
39 +import { NScrollbar, useMessage } from "naive-ui"
40 +import { nanoid } from "nanoid"
41 +import { nextTick, ref } from "vue"
42 +import Api from "@/api"
43 +import Icon from "@/components/common/Icon.vue"
44 +import Markdown from "@/components/common/Markdown.vue"
45 +import { secureLocalStorage } from "@/utils/secure-storage"
46 +import TalonChatBubble from "./TalonChatBubble.vue"
47 +import TalonChatExample from "./TalonChatExample.vue"
48 +import TalonChatQuery from "./TalonChatQuery.vue"
49 +
50 +interface TalonMessage {
51 + id: string
52 + datetime: Date
53 + body: string
54 + sender: "user" | "server"
55 +}
56 +
57 +const message = useMessage()
58 +
59 +const messages = useStorage<TalonMessage[]>("talon-chat-messages", [], secureLocalStorage({ session: true }))
60 +const input = ref("")
61 +const streaming = ref(false)
62 +const streamBuffer = ref("")
63 +const scrollbar = ref<ScrollbarInst | null>(null)
64 +
65 +let abortController: AbortController | null = null
66 +
67 +function useExample(example: string) {
68 + input.value = example
69 + sendMessage({ input: example })
70 +}
71 +
72 +function clearChat() {
73 + messages.value = []
74 +}
75 +
76 +function scrollChat() {
77 + nextTick(() => {
78 + scrollbar.value?.scrollTo({ top: 99999999999999, behavior: "smooth" })
79 + })
80 +}
81 +
82 +async function sendMessage(payload: Message) {
83 + const text = payload.input.trim()
84 + if (!text || streaming.value) return
85 +
86 + messages.value.push({
87 + id: nanoid(),
88 + datetime: new Date(),
89 + body: text,
90 + sender: "user"
91 + })
92 +
93 + input.value = ""
94 + streaming.value = true
95 + streamBuffer.value = ""
96 + scrollChat()
97 +
98 + abortController = new AbortController()
99 +
100 + try {
101 + await Api.talon.streamMessage(
102 + text,
103 + (chunk: string) => {
104 + streamBuffer.value += chunk
105 + scrollChat()
106 + },
107 + () => {
108 + if (streamBuffer.value) {
109 + messages.value.push({
110 + id: nanoid(),
111 + datetime: new Date(),
112 + body: streamBuffer.value,
113 + sender: "server"
114 + })
115 + }
116 + streaming.value = false
117 + streamBuffer.value = ""
118 + scrollChat()
119 + },
120 + (err: string) => {
121 + if (err !== "AbortError") {
122 + message.error("An error occurred. Please try again.")
123 + }
124 + streaming.value = false
125 + streamBuffer.value = ""
126 + scrollChat()
127 + },
128 + abortController.signal
129 + )
130 + } catch {
131 + streaming.value = false
132 + streamBuffer.value = ""
133 + }
134 +}
135 +
136 +function stopStream() {
137 + abortController?.abort()
138 +
139 + if (streamBuffer.value) {
140 + messages.value.push({
141 + id: nanoid(),
142 + datetime: new Date(),
143 + body: streamBuffer.value,
144 + sender: "server"
145 + })
146 + }
147 +
148 + streaming.value = false
149 + streamBuffer.value = ""
150 + scrollChat()
151 +}
152 +
153 +defineExpose({
154 + clearHistory() {
155 + messages.value = []
156 + }
157 +})
158 +</script>
frontend/src/components/talonChat/TalonChatExample.vue new
+41
@@ -0,0 +1,41 @@
1 +<template>
2 + <div class="text-secondary flex flex-col items-center justify-center text-center">
3 + <Icon name="carbon:chat-bot" :size="40" class="text-tertiary mb-3" />
4 + <p class="text-lg font-semibold">Welcome to Talon AI</p>
5 + <p class="text-tertiary mt-1 max-w-md text-sm">
6 + Your AI-powered security analyst. Ask questions about alerts, threats, or your environment and Talon will
7 + investigate and respond.
8 + </p>
9 + <div class="mt-6 flex flex-col gap-2">
10 + <n-button
11 + v-for="example of exampleMessages"
12 + :key="example"
13 + size="large"
14 + secondary
15 + @click="useExample(example)"
16 + >
17 + {{ example }}
18 + </n-button>
19 + </div>
20 + </div>
21 +</template>
22 +
23 +<script setup lang="ts">
24 +import { NButton } from "naive-ui"
25 +import Icon from "@/components/common/Icon.vue"
26 +
27 +const emit = defineEmits<{
28 + (e: "example", value: string): void
29 +}>()
30 +
31 +const exampleMessages = [
32 + "Summarize the most critical alerts from today",
33 + "What MITRE ATT&CK techniques have been seen this week?",
34 + "Explain what a brute force attack looks like in our logs",
35 + "What should I investigate first as a SOC analyst?"
36 +]
37 +
38 +function useExample(example: string) {
39 + emit("example", example)
40 +}
41 +</script>
frontend/src/components/talonChat/TalonChatQuery.vue new
+93
@@ -0,0 +1,93 @@
1 +<template>
2 + <div class="relative flex flex-col overflow-hidden">
3 + <div
4 + v-if="!!editMessageBody"
5 + class="bg-secondary hover:text-primary mb-2 flex cursor-pointer items-center gap-2 rounded-lg px-2 py-2 text-sm transition-colors duration-200"
6 + @click="handleCancelEdit()"
7 + >
8 + <Icon name="carbon:close" :size="20" />
9 + <div>Edit message</div>
10 + </div>
11 + <n-input
12 + v-model:value.trim="input"
13 + class="max-h-full min-h-20 pt-1 pb-9"
14 + type="textarea"
15 + placeholder="Ask Talon..."
16 + :autosize="{
17 + minRows: 3,
18 + maxRows: 18
19 + }"
20 + />
21 + <div class="absolute right-0 bottom-0 left-0 flex items-center justify-between p-2 pr-3">
22 + <div class="flex items-center gap-2.5">
23 + <n-button size="tiny" secondary @click="clearChat()">
24 + <template #icon>
25 + <Icon name="mdi:broom" />
26 + </template>
27 + Clear chat
28 + </n-button>
29 + </div>
30 + <n-button
31 + circle
32 + size="small"
33 + secondary
34 + :type="isFormValid ? 'primary' : undefined"
35 + :disabled="!isFormValid && !loading"
36 + @click="loading ? stop() : send()"
37 + >
38 + <Icon :name="loading ? 'carbon:stop-filled-alt' : 'carbon:arrow-up'" />
39 + </n-button>
40 + </div>
41 + </div>
42 +</template>
43 +
44 +<script setup lang="ts">
45 +import _trim from "lodash/trim"
46 +import { NButton, NInput } from "naive-ui"
47 +import { computed, toRefs } from "vue"
48 +import Icon from "@/components/common/Icon.vue"
49 +
50 +export interface Message {
51 + input: string
52 +}
53 +
54 +const props = defineProps<{ loading?: boolean; editMessageBody?: string | null }>()
55 +
56 +const emit = defineEmits<{
57 + (e: "message", value: Message): void
58 + (e: "stop"): void
59 + (e: "cancel-edit"): void
60 + (e: "clear-chat"): void
61 +}>()
62 +
63 +const { loading, editMessageBody } = toRefs(props)
64 +const input = defineModel<string | null>("input", { default: null })
65 +const isFormValid = computed(() => !!_trim(input.value || ""))
66 +
67 +function reset() {
68 + input.value = null
69 +}
70 +
71 +function send() {
72 + if (input.value) {
73 + emit("message", {
74 + input: input.value
75 + })
76 +
77 + reset()
78 + }
79 +}
80 +
81 +function stop() {
82 + emit("stop")
83 +}
84 +
85 +function handleCancelEdit() {
86 + emit("cancel-edit")
87 + reset()
88 +}
89 +
90 +function clearChat() {
91 + emit("clear-chat")
92 +}
93 +</script>
frontend/src/router/index.ts
+1 -1
@@ -278,7 +278,7 @@ const router = createRouter({
278 {
279 path: "/ai-analyst",
280 name: "AiAnalyst",
281 - component: () => import("@/views/incidentManagement/AiAnalyst.vue"),
281 + component: () => import("@/views/AiAnalyst.vue"),
282 meta: { title: "AI Analyst", auth: true, roles: RouteRole.All }
283 },
284 {
frontend/src/types/flask.d.ts
+1 -2
@@ -1,6 +1,5 @@
1 export interface FlaskBaseResponse {
2 - /** common response property */
2 message: string
4 - /** common response property */
3 + detail?: string
4 success: boolean
5 }
frontend/src/utils/index.ts
+22 -1
@@ -1,5 +1,5 @@
1 import type { Component } from "vue"
2 -import type { OsTypesFull, SafeAny } from "@/types/common.d"
2 +import type { ApiError, OsTypesFull, SafeAny } from "@/types/common.d"
3 import process from "node:process"
4 import { isMobile as detectMobile } from "detect-touch-device"
5 import { md5 } from "js-md5"
@@ -9,6 +9,8 @@ import { h } from "vue"
9 import Icon from "@/components/common/Icon.vue"
10 import dayjs from "./dayjs"
11
12 +const TRAILING_DOT_REGEX = /\.$/
13 +
14 // Transform File Instance in base64 string
15 export function file2Base64(blob: Blob): Promise<string> {
16 return new Promise((resolve, reject) => {
@@ -180,3 +182,22 @@ export function formatCompactNumber(value: number | null | undefined): string {
182 if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
183 return value.toLocaleString()
184 }
185 +
186 +export function getApiErrorMessage(err: ApiError): string {
187 + const axiosMessage = err.message
188 + const message = err.response?.data?.message
189 + const detail = err.response?.data?.detail
190 +
191 + // Handle string detail
192 + if (detail && typeof detail === "string") {
193 + return `${detail.replace(TRAILING_DOT_REGEX, "")}.`
194 + }
195 +
196 + // Handle string message
197 + if (message && typeof message === "string") {
198 + return `${message.replace(TRAILING_DOT_REGEX, "")}.`
199 + }
200 +
201 + // Fallback to axios message
202 + return `${axiosMessage.replace(TRAILING_DOT_REGEX, "")}.`
203 +}
frontend/src/views/AiAnalyst.vue renamed
+13 -16
@@ -1,23 +1,15 @@
1 <template>
2 - <div class="page flex flex-col">
3 - <n-tabs v-model:value="activeTab" type="line" animated>
4 - <n-tab-pane name="overview" display-directive="show:lazy">
5 - <template #tab>
6 - <div class="flex items-center gap-1.5">
7 - <Icon name="carbon:machine-learning-model" :size="14" class="text-primary" />
8 - <span>Overview</span>
9 - </div>
10 - </template>
2 + <div class="page flex flex-col pb-0!">
3 + <n-tabs v-model:value="activeTab" type="segment" animated tab-class="px-5!" class="[&_.n-tabs-nav]:mx-auto">
4 + <n-tab-pane name="overview" tab="Overview" display-directive="show:lazy">
5 <TalonOverview />
6 </n-tab-pane>
7 + <n-tab-pane name="talon" tab="Talon Chat" display-directive="show:lazy">
8 + <TalonChat class="h-fixed" />
9 + </n-tab-pane>
10 <n-tab-pane name="reports" tab="Reports" display-directive="show:lazy">
11 <AlertsReportsList />
12 </n-tab-pane>
16 - <n-tab-pane name="talon" tab="Talon Chat" display-directive="show:lazy" class="p-0!">
17 - <div class="h-[calc(100vh-200px)]">
18 - <TalonChat />
19 - </div>
20 - </n-tab-pane>
13 </n-tabs>
14 </div>
15 </template>
@@ -26,9 +18,14 @@
18 import { NTabPane, NTabs } from "naive-ui"
19 import { ref } from "vue"
20 import AlertsReportsList from "@/components/aiAnalyst/AlertsReportsList.vue"
29 -import TalonChat from "@/components/aiAnalyst/TalonChat.vue"
21 import TalonOverview from "@/components/aiAnalyst/TalonOverview.vue"
31 -import Icon from "@/components/common/Icon.vue"
22 +import TalonChat from "@/components/talonChat/TalonChatContainer.vue"
23
24 const activeTab = ref("overview")
25 </script>
26 +
27 +<style lang="scss">
28 +.h-fixed {
29 + height: calc(100svh - var(--toolbar-height) - var(--view-padding) - 70px);
30 +}
31 +</style>