mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-05 01:40:33 +01:00
refactor: support auto-derived contexts and enhance diff display functionality
This commit is contained in:
@@ -51,7 +51,7 @@
|
||||
"@signozhq/design-tokens": "2.1.4",
|
||||
"@signozhq/icons": "0.1.0",
|
||||
"@signozhq/resizable": "0.0.2",
|
||||
"@signozhq/ui": "0.0.10",
|
||||
"@signozhq/ui": "0.0.13",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "3.13.22",
|
||||
"@uiw/codemirror-theme-copilot": "4.23.11",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import cx from 'classnames';
|
||||
|
||||
import ChatInput from './components/ChatInput';
|
||||
import ChatInput, { autoContextKey } from './components/ChatInput';
|
||||
import ConversationSkeleton from './components/ConversationSkeleton';
|
||||
import VirtualizedMessages from './components/VirtualizedMessages';
|
||||
import { getAutoContexts } from './getAutoContexts';
|
||||
import { useAIAssistantStore } from './store/useAIAssistantStore';
|
||||
import { MessageAttachment } from './types';
|
||||
import { MessageContext } from '../../api/ai/chat';
|
||||
@@ -20,6 +22,7 @@ export default function ConversationView({
|
||||
}: ConversationViewProps): JSX.Element {
|
||||
const variant = useVariant();
|
||||
const isCompact = variant === 'panel';
|
||||
const location = useLocation();
|
||||
|
||||
const conversation = useAIAssistantStore(
|
||||
(s) => s.conversations[conversationId],
|
||||
@@ -37,6 +40,42 @@ export default function ConversationView({
|
||||
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
|
||||
const cancelStream = useAIAssistantStore((s) => s.cancelStream);
|
||||
|
||||
// Auto-derived contexts come from the route the user is currently looking
|
||||
// at (dashboard detail, service metrics, an explorer, …). Skip when the
|
||||
// user is on the standalone AI Assistant page — there's no "underlying"
|
||||
// page context to attach. ChatInput renders these as chips and merges
|
||||
// them with the user's `@`-mention picks before invoking onSend.
|
||||
const allAutoContexts = useMemo(
|
||||
() =>
|
||||
variant === 'page'
|
||||
? []
|
||||
: getAutoContexts(location.pathname, location.search),
|
||||
[variant, location.pathname, location.search],
|
||||
);
|
||||
|
||||
// User-dismissed auto-context entries. Reset whenever the URL changes —
|
||||
// dismissals are scoped to "this page", not the whole conversation.
|
||||
const [dismissedAutoKeys, setDismissedAutoKeys] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
useEffect(() => {
|
||||
setDismissedAutoKeys(new Set());
|
||||
}, [location.pathname, location.search]);
|
||||
|
||||
const autoContexts = useMemo(
|
||||
() =>
|
||||
allAutoContexts.filter((ctx) => !dismissedAutoKeys.has(autoContextKey(ctx))),
|
||||
[allAutoContexts, dismissedAutoKeys],
|
||||
);
|
||||
|
||||
const handleDismissAutoContext = useCallback((key: string): void => {
|
||||
setDismissedAutoKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(
|
||||
(
|
||||
text: string,
|
||||
@@ -72,7 +111,12 @@ export default function ConversationView({
|
||||
<div className={styles.conversation}>
|
||||
<ConversationSkeleton />
|
||||
<div className={inputWrapperClass}>
|
||||
<ChatInput onSend={handleSend} disabled />
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
disabled
|
||||
autoContexts={autoContexts}
|
||||
onDismissAutoContext={handleDismissAutoContext}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -96,6 +140,8 @@ export default function ConversationView({
|
||||
onCancel={handleCancel}
|
||||
disabled={inputDisabled}
|
||||
isStreaming={isStreamingHere}
|
||||
autoContexts={autoContexts}
|
||||
onDismissAutoContext={handleDismissAutoContext}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@use '../_scrollbar' as *;
|
||||
|
||||
$radius: 4px;
|
||||
|
||||
.card {
|
||||
@@ -55,9 +57,131 @@ $radius: 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.diffSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.diffHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.diffHeaderLabel {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.diff {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
// Fixed-height dialog (70vh) — let the diff fill the body and the
|
||||
// JSON panes scroll internally rather than pushing the dialog taller.
|
||||
&.expanded {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
.diffBlock {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.diffJson {
|
||||
flex: 1;
|
||||
max-height: none;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// Unified view: a single column instead of two side-by-side blocks.
|
||||
// The block-level flex switches to column so the diff pane fills.
|
||||
&.unified {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.diffHeaderActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
// Container for line-by-line diff output. Mirrors `.diffJson` for scroll
|
||||
// + monospace styling but renders an inner stack of `.diffLine` rows
|
||||
// instead of a single `<pre>` so individual lines can be colored.
|
||||
.diffPane {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 12px;
|
||||
background: var(--l2-background);
|
||||
border-radius: $radius;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
color: var(--l2-foreground);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@include scrollbar(0.4rem);
|
||||
|
||||
&.wrapped .diffLineText {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.diffLine {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
min-height: 18px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.diffLineAdd {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: var(--l1-foreground);
|
||||
|
||||
.diffGutter {
|
||||
color: var(--bg-forest-500, #22c55e);
|
||||
}
|
||||
}
|
||||
|
||||
.diffLineRemove {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: var(--l1-foreground);
|
||||
|
||||
.diffGutter {
|
||||
color: var(--bg-cherry-500, #ef4444);
|
||||
}
|
||||
}
|
||||
|
||||
// Empty filler row in split view to keep before/after columns aligned
|
||||
// when one side has an added/removed line. Visible as a faint band so
|
||||
// the eye still tracks the row.
|
||||
.diffLinePlaceholder {
|
||||
background: rgba(120, 120, 120, 0.06);
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.diffGutter {
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
color: var(--l3-foreground, var(--l2-foreground));
|
||||
}
|
||||
|
||||
.diffLineText {
|
||||
white-space: pre;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.diffBlock {
|
||||
@@ -75,6 +199,14 @@ $radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.diffBlockHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.diffLabel {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
@@ -89,10 +221,45 @@ $radius: 4px;
|
||||
border-radius: $radius;
|
||||
padding: 5px 7px;
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
max-height: 140px;
|
||||
color: var(--l2-foreground);
|
||||
@include scrollbar(0.4rem);
|
||||
|
||||
// Wrap long lines instead of horizontal scrolling. Used in the
|
||||
// expanded modal when the user toggles the "Wrap text" button.
|
||||
&.wrapped {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.diffModalBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.diffToolbarRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.diffModalSummary {
|
||||
font-size: 13px;
|
||||
color: var(--l2-foreground);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
|
||||
@@ -1,8 +1,31 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import cx from 'classnames';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import type { ApprovalEventDTO } from 'api/generated/services/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import { Check, Shield, X } from '@signozhq/icons';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogSubtitle,
|
||||
DialogTitle,
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from '@signozhq/ui';
|
||||
import type {
|
||||
ApprovalEventDTO,
|
||||
ApprovalEventDTODiffAnyOf,
|
||||
} from 'api/generated/services/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import {
|
||||
Check,
|
||||
Columns2,
|
||||
Copy,
|
||||
List,
|
||||
Maximize2,
|
||||
Shield,
|
||||
WrapText,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
|
||||
@@ -29,6 +52,9 @@ export default function ApprovalCard({
|
||||
);
|
||||
|
||||
const [decided, setDecided] = useState<'approved' | 'rejected' | null>(null);
|
||||
const [diffExpanded, setDiffExpanded] = useState(false);
|
||||
const [wrapText, setWrapText] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<DiffViewMode>('split');
|
||||
|
||||
const handleApprove = async (): Promise<void> => {
|
||||
setDecided('approved');
|
||||
@@ -72,26 +98,84 @@ export default function ApprovalCard({
|
||||
<p className={styles.summary}>{approval.summary}</p>
|
||||
|
||||
{approval.diff && (
|
||||
<div className={styles.diff}>
|
||||
{approval.diff.before !== undefined && (
|
||||
<div className={cx(styles.diffBlock, styles.before)}>
|
||||
<span className={styles.diffLabel}>Before</span>
|
||||
<pre className={styles.diffJson}>
|
||||
{JSON.stringify(approval.diff.before, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{approval.diff.after !== undefined && (
|
||||
<div className={cx(styles.diffBlock, styles.after)}>
|
||||
<span className={styles.diffLabel}>After</span>
|
||||
<pre className={styles.diffJson}>
|
||||
{JSON.stringify(approval.diff.after, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.diffSection}>
|
||||
<div className={styles.diffHeader}>
|
||||
<span className={styles.diffHeaderLabel}>Diff</span>
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={(): void => setDiffExpanded(true)}
|
||||
title="Expand diff"
|
||||
aria-label="Expand diff"
|
||||
>
|
||||
<Maximize2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
<DiffView diff={approval.diff} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={diffExpanded} onOpenChange={setDiffExpanded}>
|
||||
<DialogContent
|
||||
className={styles.diffDialog}
|
||||
style={{ width: '80vw', maxWidth: '80vw', height: '70vh' }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Approval diff</DialogTitle>
|
||||
<DialogSubtitle>
|
||||
{approval.actionType} · {approval.resourceType}
|
||||
</DialogSubtitle>
|
||||
</DialogHeader>
|
||||
<div className={styles.diffModalBody}>
|
||||
<p className={styles.diffModalSummary}>{approval.summary}</p>
|
||||
<div className={styles.diffToolbarRow}>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
size="sm"
|
||||
value={viewMode}
|
||||
onChange={(next): void => {
|
||||
// Radix `single` group can emit '' when the active item
|
||||
// is clicked again — preserve the current mode.
|
||||
if (next === 'split' || next === 'unified') {
|
||||
setViewMode(next);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value="split" aria-label="Split view">
|
||||
<Columns2 size={12} />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="unified" aria-label="Unified view">
|
||||
<List size={12} />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<ToggleGroup
|
||||
type="multiple"
|
||||
size="sm"
|
||||
value={wrapText ? ['wrap'] : []}
|
||||
onChange={(next): void => setWrapText(next.includes('wrap'))}
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="wrap"
|
||||
aria-label={wrapText ? 'Disable text wrap' : 'Wrap long lines'}
|
||||
>
|
||||
<WrapText size={12} />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
{approval.diff && (
|
||||
<DiffView
|
||||
diff={approval.diff}
|
||||
expanded
|
||||
wrapText={wrapText}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<DialogCloseButton onClick={(): void => setDiffExpanded(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
variant="solid"
|
||||
@@ -116,3 +200,273 @@ export default function ApprovalCard({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DiffViewMode = 'split' | 'unified';
|
||||
|
||||
interface DiffViewProps {
|
||||
diff: ApprovalEventDTODiffAnyOf;
|
||||
expanded?: boolean;
|
||||
/** When true, long lines wrap instead of horizontally scrolling. */
|
||||
wrapText?: boolean;
|
||||
/** Side-by-side ('split') vs single-column ('unified'). Only honored when expanded. */
|
||||
viewMode?: DiffViewMode;
|
||||
}
|
||||
|
||||
function DiffView({
|
||||
diff,
|
||||
expanded = false,
|
||||
wrapText = false,
|
||||
viewMode = 'split',
|
||||
}: DiffViewProps): JSX.Element {
|
||||
const beforeText =
|
||||
diff.before !== undefined ? JSON.stringify(diff.before, null, 2) : '';
|
||||
const afterText =
|
||||
diff.after !== undefined ? JSON.stringify(diff.after, null, 2) : '';
|
||||
|
||||
// In the inline (collapsed) preview keep the original two-pane layout
|
||||
// without diff highlighting — diffing is opt-in via the expanded modal.
|
||||
if (!expanded) {
|
||||
const jsonClass = cx(styles.diffJson, { [styles.wrapped]: wrapText });
|
||||
return (
|
||||
<div className={styles.diff}>
|
||||
{diff.before !== undefined && (
|
||||
<div className={cx(styles.diffBlock, styles.before)}>
|
||||
<div className={styles.diffBlockHeader}>
|
||||
<span className={styles.diffLabel}>Before</span>
|
||||
</div>
|
||||
<pre className={jsonClass}>{beforeText}</pre>
|
||||
</div>
|
||||
)}
|
||||
{diff.after !== undefined && (
|
||||
<div className={cx(styles.diffBlock, styles.after)}>
|
||||
<div className={styles.diffBlockHeader}>
|
||||
<span className={styles.diffLabel}>After</span>
|
||||
</div>
|
||||
<pre className={jsonClass}>{afterText}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const lines = computeLineDiff(beforeText, afterText);
|
||||
|
||||
if (viewMode === 'unified') {
|
||||
// Build the same +/-/space-prefixed text that's on screen so Copy
|
||||
// gives the user exactly what they see.
|
||||
const unifiedText = lines
|
||||
.map((line) => `${prefixFor(line.op)} ${line.text}`)
|
||||
.join('\n');
|
||||
return (
|
||||
<div className={cx(styles.diff, styles.expanded, styles.unified)}>
|
||||
<div className={styles.diffBlockHeader}>
|
||||
<span className={styles.diffLabel}>Diff</span>
|
||||
<div className={styles.diffHeaderActions}>
|
||||
<CopyButton text={unifiedText} label="diff" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={cx(styles.diffPane, { [styles.wrapped]: wrapText })}>
|
||||
{lines.map((line, idx) => (
|
||||
<DiffLine
|
||||
// stable enough — input strings are immutable for the view's lifetime
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={idx}
|
||||
op={line.op}
|
||||
text={line.text}
|
||||
prefix={prefixFor(line.op)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Split view: align side-by-side using the LCS result. `equal` lines
|
||||
// appear on both sides; `remove` only on the left, `add` only on the
|
||||
// right (with an empty placeholder on the missing side so rows stay
|
||||
// aligned vertically).
|
||||
return (
|
||||
<div className={cx(styles.diff, styles.expanded)}>
|
||||
<div className={cx(styles.diffBlock, styles.before)}>
|
||||
<div className={styles.diffBlockHeader}>
|
||||
<span className={styles.diffLabel}>Before</span>
|
||||
<CopyButton text={beforeText} label="before" />
|
||||
</div>
|
||||
<div className={cx(styles.diffPane, { [styles.wrapped]: wrapText })}>
|
||||
{lines.map((line, idx) => {
|
||||
const op = line.op === 'add' ? 'placeholder' : line.op;
|
||||
const text = line.op === 'add' ? '' : line.text;
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
return <DiffLine key={idx} op={op} text={text} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cx(styles.diffBlock, styles.after)}>
|
||||
<div className={styles.diffBlockHeader}>
|
||||
<span className={styles.diffLabel}>After</span>
|
||||
<CopyButton text={afterText} label="after" />
|
||||
</div>
|
||||
<div className={cx(styles.diffPane, { [styles.wrapped]: wrapText })}>
|
||||
{lines.map((line, idx) => {
|
||||
const op = line.op === 'remove' ? 'placeholder' : line.op;
|
||||
const text = line.op === 'remove' ? '' : line.text;
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
return <DiffLine key={idx} op={op} text={text} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Line diff — small LCS-based implementation. Avoids pulling in `diff`
|
||||
// since the inputs are JSON.stringify output (line-oriented, typically
|
||||
// well under a few hundred lines for resource diffs).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type LineOp = 'equal' | 'add' | 'remove';
|
||||
type RenderOp = LineOp | 'placeholder';
|
||||
interface DiffLineEntry {
|
||||
op: LineOp;
|
||||
text: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function computeLineDiff(before: string, after: string): DiffLineEntry[] {
|
||||
if (before === after) {
|
||||
return splitLines(before).map((text) => ({ op: 'equal', text }));
|
||||
}
|
||||
const a = splitLines(before);
|
||||
const b = splitLines(after);
|
||||
const m = a.length;
|
||||
const n = b.length;
|
||||
|
||||
// dp[i][j] = length of LCS between a[0..i] and b[0..j]
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () =>
|
||||
new Array<number>(n + 1).fill(0),
|
||||
);
|
||||
for (let i = 1; i <= m; i += 1) {
|
||||
for (let j = 1; j <= n; j += 1) {
|
||||
if (a[i - 1] === b[j - 1]) {
|
||||
dp[i][j] = dp[i - 1][j - 1] + 1;
|
||||
} else {
|
||||
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backtrack to produce the diff
|
||||
const result: DiffLineEntry[] = [];
|
||||
let i = m;
|
||||
let j = n;
|
||||
while (i > 0 && j > 0) {
|
||||
if (a[i - 1] === b[j - 1]) {
|
||||
result.push({ op: 'equal', text: a[i - 1] });
|
||||
i -= 1;
|
||||
j -= 1;
|
||||
} else if (dp[i - 1][j] >= dp[i][j - 1]) {
|
||||
result.push({ op: 'remove', text: a[i - 1] });
|
||||
i -= 1;
|
||||
} else {
|
||||
result.push({ op: 'add', text: b[j - 1] });
|
||||
j -= 1;
|
||||
}
|
||||
}
|
||||
while (i > 0) {
|
||||
result.push({ op: 'remove', text: a[i - 1] });
|
||||
i -= 1;
|
||||
}
|
||||
while (j > 0) {
|
||||
result.push({ op: 'add', text: b[j - 1] });
|
||||
j -= 1;
|
||||
}
|
||||
result.reverse();
|
||||
return result;
|
||||
}
|
||||
|
||||
function splitLines(text: string): string[] {
|
||||
if (text === '') {
|
||||
return [];
|
||||
}
|
||||
return text.split('\n');
|
||||
}
|
||||
|
||||
function prefixFor(op: LineOp): string {
|
||||
if (op === 'add') {
|
||||
return '+';
|
||||
}
|
||||
if (op === 'remove') {
|
||||
return '-';
|
||||
}
|
||||
return ' ';
|
||||
}
|
||||
|
||||
interface DiffLineProps {
|
||||
op: RenderOp;
|
||||
text: string;
|
||||
/** Optional gutter prefix used in unified view (`+` / `-` / ` `). */
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
function DiffLine({ op, text, prefix }: DiffLineProps): JSX.Element {
|
||||
const cls = cx(styles.diffLine, {
|
||||
[styles.diffLineAdd]: op === 'add',
|
||||
[styles.diffLineRemove]: op === 'remove',
|
||||
[styles.diffLinePlaceholder]: op === 'placeholder',
|
||||
});
|
||||
return (
|
||||
<div className={cls}>
|
||||
{prefix !== undefined && (
|
||||
<span className={styles.diffGutter} aria-hidden="true">
|
||||
{prefix}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.diffLineText}>{text || ' '}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CopyButtonProps {
|
||||
text: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function CopyButton({ text, label }: CopyButtonProps): JSX.Element {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
// Track the timeout so an unmount mid-flight doesn't try to setState on
|
||||
// a dead component (and so a rapid re-click resets the 1.5s window).
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCopy = (): void => {
|
||||
copyToClipboard(text);
|
||||
setCopied(true);
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={handleCopy}
|
||||
title={copied ? `Copied ${label}` : `Copy ${label}`}
|
||||
aria-label={copied ? `Copied ${label}` : `Copy ${label}`}
|
||||
>
|
||||
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,19 @@ $radius: 4px;
|
||||
min-height: 0 !important;
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
// `auto` chips are derived from the URL (current page) — visually
|
||||
// distinguished by a dashed border + slightly muted text so the user
|
||||
// can tell them apart from explicit @-mentions. Tighter padding /
|
||||
// font-size keeps them visually subordinate to user `@`-picks.
|
||||
&.auto {
|
||||
border-style: dashed;
|
||||
color: var(--l2-foreground);
|
||||
background: transparent;
|
||||
font-size: 10px;
|
||||
padding: 2px 4px 2px 6px;
|
||||
gap: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.contextTagContent {
|
||||
|
||||
@@ -44,6 +44,89 @@ interface ChatInputProps {
|
||||
onCancel?: () => void;
|
||||
disabled?: boolean;
|
||||
isStreaming?: boolean;
|
||||
/**
|
||||
* URL-derived `source: 'auto'` contexts representing the page the user is
|
||||
* currently looking at. Rendered as chips alongside the user's `@`-mention
|
||||
* picks and merged into the outgoing `contexts` array.
|
||||
*/
|
||||
autoContexts?: MessageContext[];
|
||||
/**
|
||||
* Called when the user dismisses an auto-context chip. The parent owns
|
||||
* the dismissed set and is responsible for filtering the next render's
|
||||
* `autoContexts` to exclude the key.
|
||||
*/
|
||||
onDismissAutoContext?: (key: string) => void;
|
||||
}
|
||||
|
||||
/** Stable identity for an auto-context entry — used as React key + dismissal id. */
|
||||
export function autoContextKey(ctx: MessageContext): string {
|
||||
const page = (ctx.metadata as { page?: string } | null | undefined)?.page;
|
||||
return `auto:${ctx.type}:${ctx.resourceId ?? ''}:${page ?? ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Friendly label for an auto-derived context chip. We don't fetch resource
|
||||
* names from the URL alone, so we lean on the page identity that already
|
||||
* lives in `metadata.page`, falling back to the resource type.
|
||||
*/
|
||||
function autoContextLabel(ctx: MessageContext): string {
|
||||
const page = (ctx.metadata as { page?: string } | null | undefined)?.page;
|
||||
switch (page) {
|
||||
case 'dashboard_detail':
|
||||
return 'Current dashboard';
|
||||
case 'panel_edit':
|
||||
return 'Editing panel';
|
||||
case 'panel_fullscreen':
|
||||
return 'Panel (fullscreen)';
|
||||
case 'dashboard_list':
|
||||
return 'Dashboards';
|
||||
case 'alert_edit':
|
||||
return 'Editing alert';
|
||||
case 'alert_new':
|
||||
return 'New alert';
|
||||
case 'alerts_triggered':
|
||||
return 'Triggered alerts';
|
||||
case 'alert_list':
|
||||
return 'Alerts';
|
||||
case 'service_detail':
|
||||
return 'Current service';
|
||||
case 'services_list':
|
||||
return 'Services';
|
||||
case 'logs_explorer':
|
||||
return 'Logs explorer';
|
||||
case 'log_detail':
|
||||
return 'Log details';
|
||||
case 'traces_explorer':
|
||||
return 'Traces explorer';
|
||||
case 'trace_detail':
|
||||
return 'Trace details';
|
||||
case 'metrics_explorer':
|
||||
return 'Metrics explorer';
|
||||
default:
|
||||
return ctx.type;
|
||||
}
|
||||
}
|
||||
|
||||
/** Capitalised category badge text — e.g. "Dashboard", "Logs explorer". */
|
||||
function autoContextCategory(ctx: MessageContext): string {
|
||||
switch (ctx.type) {
|
||||
case 'dashboard':
|
||||
return 'Dashboard';
|
||||
case 'alert':
|
||||
return 'Alert';
|
||||
case 'service':
|
||||
return 'Service';
|
||||
case 'logs_explorer':
|
||||
return 'Logs';
|
||||
case 'traces_explorer':
|
||||
return 'Traces';
|
||||
case 'metrics_explorer':
|
||||
return 'Metrics';
|
||||
case 'saved_view':
|
||||
return 'Saved view';
|
||||
default:
|
||||
return ctx.type;
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_INPUT_LENGTH = 20000;
|
||||
@@ -129,6 +212,8 @@ export default function ChatInput({
|
||||
onCancel,
|
||||
disabled,
|
||||
isStreaming = false,
|
||||
autoContexts,
|
||||
onDismissAutoContext,
|
||||
}: ChatInputProps): JSX.Element {
|
||||
const { selectedTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
@@ -141,6 +226,15 @@ export default function ChatInput({
|
||||
const [isContextPickerOpen, setIsContextPickerOpen] = useState(false);
|
||||
const [activeContextCategory, setActiveContextCategory] =
|
||||
useState<ContextCategory>('Dashboards');
|
||||
// When the picker was opened by typing `@` in the textarea, this holds the
|
||||
// span of `@<query>` (start / end indices into `text`). Used both for live
|
||||
// filtering of the entity list and for splicing the trigger out of the
|
||||
// text once the user picks an item. `null` when the picker is opened via
|
||||
// the "Add Context" button (no trigger to strip, no query to filter).
|
||||
const [mentionRange, setMentionRange] = useState<{
|
||||
start: number;
|
||||
end: number;
|
||||
} | null>(null);
|
||||
const [servicesTimeRange] = useState(() => {
|
||||
const now = Date.now();
|
||||
return {
|
||||
@@ -164,35 +258,49 @@ export default function ChatInput({
|
||||
const atIndex = beforeCaret.lastIndexOf('@');
|
||||
if (atIndex < 0) {
|
||||
setIsContextPickerOpen(false);
|
||||
setMentionRange(null);
|
||||
return;
|
||||
}
|
||||
const query = beforeCaret.slice(atIndex + 1);
|
||||
if (/\s/.test(query)) {
|
||||
setIsContextPickerOpen(false);
|
||||
setMentionRange(null);
|
||||
return;
|
||||
}
|
||||
setIsContextPickerOpen(true);
|
||||
setMentionRange({ start: atIndex, end: caret });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleContextSelection = useCallback(
|
||||
(category: ContextCategory, entityId: string, contextValue: string) => {
|
||||
setSelectedContexts((prev) => {
|
||||
const alreadySelected = prev.some(
|
||||
(item) => item.category === category && item.entityId === entityId,
|
||||
);
|
||||
const wasSelected = selectedContexts.some(
|
||||
(item) => item.category === category && item.entityId === entityId,
|
||||
);
|
||||
|
||||
if (alreadySelected) {
|
||||
setSelectedContexts((prev) => {
|
||||
if (wasSelected) {
|
||||
return prev.filter(
|
||||
(item) => !(item.category === category && item.entityId === entityId),
|
||||
);
|
||||
}
|
||||
|
||||
return [...prev, { category, entityId, value: contextValue }];
|
||||
});
|
||||
|
||||
// When the user picks an item via the `@` trigger, splice the
|
||||
// `@<query>` span out of the textarea so their prose stays clean.
|
||||
// Skip on remove (no trigger to strip) and when the picker was
|
||||
// opened from the "Add Context" button (no mention range tracked).
|
||||
if (!wasSelected && mentionRange) {
|
||||
const next =
|
||||
text.slice(0, mentionRange.start) + text.slice(mentionRange.end);
|
||||
setText(next);
|
||||
committedTextRef.current = next;
|
||||
setMentionRange(null);
|
||||
}
|
||||
},
|
||||
[],
|
||||
[mentionRange, selectedContexts, text],
|
||||
);
|
||||
|
||||
// Focus the textarea when this component mounts (panel/modal open)
|
||||
@@ -217,9 +325,12 @@ export default function ChatInput({
|
||||
}),
|
||||
);
|
||||
|
||||
const contexts = selectedContexts
|
||||
const userContexts = selectedContexts
|
||||
.map(toMessageContext)
|
||||
.filter((context): context is MessageContext => context !== null);
|
||||
// Auto contexts come first so the agent reads "current page" before
|
||||
// any explicit @-mentions when both are present.
|
||||
const contexts = [...(autoContexts ?? []), ...userContexts];
|
||||
const payload = capText(trimmed);
|
||||
|
||||
onSend(
|
||||
@@ -232,7 +343,7 @@ export default function ChatInput({
|
||||
setPendingFiles([]);
|
||||
setSelectedContexts([]);
|
||||
textareaRef.current?.focus();
|
||||
}, [text, pendingFiles, onSend, selectedContexts, capText]);
|
||||
}, [text, pendingFiles, onSend, selectedContexts, autoContexts, capText]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
@@ -303,13 +414,21 @@ export default function ChatInput({
|
||||
isError: isDashboardsError,
|
||||
} = useGetAllDashboard();
|
||||
|
||||
// Enable list fetches both for the picker (when the corresponding category
|
||||
// is open) AND for auto-context chips that need to resolve a name from a
|
||||
// resource id. Dashboards is unconditional since the underlying hook
|
||||
// has no `enabled` flag.
|
||||
const needsAlertResolve = (autoContexts ?? []).some(
|
||||
(c) => c.type === 'alert' && Boolean(c.resourceId),
|
||||
);
|
||||
|
||||
const {
|
||||
data: alertsResponse,
|
||||
isLoading: isAlertsLoading,
|
||||
isError: isAlertsError,
|
||||
} = useListRules({
|
||||
query: {
|
||||
enabled: activeContextCategory === 'Alerts',
|
||||
enabled: activeContextCategory === 'Alerts' || needsAlertResolve,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -328,6 +447,43 @@ export default function ChatInput({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolves an auto-context to a human label: dashboard title, alert name,
|
||||
* service name (the service `resourceId` IS the name), or a generic page
|
||||
* label as fallback while the lookup data is still loading.
|
||||
*/
|
||||
const resolveAutoContextName = useCallback(
|
||||
(ctx: MessageContext): string => {
|
||||
if (ctx.type === 'service' && ctx.resourceId) {
|
||||
return ctx.resourceId;
|
||||
}
|
||||
if (ctx.type === 'dashboard' && ctx.resourceId) {
|
||||
const dash = dashboardsResponse?.data?.find((d) => d.id === ctx.resourceId);
|
||||
if (dash?.data.title) {
|
||||
return dash.data.title;
|
||||
}
|
||||
}
|
||||
if (ctx.type === 'alert' && ctx.resourceId) {
|
||||
const rule = alertsResponse?.data?.find((r) => r.id === ctx.resourceId);
|
||||
if (rule?.alert) {
|
||||
return rule.alert;
|
||||
}
|
||||
}
|
||||
const page = (
|
||||
ctx.metadata as { page?: string; traceId?: string } | null | undefined
|
||||
)?.page;
|
||||
if (page === 'trace_detail') {
|
||||
const traceId = (ctx.metadata as { traceId?: string } | null | undefined)
|
||||
?.traceId;
|
||||
if (traceId) {
|
||||
return `${traceId.slice(0, 8)}…`;
|
||||
}
|
||||
}
|
||||
return autoContextLabel(ctx);
|
||||
},
|
||||
[dashboardsResponse, alertsResponse],
|
||||
);
|
||||
|
||||
const contextEntitiesByCategory: Record<ContextCategory, ContextEntityItem[]> =
|
||||
{
|
||||
Dashboards:
|
||||
@@ -374,8 +530,17 @@ export default function ChatInput({
|
||||
},
|
||||
};
|
||||
|
||||
const filteredContextOptions =
|
||||
contextEntitiesByCategory[activeContextCategory];
|
||||
// Type-ahead filter against the `@<query>` typed in the textarea. When the
|
||||
// picker was opened from the "Add Context" button there's no query, so we
|
||||
// show every entity for the active category.
|
||||
const mentionQuery = mentionRange
|
||||
? text.slice(mentionRange.start + 1, mentionRange.end).toLowerCase()
|
||||
: '';
|
||||
const filteredContextOptions = mentionQuery
|
||||
? contextEntitiesByCategory[activeContextCategory].filter((entity) =>
|
||||
entity.value.toLowerCase().includes(mentionQuery),
|
||||
)
|
||||
: contextEntitiesByCategory[activeContextCategory];
|
||||
const { isLoading: isActiveContextLoading, isError: isActiveContextError } =
|
||||
contextCategoryStateByCategory[activeContextCategory];
|
||||
const currentLength = text.length;
|
||||
@@ -402,8 +567,39 @@ export default function ChatInput({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedContexts.length > 0 && (
|
||||
{(selectedContexts.length > 0 ||
|
||||
(autoContexts && autoContexts.length > 0)) && (
|
||||
<div className={styles.contextTags}>
|
||||
{autoContexts?.map((ctx) => {
|
||||
const key = autoContextKey(ctx);
|
||||
const label = resolveAutoContextName(ctx);
|
||||
const category = autoContextCategory(ctx);
|
||||
return (
|
||||
<div key={key} className={cx(styles.contextTag, styles.auto)}>
|
||||
<div className={styles.contextTagContent}>
|
||||
<Badge
|
||||
color="secondary"
|
||||
variant="outline"
|
||||
className={styles.contextTagCategory}
|
||||
>
|
||||
{category}
|
||||
</Badge>
|
||||
<span className={styles.contextTagLabel}>{label}</span>
|
||||
</div>
|
||||
{onDismissAutoContext && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
className={styles.contextTagRemove}
|
||||
onClick={(): void => onDismissAutoContext(key)}
|
||||
aria-label={`Remove ${category}: ${label} context`}
|
||||
prefix={<X size={10} />}
|
||||
></Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{selectedContexts.map((contextItem) => (
|
||||
<div
|
||||
key={`${contextItem.category}:${contextItem.entityId}`}
|
||||
|
||||
@@ -97,6 +97,14 @@ $radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// Constrain the Radix-based SelectContent popover so it never grows wider
|
||||
// than the trigger button. `--radix-select-trigger-width` is set by Radix
|
||||
// at the popper layer when `position: 'popper'` (the default).
|
||||
.selectContent {
|
||||
width: var(--radix-select-trigger-width);
|
||||
min-width: var(--radix-select-trigger-width);
|
||||
}
|
||||
|
||||
.radioGroup,
|
||||
.checkboxGroup {
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Input,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@signozhq/ui';
|
||||
import type {
|
||||
ClarificationEventDTO,
|
||||
ClarificationFieldEventDTO,
|
||||
@@ -11,6 +21,11 @@ import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
|
||||
import styles from './ClarificationForm.module.scss';
|
||||
|
||||
/** Sentinel emitted by the select dropdown when the user picks the custom slot. */
|
||||
const CUSTOM_OPTION_SENTINEL = '__signoz_ai_custom__';
|
||||
/** User-facing label for the synthetic "type your own answer" option. */
|
||||
const CUSTOM_OPTION_LABEL = 'Other (type your own)';
|
||||
|
||||
interface ClarificationFormProps {
|
||||
conversationId: string;
|
||||
clarification: ClarificationEventDTO;
|
||||
@@ -131,67 +146,176 @@ interface FieldInputProps {
|
||||
}
|
||||
|
||||
function FieldInput({ field, value, onChange }: FieldInputProps): JSX.Element {
|
||||
const { id, type, label, required, options } = field;
|
||||
const { id, type, label, required, options, allowCustom } = field;
|
||||
|
||||
// Local UI state for the synthetic "custom" option on select / radio /
|
||||
// checkbox fields with `allowCustom`. The free-text input only renders
|
||||
// when this is true; the typed value is what's actually sent up via
|
||||
// `onChange` (never the sentinel / "Other" label).
|
||||
const [isCustom, setIsCustom] = useState(false);
|
||||
const [customValue, setCustomValue] = useState('');
|
||||
|
||||
// Render the select if the field has options OR if the server marked it
|
||||
// `allowCustom` (in which case the dropdown still appears with just the
|
||||
// "Other (type your own)" entry — a plain `options: null` would
|
||||
// otherwise fall through to the bare text-input renderer).
|
||||
if (type === 'select' && (options || allowCustom)) {
|
||||
const handleSelectChange = (next: string | string[]): void => {
|
||||
// `multiple` is off → callback receives a single string. The wider
|
||||
// `string | string[]` typing comes from the shared Select root.
|
||||
const picked = Array.isArray(next) ? (next[0] ?? '') : next;
|
||||
if (picked === CUSTOM_OPTION_SENTINEL) {
|
||||
setIsCustom(true);
|
||||
onChange(customValue);
|
||||
} else {
|
||||
setIsCustom(false);
|
||||
onChange(picked);
|
||||
}
|
||||
};
|
||||
|
||||
if (type === 'select' && options) {
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label} htmlFor={id}>
|
||||
{label}
|
||||
{required && <span className={styles.required}>*</span>}
|
||||
</label>
|
||||
<select
|
||||
id={id}
|
||||
className={styles.select}
|
||||
value={String(value ?? '')}
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
<Select
|
||||
value={isCustom ? CUSTOM_OPTION_SENTINEL : String(value ?? '')}
|
||||
onChange={handleSelectChange}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{options.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger id={id} placeholder="Select…" />
|
||||
{/* Pin the dropdown width to the trigger via Radix's
|
||||
`--radix-select-trigger-width`; otherwise the popover
|
||||
sizes to its widest item and looks misaligned. */}
|
||||
<SelectContent className={styles.selectContent}>
|
||||
{options?.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
{allowCustom && (
|
||||
<SelectItem value={CUSTOM_OPTION_SENTINEL}>
|
||||
{CUSTOM_OPTION_LABEL}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isCustom && (
|
||||
<Input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
placeholder="Enter a custom value"
|
||||
value={customValue}
|
||||
onChange={(e): void => {
|
||||
setCustomValue(e.target.value);
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'radio' && options) {
|
||||
// Same fallback logic as the select branch — render the radio group
|
||||
// when there are options OR when the field is `allowCustom` only.
|
||||
if (type === 'radio' && (options || allowCustom)) {
|
||||
const handleRadioChange = (next: string): void => {
|
||||
if (next === CUSTOM_OPTION_SENTINEL) {
|
||||
setIsCustom(true);
|
||||
onChange(customValue);
|
||||
} else {
|
||||
setIsCustom(false);
|
||||
onChange(next);
|
||||
}
|
||||
};
|
||||
|
||||
const radioValue = isCustom
|
||||
? CUSTOM_OPTION_SENTINEL
|
||||
: typeof value === 'string'
|
||||
? value
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>
|
||||
{label}
|
||||
{required && <span className={styles.required}>*</span>}
|
||||
</span>
|
||||
<div className={styles.radioGroup}>
|
||||
{options.map((opt) => (
|
||||
<label key={opt} className={styles.radioLabel}>
|
||||
<input
|
||||
type="radio"
|
||||
name={id}
|
||||
value={opt}
|
||||
checked={value === opt}
|
||||
onChange={(): void => onChange(opt)}
|
||||
className={styles.radio}
|
||||
/>
|
||||
<RadioGroup
|
||||
name={id}
|
||||
value={radioValue}
|
||||
onChange={handleRadioChange}
|
||||
className={styles.radioGroup}
|
||||
>
|
||||
{options?.map((opt) => (
|
||||
<RadioGroupItem
|
||||
key={opt}
|
||||
value={opt}
|
||||
containerClassName={styles.radioLabel}
|
||||
>
|
||||
{opt}
|
||||
</label>
|
||||
</RadioGroupItem>
|
||||
))}
|
||||
</div>
|
||||
{allowCustom && (
|
||||
<RadioGroupItem
|
||||
value={CUSTOM_OPTION_SENTINEL}
|
||||
containerClassName={styles.radioLabel}
|
||||
>
|
||||
{CUSTOM_OPTION_LABEL}
|
||||
</RadioGroupItem>
|
||||
)}
|
||||
</RadioGroup>
|
||||
{isCustom && (
|
||||
<Input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
placeholder="Enter a custom value"
|
||||
value={customValue}
|
||||
onChange={(e): void => {
|
||||
setCustomValue(e.target.value);
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'checkbox' && options) {
|
||||
// Same fallback logic as the select branch — render the checkbox group
|
||||
// when there are options OR when the field is `allowCustom` only.
|
||||
if (type === 'checkbox' && (options || allowCustom)) {
|
||||
const selected = Array.isArray(value) ? (value as string[]) : [];
|
||||
const toggle = (opt: string): void => {
|
||||
// Anything in the value array that isn't one of the predefined options
|
||||
// is treated as a custom entry — we keep at most one custom entry,
|
||||
// driven by the local `customValue` + `isCustom` state below.
|
||||
const regularSelected = selected.filter((v) => options?.includes(v));
|
||||
|
||||
const toggleRegular = (opt: string): void => {
|
||||
const nextRegular = regularSelected.includes(opt)
|
||||
? regularSelected.filter((v) => v !== opt)
|
||||
: [...regularSelected, opt];
|
||||
onChange(
|
||||
selected.includes(opt)
|
||||
? selected.filter((v) => v !== opt)
|
||||
: [...selected, opt],
|
||||
isCustom && customValue ? [...nextRegular, customValue] : nextRegular,
|
||||
);
|
||||
};
|
||||
|
||||
const toggleCustom = (): void => {
|
||||
if (isCustom) {
|
||||
setIsCustom(false);
|
||||
onChange(regularSelected);
|
||||
} else {
|
||||
setIsCustom(true);
|
||||
onChange(customValue ? [...regularSelected, customValue] : regularSelected);
|
||||
}
|
||||
};
|
||||
|
||||
const updateCustomValue = (next: string): void => {
|
||||
setCustomValue(next);
|
||||
if (isCustom) {
|
||||
onChange(next ? [...regularSelected, next] : regularSelected);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>
|
||||
@@ -199,18 +323,35 @@ function FieldInput({ field, value, onChange }: FieldInputProps): JSX.Element {
|
||||
{required && <span className={styles.required}>*</span>}
|
||||
</span>
|
||||
<div className={styles.checkboxGroup}>
|
||||
{options.map((opt) => (
|
||||
<label key={opt} className={styles.checkboxLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(opt)}
|
||||
onChange={(): void => toggle(opt)}
|
||||
className={styles.checkbox}
|
||||
/>
|
||||
{options?.map((opt) => (
|
||||
<Checkbox
|
||||
key={opt}
|
||||
className={styles.checkboxLabel}
|
||||
value={regularSelected.includes(opt)}
|
||||
onChange={(): void => toggleRegular(opt)}
|
||||
>
|
||||
{opt}
|
||||
</label>
|
||||
</Checkbox>
|
||||
))}
|
||||
{allowCustom && (
|
||||
<Checkbox
|
||||
className={styles.checkboxLabel}
|
||||
value={isCustom}
|
||||
onChange={toggleCustom}
|
||||
>
|
||||
{CUSTOM_OPTION_LABEL}
|
||||
</Checkbox>
|
||||
)}
|
||||
</div>
|
||||
{isCustom && (
|
||||
<Input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
placeholder="Enter a custom value"
|
||||
value={customValue}
|
||||
onChange={(e): void => updateCustomValue(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -222,7 +363,7 @@ function FieldInput({ field, value, onChange }: FieldInputProps): JSX.Element {
|
||||
{label}
|
||||
{required && <span className={styles.required}>*</span>}
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
id={id}
|
||||
type={type === 'number' ? 'number' : 'text'}
|
||||
className={styles.input}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Button, Tooltip } from '@signozhq/ui';
|
||||
import { Button, Input, Tooltip } from '@signozhq/ui';
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
@@ -130,7 +130,7 @@ export default function ConversationItem({
|
||||
|
||||
<div className={styles.body}>
|
||||
{isEditing ? (
|
||||
<input
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className={styles.input}
|
||||
value={editValue}
|
||||
|
||||
@@ -44,7 +44,7 @@ $radius: 4px;
|
||||
.bubble {
|
||||
border-radius: $radius;
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 153.846% */
|
||||
|
||||
333
frontend/src/container/AIAssistant/getAutoContexts.ts
Normal file
333
frontend/src/container/AIAssistant/getAutoContexts.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import type { MessageContext } from 'api/ai/chat';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { matchPath } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* Resolves the page the user is currently on into structured `MessageContext`
|
||||
* entries with `source: 'auto'`. The agent uses these as implicit context
|
||||
* (e.g. "the user is currently looking at dashboard X with this time range
|
||||
* and this query").
|
||||
*
|
||||
* **URL-only.** All inputs come from `pathname` + `search`; we never reach
|
||||
* into Redux / context. SigNoz already encodes most page state into the URL
|
||||
* (`compositeQuery`, `startTime`/`endTime`, `viewName`/`viewKey`,
|
||||
* `variables`, `expandedWidgetId`, `activeLogId`, …), so URL parsing is
|
||||
* sufficient. Anything not in the URL is simply omitted from `metadata` —
|
||||
* the server applies its own defaults.
|
||||
*
|
||||
* Returns `[]` when no mapping exists, so callers can spread the result
|
||||
* unconditionally before user-picked contexts.
|
||||
*/
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export function getAutoContexts(
|
||||
pathname: string,
|
||||
search: string,
|
||||
): MessageContext[] {
|
||||
const params = new URLSearchParams(search);
|
||||
const sharedMetadata = collectSharedMetadata(params);
|
||||
|
||||
// ── Dashboards ────────────────────────────────────────────────────────────
|
||||
|
||||
// Widget edit (panel_edit) — `/dashboard/:dashboardId/:widgetId`.
|
||||
const widgetMatch = matchPath<{ dashboardId: string; widgetId: string }>(
|
||||
pathname,
|
||||
{ path: ROUTES.DASHBOARD_WIDGET, exact: true },
|
||||
);
|
||||
if (widgetMatch) {
|
||||
return [
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'dashboard',
|
||||
resourceId: widgetMatch.params.dashboardId,
|
||||
metadata: {
|
||||
page: 'panel_edit',
|
||||
widgetId: widgetMatch.params.widgetId,
|
||||
...sharedMetadata,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Dashboard detail — `/dashboard/:dashboardId`. The `expandedWidgetId`
|
||||
// query param signals the panel-fullscreen overlay; otherwise it's the
|
||||
// plain dashboard view.
|
||||
const dashboardMatch = matchPath<{ dashboardId: string }>(pathname, {
|
||||
path: ROUTES.DASHBOARD,
|
||||
exact: true,
|
||||
});
|
||||
if (dashboardMatch) {
|
||||
const expandedWidgetId = params.get(QueryParams.expandedWidgetId);
|
||||
if (expandedWidgetId) {
|
||||
return [
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'dashboard',
|
||||
resourceId: dashboardMatch.params.dashboardId,
|
||||
metadata: {
|
||||
page: 'panel_fullscreen',
|
||||
widgetId: expandedWidgetId,
|
||||
...sharedMetadata,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'dashboard',
|
||||
resourceId: dashboardMatch.params.dashboardId,
|
||||
metadata: {
|
||||
page: 'dashboard_detail',
|
||||
...sharedMetadata,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Dashboard list — `/dashboard`.
|
||||
if (matchPath(pathname, { path: ROUTES.ALL_DASHBOARD, exact: true })) {
|
||||
return [
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'dashboard',
|
||||
resourceId: null,
|
||||
metadata: { page: 'dashboard_list' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Alerts ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Alert edit — `/alerts/edit?ruleId=…`.
|
||||
if (matchPath(pathname, { path: ROUTES.EDIT_ALERTS, exact: true })) {
|
||||
const ruleId = params.get(QueryParams.ruleId);
|
||||
if (ruleId) {
|
||||
return [
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: ruleId,
|
||||
metadata: { page: 'alert_edit' },
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (matchPath(pathname, { path: ROUTES.ALERTS_NEW, exact: true })) {
|
||||
return [
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: null,
|
||||
metadata: { page: 'alert_new' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (matchPath(pathname, { path: ROUTES.ALERT_HISTORY, exact: true })) {
|
||||
return [
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: null,
|
||||
metadata: {
|
||||
page: 'alerts_triggered',
|
||||
...sharedMetadata,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (matchPath(pathname, { path: ROUTES.LIST_ALL_ALERT, exact: true })) {
|
||||
return [
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: null,
|
||||
metadata: { page: 'alert_list' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Services ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Service detail (covers sub-routes like top-level-operations) —
|
||||
// `/services/:servicename[/...]`.
|
||||
const serviceMatch = matchPath<{ servicename: string }>(pathname, {
|
||||
path: ROUTES.SERVICE_METRICS,
|
||||
exact: false,
|
||||
});
|
||||
if (serviceMatch?.params.servicename) {
|
||||
return [
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'service',
|
||||
resourceId: serviceMatch.params.servicename,
|
||||
metadata: {
|
||||
page: 'service_detail',
|
||||
...sharedMetadata,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Services list — `/services`.
|
||||
if (matchPath(pathname, { path: ROUTES.APPLICATION, exact: true })) {
|
||||
return [
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'service',
|
||||
resourceId: null,
|
||||
metadata: {
|
||||
page: 'services_list',
|
||||
...sharedMetadata,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Logs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
if (matchPath(pathname, { path: ROUTES.LOGS_EXPLORER, exact: false })) {
|
||||
const activeLogId = params.get(QueryParams.activeLogId);
|
||||
// `?activeLogId=…` indicates a log-detail panel is open. Per the
|
||||
// schema, log_detail requires payload fields (timestamp, service,
|
||||
// body) we can't recover from the URL alone, so we still emit the
|
||||
// surrounding logs_explorer page context — the panel registry can
|
||||
// layer richer log_detail context on top in a follow-up.
|
||||
return [
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'logs_explorer',
|
||||
resourceId: null,
|
||||
metadata: {
|
||||
page: 'logs_explorer',
|
||||
...sharedMetadata,
|
||||
...(activeLogId ? { activeLogId } : {}),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Traces ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Trace detail — `/trace/:id`. Treated as a detail-as-metadata page
|
||||
// (resourceId null, `traceId` lives in metadata).
|
||||
const traceMatch = matchPath<{ id: string }>(pathname, {
|
||||
path: ROUTES.TRACE_DETAIL,
|
||||
exact: true,
|
||||
});
|
||||
if (traceMatch?.params.id) {
|
||||
const spanId = params.get(QueryParams.selectedTimelineQuery);
|
||||
return [
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'traces_explorer',
|
||||
resourceId: null,
|
||||
metadata: {
|
||||
page: 'trace_detail',
|
||||
traceId: traceMatch.params.id,
|
||||
...(spanId ? { spanId } : {}),
|
||||
...sharedMetadata,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (matchPath(pathname, { path: ROUTES.TRACES_EXPLORER, exact: false })) {
|
||||
return [
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'traces_explorer',
|
||||
resourceId: null,
|
||||
metadata: {
|
||||
page: 'traces_explorer',
|
||||
...sharedMetadata,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Metrics ───────────────────────────────────────────────────────────────
|
||||
|
||||
if (
|
||||
matchPath(pathname, { path: ROUTES.METRICS_EXPLORER_EXPLORER, exact: false })
|
||||
) {
|
||||
return [
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'metrics_explorer',
|
||||
resourceId: null,
|
||||
metadata: {
|
||||
page: 'metrics_explorer',
|
||||
...sharedMetadata,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls metadata fields that any page may carry in its query string —
|
||||
* `timeRange`, `query`, saved-view selectors, dashboard variables. Each
|
||||
* piece is omitted when the URL doesn't carry it (rather than being filled
|
||||
* in with a stale default).
|
||||
*/
|
||||
function collectSharedMetadata(
|
||||
params: URLSearchParams,
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
|
||||
// Time range — emit only when both bounds are explicit. SigNoz writes
|
||||
// `startTime` / `endTime` in milliseconds when the user picks a custom
|
||||
// range; relative ranges (`relativeTime=15m`) are left out because the
|
||||
// server applies its own freshly-anchored window.
|
||||
const startTime = numericParam(params, QueryParams.startTime);
|
||||
const endTime = numericParam(params, QueryParams.endTime);
|
||||
if (startTime !== null && endTime !== null) {
|
||||
out.timeRange = { start: startTime, end: endTime };
|
||||
}
|
||||
|
||||
// Query Builder state — URL-encoded JSON written by `QueryBuilderProvider`.
|
||||
const compositeQueryRaw = params.get(QueryParams.compositeQuery);
|
||||
if (compositeQueryRaw) {
|
||||
try {
|
||||
out.query = JSON.parse(decodeURIComponent(compositeQueryRaw));
|
||||
} catch {
|
||||
// Malformed JSON in the URL — drop silently rather than throw
|
||||
// inside a context-collection helper.
|
||||
}
|
||||
}
|
||||
|
||||
// Saved view selectors (logs / traces explorer) and dashboard variables.
|
||||
const viewKey = params.get(QueryParams.viewKey);
|
||||
if (viewKey) {
|
||||
out.savedViewId = viewKey;
|
||||
}
|
||||
const viewName = params.get(QueryParams.viewName);
|
||||
if (viewName) {
|
||||
out.savedViewName = viewName;
|
||||
}
|
||||
const variablesRaw = params.get(QueryParams.variables);
|
||||
if (variablesRaw) {
|
||||
try {
|
||||
out.variables = JSON.parse(decodeURIComponent(variablesRaw));
|
||||
} catch {
|
||||
// Same fallback as compositeQuery — keep silent on malformed JSON.
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function numericParam(params: URLSearchParams, key: string): number | null {
|
||||
const raw = params.get(key);
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
const value = Number(raw);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
@@ -287,14 +287,11 @@ async function runStreamingLoop(
|
||||
set((s) => {
|
||||
const st = s.streams[conversationId];
|
||||
if (st) {
|
||||
st.pendingApproval = {
|
||||
approvalId: event.approvalId,
|
||||
executionId: event.executionId,
|
||||
actionType: event.actionType,
|
||||
resourceType: event.resourceType,
|
||||
summary: event.summary,
|
||||
diff: event.diff ?? null,
|
||||
};
|
||||
// The SSE event is already an `ApprovalEventDTO`, the same
|
||||
// shape the slot is typed against — pass through verbatim
|
||||
// rather than re-projecting (which previously dropped any
|
||||
// fields the projection didn't enumerate explicitly).
|
||||
st.pendingApproval = event;
|
||||
st.streamingStatus = 'awaiting_approval';
|
||||
st.isStreaming = false;
|
||||
}
|
||||
@@ -304,20 +301,11 @@ async function runStreamingLoop(
|
||||
set((s) => {
|
||||
const st = s.streams[conversationId];
|
||||
if (st) {
|
||||
st.pendingClarification = {
|
||||
clarificationId: event.clarificationId,
|
||||
executionId: event.executionId,
|
||||
message: event.message,
|
||||
discoveredContext: event.discoveredContext ?? null,
|
||||
fields: (event.fields ?? []).map((f) => ({
|
||||
id: f.id,
|
||||
type: f.type,
|
||||
label: f.label,
|
||||
required: f.required ?? false,
|
||||
options: f.options ?? null,
|
||||
default: f.default ?? null,
|
||||
})),
|
||||
};
|
||||
// Same rationale as the approval branch — `event` is a
|
||||
// `ClarificationEventDTO` whose `fields` already carry
|
||||
// `allowCustom` (which the previous manual projection
|
||||
// silently stripped).
|
||||
st.pendingClarification = event;
|
||||
st.streamingStatus = 'awaiting_clarification';
|
||||
st.isStreaming = false;
|
||||
}
|
||||
|
||||
@@ -4496,10 +4496,10 @@
|
||||
tailwind-merge "^2.5.2"
|
||||
tailwindcss-animate "^1.0.7"
|
||||
|
||||
"@signozhq/ui@0.0.10":
|
||||
version "0.0.10"
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.10.tgz#cdbab838f8cb543cf5b483a86e9d9b65265b81ff"
|
||||
integrity sha512-XLeET+PgSP7heqKMsb9YZOSRT3TpfMPHNQRnY1I4SK8mXSct7BYWwK0Q3Je0uf4Z3aWOcpRYoRUPHWZQBpweFQ==
|
||||
"@signozhq/ui@0.0.13":
|
||||
version "0.0.13"
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.13.tgz#63e64d203df662a84ffa38ea18534ba2941fd051"
|
||||
integrity sha512-YFFFNTnF9ZG9ddKhFV6feQRLUBg6vuDTcvWaStzfOCS0xF3ocd+Lpg73TpFkKidLx/Ij/TJm5u/tTobkdLyC8A==
|
||||
dependencies:
|
||||
"@chenglou/pretext" "^0.0.5"
|
||||
"@radix-ui/react-checkbox" "^1.2.3"
|
||||
@@ -4521,7 +4521,7 @@
|
||||
clsx "^2.1.1"
|
||||
cmdk "^1.1.1"
|
||||
dayjs "^1.11.10"
|
||||
lodash-es "^4.17.21"
|
||||
lodash-es "^4.18.1"
|
||||
motion "^11.11.17"
|
||||
next-themes "^0.4.6"
|
||||
nuqs "^2.8.9"
|
||||
@@ -11914,6 +11914,11 @@ lodash-es@4, lodash-es@^4.17.21:
|
||||
resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz"
|
||||
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
|
||||
|
||||
lodash-es@^4.18.1:
|
||||
version "4.18.1"
|
||||
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.18.1.tgz#b962eeb80d9d983a900bf342961fb7418ca10b1d"
|
||||
integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==
|
||||
|
||||
lodash.camelcase@^4.3.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz"
|
||||
|
||||
Reference in New Issue
Block a user