mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-17 17:30:31 +01:00
Compare commits
12 Commits
feat/markd
...
feat/ai-as
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f9f383a95 | ||
|
|
17a7227831 | ||
|
|
9c8846ae63 | ||
|
|
18bb87f778 | ||
|
|
71a5e4500c | ||
|
|
7305470e62 | ||
|
|
1e140285ae | ||
|
|
4f039da2a6 | ||
|
|
d4afc49882 | ||
|
|
2bce8c9ea0 | ||
|
|
cae757041a | ||
|
|
adabd1d8db |
39763
frontend/package-lock.json
generated
Normal file
39763
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -125,6 +125,7 @@
|
||||
"react": "18.2.0",
|
||||
"react-addons-update": "15.6.3",
|
||||
"react-beautiful-dnd": "13.1.1",
|
||||
"react-chartjs-2": "4",
|
||||
"react-dnd": "16.0.1",
|
||||
"react-dnd-html5-backend": "16.0.1",
|
||||
"react-dom": "18.2.0",
|
||||
@@ -148,6 +149,7 @@
|
||||
"redux": "^4.0.5",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"rehype-raw": "7.0.0",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"rollup-plugin-visualizer": "7.0.0",
|
||||
"rrule": "2.8.1",
|
||||
"stream": "^0.0.2",
|
||||
|
||||
9
frontend/req.md
Normal file
9
frontend/req.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# SigNoz AI Assistant
|
||||
|
||||
1. Chat interface (Side Drawer View)
|
||||
1. Should be able to expand the view to full screen (open in a new route - with converstation ID)
|
||||
2. Conversation would be stream (for in process message), the older messages would be listed (Virtualized) - older - newest
|
||||
2. Input Section
|
||||
1. Users should be able to upload images / files to the chat
|
||||
|
||||
|
||||
@@ -221,7 +221,8 @@ function App(): JSX.Element {
|
||||
useEffect(() => {
|
||||
if (
|
||||
pathname === ROUTES.ONBOARDING ||
|
||||
pathname.startsWith('/public/dashboard/')
|
||||
pathname.startsWith('/public/dashboard/') ||
|
||||
pathname.startsWith('/ai-assistant/')
|
||||
) {
|
||||
window.Pylon?.('hideChatBubble');
|
||||
} else {
|
||||
|
||||
@@ -311,3 +311,10 @@ export const MeterExplorerPage = Loadable(
|
||||
() =>
|
||||
import(/* webpackChunkName: "Meter Explorer Page" */ 'pages/MeterExplorer'),
|
||||
);
|
||||
|
||||
export const AIAssistantPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "AI Assistant Page" */ 'pages/AIAssistantPage/AIAssistantPage'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { RouteProps } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import {
|
||||
AIAssistantPage,
|
||||
AlertHistory,
|
||||
AlertOverview,
|
||||
AlertTypeSelectionPage,
|
||||
@@ -488,6 +489,13 @@ const routes: AppRoutes[] = [
|
||||
key: 'API_MONITORING',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.AI_ASSISTANT,
|
||||
exact: true,
|
||||
component: AIAssistantPage,
|
||||
key: 'AI_ASSISTANT',
|
||||
isPrivate: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const SUPPORT_ROUTE: AppRoutes = {
|
||||
|
||||
467
frontend/src/api/ai/chat.ts
Normal file
467
frontend/src/api/ai/chat.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
/**
|
||||
* AI Assistant API client.
|
||||
*
|
||||
* Flow:
|
||||
* 1. POST /api/v1/assistant/threads → { threadId }
|
||||
* 2. POST /api/v1/assistant/threads/{threadId}/messages → { executionId }
|
||||
* 3. GET /api/v1/assistant/executions/{executionId}/events → SSE stream (closes on 'done')
|
||||
*
|
||||
* For subsequent messages in the same thread, repeat steps 2–3.
|
||||
* Approval/clarification events pause the stream; use approveExecution/clarifyExecution
|
||||
* to resume, which each return a new executionId to open a fresh SSE stream.
|
||||
*/
|
||||
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
// Direct URL to the AI backend — set VITE_AI_BACKEND_URL in .env to override.
|
||||
const AI_BACKEND =
|
||||
import.meta.env.VITE_AI_BACKEND_URL || 'http://localhost:8001';
|
||||
const BASE = `${AI_BACKEND}/api/v1/assistant`;
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = getLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN) || '';
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSE event types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SSEEvent =
|
||||
| { type: 'status'; executionId: string; state: string; eventId: number }
|
||||
| {
|
||||
type: 'message';
|
||||
executionId: string;
|
||||
messageId: string;
|
||||
delta: string;
|
||||
done: boolean;
|
||||
actions: unknown[] | null;
|
||||
eventId: number;
|
||||
}
|
||||
| {
|
||||
type: 'thinking';
|
||||
executionId: string;
|
||||
content: string;
|
||||
eventId: number;
|
||||
}
|
||||
| {
|
||||
type: 'tool_call';
|
||||
executionId: string;
|
||||
toolName: string;
|
||||
toolInput: unknown;
|
||||
eventId: number;
|
||||
}
|
||||
| {
|
||||
type: 'tool_result';
|
||||
executionId: string;
|
||||
toolName: string;
|
||||
result: unknown;
|
||||
eventId: number;
|
||||
}
|
||||
| {
|
||||
type: 'approval';
|
||||
executionId: string;
|
||||
approvalId: string;
|
||||
actionType: string;
|
||||
resourceType: string;
|
||||
summary: string;
|
||||
diff: { before: unknown; after: unknown } | null;
|
||||
eventId: number;
|
||||
}
|
||||
| {
|
||||
type: 'clarification';
|
||||
executionId: string;
|
||||
clarificationId: string;
|
||||
message: string;
|
||||
discoveredContext: Record<string, unknown> | null;
|
||||
fields: ClarificationFieldRaw[];
|
||||
eventId: number;
|
||||
}
|
||||
| {
|
||||
type: 'error';
|
||||
executionId: string;
|
||||
error: { type: string; code: string; message: string; details: unknown };
|
||||
retryAction: 'auto' | 'manual' | 'none';
|
||||
eventId: number;
|
||||
}
|
||||
| { type: 'conversation'; threadId: string; title: string; eventId: number }
|
||||
| {
|
||||
type: 'done';
|
||||
executionId: string;
|
||||
tokenInput: number;
|
||||
tokenOutput: number;
|
||||
latencyMs: number;
|
||||
toolCallCount?: number;
|
||||
retryCount?: number;
|
||||
eventId: number;
|
||||
};
|
||||
|
||||
export interface ClarificationFieldRaw {
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
options?: string[] | null;
|
||||
default?: string | string[] | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 1 — Create thread
|
||||
// POST /api/v1/assistant/threads → { threadId }
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thread listing & detail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ThreadSummary {
|
||||
threadId: string;
|
||||
title: string | null;
|
||||
state: string | null;
|
||||
activeExecutionId: string | null;
|
||||
archived: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ThreadListResponse {
|
||||
threads: ThreadSummary[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface MessageSummaryBlock {
|
||||
type: string;
|
||||
content?: string;
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
toolInput?: unknown;
|
||||
result?: unknown;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
export interface MessageSummary {
|
||||
messageId: string;
|
||||
role: string;
|
||||
contentType: string;
|
||||
content: string | null;
|
||||
complete: boolean;
|
||||
toolCalls: Record<string, unknown>[] | null;
|
||||
blocks: MessageSummaryBlock[] | null;
|
||||
actions: unknown[] | null;
|
||||
feedbackRating: 'positive' | 'negative' | null;
|
||||
feedbackComment: string | null;
|
||||
executionId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ThreadDetailResponse {
|
||||
threadId: string;
|
||||
title: string | null;
|
||||
state: string | null;
|
||||
activeExecutionId: string | null;
|
||||
archived: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
messages: MessageSummary[];
|
||||
pendingApproval: unknown | null;
|
||||
pendingClarification: unknown | null;
|
||||
}
|
||||
|
||||
export async function listThreads(
|
||||
cursor?: string | null,
|
||||
limit = 20,
|
||||
): Promise<ThreadListResponse> {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (cursor) {
|
||||
params.set('cursor', cursor);
|
||||
}
|
||||
const res = await fetch(`${BASE}/threads?${params.toString()}`, {
|
||||
headers: { ...authHeaders() },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to list threads: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateThread(
|
||||
threadId: string,
|
||||
update: { title?: string | null; archived?: boolean | null },
|
||||
): Promise<ThreadSummary> {
|
||||
const res = await fetch(`${BASE}/threads/${threadId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(update),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to update thread: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getThreadDetail(
|
||||
threadId: string,
|
||||
): Promise<ThreadDetailResponse> {
|
||||
const res = await fetch(`${BASE}/threads/${threadId}`, {
|
||||
headers: { ...authHeaders() },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to get thread: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thread creation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createThread(signal?: AbortSignal): Promise<string> {
|
||||
const res = await fetch(`${BASE}/threads`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({}),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to create thread: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
const data: { threadId: string } = await res.json();
|
||||
return data.threadId;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 2 — Send message
|
||||
// POST /api/v1/assistant/threads/{threadId}/messages → { executionId }
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Fetches the thread's active executionId for reconnect on thread_busy (409). */
|
||||
async function getActiveExecutionId(threadId: string): Promise<string | null> {
|
||||
const res = await fetch(`${BASE}/threads/${threadId}`, {
|
||||
headers: { ...authHeaders() },
|
||||
});
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
const data: { activeExecutionId?: string | null } = await res.json();
|
||||
return data.activeExecutionId ?? null;
|
||||
}
|
||||
|
||||
export async function sendMessage(
|
||||
threadId: string,
|
||||
content: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const res = await fetch(`${BASE}/threads/${threadId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ content }),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (res.status === 409) {
|
||||
// Thread has an active execution — reconnect to it instead of failing.
|
||||
const executionId = await getActiveExecutionId(threadId);
|
||||
if (executionId) {
|
||||
return executionId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to send message: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
const data: { executionId: string } = await res.json();
|
||||
return data.executionId;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 3 — Stream execution events
|
||||
// GET /api/v1/assistant/executions/{executionId}/events → SSE
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseSSELine(line: string): SSEEvent | null {
|
||||
if (!line.startsWith('data: ')) {
|
||||
return null;
|
||||
}
|
||||
const json = line.slice('data: '.length).trim();
|
||||
if (!json || json === '[DONE]') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(json) as SSEEvent;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseSSEChunk(chunk: string): SSEEvent[] {
|
||||
return chunk
|
||||
.split('\n\n')
|
||||
.map((part) => part.split('\n').find((l) => l.startsWith('data: ')) ?? '')
|
||||
.map(parseSSELine)
|
||||
.filter((e): e is SSEEvent => e !== null);
|
||||
}
|
||||
|
||||
async function* readSSEReader(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
): AsyncGenerator<SSEEvent> {
|
||||
const decoder = new TextDecoder();
|
||||
let lineBuffer = '';
|
||||
try {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
lineBuffer += decoder.decode(value, { stream: true });
|
||||
const parts = lineBuffer.split('\n\n');
|
||||
lineBuffer = parts.pop() ?? '';
|
||||
yield* parts.flatMap(parseSSEChunk);
|
||||
}
|
||||
yield* parseSSEChunk(lineBuffer);
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
export async function* streamEvents(
|
||||
executionId: string,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<SSEEvent> {
|
||||
const res = await fetch(`${BASE}/executions/${executionId}/events`, {
|
||||
headers: { ...authHeaders() },
|
||||
signal,
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
throw new Error(`SSE stream failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
yield* readSSEReader(res.body.getReader());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Approval / Clarification / Cancel actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Approve a pending action. Returns a new executionId — open a fresh SSE stream for it. */
|
||||
export async function approveExecution(
|
||||
approvalId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const res = await fetch(`${BASE}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ approvalId }),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to approve: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
const data: { executionId: string } = await res.json();
|
||||
return data.executionId;
|
||||
}
|
||||
|
||||
/** Reject a pending action. */
|
||||
export async function rejectExecution(
|
||||
approvalId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${BASE}/reject`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ approvalId }),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to reject: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Submit clarification answers. Returns a new executionId — open a fresh SSE stream for it. */
|
||||
export async function clarifyExecution(
|
||||
clarificationId: string,
|
||||
answers: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const res = await fetch(`${BASE}/clarify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ clarificationId, answers }),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to clarify: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
const data: { executionId: string } = await res.json();
|
||||
return data.executionId;
|
||||
}
|
||||
|
||||
/** Cancel the active execution on a thread. */
|
||||
export async function cancelExecution(
|
||||
threadId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${BASE}/cancel`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ threadId }),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to cancel: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Feedback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FeedbackRating = 'positive' | 'negative';
|
||||
|
||||
export async function submitFeedback(
|
||||
messageId: string,
|
||||
rating: FeedbackRating,
|
||||
comment?: string,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${BASE}/messages/${messageId}/feedback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ rating, comment: comment ?? null }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to submit feedback: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button as PeriscopeButton } from '@signozhq/button';
|
||||
import { Tooltip as PeriscopeTooltip } from '@signozhq/tooltip';
|
||||
import { Button, Popover } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import AIAssistantIcon from 'container/AIAssistant/components/AIAssistantIcon';
|
||||
import { openAIAssistant, useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { Globe, Inbox, SquarePen } from 'lucide-react';
|
||||
|
||||
@@ -67,9 +71,23 @@ function HeaderRightSection({
|
||||
};
|
||||
|
||||
const isLicenseEnabled = isEnterpriseSelfHostedUser || isCloudUser;
|
||||
const isDrawerOpen = useAIAssistantStore((s) => s.isDrawerOpen);
|
||||
|
||||
return (
|
||||
<div className="header-right-section-container">
|
||||
{!isDrawerOpen && (
|
||||
<PeriscopeTooltip title="AI Assistant">
|
||||
<PeriscopeButton
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={openAIAssistant}
|
||||
aria-label="Open AI Assistant"
|
||||
>
|
||||
<AIAssistantIcon size={18} />
|
||||
</PeriscopeButton>
|
||||
</PeriscopeTooltip>
|
||||
)}
|
||||
|
||||
{enableFeedback && isLicenseEnabled && (
|
||||
<Popover
|
||||
rootClassName="header-section-popover-root"
|
||||
|
||||
@@ -86,6 +86,8 @@ const ROUTES = {
|
||||
HOME_PAGE: '/',
|
||||
PUBLIC_DASHBOARD: '/public/dashboard/:dashboardId',
|
||||
SERVICE_ACCOUNTS_SETTINGS: '/settings/service-accounts',
|
||||
AI_ASSISTANT: '/ai-assistant/:conversationId',
|
||||
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
|
||||
} as const;
|
||||
|
||||
export default ROUTES;
|
||||
|
||||
1852
frontend/src/container/AIAssistant/AIAssistant.styles.scss
Normal file
1852
frontend/src/container/AIAssistant/AIAssistant.styles.scss
Normal file
File diff suppressed because it is too large
Load Diff
97
frontend/src/container/AIAssistant/AIAssistantDrawer.tsx
Normal file
97
frontend/src/container/AIAssistant/AIAssistantDrawer.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Drawer, Tooltip } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Maximize2, MessageSquare, Plus, X } from 'lucide-react';
|
||||
|
||||
import ConversationView from './ConversationView';
|
||||
import { useAIAssistantStore } from './store/useAIAssistantStore';
|
||||
|
||||
import './AIAssistant.styles.scss';
|
||||
|
||||
export default function AIAssistantDrawer(): JSX.Element {
|
||||
const history = useHistory();
|
||||
|
||||
const isDrawerOpen = useAIAssistantStore((s) => s.isDrawerOpen);
|
||||
const activeConversationId = useAIAssistantStore(
|
||||
(s) => s.activeConversationId,
|
||||
);
|
||||
const closeDrawer = useAIAssistantStore((s) => s.closeDrawer);
|
||||
const startNewConversation = useAIAssistantStore(
|
||||
(s) => s.startNewConversation,
|
||||
);
|
||||
|
||||
const handleExpand = useCallback(() => {
|
||||
if (!activeConversationId) {
|
||||
return;
|
||||
}
|
||||
closeDrawer();
|
||||
history.push(
|
||||
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
|
||||
);
|
||||
}, [activeConversationId, closeDrawer, history]);
|
||||
|
||||
const handleNewConversation = useCallback(() => {
|
||||
startNewConversation();
|
||||
}, [startNewConversation]);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={isDrawerOpen}
|
||||
onClose={closeDrawer}
|
||||
placement="right"
|
||||
width={420}
|
||||
className="ai-assistant-drawer"
|
||||
// Suppress default close button — we render our own header
|
||||
closeIcon={null}
|
||||
title={
|
||||
<div className="ai-assistant-drawer__header">
|
||||
<div className="ai-assistant-drawer__title">
|
||||
<MessageSquare size={16} />
|
||||
<span>AI Assistant</span>
|
||||
</div>
|
||||
|
||||
<div className="ai-assistant-drawer__actions">
|
||||
<Tooltip title="New conversation">
|
||||
<button
|
||||
type="button"
|
||||
className="ai-assistant-drawer__action-btn"
|
||||
onClick={handleNewConversation}
|
||||
aria-label="New conversation"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Open full screen">
|
||||
<button
|
||||
type="button"
|
||||
className="ai-assistant-drawer__action-btn"
|
||||
onClick={handleExpand}
|
||||
disabled={!activeConversationId}
|
||||
aria-label="Open full screen"
|
||||
>
|
||||
<Maximize2 size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Close">
|
||||
<button
|
||||
type="button"
|
||||
className="ai-assistant-drawer__action-btn"
|
||||
onClick={closeDrawer}
|
||||
aria-label="Close drawer"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{activeConversationId ? (
|
||||
<ConversationView conversationId={activeConversationId} />
|
||||
) : null}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
223
frontend/src/container/AIAssistant/AIAssistantModal.tsx
Normal file
223
frontend/src/container/AIAssistant/AIAssistantModal.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Tooltip } from '@signozhq/tooltip';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Eraser, History, Maximize2, Minus, Plus, X } from 'lucide-react';
|
||||
|
||||
import AIAssistantIcon from './components/AIAssistantIcon';
|
||||
import HistorySidebar from './components/HistorySidebar';
|
||||
import ConversationView from './ConversationView';
|
||||
import { useAIAssistantStore } from './store/useAIAssistantStore';
|
||||
|
||||
import './AIAssistant.styles.scss';
|
||||
|
||||
/**
|
||||
* Global floating modal for the AI Assistant.
|
||||
*
|
||||
* - Triggered by Cmd+P (Mac) / Ctrl+P (Windows/Linux)
|
||||
* - Escape or the × button fully closes it
|
||||
* - The − (minimize) button collapses to the side panel
|
||||
* - Mounted once in AppLayout; always in the DOM, conditionally visible
|
||||
*/
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export default function AIAssistantModal(): JSX.Element | null {
|
||||
const history = useHistory();
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
const isOpen = useAIAssistantStore((s) => s.isModalOpen);
|
||||
const activeConversationId = useAIAssistantStore(
|
||||
(s) => s.activeConversationId,
|
||||
);
|
||||
const openModal = useAIAssistantStore((s) => s.openModal);
|
||||
const closeModal = useAIAssistantStore((s) => s.closeModal);
|
||||
const minimizeModal = useAIAssistantStore((s) => s.minimizeModal);
|
||||
const startNewConversation = useAIAssistantStore(
|
||||
(s) => s.startNewConversation,
|
||||
);
|
||||
const clearConversation = useAIAssistantStore((s) => s.clearConversation);
|
||||
|
||||
// ── Keyboard shortcuts ──────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
// Cmd+P (Mac) / Ctrl+P (Win/Linux) — toggle modal
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'p') {
|
||||
// Don't intercept Cmd+P inside input/textarea — those are for the user
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault(); // stop browser print dialog
|
||||
if (isOpen) {
|
||||
closeModal();
|
||||
} else {
|
||||
openModal();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape — close modal
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return (): void => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, openModal, closeModal]);
|
||||
|
||||
// ── Handlers ────────────────────────────────────────────────────────────────
|
||||
|
||||
const handleExpand = useCallback(() => {
|
||||
if (!activeConversationId) {
|
||||
return;
|
||||
}
|
||||
closeModal();
|
||||
history.push(
|
||||
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
|
||||
);
|
||||
}, [activeConversationId, closeModal, history]);
|
||||
|
||||
const handleNew = useCallback(() => {
|
||||
startNewConversation();
|
||||
setShowHistory(false);
|
||||
}, [startNewConversation]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
if (activeConversationId) {
|
||||
clearConversation(activeConversationId);
|
||||
}
|
||||
}, [activeConversationId, clearConversation]);
|
||||
|
||||
const handleHistorySelect = useCallback(() => {
|
||||
setShowHistory(false);
|
||||
}, []);
|
||||
|
||||
const handleMinimize = useCallback(() => {
|
||||
minimizeModal();
|
||||
setShowHistory(false);
|
||||
}, [minimizeModal]);
|
||||
|
||||
const handleBackdropClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Only close when clicking the backdrop itself, not the modal card
|
||||
if (e.target === e.currentTarget) {
|
||||
closeModal();
|
||||
}
|
||||
},
|
||||
[closeModal],
|
||||
);
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="ai-modal-backdrop"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="AI Assistant"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="ai-modal">
|
||||
{/* Header */}
|
||||
<div className="ai-modal__header">
|
||||
<div className="ai-modal__title">
|
||||
<AIAssistantIcon size={16} />
|
||||
<span>AI Assistant</span>
|
||||
<kbd className="ai-modal__shortcut">⌘P</kbd>
|
||||
</div>
|
||||
|
||||
<div className="ai-modal__actions">
|
||||
<Tooltip title={showHistory ? 'Back to chat' : 'Chat history'}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={(): void => setShowHistory((v) => !v)}
|
||||
aria-label="Toggle history"
|
||||
className={showHistory ? 'ai-panel-btn--active' : ''}
|
||||
>
|
||||
<History size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Clear chat">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleClear}
|
||||
disabled={!activeConversationId || showHistory}
|
||||
aria-label="Clear chat"
|
||||
>
|
||||
<Eraser size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="New conversation">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleNew}
|
||||
aria-label="New conversation"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Open full screen">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleExpand}
|
||||
disabled={!activeConversationId}
|
||||
aria-label="Open full screen"
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Minimize to side panel">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleMinimize}
|
||||
aria-label="Minimize to side panel"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Close">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={closeModal}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="ai-modal__body">
|
||||
{showHistory ? (
|
||||
<HistorySidebar onSelect={handleHistorySelect} />
|
||||
) : (
|
||||
activeConversationId && (
|
||||
<ConversationView conversationId={activeConversationId} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
180
frontend/src/container/AIAssistant/AIAssistantPanel.tsx
Normal file
180
frontend/src/container/AIAssistant/AIAssistantPanel.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { matchPath, useHistory, useLocation } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Tooltip } from '@signozhq/tooltip';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Eraser, History, Maximize2, Plus, X } from 'lucide-react';
|
||||
|
||||
import AIAssistantIcon from './components/AIAssistantIcon';
|
||||
import HistorySidebar from './components/HistorySidebar';
|
||||
import ConversationView from './ConversationView';
|
||||
import { useAIAssistantStore } from './store/useAIAssistantStore';
|
||||
|
||||
import './AIAssistant.styles.scss';
|
||||
|
||||
export default function AIAssistantPanel(): JSX.Element | null {
|
||||
const history = useHistory();
|
||||
const { pathname } = useLocation();
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
const isOpen = useAIAssistantStore((s) => s.isDrawerOpen);
|
||||
const isFullScreenPage = !!matchPath(pathname, {
|
||||
path: ROUTES.AI_ASSISTANT,
|
||||
exact: true,
|
||||
});
|
||||
const activeConversationId = useAIAssistantStore(
|
||||
(s) => s.activeConversationId,
|
||||
);
|
||||
const closeDrawer = useAIAssistantStore((s) => s.closeDrawer);
|
||||
const startNewConversation = useAIAssistantStore(
|
||||
(s) => s.startNewConversation,
|
||||
);
|
||||
const clearConversation = useAIAssistantStore((s) => s.clearConversation);
|
||||
|
||||
const handleExpand = useCallback(() => {
|
||||
if (!activeConversationId) {
|
||||
return;
|
||||
}
|
||||
closeDrawer();
|
||||
history.push(
|
||||
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
|
||||
);
|
||||
}, [activeConversationId, closeDrawer, history]);
|
||||
|
||||
const handleNew = useCallback(() => {
|
||||
startNewConversation();
|
||||
setShowHistory(false);
|
||||
}, [startNewConversation]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
if (activeConversationId) {
|
||||
clearConversation(activeConversationId);
|
||||
}
|
||||
}, [activeConversationId, clearConversation]);
|
||||
|
||||
// When user picks a conversation from history, close the sidebar
|
||||
const handleHistorySelect = useCallback(() => {
|
||||
setShowHistory(false);
|
||||
}, []);
|
||||
|
||||
// ── Resize logic ──────────────────────────────────────────────────────────
|
||||
const [panelWidth, setPanelWidth] = useState(380);
|
||||
const dragStartX = useRef(0);
|
||||
const dragStartWidth = useRef(0);
|
||||
|
||||
const handleResizeMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dragStartX.current = e.clientX;
|
||||
dragStartWidth.current = panelWidth;
|
||||
|
||||
const onMouseMove = (ev: MouseEvent): void => {
|
||||
// Panel is on the right; dragging left (lower clientX) increases width
|
||||
const delta = dragStartX.current - ev.clientX;
|
||||
const next = Math.min(Math.max(dragStartWidth.current + delta, 380), 800);
|
||||
setPanelWidth(next);
|
||||
};
|
||||
|
||||
const onMouseUp = (): void => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
},
|
||||
[panelWidth],
|
||||
);
|
||||
|
||||
if (!isOpen || isFullScreenPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-assistant-panel" style={{ width: panelWidth }}>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className="ai-assistant-panel__resize-handle"
|
||||
onMouseDown={handleResizeMouseDown}
|
||||
/>
|
||||
<div className="ai-assistant-panel__header">
|
||||
<div className="ai-assistant-panel__title">
|
||||
<AIAssistantIcon size={18} />
|
||||
<span>AI Assistant</span>
|
||||
</div>
|
||||
|
||||
<div className="ai-assistant-panel__actions">
|
||||
<Tooltip title={showHistory ? 'Back to chat' : 'Chat history'}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={(): void => setShowHistory((v) => !v)}
|
||||
aria-label="Toggle history"
|
||||
className={showHistory ? 'ai-panel-btn--active' : ''}
|
||||
>
|
||||
<History size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Clear chat">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleClear}
|
||||
disabled={!activeConversationId || showHistory}
|
||||
aria-label="Clear chat"
|
||||
>
|
||||
<Eraser size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="New conversation">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleNew}
|
||||
aria-label="New conversation"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Open full screen">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleExpand}
|
||||
disabled={!activeConversationId}
|
||||
aria-label="Open full screen"
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Close">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={closeDrawer}
|
||||
aria-label="Close panel"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showHistory ? (
|
||||
<HistorySidebar onSelect={handleHistorySelect} />
|
||||
) : (
|
||||
activeConversationId && (
|
||||
<ConversationView conversationId={activeConversationId} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
frontend/src/container/AIAssistant/AIAssistantTrigger.tsx
Normal file
43
frontend/src/container/AIAssistant/AIAssistantTrigger.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { Tooltip } from '@signozhq/tooltip';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Bot } from 'lucide-react';
|
||||
|
||||
import {
|
||||
openAIAssistant,
|
||||
useAIAssistantStore,
|
||||
} from './store/useAIAssistantStore';
|
||||
|
||||
import './AIAssistant.styles.scss';
|
||||
|
||||
/**
|
||||
* Floating action button anchored to the bottom-right of the content area.
|
||||
* Hidden when the panel is already open or when on the full-screen AI Assistant page.
|
||||
*/
|
||||
export default function AIAssistantTrigger(): JSX.Element | null {
|
||||
const { pathname } = useLocation();
|
||||
const isDrawerOpen = useAIAssistantStore((s) => s.isDrawerOpen);
|
||||
const isModalOpen = useAIAssistantStore((s) => s.isModalOpen);
|
||||
|
||||
const isFullScreenPage = !!matchPath(pathname, {
|
||||
path: ROUTES.AI_ASSISTANT,
|
||||
exact: true,
|
||||
});
|
||||
|
||||
if (isDrawerOpen || isModalOpen || isFullScreenPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title="AI Assistant">
|
||||
<button
|
||||
type="button"
|
||||
className="ai-trigger"
|
||||
onClick={openAIAssistant}
|
||||
aria-label="Open AI Assistant"
|
||||
>
|
||||
<Bot size={20} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
81
frontend/src/container/AIAssistant/ConversationView.tsx
Normal file
81
frontend/src/container/AIAssistant/ConversationView.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import ChatInput from './components/ChatInput';
|
||||
import VirtualizedMessages from './components/VirtualizedMessages';
|
||||
import { useAIAssistantStore } from './store/useAIAssistantStore';
|
||||
import { MessageAttachment } from './types';
|
||||
|
||||
interface ConversationViewProps {
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
export default function ConversationView({
|
||||
conversationId,
|
||||
}: ConversationViewProps): JSX.Element {
|
||||
const conversation = useAIAssistantStore(
|
||||
(s) => s.conversations[conversationId],
|
||||
);
|
||||
const isStreamingHere = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.isStreaming ?? false,
|
||||
);
|
||||
const isLoadingThread = useAIAssistantStore((s) => s.isLoadingThread);
|
||||
const pendingApprovalHere = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.pendingApproval ?? null,
|
||||
);
|
||||
const pendingClarificationHere = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.pendingClarification ?? null,
|
||||
);
|
||||
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
|
||||
const cancelStream = useAIAssistantStore((s) => s.cancelStream);
|
||||
|
||||
const handleSend = useCallback(
|
||||
(text: string, attachments?: MessageAttachment[]) => {
|
||||
sendMessage(text, attachments);
|
||||
},
|
||||
[sendMessage],
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
cancelStream(conversationId);
|
||||
}, [cancelStream, conversationId]);
|
||||
|
||||
const messages = conversation?.messages ?? [];
|
||||
const inputDisabled =
|
||||
isStreamingHere ||
|
||||
isLoadingThread ||
|
||||
Boolean(pendingApprovalHere) ||
|
||||
Boolean(pendingClarificationHere);
|
||||
|
||||
if (isLoadingThread && messages.length === 0) {
|
||||
return (
|
||||
<div className="ai-conversation">
|
||||
<div className="ai-conversation__loading">
|
||||
<Loader2 size={20} className="ai-history__spinner" />
|
||||
Loading conversation…
|
||||
</div>
|
||||
<div className="ai-conversation__input-wrapper">
|
||||
<ChatInput onSend={handleSend} disabled />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-conversation">
|
||||
<VirtualizedMessages
|
||||
conversationId={conversationId}
|
||||
messages={messages}
|
||||
isStreaming={isStreamingHere}
|
||||
/>
|
||||
<div className="ai-conversation__input-wrapper">
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
onCancel={handleCancel}
|
||||
disabled={inputDisabled}
|
||||
isStreaming={isStreamingHere}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
649
frontend/src/container/AIAssistant/PAGE_ACTIONS_DESIGN.md
Normal file
649
frontend/src/container/AIAssistant/PAGE_ACTIONS_DESIGN.md
Normal file
@@ -0,0 +1,649 @@
|
||||
# Page-Aware AI Action System — Technical Design
|
||||
|
||||
**Status:** Draft
|
||||
**Author:** AI Assistant
|
||||
**Created:** 2026-03-31
|
||||
**Scope:** Frontend — AI Assistant integration with page-specific actions
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The Page-Aware AI Action System extends the AI Assistant so that it can understand what page the user is currently on, read the page's live state (active filters, time range, selected entities, etc.), and execute actions available on that page — all through a natural-language conversation.
|
||||
|
||||
### Goals
|
||||
|
||||
- Let users query, filter, and navigate each SigNoz page by talking to the AI
|
||||
- Let users create and modify entities (dashboards, alerts, saved views) via the AI
|
||||
- Keep page-specific wiring isolated and co-located with each page — not inside the AI core
|
||||
- Zero-friction adoption: adding AI support to a new page is a single `usePageActions(...)` call
|
||||
- Prevent the AI from silently mutating state — every action requires explicit user confirmation
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- Backend AI model training or fine-tuning
|
||||
- Real-time data streaming inside the AI chat (charts already handle that via existing blocks)
|
||||
- Cross-session memory of user preferences (deferred to a future persistent-context system)
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture Diagram
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ Browser │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ Active Page (e.g. LogsExplorer) │ │
|
||||
│ │ │ │
|
||||
│ │ usePageActions('logs-explorer', [...actions]) │ │
|
||||
│ │ │ registers on mount │ │
|
||||
│ │ │ unregisters on unmount │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ ┌──────────────────┐ │ │
|
||||
│ │ │ PageActionRegistry│ ◄── singleton │ │
|
||||
│ │ │ Map<id, Action> │ │ │
|
||||
│ │ └────────┬─────────┘ │ │
|
||||
│ └───────────┼─────────────────────────────────────┘ │
|
||||
│ │ getAll() + context snapshot │
|
||||
│ ▼ │
|
||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||
│ │ AI Assistant (Drawer / Full-page) │ │
|
||||
│ │ │ │
|
||||
│ │ sendMessage() │ │
|
||||
│ │ ├── builds [PAGE_CONTEXT] block from registry │ │
|
||||
│ │ ├── appends user text │ │
|
||||
│ │ └── sends to API ──────────────────────────────────► │ │
|
||||
│ │ AI Backend / Mock │ │
|
||||
│ │ ◄── streaming response │ │
|
||||
│ │ │ │
|
||||
│ │ MessageBubble │ │
|
||||
│ │ └── RichCodeBlock detects ```ai-action │ │
|
||||
│ │ └── ActionBlock │ │
|
||||
│ │ ├── renders description + param preview │ │
|
||||
│ │ ├── Accept → PageActionRegistry.execute() │ │
|
||||
│ │ └── Reject → no-op │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Data Model
|
||||
|
||||
### 3.1 `PageAction<TParams>`
|
||||
|
||||
The descriptor for a single action a page exposes to the AI.
|
||||
|
||||
```typescript
|
||||
interface PageAction<TParams = Record<string, unknown>> {
|
||||
/**
|
||||
* Stable identifier, dot-namespaced by page.
|
||||
* e.g. "logs.runQuery", "dashboard.createPanel", "alert.save"
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Natural-language description sent verbatim in the page context block.
|
||||
* The AI uses this to decide which action to invoke.
|
||||
*/
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* JSON Schema (draft-07) describing the parameters this action accepts.
|
||||
* Sent to the AI so it can generate structurally valid calls.
|
||||
*/
|
||||
parameters: JSONSchemaObject;
|
||||
|
||||
/**
|
||||
* Performs the action. Resolves with a result the AI can narrate back to
|
||||
* the user. Rejects if the action cannot be completed.
|
||||
*/
|
||||
execute: (params: TParams) => Promise<ActionResult>;
|
||||
|
||||
/**
|
||||
* Optional snapshot of the current page state.
|
||||
* Called at message-send time so the AI has fresh context.
|
||||
* Return value is JSON-serialised into the [PAGE_CONTEXT] block.
|
||||
*/
|
||||
getContext?: () => unknown;
|
||||
}
|
||||
|
||||
interface ActionResult {
|
||||
/** Short human-readable outcome: "Query updated with 2 new filters." */
|
||||
summary: string;
|
||||
/** Optional structured data the AI block can display (e.g. a new URL) */
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type JSONSchemaObject = {
|
||||
type: 'object';
|
||||
properties: Record<string, JSONSchemaProperty>;
|
||||
required?: string[];
|
||||
};
|
||||
```
|
||||
|
||||
### 3.2 `PageActionDescriptor`
|
||||
|
||||
A lightweight, serialisable version of `PageAction` — safe to include in the API payload (no function references).
|
||||
|
||||
```typescript
|
||||
interface PageActionDescriptor {
|
||||
id: string;
|
||||
description: string;
|
||||
parameters: JSONSchemaObject;
|
||||
context?: unknown; // snapshot from getContext()
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 `AIActionBlock`
|
||||
|
||||
The JSON payload the AI emits inside an ` ```ai-action ``` ` fenced block when it wants to invoke an action.
|
||||
|
||||
```typescript
|
||||
interface AIActionBlock {
|
||||
/** Must match a registered PageAction.id */
|
||||
actionId: string;
|
||||
|
||||
/**
|
||||
* One-sentence explanation of what the action will do.
|
||||
* Displayed in the confirmation card.
|
||||
*/
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* Parameters the AI chose for this action.
|
||||
* Validated against the action's JSON Schema before execute() is called.
|
||||
*/
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. PageActionRegistry
|
||||
|
||||
A module-level singleton (like `BlockRegistry`). Keeps a flat `Map<id, PageAction>` so look-up is O(1). Supports batch register/unregister keyed by a `pageId` so a page can remove all its actions at once on unmount.
|
||||
|
||||
```
|
||||
src/container/AIAssistant/pageActions/PageActionRegistry.ts
|
||||
```
|
||||
|
||||
### Interface
|
||||
|
||||
```typescript
|
||||
const PageActionRegistry = {
|
||||
/** Register a set of actions under a page scope key. */
|
||||
register(pageId: string, actions: PageAction[]): void;
|
||||
|
||||
/** Remove all actions registered under a page scope key. */
|
||||
unregister(pageId: string): void;
|
||||
|
||||
/** Look up a single action by its dot-namespaced id. */
|
||||
get(actionId: string): PageAction | undefined;
|
||||
|
||||
/**
|
||||
* Return serialisable descriptors for all currently registered actions,
|
||||
* with context snapshots already collected.
|
||||
*/
|
||||
snapshot(): PageActionDescriptor[];
|
||||
};
|
||||
```
|
||||
|
||||
### Internal structure
|
||||
|
||||
```typescript
|
||||
// pageId → action[] (for clean unregister)
|
||||
const _byPage = new Map<string, PageAction[]>();
|
||||
|
||||
// actionId → action (for O(1) lookup at execute time)
|
||||
const _byId = new Map<string, PageAction>();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. `usePageActions` Hook
|
||||
|
||||
Pages call this hook to register their actions declaratively. React lifecycle handles cleanup.
|
||||
|
||||
```
|
||||
src/container/AIAssistant/pageActions/usePageActions.ts
|
||||
```
|
||||
|
||||
```typescript
|
||||
function usePageActions(pageId: string, actions: PageAction[]): void {
|
||||
useEffect(() => {
|
||||
PageActionRegistry.register(pageId, actions);
|
||||
return () => PageActionRegistry.unregister(pageId);
|
||||
// Re-register if action definitions change (e.g. new callbacks after query update)
|
||||
}, [pageId, actions]);
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** action factories (see §8) memoize with `useMemo` so that the `actions` array reference is stable — preventing unnecessary re-registrations.
|
||||
|
||||
---
|
||||
|
||||
## 6. Context Injection in `sendMessage`
|
||||
|
||||
Before every outgoing message, the AI store reads the registry and prepends a machine-readable context block to the API payload content. This block is **never stored in the conversation** (not visible in the message list) — it exists only in the network payload.
|
||||
|
||||
```
|
||||
[PAGE_CONTEXT]
|
||||
page: logs-explorer
|
||||
actions:
|
||||
- id: logs.runQuery
|
||||
description: "Run the current log query with updated filters or time range"
|
||||
params: { filters: TagFilter[], timeRange?: string }
|
||||
- id: logs.saveView
|
||||
description: "Save the current query as a named view"
|
||||
params: { name: string }
|
||||
state:
|
||||
filters: [{ key: "level", op: "=", value: "error" }]
|
||||
timeRange: "Last 15 minutes"
|
||||
panelType: "list"
|
||||
[/PAGE_CONTEXT]
|
||||
|
||||
{user's message}
|
||||
```
|
||||
|
||||
### Implementation in `useAIAssistantStore.sendMessage`
|
||||
|
||||
```typescript
|
||||
// Build context prefix from registry
|
||||
function buildContextPrefix(): string {
|
||||
const descriptors = PageActionRegistry.snapshot();
|
||||
if (descriptors.length === 0) return '';
|
||||
|
||||
const actionLines = descriptors.map(a =>
|
||||
` - id: ${a.id}\n description: "${a.description}"\n params: ${JSON.stringify(a.parameters.properties)}`
|
||||
).join('\n');
|
||||
|
||||
const contextLines = descriptors
|
||||
.filter(a => a.context !== undefined)
|
||||
.map(a => ` ${a.id}: ${JSON.stringify(a.context)}`)
|
||||
.join('\n');
|
||||
|
||||
return [
|
||||
'[PAGE_CONTEXT]',
|
||||
'actions:',
|
||||
actionLines,
|
||||
contextLines ? 'state:' : '',
|
||||
contextLines,
|
||||
'[/PAGE_CONTEXT]',
|
||||
'',
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
// In sendMessage, when building the API payload:
|
||||
const payload = {
|
||||
conversationId: activeConversationId,
|
||||
messages: history.map((m, i) => {
|
||||
const content = (i === history.length - 1 && m.role === 'user')
|
||||
? buildContextPrefix() + m.content
|
||||
: m.content;
|
||||
return { role: m.role, content };
|
||||
}),
|
||||
};
|
||||
```
|
||||
|
||||
The displayed message in the UI always shows only `m.content` (the user's raw text). The context prefix only exists in the wire payload.
|
||||
|
||||
---
|
||||
|
||||
## 7. `ActionBlock` Component
|
||||
|
||||
Registered as `BlockRegistry.register('action', ActionBlock)`.
|
||||
|
||||
```
|
||||
src/container/AIAssistant/components/blocks/ActionBlock.tsx
|
||||
```
|
||||
|
||||
### Render states
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ ⚡ Suggested Action │
|
||||
│ │
|
||||
│ "Filter logs for ERROR level from payment-service" │
|
||||
│ │
|
||||
│ Parameters: │
|
||||
│ • level = ERROR │
|
||||
│ • service.name = payment-service │
|
||||
│ │
|
||||
│ [ Apply ] [ Dismiss ] │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
|
||||
── After Apply ──
|
||||
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ ✓ Applied: "Filter logs for ERROR level from │
|
||||
│ payment-service" │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
|
||||
── After error ──
|
||||
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ ✗ Failed: "Action 'logs.runQuery' is not │
|
||||
│ available on the current page." │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Execution flow
|
||||
|
||||
1. Parse `AIActionBlock` JSON from the fenced block content
|
||||
2. Validate `parameters` against the action's JSON Schema (fail fast with a clear error)
|
||||
3. Look up `PageActionRegistry.get(actionId)` — if missing, show "not available" state
|
||||
4. On Accept: call `action.execute(parameters)`, show loading spinner
|
||||
5. On success: show summary from `ActionResult.summary`, call `markBlockAnswered(messageId, 'applied')`
|
||||
6. On failure: show error, allow retry
|
||||
7. On Dismiss: call `markBlockAnswered(messageId, 'dismissed')`
|
||||
|
||||
Like `ConfirmBlock` and `InteractiveQuestion`, `ActionBlock` uses `MessageContext` to get `messageId` and `answeredBlocks` from the store to persist its answered state across remounts.
|
||||
|
||||
---
|
||||
|
||||
## 8. Page Action Factories
|
||||
|
||||
Each page co-locates its action definitions in an `aiActions.ts` file. Factories are functions that close over the page's live state and handlers, so the `execute` callbacks always operate on current data.
|
||||
|
||||
### Example: `src/pages/LogsExplorer/aiActions.ts`
|
||||
|
||||
```typescript
|
||||
export function logsRunQueryAction(deps: {
|
||||
handleRunQuery: () => void;
|
||||
updateQueryFilters: (filters: TagFilterItem[]) => void;
|
||||
currentQuery: Query;
|
||||
globalTime: GlobalReducer;
|
||||
}): PageAction {
|
||||
return {
|
||||
id: 'logs.runQuery',
|
||||
description: 'Update the active log filters and run the query',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
filters: {
|
||||
type: 'array',
|
||||
description: 'Replacement filter list. Each item has key, op, value.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: { type: 'string' },
|
||||
op: { type: 'string', enum: ['=', '!=', 'IN', 'NOT_IN', 'CONTAINS', 'NOT_CONTAINS'] },
|
||||
value: { type: 'string' },
|
||||
},
|
||||
required: ['key', 'op', 'value'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
execute: async ({ filters }) => {
|
||||
deps.updateQueryFilters(filters as TagFilterItem[]);
|
||||
deps.handleRunQuery();
|
||||
return { summary: `Query updated with ${filters.length} filter(s) and re-run.` };
|
||||
},
|
||||
getContext: () => ({
|
||||
filters: deps.currentQuery.builder.queryData[0]?.filters?.items ?? [],
|
||||
timeRange: deps.globalTime.selectedTime,
|
||||
panelType: 'list',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function logsSaveViewAction(deps: {
|
||||
saveView: (name: string) => Promise<void>;
|
||||
}): PageAction {
|
||||
return {
|
||||
id: 'logs.saveView',
|
||||
description: 'Save the current log query as a named view',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { name: { type: 'string', description: 'View name' } },
|
||||
required: ['name'],
|
||||
},
|
||||
execute: async ({ name }) => {
|
||||
await deps.saveView(name as string);
|
||||
return { summary: `View "${name}" saved.` };
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Usage in the page component
|
||||
|
||||
```typescript
|
||||
// src/pages/LogsExplorer/index.tsx
|
||||
|
||||
const { handleRunQuery, updateQueryFilters, currentQuery } = useQueryBuilder();
|
||||
const globalTime = useSelector((s) => s.globalTime);
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
logsRunQueryAction({ handleRunQuery, updateQueryFilters, currentQuery, globalTime }),
|
||||
logsSaveViewAction({ saveView }),
|
||||
logsExportToDashboardAction({ exportToDashboard }),
|
||||
],
|
||||
[handleRunQuery, updateQueryFilters, currentQuery, globalTime, saveView, exportToDashboard],
|
||||
);
|
||||
|
||||
usePageActions('logs-explorer', actions);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Action Catalogue (Initial Scope)
|
||||
|
||||
### 9.1 Logs Explorer
|
||||
|
||||
| Action ID | Description | Key Parameters |
|
||||
|-----------|-------------|----------------|
|
||||
| `logs.runQuery` | Update filters and run the log query | `filters: TagFilterItem[]` |
|
||||
| `logs.addFilter` | Append a single filter to the existing set | `key, op, value` |
|
||||
| `logs.changeView` | Switch between list / timeseries / table | `view: 'list' \| 'timeseries' \| 'table'` |
|
||||
| `logs.saveView` | Save current query as a named view | `name: string` |
|
||||
| `logs.exportToDashboard` | Add current query as a panel to a dashboard | `dashboardId?: string, panelTitle?: string` |
|
||||
|
||||
### 9.2 Traces Explorer
|
||||
|
||||
| Action ID | Description | Key Parameters |
|
||||
|-----------|-------------|----------------|
|
||||
| `traces.runQuery` | Update filters and run the trace query | `filters: TagFilterItem[]` |
|
||||
| `traces.addFilter` | Append a single filter | `key, op, value` |
|
||||
| `traces.changeView` | Switch between list / trace / timeseries / table | `view: string` |
|
||||
| `traces.exportToDashboard` | Add to a dashboard | `dashboardId?: string, panelTitle?: string` |
|
||||
|
||||
### 9.3 Dashboards List
|
||||
|
||||
| Action ID | Description | Key Parameters |
|
||||
|-----------|-------------|----------------|
|
||||
| `dashboards.create` | Create a new blank dashboard | `title: string, description?: string` |
|
||||
| `dashboards.search` | Filter the dashboard list | `query: string` |
|
||||
| `dashboards.duplicate` | Duplicate an existing dashboard | `dashboardId: string, newTitle?: string` |
|
||||
| `dashboards.delete` | Delete a dashboard (requires confirmation) | `dashboardId: string` |
|
||||
|
||||
### 9.4 Dashboard Detail
|
||||
|
||||
| Action ID | Description | Key Parameters |
|
||||
|-----------|-------------|----------------|
|
||||
| `dashboard.createPanel` | Add a new panel to the current dashboard | `title: string, queryType: 'logs'\|'metrics'\|'traces'` |
|
||||
| `dashboard.rename` | Rename the current dashboard | `title: string` |
|
||||
| `dashboard.deletePanel` | Remove a panel | `panelId: string` |
|
||||
| `dashboard.addVariable` | Add a dashboard-level variable | `name: string, type: string, defaultValue?: string` |
|
||||
|
||||
### 9.5 Alerts
|
||||
|
||||
| Action ID | Description | Key Parameters |
|
||||
|-----------|-------------|----------------|
|
||||
| `alerts.navigateCreate` | Navigate to the Create Alert page | `alertType?: 'metrics'\|'logs'\|'traces'` |
|
||||
| `alerts.disable` | Disable an existing alert rule | `alertId: string` |
|
||||
| `alerts.enable` | Enable an existing alert rule | `alertId: string` |
|
||||
| `alerts.delete` | Delete an alert rule | `alertId: string` |
|
||||
|
||||
### 9.6 Create Alert
|
||||
|
||||
| Action ID | Description | Key Parameters |
|
||||
|-----------|-------------|----------------|
|
||||
| `alert.setCondition` | Set the alert threshold condition | `op: string, threshold: number` |
|
||||
| `alert.test` | Test the alert rule against live data | — |
|
||||
| `alert.save` | Save the alert rule | `name: string, severity?: string` |
|
||||
|
||||
### 9.7 Metrics Explorer
|
||||
|
||||
| Action ID | Description | Key Parameters |
|
||||
|-----------|-------------|----------------|
|
||||
| `metrics.runQuery` | Run a metric query | `metric: string, aggregation?: string` |
|
||||
| `metrics.saveView` | Save current query as a view | `name: string` |
|
||||
| `metrics.exportToDashboard` | Add to a dashboard | `dashboardId?: string, panelTitle?: string` |
|
||||
|
||||
---
|
||||
|
||||
## 10. Context Block Schema (Wire Format)
|
||||
|
||||
The `[PAGE_CONTEXT]` block is a freeform text section prepended to the API message content. For the real backend, this should migrate to a structured `system` role message or a dedicated field in the request body. For the initial implementation, embedding it in the user message content is sufficient and works with any LLM API.
|
||||
|
||||
```
|
||||
[PAGE_CONTEXT]
|
||||
page: <pageId>
|
||||
actions:
|
||||
- id: <actionId>
|
||||
description: "<description>"
|
||||
params: <JSON Schema properties summary>
|
||||
...
|
||||
state:
|
||||
<actionId>: <JSON context snapshot>
|
||||
...
|
||||
[/PAGE_CONTEXT]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Parameter Validation
|
||||
|
||||
Before `execute()` is called, parameters are validated client-side using the JSON Schema stored on the `PageAction`. This catches cases where the AI generates structurally wrong parameters.
|
||||
|
||||
```typescript
|
||||
function validateParams(schema: JSONSchemaObject, params: unknown): string | null {
|
||||
// Minimal validation: check required fields are present and types match
|
||||
// Full implementation can use ajv or a lightweight equivalent
|
||||
for (const key of schema.required ?? []) {
|
||||
if (!(params as Record<string, unknown>)[key]) {
|
||||
return `Missing required parameter: "${key}"`;
|
||||
}
|
||||
}
|
||||
return null; // valid
|
||||
}
|
||||
```
|
||||
|
||||
If validation fails, `ActionBlock` shows the error inline and does not call `execute`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Answered State Persistence
|
||||
|
||||
`ActionBlock` follows the same pattern as `ConfirmBlock` and `InteractiveQuestion`:
|
||||
|
||||
- Uses `MessageContext` to read `messageId`
|
||||
- Reads `answeredBlocks[messageId]` from the Zustand store
|
||||
- On Accept: calls `markBlockAnswered(messageId, 'applied')`
|
||||
- On Dismiss: calls `markBlockAnswered(messageId, 'dismissed')`
|
||||
- On Error: calls `markBlockAnswered(messageId, 'error:<message>')`
|
||||
|
||||
This ensures re-renders and re-mounts do not reset the block to its initial state.
|
||||
|
||||
---
|
||||
|
||||
## 13. Error Handling
|
||||
|
||||
| Failure scenario | Behaviour |
|
||||
|-----------------|-----------|
|
||||
| `actionId` not in registry (page navigated away) | Block shows: "This action is no longer available — navigate back to \<page\> and try again." |
|
||||
| Parameter validation fails | Block shows the validation error inline; does not call `execute` |
|
||||
| `execute()` throws | Block shows the error message; offers a Retry button |
|
||||
| AI emits malformed JSON in the block | `RichCodeBlock` falls back to rendering the raw fenced block as a code block |
|
||||
| User navigates away mid-execution | `execute()` promise resolves/rejects normally; result is stored in `answeredBlocks` |
|
||||
|
||||
---
|
||||
|
||||
## 14. Permissions
|
||||
|
||||
Many page actions map to protected operations (e.g., creating a dashboard, deleting an alert). Each action factory should check the relevant permission before registering — if the user doesn't have permission, the action is simply not registered and will not appear in the context block.
|
||||
|
||||
```typescript
|
||||
const canCreateDashboard = useComponentPermission(['create_new_dashboards']);
|
||||
|
||||
const actions = useMemo(() => [
|
||||
...(canCreateDashboard ? [dashboardCreateAction({ ... })] : []),
|
||||
// ...
|
||||
], [canCreateDashboard, ...]);
|
||||
|
||||
usePageActions('dashboard-list', actions);
|
||||
```
|
||||
|
||||
This way the AI never suggests actions the user cannot perform.
|
||||
|
||||
---
|
||||
|
||||
## 15. Implementation Plan
|
||||
|
||||
### Phase 1 — Infrastructure (no page integrations yet)
|
||||
|
||||
1. `src/container/AIAssistant/pageActions/types.ts`
|
||||
2. `src/container/AIAssistant/pageActions/PageActionRegistry.ts`
|
||||
3. `src/container/AIAssistant/pageActions/usePageActions.ts`
|
||||
4. `ActionBlock.tsx` + `ActionBlock.scss`
|
||||
5. Register `'action'` in `blocks/index.ts`
|
||||
6. Context injection in `useAIAssistantStore.sendMessage`
|
||||
7. Mock API support for `[PAGE_CONTEXT]` → responds with `ai-action` block
|
||||
|
||||
### Phase 2 — Logs Explorer integration
|
||||
|
||||
8. `src/pages/LogsExplorer/aiActions.ts` (factories for `logs.*` actions)
|
||||
9. Wire `usePageActions` into `LogsExplorer/index.tsx`
|
||||
|
||||
### Phase 3 — Traces, Dashboards, Alerts
|
||||
|
||||
10. `src/pages/TracesExplorer/aiActions.ts`
|
||||
11. `src/pages/DashboardsListPage/aiActions.ts`
|
||||
12. `src/pages/DashboardPage/aiActions.ts`
|
||||
13. `src/pages/AlertList/aiActions.ts`
|
||||
14. `src/pages/CreateAlert/aiActions.ts`
|
||||
|
||||
### Phase 4 — Backend handoff
|
||||
|
||||
15. Move `[PAGE_CONTEXT]` from content-embedded text to a dedicated `pageContext` field in the API request body
|
||||
16. Replace mock responses with real AI backend calls
|
||||
|
||||
---
|
||||
|
||||
## 16. Open Questions
|
||||
|
||||
| # | Question | Impact |
|
||||
|---|----------|--------|
|
||||
| 1 | Should `ActionBlock` require a single user confirmation, or show a diff-style preview of what will change? | UX complexity |
|
||||
| 2 | How should multi-step actions work? (e.g. "create dashboard then add three panels") — queue them or chain them? | Architecture |
|
||||
| 3 | Should the registry support a global `getContext()` for page-agnostic state (user, org, time range)? | Context completeness |
|
||||
| 4 | What is the max context block size before it degrades AI quality? | Prompt engineering |
|
||||
| 5 | Should failed actions add a retry message back into the conversation, or stay silent? | UX |
|
||||
| 6 | Can two pages be active simultaneously (e.g. drawer open over dashboard)? How do we prioritise which actions are "active"? | Edge case |
|
||||
|
||||
---
|
||||
|
||||
## 17. Relation to Existing AI Architecture
|
||||
|
||||
```
|
||||
BlockRegistry PageActionRegistry
|
||||
│ │
|
||||
│ render blocks │ register/unregister actions
|
||||
│ (ai-chart, ai- │ (logs.runQuery, dashboard.create...)
|
||||
│ question, ai- │
|
||||
│ confirm, ...) │
|
||||
└──────────┬────────────┘
|
||||
│
|
||||
MessageBubble / StreamingMessage
|
||||
│
|
||||
RichCodeBlock (routes to BlockRegistry)
|
||||
│
|
||||
ActionBlock ←── new: reads PageActionRegistry to execute
|
||||
```
|
||||
|
||||
The `PageActionRegistry` is a parallel singleton to `BlockRegistry`. `BlockRegistry` maps `fenced-block-type → render component`. `PageActionRegistry` maps `action-id → execute function`. `ActionBlock` bridges the two: it is a registered *block* (render side) that calls into the *action* registry (execution side).
|
||||
911
frontend/src/container/AIAssistant/TECHNICAL_DESIGN.md
Normal file
911
frontend/src/container/AIAssistant/TECHNICAL_DESIGN.md
Normal file
@@ -0,0 +1,911 @@
|
||||
# AI Assistant — Technical Design Document
|
||||
|
||||
**Status:** In Progress
|
||||
**Last Updated:** 2026-04-01
|
||||
**Scope:** Frontend AI Assistant subsystem — UI, state management, API integration, page action system
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#1-overview)
|
||||
2. [Architecture Diagram](#2-architecture-diagram)
|
||||
3. [User Flows](#3-user-flows)
|
||||
4. [UI Modes and Transitions](#4-ui-modes-and-transitions)
|
||||
5. [Control Flow: UI → API → UI](#5-control-flow-ui--api--ui)
|
||||
6. [SSE Response Handling](#6-sse-response-handling)
|
||||
7. [Page Action System](#7-page-action-system)
|
||||
8. [Block Rendering System](#8-block-rendering-system)
|
||||
9. [State Management](#9-state-management)
|
||||
10. [Page-Specific Actions](#10-page-specific-actions)
|
||||
11. [Voice Input](#11-voice-input)
|
||||
12. [File Attachments](#12-file-attachments)
|
||||
13. [Development Mode (Mock)](#13-development-mode-mock)
|
||||
14. [Adding a New Page's Actions](#14-adding-a-new-pages-actions)
|
||||
15. [Adding a New Block Type](#15-adding-a-new-block-type)
|
||||
16. [Data Contracts](#16-data-contracts)
|
||||
17. [Key Design Decisions](#17-key-design-decisions)
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The AI Assistant is an embedded chat interface inside SigNoz that understands the current page context and can execute actions on behalf of the user (e.g., filter logs, update queries, navigate views). It communicates with a backend AI service via Server-Sent Events (SSE) and renders structured responses as rich interactive blocks alongside plain markdown.
|
||||
|
||||
**Key goals:**
|
||||
- **Context-aware:** the AI always knows what page the user is on and what actions are available
|
||||
- **Streaming:** responses appear token-by-token, no waiting for a full response
|
||||
- **Actionable:** the AI can trigger page mutations (filter logs, switch views) without copy-paste
|
||||
- **Extensible:** new pages can register actions; new block types can be added independently
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ User │
|
||||
└────────────────────────────┬────────────────────────────────────────┘
|
||||
│ types text / voice / file
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ UI Layer │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌───────────────────────────┐ │
|
||||
│ │ Panel │ │ Modal │ │ Full-Screen Page │ │
|
||||
│ │ (drawer) │ │ (Cmd+P) │ │ /ai-assistant/:id │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ └─────────────┬─────────────┘ │
|
||||
│ └────────────────┴─────────────────────────┘ │
|
||||
│ │ all modes share │
|
||||
│ ┌──────▼──────┐ │
|
||||
│ │ConversationView│ │
|
||||
│ │ + ChatInput │ │
|
||||
│ └──────┬──────┘ │
|
||||
└───────────────────────────┼─────────────────────────────────────────┘
|
||||
│ sendMessage()
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Zustand Store (useAIAssistantStore) │
|
||||
│ │
|
||||
│ conversations{} isStreaming │
|
||||
│ activeConversationId streamingContent │
|
||||
│ isDrawerOpen answeredBlocks{} │
|
||||
│ isModalOpen │
|
||||
│ │
|
||||
│ sendMessage() │
|
||||
│ 1. push user message │
|
||||
│ 2. buildContextPrefix() ──► PageActionRegistry.snapshot() │
|
||||
│ 3. call streamChat(payload) [or mockStreamChat in dev] │
|
||||
│ 4. accumulate chunks into streamingContent │
|
||||
│ 5. on done: push assistant message with actions[] │
|
||||
└──────────────────────────┬──────────────────────────────────────────┘
|
||||
│ POST /api/v1/assistant/threads
|
||||
│ (SSE response)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ API Layer (src/api/ai/chat.ts) │
|
||||
│ │
|
||||
│ streamChat(payload) → AsyncGenerator<SSEEvent> │
|
||||
│ Parses data: {...}\n\n SSE frames │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Page Action System │
|
||||
│ │
|
||||
│ PageActionRegistry ◄──── usePageActions() hook │
|
||||
│ (module singleton) (called by each page on mount) │
|
||||
│ │
|
||||
│ Registry is read by buildContextPrefix() before every API call. │
|
||||
│ │
|
||||
│ AI response → ai-action block → ActionBlock component │
|
||||
│ → PageActionRegistry.get(actionId).execute(params) │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. User Flows
|
||||
|
||||
### 3.1 Basic Chat
|
||||
|
||||
```
|
||||
User opens panel (header icon / Cmd+P / trigger button)
|
||||
→ Conversation created (or resumed from store)
|
||||
→ ChatInput focused automatically
|
||||
|
||||
User types message → presses Enter
|
||||
→ User message appended to conversation
|
||||
→ StreamingMessage (typing indicator) appears
|
||||
→ SSE stream opens: tokens arrive word-by-word
|
||||
→ StreamingMessage renders live content
|
||||
→ Stream ends: StreamingMessage replaced by MessageBubble
|
||||
→ Follow-up actions (if any) shown as chips on the message
|
||||
```
|
||||
|
||||
### 3.2 AI Applies a Page Action (autoApply)
|
||||
|
||||
```
|
||||
User: "Filter logs for errors from payment-svc"
|
||||
→ PAGE_CONTEXT injected into wire payload
|
||||
(includes registered action schemas + current query state)
|
||||
→ AI responds with plain text + ai-action block
|
||||
→ ActionBlock mounts with autoApply=true
|
||||
→ execute() fires immediately on mount — no user confirmation
|
||||
→ Logs Explorer query updated via redirectWithQueryBuilderData()
|
||||
→ URL reflects new filters, QueryBuilderV2 UI updates
|
||||
→ ActionBlock shows "Applied ✓" state (persisted in answeredBlocks)
|
||||
```
|
||||
|
||||
### 3.3 AI Asks a Clarifying Question
|
||||
|
||||
```
|
||||
AI responds with ai-question block
|
||||
→ InteractiveQuestion renders (radio or checkbox)
|
||||
→ User selects answer → sendMessage() called automatically
|
||||
→ Answer persisted in answeredBlocks (survives re-renders / mode switches)
|
||||
→ Block shows answered state on re-mount
|
||||
```
|
||||
|
||||
### 3.4 AI Requests Confirmation
|
||||
|
||||
```
|
||||
AI responds with ai-confirm block
|
||||
→ ConfirmBlock renders Accept / Reject buttons
|
||||
→ Accept → sendMessage(acceptText)
|
||||
→ Reject → sendMessage(rejectText)
|
||||
→ Block shows answered state, buttons disabled
|
||||
```
|
||||
|
||||
### 3.5 Modal → Panel Minimize
|
||||
|
||||
```
|
||||
User opens modal (Cmd+P), interacts with AI
|
||||
User clicks minimize button (−)
|
||||
→ minimizeModal(): isModalOpen=false, isDrawerOpen=true (atomic)
|
||||
→ Same conversation continues in the side panel
|
||||
→ No data loss, streaming state preserved
|
||||
```
|
||||
|
||||
### 3.6 Panel → Full Screen Expand
|
||||
|
||||
```
|
||||
User clicks Maximize in panel header
|
||||
→ closeDrawer() called
|
||||
→ navigate to /ai-assistant/:conversationId
|
||||
→ Full-screen page renders same conversation
|
||||
→ TopNav (timepicker header) hidden on this route
|
||||
→ SideNav remains visible
|
||||
```
|
||||
|
||||
### 3.7 Voice Input
|
||||
|
||||
```
|
||||
User clicks mic button in ChatInput
|
||||
→ SpeechRecognition.start()
|
||||
→ isListening=true (mic turns red, CSS pulse animation)
|
||||
→ Interim results: textarea updates live as user speaks
|
||||
→ Recognition ends (auto pause detection or manual click)
|
||||
→ Final transcript committed to committedTextRef
|
||||
→ User reviews / edits text, then sends normally
|
||||
```
|
||||
|
||||
### 3.8 Resize Panel
|
||||
|
||||
```
|
||||
User hovers over left edge of panel
|
||||
→ Resize handle highlights (purple line)
|
||||
User drags left/right
|
||||
→ panel width updates live (min 380px, max 800px)
|
||||
→ document.body.cursor = 'col-resize' during drag
|
||||
→ text selection disabled during drag
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. UI Modes and Transitions
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ All Closed │
|
||||
│ (trigger shown) │
|
||||
└────────┬─────────┘
|
||||
│
|
||||
┌──────────────┼──────────────┐
|
||||
│ │ │
|
||||
Click trigger Cmd+P navigate to
|
||||
│ │ /ai-assistant/:id
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌─────────────┐
|
||||
│ Panel │ │ Modal │ │ Full-Screen │
|
||||
│ (drawer) │ │ (portal) │ │ Page │
|
||||
└────┬─────┘ └────┬─────┘ └──────┬──────┘
|
||||
│ │ │
|
||||
┌────▼──────────────▼───────────────▼────┐
|
||||
│ ConversationView (shared component) │
|
||||
└─────────────────────────────────────────┘
|
||||
|
||||
Transitions:
|
||||
Panel → Full-Screen : Maximize → closeDrawer() + history.push()
|
||||
Modal → Panel : Minimize → minimizeModal()
|
||||
Modal → Full-Screen : Maximize → closeModal() + history.push()
|
||||
Any → Closed : X button or Escape key
|
||||
|
||||
Visibility rules:
|
||||
Header AI icon : hidden when isDrawerOpen=true
|
||||
Trigger button : hidden when isDrawerOpen || isModalOpen || isFullScreenPage
|
||||
TopNav (timepicker): hidden when pathname.startsWith('/ai-assistant/')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Control Flow: UI → API → UI
|
||||
|
||||
### 5.1 Message Send
|
||||
|
||||
```
|
||||
ChatInput.handleSend()
|
||||
├── setText('') // clear textarea
|
||||
├── committedTextRef.current = '' // clear voice accumulator
|
||||
└── store.sendMessage(text, attachments)
|
||||
│
|
||||
├── Push userMessage to conversations[id].messages
|
||||
├── set isStreaming=true, streamingContent=''
|
||||
│
|
||||
├── buildContextPrefix()
|
||||
│ └── PageActionRegistry.snapshot()
|
||||
│ → returns PageActionDescriptor[] (ids, schemas, current context)
|
||||
│ → serialized as [PAGE_CONTEXT]...[/PAGE_CONTEXT] string
|
||||
│
|
||||
├── Build wire payload:
|
||||
│ {
|
||||
│ conversationId,
|
||||
│ messages: history.map((m, i) => ({
|
||||
│ role: m.role,
|
||||
│ content: i === last && role==='user'
|
||||
│ ? contextPrefix + m.content // wire only, never stored
|
||||
│ : m.content
|
||||
│ }))
|
||||
│ }
|
||||
│
|
||||
├── for await (event of streamChat(payload)):
|
||||
│ ├── streamingContent += event.content // triggers StreamingMessage re-render
|
||||
│ └── if event.done: finalActions = event.actions; break
|
||||
│
|
||||
├── Push assistantMessage { id: serverMessageId, content, actions }
|
||||
└── set isStreaming=false, streamingContent=''
|
||||
```
|
||||
|
||||
### 5.2 Streaming Render Pipeline
|
||||
|
||||
```
|
||||
streamingContent (Zustand state)
|
||||
→ StreamingMessage component (rendered while isStreaming=true)
|
||||
→ react-markdown
|
||||
→ RichCodeBlock (custom code renderer)
|
||||
→ BlockRegistry.get(lang) → renders chart / table / action / etc.
|
||||
|
||||
On stream end:
|
||||
streamingContent → assistantMessage.content (frozen in store)
|
||||
StreamingMessage removed, MessageBubble added with same content
|
||||
MessageBubble renders through identical markdown pipeline
|
||||
```
|
||||
|
||||
### 5.3 PAGE_CONTEXT Wire Format
|
||||
|
||||
The context prefix is prepended to the last user message in the API payload **only**. It is never stored in the conversation or shown in the UI.
|
||||
|
||||
```
|
||||
[PAGE_CONTEXT]
|
||||
actions:
|
||||
- id: logs.runQuery
|
||||
description: "Replace all log filters and re-run the query"
|
||||
params: {"filters": {"type": "array", "items": {...}}}
|
||||
- id: logs.addFilter
|
||||
description: "Append a single key/op/value filter"
|
||||
params: {"key": {...}, "op": {...}, "value": {...}}
|
||||
state:
|
||||
logs.runQuery: {"currentFilters": [...], "currentView": "list"}
|
||||
[/PAGE_CONTEXT]
|
||||
User's actual message text here
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. SSE Response Handling
|
||||
|
||||
### 6.1 Wire Format
|
||||
|
||||
**Request:**
|
||||
```
|
||||
POST /api/v1/assistant/threads
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"conversationId": "uuid",
|
||||
"messages": [
|
||||
{ "role": "user", "content": "[PAGE_CONTEXT]...[/PAGE_CONTEXT]\nUser text" },
|
||||
{ "role": "assistant", "content": "Previous assistant turn" },
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response (SSE stream):**
|
||||
```
|
||||
data: {"type":"message","messageId":"srv-123","role":"assistant","content":"I'll ","done":false,"actions":[]}\n\n
|
||||
data: {"type":"message","messageId":"srv-123","role":"assistant","content":"update ","done":false,"actions":[]}\n\n
|
||||
data: {"type":"message","messageId":"srv-123","role":"assistant","content":"the query.","done":true,"actions":[
|
||||
{"id":"act-1","label":"Add another filter","kind":"follow_up","payload":{},"expiresAt":null}
|
||||
]}\n\n
|
||||
```
|
||||
|
||||
### 6.2 SSE Parsing (src/api/ai/chat.ts)
|
||||
|
||||
```
|
||||
fetch() → ReadableStream → TextDecoder → string chunks
|
||||
→ lineBuffer accumulates across chunks (handles partial lines)
|
||||
→ split on '\n\n' (SSE event boundary)
|
||||
→ for each complete part:
|
||||
find line starting with 'data: '
|
||||
strip prefix → parse JSON → yield SSEEvent
|
||||
→ '[DONE]' sentinel → stop iteration
|
||||
→ malformed JSON → skip silently
|
||||
→ finally: reader.releaseLock()
|
||||
```
|
||||
|
||||
### 6.3 Structured Content in the Stream
|
||||
|
||||
The AI embeds block payloads as markdown fenced code blocks with `ai-*` language tags inside the `content` stream. These are parsed live as tokens arrive:
|
||||
|
||||
````markdown
|
||||
Here are the results:
|
||||
|
||||
```ai-graph
|
||||
{
|
||||
"title": "p99 Latency",
|
||||
"datasets": [...]
|
||||
}
|
||||
```
|
||||
|
||||
The spike started at 14:45.
|
||||
````
|
||||
|
||||
React-markdown renders the code block → `RichCodeBlock` detects the `ai-` prefix → looks up `BlockRegistry` → renders the chart/table/action component.
|
||||
|
||||
### 6.4 actions[] Array
|
||||
|
||||
Actions arrive on the **final** SSE event (`done: true`). They are stored on the `Message` object. Each action's `kind` determines UI behavior:
|
||||
|
||||
| Kind | Behavior |
|
||||
|---|---|
|
||||
| `follow_up` | Rendered as suggestion chip; click sends as new message |
|
||||
| `open_resource` | Opens a SigNoz resource (trace, log, dashboard) |
|
||||
| `navigate` | Navigates to a SigNoz route |
|
||||
| `apply_filter` | Directly triggers a registered page action |
|
||||
| `open_docs` | Opens a documentation URL |
|
||||
| `undo` | Reverts the last page mutation |
|
||||
| `revert` | Reverts to a specified previous state |
|
||||
|
||||
---
|
||||
|
||||
## 7. Page Action System
|
||||
|
||||
### 7.1 Concepts
|
||||
|
||||
| Concept | Description |
|
||||
|---|---|
|
||||
| `PageAction<TParams>` | An action a page exposes to the AI: id, description, JSON Schema params, `execute()`, optional `getContext()`, optional `autoApply` |
|
||||
| `PageActionRegistry` | Module-level singleton (`Map<pageId, actions[]>` + `Map<actionId, action>`) |
|
||||
| `usePageActions(pageId, actions)` | React hook — registers on mount, unregisters on unmount |
|
||||
| `PageActionDescriptor` | Serializable version of `PageAction` (no functions) — sent to AI via PAGE_CONTEXT |
|
||||
| `AIActionBlock` | Shape the AI emits when invoking an action: `{ actionId, description, parameters }` |
|
||||
|
||||
### 7.2 Lifecycle
|
||||
|
||||
```
|
||||
Page component mounts
|
||||
└── usePageActions('logs-explorer', actions)
|
||||
└── PageActionRegistry.register('logs-explorer', actions)
|
||||
→ added to _byPage map (for bulk unregister)
|
||||
→ added to _byId map (for O(1) lookup by actionId)
|
||||
|
||||
User sends any message
|
||||
└── buildContextPrefix()
|
||||
└── PageActionRegistry.snapshot()
|
||||
→ returns PageActionDescriptor[] with current context values
|
||||
|
||||
AI response contains ```ai-action block
|
||||
└── ActionBlock component mounts
|
||||
├── PageActionRegistry.get(actionId) → PageAction with execute()
|
||||
└── if autoApply: execute(params) on mount
|
||||
else: render confirmation card, execute on user click
|
||||
|
||||
Page component unmounts
|
||||
└── usePageActions cleanup
|
||||
└── PageActionRegistry.unregister('logs-explorer')
|
||||
→ all actions for this page removed from both maps
|
||||
```
|
||||
|
||||
### 7.3 ActionBlock State Machine
|
||||
|
||||
**autoApply: true** (fires immediately on mount):
|
||||
```
|
||||
mount
|
||||
→ hasFired ref guard (prevents double-fire in React StrictMode)
|
||||
→ PageActionRegistry.get(actionId).execute(params)
|
||||
→ render: loading spinner
|
||||
→ success: "Applied ✓" state, markBlockAnswered(messageId, 'applied')
|
||||
→ error: error state with message, markBlockAnswered(messageId, 'error:...')
|
||||
```
|
||||
|
||||
**autoApply: false** (user must confirm):
|
||||
```
|
||||
mount
|
||||
→ render: description + parameter summary + Apply / Dismiss buttons
|
||||
→ Apply clicked:
|
||||
→ execute(params) → loading → applied state
|
||||
→ markBlockAnswered(messageId, 'applied')
|
||||
→ Dismiss clicked:
|
||||
→ markBlockAnswered(messageId, 'dismissed')
|
||||
```
|
||||
|
||||
**Re-mount (scroll / mode switch):**
|
||||
```
|
||||
mount
|
||||
→ answeredBlocks[messageId] exists
|
||||
→ render answered state directly (skip pending UI)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Block Rendering System
|
||||
|
||||
### 8.1 Registration
|
||||
|
||||
`src/container/AIAssistant/components/blocks/index.ts` registers all built-in types at import time (side-effect import):
|
||||
|
||||
```typescript
|
||||
BlockRegistry.register('action', ActionBlock);
|
||||
BlockRegistry.register('question', InteractiveQuestion);
|
||||
BlockRegistry.register('confirm', ConfirmBlock);
|
||||
BlockRegistry.register('timeseries', TimeseriesBlock);
|
||||
BlockRegistry.register('barchart', BarChartBlock);
|
||||
BlockRegistry.register('piechart', PieChartBlock);
|
||||
BlockRegistry.register('linechart', LineChartBlock);
|
||||
BlockRegistry.register('graph', LineChartBlock); // alias
|
||||
```
|
||||
|
||||
### 8.2 Render Pipeline
|
||||
|
||||
```
|
||||
MessageBubble (assistant message)
|
||||
└── react-markdown
|
||||
└── components={{ code: RichCodeBlock }}
|
||||
└── RichCodeBlock
|
||||
├── lang.startsWith('ai-') ?
|
||||
│ yes → BlockRegistry.get(lang.slice(3))
|
||||
│ → parse JSON content
|
||||
│ → render block component
|
||||
└── no → render plain <code> element
|
||||
```
|
||||
|
||||
### 8.3 Block Component Interface
|
||||
|
||||
All block components receive:
|
||||
```typescript
|
||||
interface BlockProps {
|
||||
content: string; // raw JSON string from the fenced code block body
|
||||
}
|
||||
```
|
||||
|
||||
Blocks access shared context via:
|
||||
```typescript
|
||||
const { messageId } = useContext(MessageContext); // for answeredBlocks key
|
||||
const markBlockAnswered = useAIAssistantStore(s => s.markBlockAnswered);
|
||||
const sendMessage = useAIAssistantStore(s => s.sendMessage); // for interactive blocks
|
||||
```
|
||||
|
||||
### 8.4 Block Types Reference
|
||||
|
||||
| Tag | Component | Purpose |
|
||||
|---|---|---|
|
||||
| `ai-action` | `ActionBlock` | Invokes a registered page action |
|
||||
| `ai-question` | `InteractiveQuestion` | Radio or checkbox user selection |
|
||||
| `ai-confirm` | `ConfirmBlock` | Binary accept / reject prompt |
|
||||
| `ai-timeseries` | `TimeseriesBlock` | Tabular data with rows and columns |
|
||||
| `ai-barchart` | `BarChartBlock` | Horizontal / vertical bar chart |
|
||||
| `ai-piechart` | `PieChartBlock` | Doughnut / pie chart |
|
||||
| `ai-linechart` | `LineChartBlock` | Multi-series line chart |
|
||||
| `ai-graph` | `LineChartBlock` | Alias for `ai-linechart` |
|
||||
|
||||
---
|
||||
|
||||
## 9. State Management
|
||||
|
||||
### 9.1 Store Shape (Zustand + Immer)
|
||||
|
||||
```typescript
|
||||
interface AIAssistantStore {
|
||||
// UI
|
||||
isDrawerOpen: boolean;
|
||||
isModalOpen: boolean;
|
||||
activeConversationId: string | null;
|
||||
|
||||
// Data
|
||||
conversations: Record<string, Conversation>;
|
||||
|
||||
// Streaming
|
||||
streamingContent: string; // accumulates token-by-token during SSE stream
|
||||
isStreaming: boolean;
|
||||
|
||||
// Block answer persistence
|
||||
answeredBlocks: Record<string, string>; // messageId → answer string
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 Conversation Structure
|
||||
|
||||
```typescript
|
||||
interface Conversation {
|
||||
id: string;
|
||||
messages: Message[];
|
||||
createdAt: number;
|
||||
updatedAt?: number;
|
||||
title?: string; // auto-derived from first user message (60 char max)
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string; // server messageId for assistant turns, uuidv4 for user
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
attachments?: MessageAttachment[];
|
||||
actions?: AssistantAction[]; // follow-up actions, present on final assistant message only
|
||||
createdAt: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 Streaming State Machine
|
||||
|
||||
```
|
||||
idle
|
||||
→ sendMessage() called
|
||||
→ isStreaming=true, streamingContent=''
|
||||
|
||||
streaming
|
||||
→ each SSE chunk: streamingContent += event.content (triggers StreamingMessage re-render)
|
||||
→ done event: isStreaming=false, streamingContent=''
|
||||
→ assistant message pushed to conversation
|
||||
|
||||
idle (settled)
|
||||
→ MessageBubble renders final frozen content
|
||||
→ ChatInput re-enabled (disabled={isStreaming})
|
||||
```
|
||||
|
||||
### 9.4 Answered Block Persistence
|
||||
|
||||
Interactive blocks call `markBlockAnswered(messageId, answer)` on completion. On re-mount, blocks check `answeredBlocks[messageId]` and render the answered state directly. This ensures:
|
||||
- Scrolling away and back does not reset blocks
|
||||
- Switching UI modes (panel → full-screen) does not reset blocks
|
||||
- Blocks cannot be answered twice
|
||||
|
||||
---
|
||||
|
||||
## 10. Page-Specific Actions
|
||||
|
||||
### 10.1 Logs Explorer
|
||||
|
||||
**File:** `src/pages/LogsExplorer/aiActions.ts`
|
||||
**Registered in:** `src/pages/LogsExplorer/index.tsx` via `usePageActions('logs-explorer', aiActions)`
|
||||
|
||||
| Action ID | autoApply | Description |
|
||||
|---|---|---|
|
||||
| `logs.runQuery` | `true` | Replace all filters in the query builder and re-run |
|
||||
| `logs.addFilter` | `true` | Append a single `key / op / value` filter |
|
||||
| `logs.changeView` | `true` | Switch between list / timeseries / table views |
|
||||
| `logs.saveView` | `false` | Save current query as a named view (requires confirmation) |
|
||||
|
||||
**Critical implementation detail:** All query mutations use `redirectWithQueryBuilderData()`, not `handleSetQueryData`. The Logs Explorer's `QueryBuilderV2` is URL-driven — `compositeQuery` in the URL is the source of truth for displayed filters. `handleSetQueryData` updates React state only; `redirectWithQueryBuilderData` syncs the URL, making changes visible in the UI.
|
||||
|
||||
**Context shape provided to AI:**
|
||||
```typescript
|
||||
getContext: () => ({
|
||||
currentFilters: currentQuery.builder.queryData[0].filters.items,
|
||||
currentView: currentView, // 'list' | 'timeseries' | 'table'
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Voice Input
|
||||
|
||||
### 11.1 Hook: useSpeechRecognition
|
||||
|
||||
**File:** `src/container/AIAssistant/hooks/useSpeechRecognition.ts`
|
||||
|
||||
```typescript
|
||||
const { isListening, isSupported, start, stop, transcript, isFinal } =
|
||||
useSpeechRecognition({ lang: 'en-US', onError });
|
||||
```
|
||||
|
||||
Exposes `transcript` and `isFinal` as React state (not callbacks) so `ChatInput` reacts via `useEffect([transcript, isFinal])`, eliminating stale closure issues.
|
||||
|
||||
### 11.2 Interim vs Final Handling
|
||||
|
||||
```
|
||||
onresult (isFinal=false) → pendingInterim = text → setTranscript(text), setIsFinal(false)
|
||||
onresult (isFinal=true) → pendingInterim = '' → setTranscript(text), setIsFinal(true)
|
||||
onend (pendingInterim) → setTranscript(pendingInterim), setIsFinal(true)
|
||||
↑ fallback: Chrome often skips the final onresult when stop() is called manually
|
||||
```
|
||||
|
||||
### 11.3 Text Accumulation in ChatInput
|
||||
|
||||
```
|
||||
committedTextRef.current = '' // tracks finalized text (typed + confirmed speech)
|
||||
|
||||
isFinal=false (interim):
|
||||
setText(committedTextRef.current + ' ' + transcript)
|
||||
// textarea shows live speech; committedTextRef unchanged
|
||||
|
||||
isFinal=true (final):
|
||||
committedTextRef.current += ' ' + transcript
|
||||
setText(committedTextRef.current)
|
||||
// both textarea and ref updated — text is now "committed"
|
||||
|
||||
User types manually:
|
||||
setText(e.target.value)
|
||||
committedTextRef.current = e.target.value
|
||||
// keeps both in sync so next speech session appends correctly
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. File Attachments
|
||||
|
||||
`ChatInput` uses Ant Design `Upload` with `beforeUpload` returning `false` (prevents auto-upload). Files accumulate in `pendingFiles: UploadFile[]` state. On send, files are converted to data URIs (`fileToDataUrl`) and stored on the `Message` as `attachments[]`.
|
||||
|
||||
**Accepted types:** `image/*`, `.pdf`, `.txt`, `.log`, `.csv`, `.json`
|
||||
|
||||
**Rendered in MessageBubble:**
|
||||
- Images → inline `<img>` preview
|
||||
- Other files → file badge chip (name + type)
|
||||
|
||||
---
|
||||
|
||||
## 13. Development Mode (Mock)
|
||||
|
||||
Set `VITE_AI_MOCK=true` in `.env.local` to use the mock API instead of the real SSE endpoint.
|
||||
|
||||
```typescript
|
||||
// store/useAIAssistantStore.ts
|
||||
const USE_MOCK_AI = import.meta.env.VITE_AI_MOCK === 'true';
|
||||
const chat = USE_MOCK_AI ? mockStreamChat : streamChat;
|
||||
```
|
||||
|
||||
`mockStreamChat` implements the same `AsyncGenerator<SSEEvent>` interface as `streamChat`. It selects canned responses from keyword matching on the last user message and simulates word-by-word streaming with 15–45ms random delays.
|
||||
|
||||
**Trigger keywords:**
|
||||
|
||||
| Keyword(s) | Response type |
|
||||
|---|---|
|
||||
| `filter logs`, `payment` + `error` | `ai-action`: logs.runQuery |
|
||||
| `add filter` | `ai-action`: logs.addFilter |
|
||||
| `change view` / `timeseries view` | `ai-action`: logs.changeView |
|
||||
| `save view` | `ai-action`: logs.saveView |
|
||||
| `error` / `exception` | Error rates table + trace snippet |
|
||||
| `latency` / `p99` / `graph` | Line chart (p99 latency) |
|
||||
| `bar` / `top service` | Bar chart (error count) |
|
||||
| `pie` / `distribution` | Pie / doughnut chart |
|
||||
| `timeseries` / `table` | Timeseries data table |
|
||||
| `log` | Top log errors summary |
|
||||
| `confirm` / `alert` / `anomal` | `ai-confirm` block |
|
||||
| `environment` / `question` | `ai-question` (radio) |
|
||||
| `level` / `select` / `filter` | `ai-question` (checkbox) |
|
||||
|
||||
---
|
||||
|
||||
## 14. Adding a New Page's Actions
|
||||
|
||||
### Step 1 — Create an aiActions file
|
||||
|
||||
```typescript
|
||||
// src/pages/TracesExplorer/aiActions.ts
|
||||
import { PageAction } from 'container/AIAssistant/pageActions/types';
|
||||
|
||||
interface FilterTracesParams {
|
||||
service: string;
|
||||
minDurationMs?: number;
|
||||
}
|
||||
|
||||
export function tracesFilterAction(deps: {
|
||||
currentQuery: Query;
|
||||
redirectWithQueryBuilderData: (q: Query) => void;
|
||||
}): PageAction<FilterTracesParams> {
|
||||
return {
|
||||
id: 'traces.filter', // globally unique: pageName.actionName
|
||||
description: 'Filter traces by service name and minimum duration',
|
||||
autoApply: true,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
service: { type: 'string', description: 'Service name to filter by' },
|
||||
minDurationMs: { type: 'number', description: 'Minimum span duration in ms' },
|
||||
},
|
||||
required: ['service'],
|
||||
},
|
||||
execute: async ({ service, minDurationMs }) => {
|
||||
// Build updated query and redirect
|
||||
deps.redirectWithQueryBuilderData(buildUpdatedQuery(service, minDurationMs));
|
||||
return { summary: `Filtered traces for ${service}` };
|
||||
},
|
||||
getContext: () => ({
|
||||
currentFilters: deps.currentQuery.builder.queryData[0].filters.items,
|
||||
}),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2 — Register in the page component
|
||||
|
||||
```typescript
|
||||
// src/pages/TracesExplorer/index.tsx
|
||||
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
|
||||
import { tracesFilterAction } from './aiActions';
|
||||
|
||||
function TracesExplorer() {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
const aiActions = useMemo(() => [
|
||||
tracesFilterAction({ currentQuery, redirectWithQueryBuilderData }),
|
||||
], [currentQuery, redirectWithQueryBuilderData]);
|
||||
|
||||
usePageActions('traces-explorer', aiActions);
|
||||
|
||||
// ... rest of component
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- `pageId` must be unique across pages (kebab-case convention)
|
||||
- `actionId` must be globally unique — use `pageName.actionName` convention
|
||||
- `actions` array **must be memoized** (`useMemo`) — identity change triggers re-registration
|
||||
- For URL-driven state (QueryBuilder), always use the URL-sync API; never use `handleSetQueryData` alone
|
||||
- `getContext()` should return only what the AI needs to make decisions — keep it minimal
|
||||
|
||||
---
|
||||
|
||||
## 15. Adding a New Block Type
|
||||
|
||||
### Step 1 — Create the component
|
||||
|
||||
```typescript
|
||||
// src/container/AIAssistant/components/blocks/MyBlock.tsx
|
||||
import { useContext } from 'react';
|
||||
import MessageContext from '../MessageContext';
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
|
||||
interface MyBlockPayload {
|
||||
title: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
export default function MyBlock({ content }: { content: string }): JSX.Element {
|
||||
const payload = JSON.parse(content) as MyBlockPayload;
|
||||
const { messageId } = useContext(MessageContext);
|
||||
const markBlockAnswered = useAIAssistantStore(s => s.markBlockAnswered);
|
||||
const answered = useAIAssistantStore(s => s.answeredBlocks[messageId]);
|
||||
|
||||
if (answered) return <div className="ai-block--answered">Done</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4>{payload.title}</h4>
|
||||
{/* ... */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2 — Register in index.ts
|
||||
|
||||
```typescript
|
||||
// src/container/AIAssistant/components/blocks/index.ts
|
||||
import MyBlock from './MyBlock';
|
||||
BlockRegistry.register('myblock', MyBlock);
|
||||
```
|
||||
|
||||
### Step 3 — AI emits the block tag
|
||||
|
||||
````markdown
|
||||
```ai-myblock
|
||||
{
|
||||
"title": "Something",
|
||||
"items": ["a", "b"]
|
||||
}
|
||||
```
|
||||
````
|
||||
|
||||
---
|
||||
|
||||
## 16. Data Contracts
|
||||
|
||||
### 16.1 API Request
|
||||
|
||||
```typescript
|
||||
POST /api/v1/assistant/threads
|
||||
{
|
||||
conversationId: string,
|
||||
messages: Array<{
|
||||
role: 'user' | 'assistant',
|
||||
content: string // last user message includes [PAGE_CONTEXT]...[/PAGE_CONTEXT] prefix
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
### 16.2 SSE Event Schema
|
||||
|
||||
```typescript
|
||||
interface SSEEvent {
|
||||
type: 'message';
|
||||
messageId: string; // server-assigned; consistent across all chunks of one turn
|
||||
role: 'assistant';
|
||||
content: string; // incremental chunk — NOT cumulative
|
||||
done: boolean; // true on the last event of a turn
|
||||
actions: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
kind: 'follow_up' | 'open_resource' | 'navigate' | 'apply_filter' | 'open_docs' | 'undo' | 'revert';
|
||||
payload: Record<string, unknown>;
|
||||
expiresAt: string | null; // ISO-8601 or null
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
### 16.3 ai-action Block Payload (embedded in content stream)
|
||||
|
||||
```typescript
|
||||
{
|
||||
actionId: string, // must match a registered PageAction.id
|
||||
description: string, // shown in the confirmation card (autoApply=false)
|
||||
parameters: Record<string, unknown> // must conform to the action's JSON Schema
|
||||
}
|
||||
```
|
||||
|
||||
### 16.4 PageAction Interface
|
||||
|
||||
```typescript
|
||||
interface PageAction<TParams = Record<string, any>> {
|
||||
id: string;
|
||||
description: string;
|
||||
parameters: JSONSchemaObject;
|
||||
execute: (params: TParams) => Promise<{ summary: string; data?: unknown }>;
|
||||
getContext?: () => unknown; // called on every sendMessage() to populate PAGE_CONTEXT
|
||||
autoApply?: boolean; // default false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 17. Key Design Decisions
|
||||
|
||||
### Context injection is wire-only
|
||||
PAGE_CONTEXT is injected into the wire payload but never stored or shown in the UI. This keeps conversations readable, avoids polluting history with system context, and ensures the AI always gets fresh page state on every message rather than stale state from when the conversation started.
|
||||
|
||||
### URL-driven query builders require URL-sync APIs
|
||||
Pages that use URL-driven state (e.g., `QueryBuilderV2` with `compositeQuery` URL param) **must** use the URL-sync API (`redirectWithQueryBuilderData`) when actions mutate query state. Using React `setState` alone does not update the URL, so the displayed filters do not change. This was the root cause of the first major bug in the Logs Explorer integration.
|
||||
|
||||
### autoApply co-located with action definition
|
||||
The `autoApply` flag lives on the `PageAction` definition, not in the UI layer. The page that owns the action knows whether it is safe to apply without confirmation. Additive / reversible actions use `autoApply: true`. Actions that create persistent artifacts (saved views, alert rules) use `autoApply: false`.
|
||||
|
||||
### Transcript-as-state for voice input
|
||||
`useSpeechRecognition` exposes `transcript` and `isFinal` as React state rather than using an `onTranscript` callback. The callback approach had a race condition: recognition events could fire before the `useEffect` that wired up the callback had run, leaving `onTranscriptRef.current` unset. State-based approach uses normal React reactivity with no timing dependency.
|
||||
|
||||
### Block answer persistence across re-mounts
|
||||
Interactive blocks persist their answered state to `answeredBlocks[messageId]` in the Zustand store. Without this, switching UI modes or scrolling away and back would reset blocks to their unanswered state, allowing the user to re-submit answers and send duplicate messages to the AI.
|
||||
|
||||
### Panel resize is not persisted
|
||||
Panel width resets to 380px on close/reopen. If persistence is needed, save `panelWidth` to `localStorage` in the drag `onMouseUp` handler and initialize `useState` from it.
|
||||
|
||||
### Mock API shares the same interface
|
||||
`mockStreamChat` implements the same `AsyncGenerator<SSEEvent>` interface as `streamChat`. The store switches between them via `VITE_AI_MOCK=true`. This means the mock exercises the exact same store code path as production — no separate code branch to maintain.
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* AIAssistantIcon — SigNoz AI Assistant icon (V2 — Minimal Line).
|
||||
*
|
||||
* Single-weight stroke outline of a bear face. Inherits `currentColor` so it
|
||||
* adapts to any dark/light context automatically. The only hard-coded color is
|
||||
* the SigNoz red (#E8432D) eye bar — one bold accent, nothing else.
|
||||
*/
|
||||
|
||||
interface AIAssistantIconProps {
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function AIAssistantIcon({
|
||||
size = 24,
|
||||
className,
|
||||
}: AIAssistantIconProps): JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
aria-label="AI Assistant"
|
||||
role="img"
|
||||
>
|
||||
{/* Ears */}
|
||||
<path
|
||||
d="M8 13.5 C8 8 5 6 5 11 C5 14 7 15.5 9.5 15.5"
|
||||
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M24 13.5 C24 8 27 6 27 11 C27 14 25 15.5 22.5 15.5"
|
||||
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Head */}
|
||||
<rect x="7" y="12" width="18" height="15" rx="7"
|
||||
stroke="currentColor" strokeWidth="1.5" fill="none" />
|
||||
|
||||
{/* Eye bar — SigNoz red, the only accent */}
|
||||
<line x1="10" y1="18" x2="22" y2="18"
|
||||
stroke="#E8432D" strokeWidth="2.5" strokeLinecap="round" />
|
||||
<circle cx="12" cy="18" r="1" fill="#E8432D" />
|
||||
<circle cx="20" cy="18" r="1" fill="#E8432D" />
|
||||
|
||||
{/* Nose */}
|
||||
<ellipse cx="16" cy="23.5" rx="1.6" ry="1"
|
||||
stroke="currentColor" strokeWidth="1.2" fill="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
120
frontend/src/container/AIAssistant/components/ApprovalCard.tsx
Normal file
120
frontend/src/container/AIAssistant/components/ApprovalCard.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Check, Shield, X } from 'lucide-react';
|
||||
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
import { PendingApproval } from '../types';
|
||||
|
||||
interface ApprovalCardProps {
|
||||
conversationId: string;
|
||||
approval: PendingApproval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendered when the agent emits an `approval` SSE event.
|
||||
* The agent has paused execution; the user must approve or reject
|
||||
* before the stream resumes on a new execution.
|
||||
*/
|
||||
export default function ApprovalCard({
|
||||
conversationId,
|
||||
approval,
|
||||
}: ApprovalCardProps): JSX.Element {
|
||||
const approveAction = useAIAssistantStore((s) => s.approveAction);
|
||||
const rejectAction = useAIAssistantStore((s) => s.rejectAction);
|
||||
const isStreaming = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.isStreaming ?? false,
|
||||
);
|
||||
|
||||
const [decided, setDecided] = useState<'approved' | 'rejected' | null>(null);
|
||||
|
||||
const handleApprove = async (): Promise<void> => {
|
||||
setDecided('approved');
|
||||
await approveAction(conversationId, approval.approvalId);
|
||||
};
|
||||
|
||||
const handleReject = async (): Promise<void> => {
|
||||
setDecided('rejected');
|
||||
await rejectAction(conversationId, approval.approvalId);
|
||||
};
|
||||
|
||||
// After decision the card shows a compact confirmation row
|
||||
if (decided === 'approved') {
|
||||
return (
|
||||
<div className="ai-approval ai-approval--decided">
|
||||
<Check
|
||||
size={13}
|
||||
className="ai-approval__status-icon ai-approval__status-icon--ok"
|
||||
/>
|
||||
<span className="ai-approval__status-text">Approved — resuming…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (decided === 'rejected') {
|
||||
return (
|
||||
<div className="ai-approval ai-approval--decided">
|
||||
<X
|
||||
size={13}
|
||||
className="ai-approval__status-icon ai-approval__status-icon--no"
|
||||
/>
|
||||
<span className="ai-approval__status-text">Rejected.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-approval">
|
||||
<div className="ai-approval__header">
|
||||
<Shield size={13} className="ai-approval__shield-icon" />
|
||||
<span className="ai-approval__header-label">Action requires approval</span>
|
||||
<span className="ai-approval__resource-badge">
|
||||
{approval.actionType} · {approval.resourceType}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="ai-approval__summary">{approval.summary}</p>
|
||||
|
||||
{approval.diff && (
|
||||
<div className="ai-approval__diff">
|
||||
{approval.diff.before !== undefined && (
|
||||
<div className="ai-approval__diff-block ai-approval__diff-block--before">
|
||||
<span className="ai-approval__diff-label">Before</span>
|
||||
<pre className="ai-approval__diff-json">
|
||||
{JSON.stringify(approval.diff.before, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{approval.diff.after !== undefined && (
|
||||
<div className="ai-approval__diff-block ai-approval__diff-block--after">
|
||||
<span className="ai-approval__diff-label">After</span>
|
||||
<pre className="ai-approval__diff-json">
|
||||
{JSON.stringify(approval.diff.after, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ai-approval__actions">
|
||||
<Button
|
||||
variant="solid"
|
||||
size="xs"
|
||||
onClick={handleApprove}
|
||||
disabled={isStreaming}
|
||||
>
|
||||
<Check size={12} />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="xs"
|
||||
onClick={handleReject}
|
||||
disabled={isStreaming}
|
||||
>
|
||||
<X size={12} />
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
257
frontend/src/container/AIAssistant/components/ChatInput.tsx
Normal file
257
frontend/src/container/AIAssistant/components/ChatInput.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Tooltip } from '@signozhq/tooltip';
|
||||
import type { UploadFile } from 'antd';
|
||||
import { Upload } from 'antd';
|
||||
import { Mic, Paperclip, Send, Square, X } from 'lucide-react';
|
||||
|
||||
import { useSpeechRecognition } from '../hooks/useSpeechRecognition';
|
||||
import { MessageAttachment } from '../types';
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (text: string, attachments?: MessageAttachment[]) => void;
|
||||
onCancel?: () => void;
|
||||
disabled?: boolean;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
function fileToDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (): void => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export default function ChatInput({
|
||||
onSend,
|
||||
onCancel,
|
||||
disabled,
|
||||
isStreaming = false,
|
||||
}: ChatInputProps): JSX.Element {
|
||||
const [text, setText] = useState('');
|
||||
const [pendingFiles, setPendingFiles] = useState<UploadFile[]>([]);
|
||||
// Stores the already-committed final text so interim results don't overwrite it
|
||||
const committedTextRef = useRef('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Focus the textarea when this component mounts (panel/modal open)
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed && pendingFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attachments: MessageAttachment[] = await Promise.all(
|
||||
pendingFiles.map(async (f) => {
|
||||
const dataUrl = f.originFileObj ? await fileToDataUrl(f.originFileObj) : '';
|
||||
return {
|
||||
name: f.name,
|
||||
type: f.type ?? 'application/octet-stream',
|
||||
dataUrl,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
onSend(trimmed, attachments.length > 0 ? attachments : undefined);
|
||||
setText('');
|
||||
committedTextRef.current = '';
|
||||
setPendingFiles([]);
|
||||
textareaRef.current?.focus();
|
||||
}, [text, pendingFiles, onSend]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
},
|
||||
[handleSend],
|
||||
);
|
||||
|
||||
const removeFile = useCallback((uid: string) => {
|
||||
setPendingFiles((prev) => prev.filter((f) => f.uid !== uid));
|
||||
}, []);
|
||||
|
||||
// ── Voice input ────────────────────────────────────────────────────────────
|
||||
|
||||
const { isListening, isSupported, start, discard } = useSpeechRecognition({
|
||||
onTranscript: (transcriptText, isFinal) => {
|
||||
if (isFinal) {
|
||||
// Commit: append to whatever the user has already typed
|
||||
const separator = committedTextRef.current ? ' ' : '';
|
||||
const next = committedTextRef.current + separator + transcriptText;
|
||||
committedTextRef.current = next;
|
||||
setText(next);
|
||||
} else {
|
||||
// Interim: live preview appended to committed text, not yet persisted
|
||||
const separator = committedTextRef.current ? ' ' : '';
|
||||
setText(committedTextRef.current + separator + transcriptText);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Stop recording and immediately send whatever is in the textarea.
|
||||
const handleStopAndSend = useCallback(async () => {
|
||||
// Promote the displayed text (interim included) to committed so handleSend sees it.
|
||||
committedTextRef.current = text;
|
||||
// Stop recognition without triggering onTranscript again (would double-append).
|
||||
discard();
|
||||
await handleSend();
|
||||
}, [text, discard, handleSend]);
|
||||
|
||||
// Stop recording and revert the textarea to what it was before voice started.
|
||||
const handleDiscard = useCallback(() => {
|
||||
discard();
|
||||
setText(committedTextRef.current);
|
||||
textareaRef.current?.focus();
|
||||
}, [discard]);
|
||||
|
||||
return (
|
||||
<div className="ai-assistant-input">
|
||||
{pendingFiles.length > 0 && (
|
||||
<div className="ai-assistant-input__attachments">
|
||||
{pendingFiles.map((f) => (
|
||||
<div key={f.uid} className="ai-assistant-input__attachment-chip">
|
||||
<span className="ai-assistant-input__attachment-name">{f.name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="ai-assistant-input__attachment-remove"
|
||||
onClick={(): void => removeFile(f.uid)}
|
||||
aria-label={`Remove ${f.name}`}
|
||||
>
|
||||
<X size={11} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ai-assistant-input__row">
|
||||
<Upload
|
||||
multiple
|
||||
accept="image/*,.pdf,.txt,.log,.csv,.json"
|
||||
showUploadList={false}
|
||||
beforeUpload={(file): boolean => {
|
||||
setPendingFiles((prev) => [
|
||||
...prev,
|
||||
{
|
||||
uid: file.uid,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
originFileObj: file,
|
||||
},
|
||||
]);
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={disabled}
|
||||
aria-label="Attach file"
|
||||
>
|
||||
<Paperclip size={14} />
|
||||
</Button>
|
||||
</Upload>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="ai-assistant-input__textarea"
|
||||
placeholder="Ask anything… (Shift+Enter for new line)"
|
||||
value={text}
|
||||
onChange={(e): void => {
|
||||
setText(e.target.value);
|
||||
// Keep committed text in sync when the user edits manually
|
||||
committedTextRef.current = e.target.value;
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
/>
|
||||
|
||||
{isListening ? (
|
||||
<div className="ai-mic-recording">
|
||||
<button
|
||||
type="button"
|
||||
className="ai-mic-recording__discard"
|
||||
onClick={handleDiscard}
|
||||
aria-label="Discard recording"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
<span className="ai-mic-recording__waves" aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ai-mic-recording__stop"
|
||||
onClick={handleStopAndSend}
|
||||
aria-label="Stop and send"
|
||||
>
|
||||
<Square size={9} fill="currentColor" strokeWidth={0} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Tooltip
|
||||
title={
|
||||
!isSupported
|
||||
? 'Voice input not supported in this browser'
|
||||
: 'Voice input'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={start}
|
||||
disabled={disabled || !isSupported}
|
||||
aria-label="Start voice input"
|
||||
className="ai-mic-btn"
|
||||
>
|
||||
<Mic size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isStreaming && onCancel ? (
|
||||
<Tooltip title="Stop generating">
|
||||
<Button
|
||||
variant="solid"
|
||||
size="xs"
|
||||
className="ai-assistant-input__send-btn ai-assistant-input__send-btn--stop"
|
||||
onClick={onCancel}
|
||||
aria-label="Stop generating"
|
||||
>
|
||||
<Square size={10} fill="currentColor" strokeWidth={0} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
variant="solid"
|
||||
size="xs"
|
||||
className="ai-assistant-input__send-btn"
|
||||
onClick={isListening ? handleStopAndSend : handleSend}
|
||||
disabled={disabled || (!text.trim() && pendingFiles.length === 0)}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<Send size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { HelpCircle, Send } from 'lucide-react';
|
||||
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
import { ClarificationField, PendingClarification } from '../types';
|
||||
|
||||
interface ClarificationFormProps {
|
||||
conversationId: string;
|
||||
clarification: PendingClarification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendered when the agent emits a `clarification` SSE event.
|
||||
* Dynamically renders form fields based on the `fields` array and
|
||||
* submits answers to resume the agent on a new execution.
|
||||
*/
|
||||
export default function ClarificationForm({
|
||||
conversationId,
|
||||
clarification,
|
||||
}: ClarificationFormProps): JSX.Element {
|
||||
const submitClarification = useAIAssistantStore((s) => s.submitClarification);
|
||||
const isStreaming = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.isStreaming ?? false,
|
||||
);
|
||||
|
||||
const initialAnswers = Object.fromEntries(
|
||||
clarification.fields.map((f) => [f.id, f.default ?? '']),
|
||||
);
|
||||
const [answers, setAnswers] = useState<Record<string, unknown>>(
|
||||
initialAnswers,
|
||||
);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const setField = (id: string, value: unknown): void => {
|
||||
setAnswers((prev) => ({ ...prev, [id]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
setSubmitted(true);
|
||||
await submitClarification(
|
||||
conversationId,
|
||||
clarification.clarificationId,
|
||||
answers,
|
||||
);
|
||||
};
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="ai-clarification ai-clarification--submitted">
|
||||
<Send size={13} className="ai-clarification__icon" />
|
||||
<span className="ai-clarification__status-text">
|
||||
Answers submitted — resuming…
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-clarification">
|
||||
<div className="ai-clarification__header">
|
||||
<HelpCircle size={13} className="ai-clarification__header-icon" />
|
||||
<span className="ai-clarification__header-label">A few details needed</span>
|
||||
</div>
|
||||
|
||||
<p className="ai-clarification__message">{clarification.message}</p>
|
||||
|
||||
<div className="ai-clarification__fields">
|
||||
{clarification.fields.map((field) => (
|
||||
<FieldInput
|
||||
key={field.id}
|
||||
field={field}
|
||||
value={answers[field.id]}
|
||||
onChange={(val): void => setField(field.id, val)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ai-clarification__actions">
|
||||
<Button
|
||||
variant="solid"
|
||||
size="xs"
|
||||
onClick={handleSubmit}
|
||||
disabled={isStreaming}
|
||||
>
|
||||
<Send size={12} />
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Field renderer — handles text, number, select, radio, checkbox
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FieldInputProps {
|
||||
field: ClarificationField;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
}
|
||||
|
||||
function FieldInput({ field, value, onChange }: FieldInputProps): JSX.Element {
|
||||
const { id, type, label, required, options } = field;
|
||||
|
||||
if (type === 'select' && options) {
|
||||
return (
|
||||
<div className="ai-clarification__field">
|
||||
<label className="ai-clarification__label" htmlFor={id}>
|
||||
{label}
|
||||
{required && <span className="ai-clarification__required">*</span>}
|
||||
</label>
|
||||
<select
|
||||
id={id}
|
||||
className="ai-clarification__select"
|
||||
value={String(value ?? '')}
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{options.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'radio' && options) {
|
||||
return (
|
||||
<div className="ai-clarification__field">
|
||||
<span className="ai-clarification__label">
|
||||
{label}
|
||||
{required && <span className="ai-clarification__required">*</span>}
|
||||
</span>
|
||||
<div className="ai-clarification__radio-group">
|
||||
{options.map((opt) => (
|
||||
<label key={opt} className="ai-clarification__radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name={id}
|
||||
value={opt}
|
||||
checked={value === opt}
|
||||
onChange={(): void => onChange(opt)}
|
||||
className="ai-clarification__radio"
|
||||
/>
|
||||
{opt}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'checkbox' && options) {
|
||||
const selected = Array.isArray(value) ? (value as string[]) : [];
|
||||
const toggle = (opt: string): void => {
|
||||
onChange(
|
||||
selected.includes(opt)
|
||||
? selected.filter((v) => v !== opt)
|
||||
: [...selected, opt],
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="ai-clarification__field">
|
||||
<span className="ai-clarification__label">
|
||||
{label}
|
||||
{required && <span className="ai-clarification__required">*</span>}
|
||||
</span>
|
||||
<div className="ai-clarification__checkbox-group">
|
||||
{options.map((opt) => (
|
||||
<label key={opt} className="ai-clarification__checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(opt)}
|
||||
onChange={(): void => toggle(opt)}
|
||||
className="ai-clarification__checkbox"
|
||||
/>
|
||||
{opt}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// text / number (default)
|
||||
return (
|
||||
<div className="ai-clarification__field">
|
||||
<label className="ai-clarification__label" htmlFor={id}>
|
||||
{label}
|
||||
{required && <span className="ai-clarification__required">*</span>}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type={type === 'number' ? 'number' : 'text'}
|
||||
className="ai-clarification__input"
|
||||
value={String(value ?? '')}
|
||||
onChange={(e): void =>
|
||||
onChange(type === 'number' ? Number(e.target.value) : e.target.value)
|
||||
}
|
||||
placeholder={label}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Tooltip } from '@signozhq/tooltip';
|
||||
import { MessageSquare, Pencil, Trash2 } from 'lucide-react';
|
||||
|
||||
import { Conversation } from '../types';
|
||||
|
||||
interface ConversationItemProps {
|
||||
conversation: Conversation;
|
||||
isActive: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onRename: (id: string, title: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
function formatRelativeTime(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const mins = Math.floor(diff / 60_000);
|
||||
if (mins < 1) {
|
||||
return 'just now';
|
||||
}
|
||||
if (mins < 60) {
|
||||
return `${mins}m ago`;
|
||||
}
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) {
|
||||
return `${hrs}h ago`;
|
||||
}
|
||||
const days = Math.floor(hrs / 24);
|
||||
if (days < 7) {
|
||||
return `${days}d ago`;
|
||||
}
|
||||
return new Date(ts).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export default function ConversationItem({
|
||||
conversation,
|
||||
isActive,
|
||||
onSelect,
|
||||
onRename,
|
||||
onDelete,
|
||||
}: ConversationItemProps): JSX.Element {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const displayTitle = conversation.title ?? 'New conversation';
|
||||
const ts = conversation.updatedAt ?? conversation.createdAt;
|
||||
|
||||
const startEditing = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setEditValue(conversation.title ?? '');
|
||||
setIsEditing(true);
|
||||
},
|
||||
[conversation.title],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const commitEdit = useCallback(() => {
|
||||
onRename(conversation.id, editValue);
|
||||
setIsEditing(false);
|
||||
}, [conversation.id, editValue, onRename]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
commitEdit();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setIsEditing(false);
|
||||
}
|
||||
},
|
||||
[commitEdit],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDelete(conversation.id);
|
||||
},
|
||||
[conversation.id, onDelete],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ai-history__item${isActive ? ' ai-history__item--active' : ''}`}
|
||||
onClick={(): void => onSelect(conversation.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onSelect(conversation.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MessageSquare size={12} className="ai-history__item-icon" />
|
||||
|
||||
<div className="ai-history__item-body">
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="ai-history__item-input"
|
||||
value={editValue}
|
||||
onChange={(e): void => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={commitEdit}
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
maxLength={80}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="ai-history__item-title" title={displayTitle}>
|
||||
{displayTitle}
|
||||
</span>
|
||||
<span className="ai-history__item-time">{formatRelativeTime(ts)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<div className="ai-history__item-actions">
|
||||
<Tooltip title="Rename">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="ai-history__item-btn"
|
||||
onClick={startEditing}
|
||||
aria-label="Rename conversation"
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="ai-history__item-btn ai-history__item-btn--danger"
|
||||
onClick={handleDelete}
|
||||
aria-label="Delete conversation"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
frontend/src/container/AIAssistant/components/HistorySidebar.tsx
Normal file
146
frontend/src/container/AIAssistant/components/HistorySidebar.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Loader2, Plus } from 'lucide-react';
|
||||
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
import { Conversation } from '../types';
|
||||
import ConversationItem from './ConversationItem';
|
||||
|
||||
interface HistorySidebarProps {
|
||||
/** Called when a conversation is selected — lets the parent navigate if needed */
|
||||
onSelect?: (id: string) => void;
|
||||
}
|
||||
|
||||
function groupByDate(
|
||||
conversations: Conversation[],
|
||||
): { label: string; items: Conversation[] }[] {
|
||||
const now = Date.now();
|
||||
const DAY = 86_400_000;
|
||||
|
||||
const groups: Record<string, Conversation[]> = {
|
||||
Today: [],
|
||||
Yesterday: [],
|
||||
'Last 7 days': [],
|
||||
'Last 30 days': [],
|
||||
Older: [],
|
||||
};
|
||||
|
||||
for (const conv of conversations) {
|
||||
const age = now - (conv.updatedAt ?? conv.createdAt);
|
||||
if (age < DAY) {
|
||||
groups.Today.push(conv);
|
||||
} else if (age < 2 * DAY) {
|
||||
groups.Yesterday.push(conv);
|
||||
} else if (age < 7 * DAY) {
|
||||
groups['Last 7 days'].push(conv);
|
||||
} else if (age < 30 * DAY) {
|
||||
groups['Last 30 days'].push(conv);
|
||||
} else {
|
||||
groups.Older.push(conv);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.entries(groups)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([label, items]) => ({ label, items }));
|
||||
}
|
||||
|
||||
export default function HistorySidebar({
|
||||
onSelect,
|
||||
}: HistorySidebarProps): JSX.Element {
|
||||
const conversations = useAIAssistantStore((s) => s.conversations);
|
||||
const activeConversationId = useAIAssistantStore(
|
||||
(s) => s.activeConversationId,
|
||||
);
|
||||
const isLoadingThreads = useAIAssistantStore((s) => s.isLoadingThreads);
|
||||
const startNewConversation = useAIAssistantStore(
|
||||
(s) => s.startNewConversation,
|
||||
);
|
||||
const setActiveConversation = useAIAssistantStore(
|
||||
(s) => s.setActiveConversation,
|
||||
);
|
||||
const loadThread = useAIAssistantStore((s) => s.loadThread);
|
||||
const fetchThreads = useAIAssistantStore((s) => s.fetchThreads);
|
||||
const deleteConversation = useAIAssistantStore((s) => s.deleteConversation);
|
||||
const renameConversation = useAIAssistantStore((s) => s.renameConversation);
|
||||
|
||||
// Fetch thread history from backend on mount
|
||||
useEffect(() => {
|
||||
fetchThreads();
|
||||
}, [fetchThreads]);
|
||||
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
Object.values(conversations).sort(
|
||||
(a, b) => (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt),
|
||||
),
|
||||
[conversations],
|
||||
);
|
||||
|
||||
const groups = useMemo(() => groupByDate(sorted), [sorted]);
|
||||
|
||||
const handleSelect = (id: string): void => {
|
||||
const conv = conversations[id];
|
||||
if (conv?.threadId) {
|
||||
// Always load from backend — refreshes messages and reconnects
|
||||
// to active execution if the thread is still busy.
|
||||
loadThread(conv.threadId);
|
||||
} else {
|
||||
// Local-only conversation (no backend thread yet)
|
||||
setActiveConversation(id);
|
||||
}
|
||||
onSelect?.(id);
|
||||
};
|
||||
|
||||
const handleNew = (): void => {
|
||||
const id = startNewConversation();
|
||||
onSelect?.(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ai-history">
|
||||
<div className="ai-history__header">
|
||||
<span className="ai-history__heading">History</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleNew}
|
||||
aria-label="New conversation"
|
||||
className="ai-history__new-btn"
|
||||
>
|
||||
<Plus size={13} />
|
||||
New
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="ai-history__list">
|
||||
{isLoadingThreads && groups.length === 0 && (
|
||||
<div className="ai-history__loading">
|
||||
<Loader2 size={16} className="ai-history__spinner" />
|
||||
Loading conversations…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingThreads && groups.length === 0 && (
|
||||
<p className="ai-history__empty">No conversations yet.</p>
|
||||
)}
|
||||
|
||||
{groups.map(({ label, items }) => (
|
||||
<div key={label} className="ai-history__group">
|
||||
<span className="ai-history__group-label">{label}</span>
|
||||
{items.map((conv) => (
|
||||
<ConversationItem
|
||||
key={conv.id}
|
||||
conversation={conv}
|
||||
isActive={conv.id === activeConversationId}
|
||||
onSelect={handleSelect}
|
||||
onRename={renameConversation}
|
||||
onDelete={deleteConversation}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
frontend/src/container/AIAssistant/components/MessageBubble.tsx
Normal file
141
frontend/src/container/AIAssistant/components/MessageBubble.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
// Side-effect: registers all built-in block types into the BlockRegistry
|
||||
import './blocks';
|
||||
|
||||
import { Message, MessageBlock } from '../types';
|
||||
import { RichCodeBlock } from './blocks';
|
||||
import { MessageContext } from './MessageContext';
|
||||
import MessageFeedback from './MessageFeedback';
|
||||
import ThinkingStep from './ThinkingStep';
|
||||
import ToolCallStep from './ToolCallStep';
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
onRegenerate?: () => void;
|
||||
isLastAssistant?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* react-markdown renders fenced code blocks as <pre><code>...</code></pre>.
|
||||
* When RichCodeBlock replaces <code> with a custom AI block component, the
|
||||
* block ends up wrapped in <pre> which forces monospace font and white-space:pre.
|
||||
* This renderer detects that case and unwraps the <pre>.
|
||||
*/
|
||||
function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
|
||||
const childArr = React.Children.toArray(children);
|
||||
if (childArr.length === 1) {
|
||||
const child = childArr[0];
|
||||
// If the code component returned something other than a <code> element
|
||||
// (i.e. a custom AI block), render without the <pre> wrapper.
|
||||
if (React.isValidElement(child) && child.type !== 'code') {
|
||||
return <>{child}</>;
|
||||
}
|
||||
}
|
||||
return <pre>{children}</pre>;
|
||||
}
|
||||
|
||||
const MD_PLUGINS = [remarkGfm];
|
||||
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
|
||||
|
||||
/** Renders a single MessageBlock by type. */
|
||||
function renderBlock(block: MessageBlock, index: number): JSX.Element {
|
||||
switch (block.type) {
|
||||
case 'thinking':
|
||||
return <ThinkingStep key={index} content={block.content} />;
|
||||
case 'tool_call':
|
||||
// Blocks in a persisted message are always complete — done is always true.
|
||||
return (
|
||||
<ToolCallStep
|
||||
key={index}
|
||||
toolCall={{
|
||||
toolName: block.toolName,
|
||||
input: block.toolInput,
|
||||
result: block.result,
|
||||
done: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 'text':
|
||||
default:
|
||||
return (
|
||||
<ReactMarkdown
|
||||
key={index}
|
||||
className="ai-message__markdown"
|
||||
remarkPlugins={MD_PLUGINS}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
{block.content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function MessageBubble({
|
||||
message,
|
||||
onRegenerate,
|
||||
isLastAssistant = false,
|
||||
}: MessageBubbleProps): JSX.Element {
|
||||
const isUser = message.role === 'user';
|
||||
const hasBlocks = !isUser && message.blocks && message.blocks.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ai-message ai-message--${isUser ? 'user' : 'assistant'}`}
|
||||
data-testid={`ai-message-${message.id}`}
|
||||
>
|
||||
<div className="ai-message__body">
|
||||
<div className="ai-message__bubble">
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className="ai-message__attachments">
|
||||
{message.attachments.map((att) => {
|
||||
const isImage = att.type.startsWith('image/');
|
||||
return isImage ? (
|
||||
<img
|
||||
key={att.name}
|
||||
src={att.dataUrl}
|
||||
alt={att.name}
|
||||
className="ai-message__attachment-image"
|
||||
/>
|
||||
) : (
|
||||
<div key={att.name} className="ai-message__attachment-file">
|
||||
{att.name}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUser ? (
|
||||
<p className="ai-message__text">{message.content}</p>
|
||||
) : hasBlocks ? (
|
||||
<MessageContext.Provider value={{ messageId: message.id }}>
|
||||
{/* eslint-disable-next-line react/no-array-index-key */}
|
||||
{message.blocks!.map((block, i) => renderBlock(block, i))}
|
||||
</MessageContext.Provider>
|
||||
) : (
|
||||
<MessageContext.Provider value={{ messageId: message.id }}>
|
||||
<ReactMarkdown
|
||||
className="ai-message__markdown"
|
||||
remarkPlugins={MD_PLUGINS}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
</MessageContext.Provider>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isUser && (
|
||||
<MessageFeedback
|
||||
message={message}
|
||||
onRegenerate={onRegenerate}
|
||||
isLastAssistant={isLastAssistant}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
interface MessageContextValue {
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
export const MessageContext = createContext<MessageContextValue>({
|
||||
messageId: '',
|
||||
});
|
||||
|
||||
export const useMessageContext = (): MessageContextValue =>
|
||||
useContext(MessageContext);
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Tooltip } from '@signozhq/tooltip';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { Check, Copy, RefreshCw, ThumbsDown, ThumbsUp } from 'lucide-react';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
import { FeedbackRating, Message } from '../types';
|
||||
|
||||
interface MessageFeedbackProps {
|
||||
message: Message;
|
||||
onRegenerate?: () => void;
|
||||
isLastAssistant?: boolean;
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
const diffMs = Date.now() - timestamp;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
|
||||
if (diffSec < 10) {
|
||||
return 'just now';
|
||||
}
|
||||
if (diffSec < 60) {
|
||||
return `${diffSec}s ago`;
|
||||
}
|
||||
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
if (diffMin < 60) {
|
||||
return `${diffMin} min${diffMin === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
if (diffHr < 24) {
|
||||
return `${diffHr} hr${diffHr === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
export default function MessageFeedback({
|
||||
message,
|
||||
onRegenerate,
|
||||
isLastAssistant = false,
|
||||
}: MessageFeedbackProps): JSX.Element {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const submitMessageFeedback = useAIAssistantStore(
|
||||
(s) => s.submitMessageFeedback,
|
||||
);
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
// Local vote state — initialised from persisted feedbackRating, updated
|
||||
// immediately on click so the UI responds without waiting for the API.
|
||||
const [vote, setVote] = useState<FeedbackRating | null>(
|
||||
message.feedbackRating ?? null,
|
||||
);
|
||||
|
||||
const [relativeTime, setRelativeTime] = useState(() =>
|
||||
formatRelativeTime(message.createdAt),
|
||||
);
|
||||
|
||||
const absoluteTime = useMemo(
|
||||
() =>
|
||||
formatTimezoneAdjustedTimestamp(
|
||||
message.createdAt,
|
||||
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
|
||||
),
|
||||
[message.createdAt, formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
// Tick relative time every 30 s
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setRelativeTime(formatRelativeTime(message.createdAt));
|
||||
}, 30_000);
|
||||
return (): void => clearInterval(id);
|
||||
}, [message.createdAt]);
|
||||
|
||||
const handleCopy = useCallback((): void => {
|
||||
copyToClipboard(message.content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}, [copyToClipboard, message.content]);
|
||||
|
||||
const handleVote = useCallback(
|
||||
(rating: FeedbackRating): void => {
|
||||
if (vote === rating) {
|
||||
return;
|
||||
}
|
||||
setVote(rating);
|
||||
submitMessageFeedback(message.id, rating);
|
||||
},
|
||||
[vote, message.id, submitMessageFeedback],
|
||||
);
|
||||
|
||||
const feedbackClass = `ai-message-feedback${
|
||||
isLastAssistant ? ' ai-message-feedback--visible' : ''
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className={feedbackClass}>
|
||||
<div className="ai-message-feedback__actions">
|
||||
<Tooltip title={copied ? 'Copied!' : 'Copy'}>
|
||||
<Button
|
||||
className={`ai-message-feedback__btn${
|
||||
copied ? ' ai-message-feedback__btn--active' : ''
|
||||
}`}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Good response">
|
||||
<Button
|
||||
className={`ai-message-feedback__btn${
|
||||
vote === 'positive' ? ' ai-message-feedback__btn--voted-up' : ''
|
||||
}`}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={(): void => handleVote('positive')}
|
||||
>
|
||||
<ThumbsUp size={12} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Bad response">
|
||||
<Button
|
||||
className={`ai-message-feedback__btn${
|
||||
vote === 'negative' ? ' ai-message-feedback__btn--voted-down' : ''
|
||||
}`}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={(): void => handleVote('negative')}
|
||||
>
|
||||
<ThumbsDown size={12} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{onRegenerate && (
|
||||
<Tooltip title="Regenerate">
|
||||
<Button
|
||||
className="ai-message-feedback__btn"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={onRegenerate}
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="ai-message-feedback__time">
|
||||
{relativeTime} · {absoluteTime}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import {
|
||||
PendingApproval,
|
||||
PendingClarification,
|
||||
StreamingEventItem,
|
||||
} from '../types';
|
||||
import ApprovalCard from './ApprovalCard';
|
||||
import { RichCodeBlock } from './blocks';
|
||||
import ClarificationForm from './ClarificationForm';
|
||||
import ThinkingStep from './ThinkingStep';
|
||||
import ToolCallStep from './ToolCallStep';
|
||||
|
||||
function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
|
||||
const childArr = React.Children.toArray(children);
|
||||
if (childArr.length === 1) {
|
||||
const child = childArr[0];
|
||||
if (React.isValidElement(child) && child.type !== 'code') {
|
||||
return <>{child}</>;
|
||||
}
|
||||
}
|
||||
return <pre>{children}</pre>;
|
||||
}
|
||||
|
||||
const MD_PLUGINS = [remarkGfm];
|
||||
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
|
||||
|
||||
/** Human-readable labels for execution status codes shown before any events arrive. */
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
queued: 'Queued…',
|
||||
running: 'Thinking…',
|
||||
awaiting_approval: 'Waiting for your approval…',
|
||||
awaiting_clarification: 'Waiting for your input…',
|
||||
resumed: 'Resumed…',
|
||||
};
|
||||
|
||||
interface StreamingMessageProps {
|
||||
conversationId: string;
|
||||
/** Ordered timeline of text and tool-call events in arrival order. */
|
||||
events: StreamingEventItem[];
|
||||
status?: string;
|
||||
pendingApproval?: PendingApproval | null;
|
||||
pendingClarification?: PendingClarification | null;
|
||||
}
|
||||
|
||||
export default function StreamingMessage({
|
||||
conversationId,
|
||||
events,
|
||||
status = '',
|
||||
pendingApproval = null,
|
||||
pendingClarification = null,
|
||||
}: StreamingMessageProps): JSX.Element {
|
||||
const statusLabel = STATUS_LABEL[status] ?? '';
|
||||
const isEmpty =
|
||||
events.length === 0 && !pendingApproval && !pendingClarification;
|
||||
|
||||
return (
|
||||
<div className="ai-message ai-message--assistant ai-message--streaming">
|
||||
<div className="ai-message__bubble">
|
||||
{/* Status pill or typing indicator — only before any events arrive */}
|
||||
{isEmpty && statusLabel && (
|
||||
<span className="ai-streaming-status">{statusLabel}</span>
|
||||
)}
|
||||
{isEmpty && !statusLabel && (
|
||||
<span className="ai-message__typing-indicator">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* eslint-disable react/no-array-index-key */}
|
||||
{/* Events rendered in arrival order: text, thinking, and tool calls interleaved */}
|
||||
{events.map((event, i) => {
|
||||
if (event.kind === 'tool') {
|
||||
return <ToolCallStep key={i} toolCall={event.toolCall} />;
|
||||
}
|
||||
if (event.kind === 'thinking') {
|
||||
return <ThinkingStep key={i} content={event.content} />;
|
||||
}
|
||||
return (
|
||||
<ReactMarkdown
|
||||
key={i}
|
||||
className="ai-message__markdown"
|
||||
remarkPlugins={MD_PLUGINS}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
{event.content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
})}
|
||||
{/* eslint-enable react/no-array-index-key */}
|
||||
|
||||
{/* Approval / clarification cards appended after any streamed text */}
|
||||
{pendingApproval && (
|
||||
<ApprovalCard conversationId={conversationId} approval={pendingApproval} />
|
||||
)}
|
||||
{pendingClarification && (
|
||||
<ClarificationForm
|
||||
conversationId={conversationId}
|
||||
clarification={pendingClarification}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useState } from 'react';
|
||||
import { Brain, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface ThinkingStepProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** Displays a collapsible thinking/reasoning block. */
|
||||
export default function ThinkingStep({
|
||||
content,
|
||||
}: ThinkingStepProps): JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="ai-thinking-step">
|
||||
<button
|
||||
type="button"
|
||||
className="ai-thinking-step__header"
|
||||
onClick={(): void => setExpanded((v) => !v)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<Brain size={12} className="ai-thinking-step__icon" />
|
||||
<span className="ai-thinking-step__label">Thinking</span>
|
||||
{expanded ? (
|
||||
<ChevronDown size={11} className="ai-thinking-step__chevron" />
|
||||
) : (
|
||||
<ChevronRight size={11} className="ai-thinking-step__chevron" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="ai-thinking-step__body">
|
||||
<p className="ai-thinking-step__content">{content}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Loader2, Wrench } from 'lucide-react';
|
||||
|
||||
import { StreamingToolCall } from '../types';
|
||||
|
||||
interface ToolCallStepProps {
|
||||
toolCall: StreamingToolCall;
|
||||
}
|
||||
|
||||
/** Displays a single tool invocation, collapsible, with in/out detail. */
|
||||
export default function ToolCallStep({
|
||||
toolCall,
|
||||
}: ToolCallStepProps): JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { toolName, input, result, done } = toolCall;
|
||||
|
||||
// Format tool name: "signoz_get_dashboard" → "Get Dashboard"
|
||||
const label = toolName
|
||||
.replace(/^[a-z]+_/, '') // strip prefix like "signoz_"
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ai-tool-step ${
|
||||
done ? 'ai-tool-step--done' : 'ai-tool-step--running'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="ai-tool-step__header"
|
||||
onClick={(): void => setExpanded((v) => !v)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{done ? (
|
||||
<Wrench
|
||||
size={12}
|
||||
className="ai-tool-step__icon ai-tool-step__icon--done"
|
||||
/>
|
||||
) : (
|
||||
<Loader2
|
||||
size={12}
|
||||
className="ai-tool-step__icon ai-tool-step__icon--spin"
|
||||
/>
|
||||
)}
|
||||
<span className="ai-tool-step__label">{label}</span>
|
||||
<span className="ai-tool-step__tool-name">{toolName}</span>
|
||||
{expanded ? (
|
||||
<ChevronDown size={11} className="ai-tool-step__chevron" />
|
||||
) : (
|
||||
<ChevronRight size={11} className="ai-tool-step__chevron" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="ai-tool-step__body">
|
||||
<div className="ai-tool-step__section">
|
||||
<span className="ai-tool-step__section-label">Input</span>
|
||||
<pre className="ai-tool-step__json">{JSON.stringify(input, null, 2)}</pre>
|
||||
</div>
|
||||
{done && result !== undefined && (
|
||||
<div className="ai-tool-step__section">
|
||||
<span className="ai-tool-step__section-label">Output</span>
|
||||
<pre className="ai-tool-step__json">
|
||||
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
import { Activity, AlertTriangle, BarChart3, Search, Zap } from 'lucide-react';
|
||||
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
import { Message, StreamingEventItem } from '../types';
|
||||
import AIAssistantIcon from './AIAssistantIcon';
|
||||
import MessageBubble from './MessageBubble';
|
||||
import StreamingMessage from './StreamingMessage';
|
||||
|
||||
const SUGGESTIONS = [
|
||||
{
|
||||
icon: AlertTriangle,
|
||||
text: 'Show me the top errors in the last hour',
|
||||
},
|
||||
{
|
||||
icon: Activity,
|
||||
text: 'What services have the highest latency?',
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
text: 'Give me an overview of system health',
|
||||
},
|
||||
{
|
||||
icon: Search,
|
||||
text: 'Find slow database queries',
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
text: 'Which endpoints have the most 5xx errors?',
|
||||
},
|
||||
];
|
||||
|
||||
const EMPTY_EVENTS: StreamingEventItem[] = [];
|
||||
|
||||
interface VirtualizedMessagesProps {
|
||||
conversationId: string;
|
||||
messages: Message[];
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
export default function VirtualizedMessages({
|
||||
conversationId,
|
||||
messages,
|
||||
isStreaming,
|
||||
}: VirtualizedMessagesProps): JSX.Element {
|
||||
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
|
||||
const streamingStatus = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.streamingStatus ?? '',
|
||||
);
|
||||
const streamingEvents = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.streamingEvents ?? EMPTY_EVENTS,
|
||||
);
|
||||
const pendingApproval = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.pendingApproval ?? null,
|
||||
);
|
||||
const pendingClarification = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.pendingClarification ?? null,
|
||||
);
|
||||
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
|
||||
const lastUserMessage = [...messages].reverse().find((m) => m.role === 'user');
|
||||
const handleRegenerate = useCallback((): void => {
|
||||
if (lastUserMessage && !isStreaming) {
|
||||
sendMessage(lastUserMessage.content, lastUserMessage.attachments);
|
||||
}
|
||||
}, [lastUserMessage, isStreaming, sendMessage]);
|
||||
|
||||
// Scroll to bottom on new messages, streaming progress, or interactive cards
|
||||
useEffect(() => {
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index: 'LAST',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, [
|
||||
messages.length,
|
||||
streamingEvents.length,
|
||||
isStreaming,
|
||||
pendingApproval,
|
||||
pendingClarification,
|
||||
]);
|
||||
|
||||
const followOutput = useCallback(
|
||||
(atBottom: boolean): false | 'smooth' =>
|
||||
atBottom || isStreaming ? 'smooth' : false,
|
||||
[isStreaming],
|
||||
);
|
||||
|
||||
const showStreamingSlot =
|
||||
isStreaming || Boolean(pendingApproval) || Boolean(pendingClarification);
|
||||
|
||||
if (messages.length === 0 && !showStreamingSlot) {
|
||||
return (
|
||||
<div className="ai-messages__empty">
|
||||
<div className="ai-empty__icon">
|
||||
<AIAssistantIcon size={40} />
|
||||
</div>
|
||||
<h3 className="ai-empty__title">SigNoz AI Assistant</h3>
|
||||
<p className="ai-empty__subtitle">
|
||||
Ask questions about your traces, logs, metrics, and infrastructure.
|
||||
</p>
|
||||
<div className="ai-empty__suggestions">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s.text}
|
||||
type="button"
|
||||
className="ai-empty__chip"
|
||||
onClick={(): void => {
|
||||
sendMessage(s.text);
|
||||
}}
|
||||
>
|
||||
<s.icon size={14} />
|
||||
{s.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalCount = messages.length + (showStreamingSlot ? 1 : 0);
|
||||
|
||||
return (
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
className="ai-messages"
|
||||
totalCount={totalCount}
|
||||
followOutput={followOutput}
|
||||
initialTopMostItemIndex={Math.max(0, totalCount - 1)}
|
||||
itemContent={(index): JSX.Element => {
|
||||
if (index < messages.length) {
|
||||
const msg = messages[index];
|
||||
const isLastAssistant =
|
||||
msg.role === 'assistant' &&
|
||||
messages.slice(index + 1).every((m) => m.role !== 'assistant');
|
||||
return (
|
||||
<MessageBubble
|
||||
message={msg}
|
||||
onRegenerate={
|
||||
isLastAssistant && !showStreamingSlot ? handleRegenerate : undefined
|
||||
}
|
||||
isLastAssistant={isLastAssistant}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<StreamingMessage
|
||||
conversationId={conversationId}
|
||||
events={streamingEvents}
|
||||
status={streamingStatus}
|
||||
pendingApproval={pendingApproval}
|
||||
pendingClarification={pendingClarification}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { AlertCircle, Check, Loader2, X, Zap } from 'lucide-react';
|
||||
|
||||
import { PageActionRegistry } from '../../pageActions/PageActionRegistry';
|
||||
import { AIActionBlock } from '../../pageActions/types';
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
import { useMessageContext } from '../MessageContext';
|
||||
|
||||
type BlockState = 'pending' | 'loading' | 'applied' | 'dismissed' | 'error';
|
||||
|
||||
/**
|
||||
* Renders an AI-suggested page action.
|
||||
*
|
||||
* Two modes based on the registered PageAction.autoApply flag:
|
||||
*
|
||||
* autoApply = false (default)
|
||||
* Shows a confirmation card with Accept / Dismiss. The user must
|
||||
* explicitly approve before execute() is called. Use for destructive or
|
||||
* hard-to-reverse actions (create dashboard, delete alert, etc.).
|
||||
*
|
||||
* autoApply = true
|
||||
* Executes immediately on mount — no card shown, just the result summary.
|
||||
* Use for low-risk, reversible actions where the user explicitly asked for
|
||||
* the change (e.g. "filter logs for errors"). Avoids unnecessary friction.
|
||||
*
|
||||
* Persists answered state via answeredBlocks so re-mounts don't reset UI.
|
||||
*/
|
||||
export default function ActionBlock({
|
||||
data,
|
||||
}: {
|
||||
data: AIActionBlock;
|
||||
}): JSX.Element {
|
||||
const { messageId } = useMessageContext();
|
||||
const answeredBlocks = useAIAssistantStore((s) => s.answeredBlocks);
|
||||
const markBlockAnswered = useAIAssistantStore((s) => s.markBlockAnswered);
|
||||
|
||||
const [localState, setLocalState] = useState<BlockState>(() => {
|
||||
if (!messageId) {
|
||||
return 'pending';
|
||||
}
|
||||
const saved = answeredBlocks[messageId];
|
||||
if (!saved) {
|
||||
return 'pending';
|
||||
}
|
||||
if (saved === 'dismissed') {
|
||||
return 'dismissed';
|
||||
}
|
||||
if (saved.startsWith('error:')) {
|
||||
return 'error';
|
||||
}
|
||||
return 'applied';
|
||||
});
|
||||
const [resultSummary, setResultSummary] = useState<string>('');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
|
||||
const { actionId, description, parameters } = data;
|
||||
|
||||
// ── Shared execute logic ─────────────────────────────────────────────────────
|
||||
|
||||
const execute = async (): Promise<void> => {
|
||||
const action = PageActionRegistry.get(actionId);
|
||||
|
||||
if (!action) {
|
||||
const msg = `Action "${actionId}" is not available on the current page.`;
|
||||
setErrorMessage(msg);
|
||||
setLocalState('error');
|
||||
if (messageId) {
|
||||
markBlockAnswered(messageId, `error:${msg}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalState('loading');
|
||||
|
||||
try {
|
||||
const result = await action.execute(parameters as never);
|
||||
setResultSummary(result.summary);
|
||||
setLocalState('applied');
|
||||
if (messageId) {
|
||||
markBlockAnswered(messageId, `applied:${result.summary}`);
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||
setErrorMessage(msg);
|
||||
setLocalState('error');
|
||||
if (messageId) {
|
||||
markBlockAnswered(messageId, `error:${msg}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ── Auto-apply: fire immediately on mount if the action opts in ──────────────
|
||||
|
||||
const autoApplyFired = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Only auto-apply once, and only when the block hasn't been answered yet
|
||||
// (i.e. this is a fresh render, not a remount of an already-answered block).
|
||||
if (autoApplyFired.current || localState !== 'pending') {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = PageActionRegistry.get(actionId);
|
||||
if (!action?.autoApply) {
|
||||
return;
|
||||
}
|
||||
|
||||
autoApplyFired.current = true;
|
||||
execute();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleDismiss = (): void => {
|
||||
setLocalState('dismissed');
|
||||
if (messageId) {
|
||||
markBlockAnswered(messageId, 'dismissed');
|
||||
}
|
||||
};
|
||||
|
||||
// ── Terminal states ──────────────────────────────────────────────────────────
|
||||
|
||||
if (localState === 'applied') {
|
||||
return (
|
||||
<div className="ai-block ai-action ai-action--applied">
|
||||
<Check
|
||||
size={13}
|
||||
className="ai-action__status-icon ai-action__status-icon--ok"
|
||||
/>
|
||||
<span className="ai-action__status-text">
|
||||
{resultSummary || 'Applied.'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (localState === 'dismissed') {
|
||||
return (
|
||||
<div className="ai-block ai-action ai-action--dismissed">
|
||||
<X
|
||||
size={13}
|
||||
className="ai-action__status-icon ai-action__status-icon--no"
|
||||
/>
|
||||
<span className="ai-action__status-text">Dismissed.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (localState === 'error') {
|
||||
return (
|
||||
<div className="ai-block ai-action ai-action--error">
|
||||
<AlertCircle
|
||||
size={13}
|
||||
className="ai-action__status-icon ai-action__status-icon--err"
|
||||
/>
|
||||
<span className="ai-action__status-text">{errorMessage}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Loading (autoApply in progress) ─────────────────────────────────────────
|
||||
|
||||
if (localState === 'loading') {
|
||||
return (
|
||||
<div className="ai-block ai-action ai-action--loading">
|
||||
<Loader2 size={13} className="ai-action__spinner ai-action__status-icon" />
|
||||
<span className="ai-action__status-text">{description}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Pending: manual confirmation card ────────────────────────────────────────
|
||||
|
||||
const paramEntries = Object.entries(parameters ?? {});
|
||||
|
||||
return (
|
||||
<div className="ai-block ai-action">
|
||||
<div className="ai-action__header">
|
||||
<Zap size={13} className="ai-action__zap-icon" />
|
||||
<span className="ai-action__header-label">Suggested Action</span>
|
||||
</div>
|
||||
|
||||
<p className="ai-action__description">{description}</p>
|
||||
|
||||
{paramEntries.length > 0 && (
|
||||
<ul className="ai-action__params">
|
||||
{paramEntries.map(([key, val]) => (
|
||||
<li key={key} className="ai-action__param">
|
||||
<span className="ai-action__param-key">{key}</span>
|
||||
<span className="ai-action__param-val">
|
||||
{typeof val === 'object' ? JSON.stringify(val) : String(val)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="ai-action__actions">
|
||||
<Button variant="solid" size="xs" onClick={execute}>
|
||||
<Check size={12} />
|
||||
Apply
|
||||
</Button>
|
||||
<Button variant="outlined" size="xs" onClick={handleDismiss}>
|
||||
<X size={12} />
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Bar } from 'react-chartjs-2';
|
||||
|
||||
import { CHART_PALETTE, getChartTheme } from './chartSetup';
|
||||
|
||||
export interface BarDataset {
|
||||
label?: string;
|
||||
data: number[];
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface BarChartData {
|
||||
title?: string;
|
||||
unit?: string;
|
||||
/**
|
||||
* Category labels (x-axis for vertical, y-axis for horizontal).
|
||||
* Shorthand: omit `datasets` and use `bars` for single-series data.
|
||||
*/
|
||||
labels?: string[];
|
||||
datasets?: BarDataset[];
|
||||
/** Single-series shorthand: [{ label, value }] */
|
||||
bars?: { label: string; value: number; color?: string }[];
|
||||
/** 'vertical' (default) | 'horizontal' */
|
||||
direction?: 'vertical' | 'horizontal';
|
||||
}
|
||||
|
||||
export default function BarChartBlock({
|
||||
data,
|
||||
}: {
|
||||
data: BarChartData;
|
||||
}): JSX.Element {
|
||||
const { title, unit, direction = 'horizontal' } = data;
|
||||
const theme = getChartTheme();
|
||||
|
||||
// Normalise shorthand `bars` → labels + datasets
|
||||
let labels: string[];
|
||||
let datasets: BarDataset[];
|
||||
|
||||
if (data.bars) {
|
||||
labels = data.bars.map((b) => b.label);
|
||||
datasets = [
|
||||
{
|
||||
label: title ?? 'Value',
|
||||
data: data.bars.map((b) => b.value),
|
||||
color: undefined, // use palette below
|
||||
},
|
||||
];
|
||||
} else {
|
||||
labels = data.labels ?? [];
|
||||
datasets = data.datasets ?? [];
|
||||
}
|
||||
|
||||
const chartData = {
|
||||
labels,
|
||||
datasets: datasets.map((ds, i) => {
|
||||
const baseColor = ds.color ?? CHART_PALETTE[i % CHART_PALETTE.length];
|
||||
return {
|
||||
label: ds.label ?? `Series ${i + 1}`,
|
||||
data: ds.data,
|
||||
backgroundColor: data.bars
|
||||
? data.bars.map((_, j) => CHART_PALETTE[j % CHART_PALETTE.length])
|
||||
: baseColor,
|
||||
borderColor: data.bars
|
||||
? data.bars.map((_, j) => CHART_PALETTE[j % CHART_PALETTE.length])
|
||||
: baseColor,
|
||||
borderWidth: 1,
|
||||
borderRadius: 3,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const barHeight = Math.max(160, labels.length * 28 + 48);
|
||||
|
||||
return (
|
||||
<div className="ai-block ai-chart">
|
||||
{title && <p className="ai-block__title">{title}</p>}
|
||||
<div
|
||||
className="ai-chart__canvas-wrap"
|
||||
style={{ height: direction === 'horizontal' ? barHeight : 200 }}
|
||||
>
|
||||
<Bar
|
||||
data={chartData}
|
||||
options={{
|
||||
indexAxis: direction === 'horizontal' ? 'y' : 'x',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: datasets.length > 1,
|
||||
labels: { color: theme.legendColor, boxWidth: 12, font: { size: 11 } },
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: theme.tooltipBg,
|
||||
titleColor: theme.tooltipText,
|
||||
bodyColor: theme.tooltipText,
|
||||
borderColor: theme.gridColor,
|
||||
borderWidth: 1,
|
||||
callbacks: unit
|
||||
? { label: (ctx): string => `${ctx.formattedValue} ${unit}` }
|
||||
: {},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: theme.gridColor },
|
||||
ticks: {
|
||||
color: theme.tickColor,
|
||||
font: { size: 11 },
|
||||
callback:
|
||||
unit && direction !== 'horizontal'
|
||||
? (v): string => `${v} ${unit}`
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: { color: theme.gridColor },
|
||||
ticks: { color: theme.tickColor, font: { size: 11 } },
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type BlockComponent<T = any> = React.ComponentType<{ data: T }>;
|
||||
|
||||
/**
|
||||
* Global registry for AI response block renderers.
|
||||
*
|
||||
* Any part of the application can register a custom block type:
|
||||
*
|
||||
* import { BlockRegistry } from 'container/AIAssistant/components/blocks';
|
||||
* BlockRegistry.register('my-panel', MyPanelComponent);
|
||||
*
|
||||
* The AI can then emit fenced code blocks with the prefix `ai-<type>` and a
|
||||
* JSON payload, and the registered component will be rendered in-place:
|
||||
*
|
||||
* ```ai-my-panel
|
||||
* { "foo": "bar" }
|
||||
* ```
|
||||
*/
|
||||
const _registry = new Map<string, BlockComponent>();
|
||||
|
||||
export const BlockRegistry = {
|
||||
register<T>(type: string, component: BlockComponent<T>): void {
|
||||
_registry.set(type, component as BlockComponent);
|
||||
},
|
||||
|
||||
get(type: string): BlockComponent | undefined {
|
||||
return _registry.get(type);
|
||||
},
|
||||
|
||||
/** Returns all registered type names (useful for debugging). */
|
||||
types(): string[] {
|
||||
return Array.from(_registry.keys());
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Check, X } from 'lucide-react';
|
||||
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
import { useMessageContext } from '../MessageContext';
|
||||
|
||||
export interface ConfirmData {
|
||||
message?: string;
|
||||
/** Text sent back when accepted. Defaults to "Yes, proceed." */
|
||||
acceptText?: string;
|
||||
/** Text sent back when rejected. Defaults to "No, cancel." */
|
||||
rejectText?: string;
|
||||
/** Label shown on Accept button. Defaults to "Accept" */
|
||||
acceptLabel?: string;
|
||||
/** Label shown on Reject button. Defaults to "Reject" */
|
||||
rejectLabel?: string;
|
||||
}
|
||||
|
||||
export default function ConfirmBlock({
|
||||
data,
|
||||
}: {
|
||||
data: ConfirmData;
|
||||
}): JSX.Element {
|
||||
const {
|
||||
message,
|
||||
acceptText = 'Yes, proceed.',
|
||||
rejectText = 'No, cancel.',
|
||||
acceptLabel = 'Accept',
|
||||
rejectLabel = 'Reject',
|
||||
} = data;
|
||||
|
||||
const { messageId } = useMessageContext();
|
||||
const answeredBlocks = useAIAssistantStore((s) => s.answeredBlocks);
|
||||
const markBlockAnswered = useAIAssistantStore((s) => s.markBlockAnswered);
|
||||
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
|
||||
|
||||
// Durable answered state — survives re-renders/remounts
|
||||
const answeredChoice = messageId ? answeredBlocks[messageId] : undefined;
|
||||
const isAnswered = answeredChoice !== undefined;
|
||||
|
||||
const handle = (choice: 'accepted' | 'rejected'): void => {
|
||||
const responseText = choice === 'accepted' ? acceptText : rejectText;
|
||||
if (messageId) {
|
||||
markBlockAnswered(messageId, choice);
|
||||
}
|
||||
sendMessage(responseText);
|
||||
};
|
||||
|
||||
if (isAnswered) {
|
||||
const wasAccepted = answeredChoice === 'accepted';
|
||||
const icon = wasAccepted ? (
|
||||
<Check size={13} className="ai-confirm__icon ai-confirm__icon--ok" />
|
||||
) : (
|
||||
<X size={13} className="ai-confirm__icon ai-confirm__icon--no" />
|
||||
);
|
||||
return (
|
||||
<div className="ai-block ai-confirm ai-confirm--answered">
|
||||
{icon}
|
||||
<span className="ai-confirm__answer-text">
|
||||
{wasAccepted ? acceptText : rejectText}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-block ai-confirm">
|
||||
{message && <p className="ai-confirm__message">{message}</p>}
|
||||
<div className="ai-confirm__actions">
|
||||
<Button variant="solid" size="xs" onClick={(): void => handle('accepted')}>
|
||||
<Check size={12} />
|
||||
{acceptLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="xs"
|
||||
onClick={(): void => handle('rejected')}
|
||||
>
|
||||
<X size={12} />
|
||||
{rejectLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Checkbox, Radio } from 'antd';
|
||||
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
import { useMessageContext } from '../MessageContext';
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface QuestionData {
|
||||
question?: string;
|
||||
type?: 'radio' | 'checkbox';
|
||||
options: (string | Option)[];
|
||||
}
|
||||
|
||||
function normalizeOption(opt: string | Option): Option {
|
||||
return typeof opt === 'string' ? { value: opt, label: opt } : opt;
|
||||
}
|
||||
|
||||
export default function InteractiveQuestion({
|
||||
data,
|
||||
}: {
|
||||
data: QuestionData;
|
||||
}): JSX.Element {
|
||||
const { question, type = 'radio', options } = data;
|
||||
const normalized = options.map(normalizeOption);
|
||||
|
||||
const { messageId } = useMessageContext();
|
||||
const answeredBlocks = useAIAssistantStore((s) => s.answeredBlocks);
|
||||
const markBlockAnswered = useAIAssistantStore((s) => s.markBlockAnswered);
|
||||
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
|
||||
|
||||
// Persist selected state locally only for the pending (not-yet-submitted) case
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
|
||||
// Durable answered state from the store — survives re-renders/remounts
|
||||
const answeredText = messageId ? answeredBlocks[messageId] : undefined;
|
||||
const isAnswered = answeredText !== undefined;
|
||||
|
||||
const handleSubmit = (values: string[]): void => {
|
||||
if (values.length === 0) {
|
||||
return;
|
||||
}
|
||||
const answer = values.join(', ');
|
||||
if (messageId) {
|
||||
markBlockAnswered(messageId, answer);
|
||||
}
|
||||
sendMessage(answer);
|
||||
};
|
||||
|
||||
if (isAnswered) {
|
||||
return (
|
||||
<div className="ai-block ai-question ai-question--answered">
|
||||
<span className="ai-question__check">✓</span>
|
||||
<span className="ai-question__answer-text">{answeredText}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-block ai-question">
|
||||
{question && <p className="ai-block__title">{question}</p>}
|
||||
|
||||
{type === 'radio' ? (
|
||||
<Radio.Group
|
||||
className="ai-question__options"
|
||||
onChange={(e): void => {
|
||||
setSelected([e.target.value]);
|
||||
handleSubmit([e.target.value]);
|
||||
}}
|
||||
>
|
||||
{normalized.map((opt) => (
|
||||
<Radio key={opt.value} value={opt.value} className="ai-question__option">
|
||||
{opt.label}
|
||||
</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
) : (
|
||||
<>
|
||||
<Checkbox.Group
|
||||
className="ai-question__options ai-question__options--checkbox"
|
||||
onChange={(vals): void => setSelected(vals as string[])}
|
||||
>
|
||||
{normalized.map((opt) => (
|
||||
<Checkbox
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
className="ai-question__option"
|
||||
>
|
||||
{opt.label}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Checkbox.Group>
|
||||
<Button
|
||||
variant="solid"
|
||||
size="xs"
|
||||
className="ai-question__submit"
|
||||
disabled={selected.length === 0}
|
||||
onClick={(): void => handleSubmit(selected)}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Line } from 'react-chartjs-2';
|
||||
|
||||
import {
|
||||
CHART_PALETTE,
|
||||
CHART_PALETTE_ALPHA,
|
||||
getChartTheme,
|
||||
} from './chartSetup';
|
||||
|
||||
export interface LineDataset {
|
||||
label?: string;
|
||||
data: number[];
|
||||
color?: string;
|
||||
/** Fill area under line. Defaults to false. */
|
||||
fill?: boolean;
|
||||
}
|
||||
|
||||
export interface LineChartData {
|
||||
title?: string;
|
||||
unit?: string;
|
||||
/** X-axis labels (time strings, numbers, etc.) */
|
||||
labels: string[];
|
||||
datasets: LineDataset[];
|
||||
}
|
||||
|
||||
export default function LineChartBlock({
|
||||
data,
|
||||
}: {
|
||||
data: LineChartData;
|
||||
}): JSX.Element {
|
||||
const { title, unit, labels, datasets } = data;
|
||||
const theme = getChartTheme();
|
||||
|
||||
const chartData = {
|
||||
labels,
|
||||
datasets: datasets.map((ds, i) => {
|
||||
const color = ds.color ?? CHART_PALETTE[i % CHART_PALETTE.length];
|
||||
const fillColor = CHART_PALETTE_ALPHA[i % CHART_PALETTE_ALPHA.length];
|
||||
return {
|
||||
label: ds.label ?? `Series ${i + 1}`,
|
||||
data: ds.data,
|
||||
borderColor: color,
|
||||
backgroundColor: ds.fill ? fillColor : 'transparent',
|
||||
pointBackgroundColor: color,
|
||||
pointRadius: labels.length > 30 ? 0 : 3,
|
||||
pointHoverRadius: 5,
|
||||
borderWidth: 2,
|
||||
fill: ds.fill ?? false,
|
||||
tension: 0.35,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ai-block ai-chart">
|
||||
{title && <p className="ai-block__title">{title}</p>}
|
||||
<div className="ai-chart__canvas-wrap">
|
||||
<Line
|
||||
data={chartData}
|
||||
options={{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: {
|
||||
display: datasets.length > 1,
|
||||
labels: { color: theme.legendColor, boxWidth: 12, font: { size: 11 } },
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: theme.tooltipBg,
|
||||
titleColor: theme.tooltipText,
|
||||
bodyColor: theme.tooltipText,
|
||||
borderColor: theme.gridColor,
|
||||
borderWidth: 1,
|
||||
callbacks: unit
|
||||
? { label: (ctx): string => ` ${ctx.formattedValue} ${unit}` }
|
||||
: {},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: theme.gridColor },
|
||||
ticks: {
|
||||
color: theme.tickColor,
|
||||
font: { size: 11 },
|
||||
maxRotation: 0,
|
||||
maxTicksLimit: 8,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: { color: theme.gridColor },
|
||||
ticks: {
|
||||
color: theme.tickColor,
|
||||
font: { size: 11 },
|
||||
callback: unit ? (v): string => `${v} ${unit}` : undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Doughnut } from 'react-chartjs-2';
|
||||
|
||||
import { CHART_PALETTE, getChartTheme } from './chartSetup';
|
||||
|
||||
export interface SliceData {
|
||||
label: string;
|
||||
value: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface PieChartData {
|
||||
title?: string;
|
||||
slices: SliceData[];
|
||||
}
|
||||
|
||||
export default function PieChartBlock({
|
||||
data,
|
||||
}: {
|
||||
data: PieChartData;
|
||||
}): JSX.Element {
|
||||
const { title, slices } = data;
|
||||
const theme = getChartTheme();
|
||||
|
||||
const chartData = {
|
||||
labels: slices.map((s) => s.label),
|
||||
datasets: [
|
||||
{
|
||||
data: slices.map((s) => s.value),
|
||||
backgroundColor: slices.map(
|
||||
(s, i) => s.color ?? CHART_PALETTE[i % CHART_PALETTE.length],
|
||||
),
|
||||
borderColor: theme.tooltipBg,
|
||||
borderWidth: 2,
|
||||
hoverOffset: 6,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ai-block ai-chart">
|
||||
{title && <p className="ai-block__title">{title}</p>}
|
||||
<div className="ai-chart__canvas-wrap ai-chart__canvas-wrap--pie">
|
||||
<Doughnut
|
||||
data={chartData}
|
||||
options={{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '58%',
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
labels: {
|
||||
color: theme.legendColor,
|
||||
boxWidth: 10,
|
||||
padding: 10,
|
||||
font: { size: 11 },
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: theme.tooltipBg,
|
||||
titleColor: theme.tooltipText,
|
||||
bodyColor: theme.tooltipText,
|
||||
borderColor: theme.gridColor,
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
label: (ctx): string => {
|
||||
const total = (ctx.dataset.data as number[]).reduce(
|
||||
(a, b) => a + b,
|
||||
0,
|
||||
);
|
||||
const pct = ((ctx.parsed / total) * 100).toFixed(1);
|
||||
return ` ${ctx.formattedValue} (${pct}%)`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
|
||||
import { BlockRegistry } from './BlockRegistry';
|
||||
|
||||
interface CodeProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
// react-markdown passes `node` — accept and ignore it
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
node?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop-in replacement for react-markdown's `code` renderer.
|
||||
*
|
||||
* When the language tag begins with `ai-` the remaining part is looked up in
|
||||
* the BlockRegistry and, if a component is found, the JSON payload is parsed
|
||||
* and the component is rendered.
|
||||
*
|
||||
* Falls back to a regular <code> element for all other blocks (including plain
|
||||
* inline code and unknown `ai-*` types).
|
||||
*/
|
||||
export default function RichCodeBlock({
|
||||
className,
|
||||
children,
|
||||
}: CodeProps): JSX.Element {
|
||||
const lang = /language-(\S+)/.exec(className ?? '')?.[1];
|
||||
|
||||
if (lang?.startsWith('ai-')) {
|
||||
const blockType = lang.slice(3); // strip the 'ai-' prefix
|
||||
const BlockComp = BlockRegistry.get(blockType);
|
||||
|
||||
if (BlockComp) {
|
||||
const raw = String(children ?? '').trim();
|
||||
try {
|
||||
const parsedData = JSON.parse(raw);
|
||||
return <BlockComp data={parsedData} />;
|
||||
} catch {
|
||||
// Invalid JSON — fall through and render as a code block
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return <code className={className}>{children}</code>;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export interface TimeseriesData {
|
||||
title?: string;
|
||||
unit?: string;
|
||||
/** Column header labels. Defaults to ["Time", "Value"]. */
|
||||
columns?: string[];
|
||||
/** Each row is an array of cell values (strings or numbers). */
|
||||
rows: (string | number)[][];
|
||||
}
|
||||
|
||||
export default function TimeseriesBlock({
|
||||
data,
|
||||
}: {
|
||||
data: TimeseriesData;
|
||||
}): JSX.Element {
|
||||
const { title, unit, columns, rows } = data;
|
||||
const cols = columns ?? ['Time', 'Value'];
|
||||
|
||||
return (
|
||||
<div className="ai-block ai-timeseries">
|
||||
{(title || unit) && (
|
||||
<p className="ai-block__title">
|
||||
{title}
|
||||
{unit ? <span className="ai-block__unit"> ({unit})</span> : null}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="ai-timeseries__scroll">
|
||||
<table className="ai-timeseries__table">
|
||||
<thead>
|
||||
<tr>
|
||||
{cols.map((col) => (
|
||||
<th key={col}>{col}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
// Row index is the stable key here since rows have no IDs
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<tr key={i}>
|
||||
{row.map((cell, j) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<td key={j}>{cell}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 && <p className="ai-block__empty">No data available.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Registers all Chart.js components that the AI assistant blocks need.
|
||||
* Import this module once (via blocks/index.ts) — safe to import multiple times.
|
||||
*/
|
||||
import {
|
||||
ArcElement,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Chart as ChartJS,
|
||||
Filler,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
TimeScale,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from 'chart.js';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
BarElement,
|
||||
PointElement,
|
||||
LineElement,
|
||||
ArcElement,
|
||||
Filler,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
);
|
||||
|
||||
// ─── Colour palette (SigNoz brand colours as explicit hex) ───────────────────
|
||||
|
||||
export const CHART_PALETTE = [
|
||||
'#4E74F8', // robin (blue primary)
|
||||
'#2DB699', // aquamarine
|
||||
'#F5A623', // amber
|
||||
'#F05944', // cherry (red)
|
||||
'#06B6D4', // aqua (cyan)
|
||||
'#F97316', // sienna (orange)
|
||||
'#8B5CF6', // violet
|
||||
'#EC4899', // sakura (pink)
|
||||
];
|
||||
|
||||
export const CHART_PALETTE_ALPHA = CHART_PALETTE.map((c) => `${c}33`); // 20% opacity fills
|
||||
|
||||
// ─── Theme helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function isDark(): boolean {
|
||||
return document.body.classList.contains('dark');
|
||||
}
|
||||
|
||||
export function getChartTheme(): {
|
||||
gridColor: string;
|
||||
tickColor: string;
|
||||
legendColor: string;
|
||||
tooltipBg: string;
|
||||
tooltipText: string;
|
||||
} {
|
||||
const dark = isDark();
|
||||
return {
|
||||
gridColor: dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)',
|
||||
tickColor: dark ? '#8c9bb5' : '#6b7280',
|
||||
legendColor: dark ? '#c0cbe0' : '#374151',
|
||||
tooltipBg: dark ? '#1a1f2e' : '#ffffff',
|
||||
tooltipText: dark ? '#e2e8f0' : '#111827',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* AI response block system.
|
||||
*
|
||||
* Import this module once (e.g. in MessageBubble / StreamingMessage) to
|
||||
* register all built-in block types. External modules can extend the registry
|
||||
* at any time:
|
||||
*
|
||||
* import { BlockRegistry } from 'container/AIAssistant/components/blocks';
|
||||
* BlockRegistry.register('my-panel', MyPanelComponent);
|
||||
*/
|
||||
|
||||
// Side-effect: ensure Chart.js components are registered before any chart renders
|
||||
import './chartSetup';
|
||||
|
||||
import ActionBlock from './ActionBlock';
|
||||
import BarChartBlock from './BarChartBlock';
|
||||
import { BlockRegistry } from './BlockRegistry';
|
||||
import ConfirmBlock from './ConfirmBlock';
|
||||
import InteractiveQuestion from './InteractiveQuestion';
|
||||
import LineChartBlock from './LineChartBlock';
|
||||
import PieChartBlock from './PieChartBlock';
|
||||
import TimeseriesBlock from './TimeseriesBlock';
|
||||
|
||||
// ─── Register built-in block types ───────────────────────────────────────────
|
||||
|
||||
BlockRegistry.register('question', InteractiveQuestion);
|
||||
BlockRegistry.register('confirm', ConfirmBlock);
|
||||
BlockRegistry.register('timeseries', TimeseriesBlock);
|
||||
BlockRegistry.register('barchart', BarChartBlock);
|
||||
BlockRegistry.register('piechart', PieChartBlock);
|
||||
// ai-linechart and ai-graph are aliases for the same component
|
||||
BlockRegistry.register('linechart', LineChartBlock);
|
||||
BlockRegistry.register('graph', LineChartBlock);
|
||||
// Page-aware action block
|
||||
BlockRegistry.register('action', ActionBlock);
|
||||
|
||||
// ─── Public exports ───────────────────────────────────────────────────────────
|
||||
|
||||
export { BlockRegistry } from './BlockRegistry';
|
||||
export { default as RichCodeBlock } from './RichCodeBlock';
|
||||
193
frontend/src/container/AIAssistant/hooks/useSpeechRecognition.ts
Normal file
193
frontend/src/container/AIAssistant/hooks/useSpeechRecognition.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
// ── Web Speech API types (not yet in lib.dom.d.ts) ────────────────────────────
|
||||
|
||||
interface SpeechRecognitionResult {
|
||||
readonly length: number;
|
||||
readonly isFinal: boolean;
|
||||
[index: number]: { transcript: string; confidence: number };
|
||||
}
|
||||
|
||||
interface SpeechRecognitionResultList {
|
||||
readonly length: number;
|
||||
[index: number]: SpeechRecognitionResult;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionEvent extends Event {
|
||||
readonly resultIndex: number;
|
||||
readonly results: SpeechRecognitionResultList;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionErrorEvent extends Event {
|
||||
readonly error: string;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
interface ISpeechRecognition extends EventTarget {
|
||||
lang: string;
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
onstart: (() => void) | null;
|
||||
onend: (() => void) | null;
|
||||
onresult: ((event: SpeechRecognitionEvent) => void) | null;
|
||||
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
type SpeechRecognitionConstructor = new () => ISpeechRecognition;
|
||||
|
||||
// ── Vendor-prefix shim for Safari / older browsers ────────────────────────────
|
||||
|
||||
const SpeechRecognitionAPI: SpeechRecognitionConstructor | null =
|
||||
typeof window !== 'undefined'
|
||||
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).SpeechRecognition ??
|
||||
(window as any).webkitSpeechRecognition ??
|
||||
null
|
||||
: null;
|
||||
|
||||
export type SpeechRecognitionError =
|
||||
| 'not-supported'
|
||||
| 'not-allowed'
|
||||
| 'no-speech'
|
||||
| 'network'
|
||||
| 'unknown';
|
||||
|
||||
interface UseSpeechRecognitionOptions {
|
||||
onError?: (error: SpeechRecognitionError) => void;
|
||||
/**
|
||||
* Called directly from browser recognition events — no React state intermediary.
|
||||
* `isFinal=false` → interim (live preview), `isFinal=true` → committed text.
|
||||
*/
|
||||
onTranscript?: (text: string, isFinal: boolean) => void;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
interface UseSpeechRecognitionReturn {
|
||||
isListening: boolean;
|
||||
isSupported: boolean;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
/** Stop recognition and discard any pending interim text (no onTranscript call). */
|
||||
discard: () => void;
|
||||
}
|
||||
|
||||
export function useSpeechRecognition({
|
||||
onError,
|
||||
onTranscript,
|
||||
lang = 'en-US',
|
||||
}: UseSpeechRecognitionOptions = {}): UseSpeechRecognitionReturn {
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const recognitionRef = useRef<ISpeechRecognition | null>(null);
|
||||
const isDiscardingRef = useRef(false);
|
||||
const isSupported = SpeechRecognitionAPI !== null;
|
||||
|
||||
// Always-current refs — updated synchronously on every render so closures
|
||||
// inside recognition event handlers always call the latest version.
|
||||
const onErrorRef = useRef(onError);
|
||||
onErrorRef.current = onError;
|
||||
|
||||
const onTranscriptRef = useRef(onTranscript);
|
||||
onTranscriptRef.current = onTranscript;
|
||||
|
||||
const stop = useCallback(() => {
|
||||
recognitionRef.current?.stop();
|
||||
}, []);
|
||||
|
||||
const discard = useCallback(() => {
|
||||
isDiscardingRef.current = true;
|
||||
recognitionRef.current?.stop();
|
||||
}, []);
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const start = useCallback(() => {
|
||||
if (!isSupported) {
|
||||
onErrorRef.current?.('not-supported');
|
||||
return;
|
||||
}
|
||||
|
||||
// If already listening, stop
|
||||
if (recognitionRef.current) {
|
||||
recognitionRef.current.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const recognition = new SpeechRecognitionAPI!();
|
||||
recognition.lang = lang;
|
||||
recognition.continuous = true; // keep listening until user clicks stop
|
||||
recognition.interimResults = true; // live updates while speaking
|
||||
|
||||
// Track the last interim text so we can commit it as final in onend —
|
||||
// Chrome often skips the isFinal result when stop() is called manually.
|
||||
let pendingInterim = '';
|
||||
|
||||
recognition.onstart = (): void => {
|
||||
setIsListening(true);
|
||||
};
|
||||
|
||||
recognition.onresult = (event): void => {
|
||||
let interim = '';
|
||||
let finalText = '';
|
||||
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const text = event.results[i][0].transcript;
|
||||
if (event.results[i].isFinal) {
|
||||
finalText += text;
|
||||
} else {
|
||||
interim += text;
|
||||
}
|
||||
}
|
||||
|
||||
if (finalText) {
|
||||
pendingInterim = '';
|
||||
onTranscriptRef.current?.(finalText, true);
|
||||
} else if (interim) {
|
||||
pendingInterim = interim;
|
||||
onTranscriptRef.current?.(interim, false);
|
||||
}
|
||||
};
|
||||
|
||||
recognition.onerror = (event): void => {
|
||||
pendingInterim = '';
|
||||
let mapped: SpeechRecognitionError = 'unknown';
|
||||
if (event.error === 'not-allowed' || event.error === 'service-not-allowed') {
|
||||
mapped = 'not-allowed';
|
||||
} else if (event.error === 'no-speech') {
|
||||
mapped = 'no-speech';
|
||||
} else if (event.error === 'network') {
|
||||
mapped = 'network';
|
||||
}
|
||||
onErrorRef.current?.(mapped);
|
||||
};
|
||||
|
||||
recognition.onend = (): void => {
|
||||
// Commit any interim text that never received a final result,
|
||||
// unless the session was explicitly discarded.
|
||||
if (!isDiscardingRef.current && pendingInterim) {
|
||||
const committed = pendingInterim;
|
||||
pendingInterim = '';
|
||||
onTranscriptRef.current?.(committed, true);
|
||||
}
|
||||
isDiscardingRef.current = false;
|
||||
pendingInterim = '';
|
||||
setIsListening(false);
|
||||
recognitionRef.current = null;
|
||||
};
|
||||
|
||||
recognitionRef.current = recognition;
|
||||
recognition.start();
|
||||
}, [isSupported, lang]);
|
||||
|
||||
// Clean up on unmount
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
recognitionRef.current?.abort();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { isListening, isSupported, start, stop, discard };
|
||||
}
|
||||
391
frontend/src/container/AIAssistant/mock/mockAIApi.ts
Normal file
391
frontend/src/container/AIAssistant/mock/mockAIApi.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* Dummy streaming API — mimics a real fetch() response with a ReadableStream body.
|
||||
* Swap `mockAIStream` for `fetch(...)` in the store when the real backend is ready.
|
||||
*/
|
||||
|
||||
const CANNED_RESPONSES: Record<string, string> = {
|
||||
default: `I'm the SigNoz AI Assistant. I can help you explore your observability data — traces, logs, metrics, and more.
|
||||
|
||||
Here are a few things you can ask me:
|
||||
|
||||
- **"Show me error rates"** — table view of errors per service
|
||||
- **"Show me a latency graph"** — line chart of p99 latency over time
|
||||
- **"Show me a bar chart of top services"** — horizontal bar chart
|
||||
- **"Show me a pie chart of errors"** — doughnut chart by service
|
||||
- **"Any anomalies?"** — confirmation flow example
|
||||
|
||||
What would you like to investigate?`,
|
||||
|
||||
error: `I found several issues in your traces over the last 15 minutes:
|
||||
|
||||
\`\`\`ai-timeseries
|
||||
{
|
||||
"title": "Error Rates by Service",
|
||||
"columns": ["Service", "Error Rate", "Change"],
|
||||
"rows": [
|
||||
["payment-svc", "4.2%", "↑ +3.1%"],
|
||||
["auth-svc", "0.8%", "→ stable"],
|
||||
["cart-svc", "12.1%", "↑ +11.4%"]
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
The \`cart-svc\` spike started around **14:32 UTC** — correlates with a deploy event.
|
||||
|
||||
\`\`\`
|
||||
TraceID: 7f3a9c2b1e4d6f80
|
||||
Span: cart-svc → inventory-svc
|
||||
Error: connection timeout after 5000ms
|
||||
\`\`\``,
|
||||
|
||||
latency: `Here's the p99 latency over the last hour for your top services:
|
||||
|
||||
\`\`\`ai-graph
|
||||
{
|
||||
"title": "p99 Latency (ms)",
|
||||
"unit": "ms",
|
||||
"labels": ["13:00","13:10","13:20","13:30","13:40","13:45","13:50","14:00"],
|
||||
"datasets": [
|
||||
{
|
||||
"label": "checkout-svc",
|
||||
"data": [310, 318, 325, 340, 480, 842, 790, 650],
|
||||
"fill": true
|
||||
},
|
||||
{
|
||||
"label": "payment-svc",
|
||||
"data": [195, 201, 198, 205, 210, 208, 203, 201]
|
||||
},
|
||||
{
|
||||
"label": "user-svc",
|
||||
"data": [112, 108, 105, 102, 99, 98, 96, 98]
|
||||
}
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
The **checkout-svc** degradation started at ~13:45. Its upstream dependency \`inventory-svc\` shows the same pattern — likely the root cause.`,
|
||||
|
||||
logs: `I searched your logs for the last 30 minutes and found **1,247 ERROR** entries.
|
||||
|
||||
Top error messages:
|
||||
|
||||
1. \`NullPointerException in OrderProcessor.java:142\` — 843 occurrences
|
||||
2. \`Database connection pool exhausted\` — 312 occurrences
|
||||
3. \`HTTP 429 Too Many Requests from stripe-api\` — 92 occurrences
|
||||
|
||||
The \`NullPointerException\` is new — first seen at **14:01 UTC**, which lines up with your latest deployment.`,
|
||||
|
||||
barchart: `Here are the **top 8 services** ranked by total error count in the last hour:
|
||||
|
||||
\`\`\`ai-barchart
|
||||
{
|
||||
"title": "Error Count by Service (last 1h)",
|
||||
"unit": "errors",
|
||||
"bars": [
|
||||
{ "label": "cart-svc", "value": 3842 },
|
||||
{ "label": "checkout-svc", "value": 2910 },
|
||||
{ "label": "payment-svc", "value": 1204 },
|
||||
{ "label": "inventory-svc", "value": 987 },
|
||||
{ "label": "user-svc", "value": 543 },
|
||||
{ "label": "recommendation", "value": 312 },
|
||||
{ "label": "notification-svc", "value": 198 },
|
||||
{ "label": "auth-svc", "value": 74 }
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**cart-svc** is the clear outlier — 3.8× more errors than the next service. I'd start there.`,
|
||||
|
||||
piechart: `Here is how errors are distributed across your top services:
|
||||
|
||||
\`\`\`ai-piechart
|
||||
{
|
||||
"title": "Error Share by Service (last 1h)",
|
||||
"slices": [
|
||||
{ "label": "cart-svc", "value": 3842 },
|
||||
{ "label": "checkout-svc", "value": 2910 },
|
||||
{ "label": "payment-svc", "value": 1204 },
|
||||
{ "label": "inventory-svc", "value": 987 },
|
||||
{ "label": "user-svc", "value": 543 },
|
||||
{ "label": "other", "value": 584 }
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**cart-svc** and **checkout-svc** together account for more than 65% of all errors. Both share a dependency on \`inventory-svc\` — that's likely the common root cause.`,
|
||||
|
||||
timeseries: `Here is the request-rate trend for **checkout-svc** over the last 10 minutes:
|
||||
|
||||
\`\`\`ai-timeseries
|
||||
{
|
||||
"title": "Request Rate — checkout-svc",
|
||||
"unit": "req/min",
|
||||
"columns": ["Time (UTC)", "Requests", "Errors", "Error %"],
|
||||
"rows": [
|
||||
["14:50", 1240, 12, "0.97%"],
|
||||
["14:51", 1318, 14, "1.06%"],
|
||||
["14:52", 1290, 18, "1.40%"],
|
||||
["14:53", 1355, 31, "2.29%"],
|
||||
["14:54", 1401, 58, "4.14%"],
|
||||
["14:55", 1389, 112, "8.07%"],
|
||||
["14:56", 1342, 198, "14.75%"],
|
||||
["14:57", 1278, 176, "13.77%"],
|
||||
["14:58", 1310, 143, "10.92%"],
|
||||
["14:59", 1365, 89, "6.52%"]
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
The error rate started climbing at **14:53** — coinciding with a config push to the \`inventory-svc\` dependency.`,
|
||||
|
||||
question: `Sure! To narrow down the investigation, I need a bit more context.
|
||||
|
||||
\`\`\`ai-question
|
||||
{
|
||||
"question": "Which environment are you interested in?",
|
||||
"type": "radio",
|
||||
"options": [
|
||||
{ "value": "production", "label": "Production" },
|
||||
{ "value": "staging", "label": "Staging" },
|
||||
{ "value": "development","label": "Development" }
|
||||
]
|
||||
}
|
||||
\`\`\``,
|
||||
|
||||
multiselect: `Got it. Which log levels should I focus on?
|
||||
|
||||
\`\`\`ai-question
|
||||
{
|
||||
"question": "Select the log levels to include:",
|
||||
"type": "checkbox",
|
||||
"options": ["ERROR", "WARN", "INFO", "DEBUG", "TRACE"]
|
||||
}
|
||||
\`\`\``,
|
||||
|
||||
confirm: `I found a potential anomaly in \`cart-svc\`. The error rate jumped from 0.8% to 12.1% in the last 5 minutes.
|
||||
|
||||
\`\`\`ai-confirm
|
||||
{
|
||||
"message": "Would you like me to create an alert rule for this service so you're notified if it happens again?",
|
||||
"acceptLabel": "Yes, create alert",
|
||||
"rejectLabel": "No thanks",
|
||||
"acceptText": "Yes, please create an alert rule for cart-svc error rate > 5%.",
|
||||
"rejectText": "No, don't create an alert."
|
||||
}
|
||||
\`\`\``,
|
||||
|
||||
actionRunQuery: `Sure! I'll update the log query to filter for ERROR-level logs from \`payment-svc\`.
|
||||
|
||||
\`\`\`ai-action
|
||||
{
|
||||
"actionId": "logs.runQuery",
|
||||
"description": "Filter logs to ERROR level from payment-svc and re-run the query",
|
||||
"parameters": {
|
||||
"filters": [
|
||||
{ "key": "severity_text", "op": "=", "value": "ERROR" },
|
||||
{ "key": "service.name", "op": "=", "value": "payment-svc" }
|
||||
]
|
||||
}
|
||||
}
|
||||
\`\`\``,
|
||||
|
||||
actionAddFilter: `I'll add a filter for \`ERROR\` severity to your current query.
|
||||
|
||||
\`\`\`ai-action
|
||||
{
|
||||
"actionId": "logs.addFilter",
|
||||
"description": "Add a severity_text = ERROR filter to the current query",
|
||||
"parameters": {
|
||||
"key": "severity_text",
|
||||
"op": "=",
|
||||
"value": "ERROR"
|
||||
}
|
||||
}
|
||||
\`\`\``,
|
||||
|
||||
actionChangeView: `I'll switch the Logs Explorer to the timeseries view so you can see the log volume over time.
|
||||
|
||||
\`\`\`ai-action
|
||||
{
|
||||
"actionId": "logs.changeView",
|
||||
"description": "Switch to the timeseries panel view",
|
||||
"parameters": {
|
||||
"view": "timeseries"
|
||||
}
|
||||
}
|
||||
\`\`\``,
|
||||
|
||||
actionSaveView: `I can save your current query as a named view. What should it be called?
|
||||
|
||||
\`\`\`ai-action
|
||||
{
|
||||
"actionId": "logs.saveView",
|
||||
"description": "Save the current log query as \\"Error Logs — Payment\\"",
|
||||
"parameters": {
|
||||
"name": "Error Logs — Payment"
|
||||
}
|
||||
}
|
||||
\`\`\``,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function pickResponse(messages: { role: string; content: string }[]): string {
|
||||
const lastRaw =
|
||||
[...messages].reverse().find((m) => m.role === 'user')?.content ?? '';
|
||||
|
||||
// Strip the PAGE_CONTEXT block if present — match against the user's actual text
|
||||
const last = lastRaw
|
||||
.replace(/\[PAGE_CONTEXT\][\s\S]*?\[\/PAGE_CONTEXT\]\n?/g, '')
|
||||
.toLowerCase();
|
||||
|
||||
// ── Page action triggers ──────────────────────────────────────────────────
|
||||
if (
|
||||
last.includes('save view') ||
|
||||
last.includes('save this view') ||
|
||||
last.includes('save query')
|
||||
) {
|
||||
return CANNED_RESPONSES.actionSaveView;
|
||||
}
|
||||
if (
|
||||
last.includes('change view') ||
|
||||
last.includes('switch to timeseries') ||
|
||||
last.includes('timeseries view')
|
||||
) {
|
||||
return CANNED_RESPONSES.actionChangeView;
|
||||
}
|
||||
if (
|
||||
last.includes('add filter') ||
|
||||
last.includes('filter for error') ||
|
||||
last.includes('show only error')
|
||||
) {
|
||||
return CANNED_RESPONSES.actionAddFilter;
|
||||
}
|
||||
if (
|
||||
last.includes('run query') ||
|
||||
last.includes('update query') ||
|
||||
last.includes('filter logs') ||
|
||||
last.includes('search logs') ||
|
||||
(last.includes('payment') && last.includes('error'))
|
||||
) {
|
||||
return CANNED_RESPONSES.actionRunQuery;
|
||||
}
|
||||
|
||||
// ── Original triggers ─────────────────────────────────────────────────────
|
||||
if (
|
||||
last.includes('confirm') ||
|
||||
last.includes('alert') ||
|
||||
last.includes('anomal')
|
||||
) {
|
||||
return CANNED_RESPONSES.confirm;
|
||||
}
|
||||
if (
|
||||
last.includes('pie') ||
|
||||
last.includes('distribution') ||
|
||||
last.includes('share')
|
||||
) {
|
||||
return CANNED_RESPONSES.piechart;
|
||||
}
|
||||
if (
|
||||
last.includes('bar') ||
|
||||
last.includes('breakdown') ||
|
||||
last.includes('top service') ||
|
||||
last.includes('top 5') ||
|
||||
last.includes('top 8')
|
||||
) {
|
||||
return CANNED_RESPONSES.barchart;
|
||||
}
|
||||
if (
|
||||
last.includes('timeseries') ||
|
||||
last.includes('time series') ||
|
||||
last.includes('table') ||
|
||||
last.includes('request rate')
|
||||
) {
|
||||
return CANNED_RESPONSES.timeseries;
|
||||
}
|
||||
if (
|
||||
last.includes('graph') ||
|
||||
last.includes('linechart') ||
|
||||
last.includes('line chart') ||
|
||||
last.includes('latency') ||
|
||||
last.includes('slow') ||
|
||||
last.includes('p99') ||
|
||||
last.includes('over time') ||
|
||||
last.includes('trend')
|
||||
) {
|
||||
return CANNED_RESPONSES.latency;
|
||||
}
|
||||
if (
|
||||
last.includes('select') ||
|
||||
last.includes('level') ||
|
||||
last.includes('filter')
|
||||
) {
|
||||
return CANNED_RESPONSES.multiselect;
|
||||
}
|
||||
if (
|
||||
last.includes('ask') ||
|
||||
last.includes('which env') ||
|
||||
last.includes('environment') ||
|
||||
last.includes('question')
|
||||
) {
|
||||
return CANNED_RESPONSES.question;
|
||||
}
|
||||
if (last.includes('error') || last.includes('exception')) {
|
||||
return CANNED_RESPONSES.error;
|
||||
}
|
||||
if (last.includes('log')) {
|
||||
return CANNED_RESPONSES.logs;
|
||||
}
|
||||
return CANNED_RESPONSES.default;
|
||||
}
|
||||
|
||||
import { SSEEvent } from '../../../api/ai/chat';
|
||||
|
||||
interface MockChatPayload {
|
||||
conversationId: string;
|
||||
messages: { role: 'user' | 'assistant'; content: string }[];
|
||||
}
|
||||
|
||||
export async function* mockStreamChat(
|
||||
payload: MockChatPayload,
|
||||
): AsyncGenerator<SSEEvent> {
|
||||
const text = pickResponse(payload.messages);
|
||||
const words = text.split(/(?<=\s)/);
|
||||
const messageId = `mock-${Date.now()}`;
|
||||
const executionId = `mock-exec-${Date.now()}`;
|
||||
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 15 + Math.random() * 30);
|
||||
});
|
||||
yield {
|
||||
type: 'message',
|
||||
executionId,
|
||||
messageId,
|
||||
delta: words[i],
|
||||
done: false,
|
||||
actions: null,
|
||||
eventId: i + 1,
|
||||
};
|
||||
}
|
||||
|
||||
// Final message event with done: true
|
||||
yield {
|
||||
type: 'message',
|
||||
executionId,
|
||||
messageId,
|
||||
delta: '',
|
||||
done: true,
|
||||
actions: null,
|
||||
eventId: words.length + 1,
|
||||
};
|
||||
|
||||
yield {
|
||||
type: 'done',
|
||||
executionId,
|
||||
tokenInput: 0,
|
||||
tokenOutput: words.length,
|
||||
latencyMs: 0,
|
||||
eventId: words.length + 2,
|
||||
};
|
||||
}
|
||||
2258
frontend/src/container/AIAssistant/openapi.yaml
Normal file
2258
frontend/src/container/AIAssistant/openapi.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
import { PageAction, PageActionDescriptor } from './types';
|
||||
|
||||
/**
|
||||
* Module-level singleton (mirrors BlockRegistry) that maps action IDs to their
|
||||
* PageAction descriptors. Pages register their actions on mount and unregister
|
||||
* on unmount via `usePageActions`.
|
||||
*
|
||||
* Internal structure:
|
||||
* _byPage: pageId → PageAction[] (for batch unregister)
|
||||
* _byId: actionId → PageAction (O(1) lookup at execute time)
|
||||
*/
|
||||
|
||||
// pageId → actions[]
|
||||
const _byPage = new Map<string, PageAction[]>();
|
||||
|
||||
// actionId → action (flat, for O(1) lookup)
|
||||
const _byId = new Map<string, PageAction>();
|
||||
|
||||
export const PageActionRegistry = {
|
||||
/**
|
||||
* Register a set of actions under a page scope key.
|
||||
* Calling register() again with the same pageId replaces the previous set.
|
||||
*/
|
||||
register(pageId: string, actions: PageAction[]): void {
|
||||
// Remove any previously registered actions for this page
|
||||
const prev = _byPage.get(pageId) ?? [];
|
||||
prev.forEach((a) => _byId.delete(a.id));
|
||||
|
||||
_byPage.set(pageId, actions);
|
||||
actions.forEach((a) => _byId.set(a.id, a));
|
||||
},
|
||||
|
||||
/** Remove all actions registered under a page scope key. */
|
||||
unregister(pageId: string): void {
|
||||
const prev = _byPage.get(pageId) ?? [];
|
||||
prev.forEach((a) => _byId.delete(a.id));
|
||||
_byPage.delete(pageId);
|
||||
},
|
||||
|
||||
/** Look up a single action by its dot-namespaced id. */
|
||||
get(actionId: string): PageAction | undefined {
|
||||
return _byId.get(actionId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns serialisable descriptors for all currently registered actions,
|
||||
* with context snapshots already collected. Safe to embed in API payload.
|
||||
*/
|
||||
snapshot(): PageActionDescriptor[] {
|
||||
return Array.from(_byId.values()).map((action) => ({
|
||||
id: action.id,
|
||||
description: action.description,
|
||||
parameters: action.parameters,
|
||||
context: action.getContext?.(),
|
||||
}));
|
||||
},
|
||||
|
||||
/** Returns all registered action IDs (useful for debugging). */
|
||||
ids(): string[] {
|
||||
return Array.from(_byId.keys());
|
||||
},
|
||||
};
|
||||
93
frontend/src/container/AIAssistant/pageActions/types.ts
Normal file
93
frontend/src/container/AIAssistant/pageActions/types.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
export type JSONSchemaProperty =
|
||||
| { type: 'string'; description?: string; enum?: string[] }
|
||||
| { type: 'number'; description?: string }
|
||||
| { type: 'boolean'; description?: string }
|
||||
| {
|
||||
type: 'array';
|
||||
description?: string;
|
||||
items: JSONSchemaProperty | JSONSchemaObject;
|
||||
}
|
||||
| { type: 'object'; description?: string; properties?: Record<string, JSONSchemaProperty>; required?: string[] };
|
||||
|
||||
export type JSONSchemaObject = {
|
||||
type: 'object';
|
||||
properties: Record<string, JSONSchemaProperty>;
|
||||
required?: string[];
|
||||
};
|
||||
|
||||
export interface ActionResult {
|
||||
/** Short human-readable outcome shown after the action completes. */
|
||||
summary: string;
|
||||
/** Optional structured data the block can display. */
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes a single action a page exposes to the AI Assistant.
|
||||
* Pages register these via `usePageActions`.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export interface PageAction<TParams = Record<string, any>> {
|
||||
/**
|
||||
* Stable dot-namespaced identifier — e.g. "logs.runQuery", "dashboard.create".
|
||||
* The AI uses this to target the correct action.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Natural-language description sent in the PAGE_CONTEXT block.
|
||||
* The AI uses this to decide which action to invoke.
|
||||
*/
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* JSON Schema (draft-07) describing the parameters accepted by this action.
|
||||
* Sent to the AI so it can generate structurally valid calls.
|
||||
*/
|
||||
parameters: JSONSchemaObject;
|
||||
|
||||
/**
|
||||
* Executes the action. Resolves with a result summary on success.
|
||||
* Rejects with an Error if the action cannot be completed.
|
||||
*/
|
||||
execute: (params: TParams) => Promise<ActionResult>;
|
||||
|
||||
/**
|
||||
* When true, ActionBlock executes the action immediately on mount without
|
||||
* showing a confirmation card. Use for low-risk, reversible actions where
|
||||
* the user explicitly requested the change (e.g. updating a query filter).
|
||||
* Default: false (shows Accept / Dismiss card).
|
||||
*/
|
||||
autoApply?: boolean;
|
||||
|
||||
/**
|
||||
* Optional: returns a snapshot of the current page state to include in
|
||||
* the PAGE_CONTEXT block. Called fresh at message-send time.
|
||||
*/
|
||||
getContext?: () => unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialisable version of PageAction (no function references).
|
||||
* Safe to embed in the API payload.
|
||||
*/
|
||||
export interface PageActionDescriptor {
|
||||
id: string;
|
||||
description: string;
|
||||
parameters: JSONSchemaObject;
|
||||
/** Context snapshot returned by PageAction.getContext() */
|
||||
context?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* The JSON payload the AI emits inside an ```ai-action``` fenced block
|
||||
* when it wants to invoke an action.
|
||||
*/
|
||||
export interface AIActionBlock {
|
||||
/** Must match a registered PageAction.id */
|
||||
actionId: string;
|
||||
/** One-sentence explanation shown in the confirmation card. */
|
||||
description: string;
|
||||
/** Parameters chosen by the AI — validated against the action's JSON Schema. */
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { PageActionRegistry } from './PageActionRegistry';
|
||||
import { PageAction } from './types';
|
||||
|
||||
/**
|
||||
* Registers page-specific actions into the PageActionRegistry for the lifetime
|
||||
* of the calling component. Cleanup (unregister) happens automatically on unmount.
|
||||
*
|
||||
* Usage:
|
||||
* const actions = useMemo(() => [
|
||||
* logsRunQueryAction({ handleRunQuery, ... }),
|
||||
* ], [handleRunQuery, ...]);
|
||||
*
|
||||
* usePageActions('logs-explorer', actions);
|
||||
*
|
||||
* IMPORTANT: memoize the `actions` array with useMemo so that the reference
|
||||
* stays stable and we don't re-register on every render.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function usePageActions(pageId: string, actions: PageAction<any>[]): void {
|
||||
useEffect(() => {
|
||||
PageActionRegistry.register(pageId, actions);
|
||||
return (): void => {
|
||||
PageActionRegistry.unregister(pageId);
|
||||
};
|
||||
// Re-register when actions reference changes (e.g. new callbacks after store update)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pageId, actions]);
|
||||
}
|
||||
1070
frontend/src/container/AIAssistant/store/useAIAssistantStore.ts
Normal file
1070
frontend/src/container/AIAssistant/store/useAIAssistantStore.ts
Normal file
File diff suppressed because it is too large
Load Diff
141
frontend/src/container/AIAssistant/types.ts
Normal file
141
frontend/src/container/AIAssistant/types.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
export interface MessageAttachment {
|
||||
name: string;
|
||||
type: string;
|
||||
/** data URI for images, or a download URL for other files */
|
||||
dataUrl: string;
|
||||
}
|
||||
|
||||
export type MessageRole = 'user' | 'assistant';
|
||||
|
||||
export type ActionKind =
|
||||
| 'follow_up'
|
||||
| 'open_resource'
|
||||
| 'navigate'
|
||||
| 'apply_filter'
|
||||
| 'open_docs'
|
||||
| 'undo'
|
||||
| 'revert';
|
||||
|
||||
export interface AssistantAction {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: ActionKind;
|
||||
payload: Record<string, unknown>;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export type FeedbackRating = 'positive' | 'negative';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message blocks — ordered content blocks for assistant replies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TextBlock {
|
||||
type: 'text';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ThinkingBlock {
|
||||
type: 'thinking';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ToolCallBlock {
|
||||
type: 'tool_call';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
toolInput: unknown;
|
||||
result?: unknown;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
export type MessageBlock = TextBlock | ThinkingBlock | ToolCallBlock;
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: MessageRole;
|
||||
content: string;
|
||||
attachments?: MessageAttachment[];
|
||||
/** Ordered content blocks for structured rendering of assistant replies. */
|
||||
blocks?: MessageBlock[];
|
||||
/** Suggested follow-up actions returned by the assistant (final message only). */
|
||||
actions?: AssistantAction[];
|
||||
/** Persisted feedback rating — set after user votes and the API confirms. */
|
||||
feedbackRating?: FeedbackRating | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
/** Opaque thread ID assigned by the backend after first message. */
|
||||
threadId?: string;
|
||||
messages: Message[];
|
||||
createdAt: number;
|
||||
updatedAt?: number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Streaming-only types — live during an active SSE stream, never persisted
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A single tool invocation tracked during streaming. */
|
||||
export interface StreamingToolCall {
|
||||
/** Matches the toolName field in SSE tool_call / tool_result events. */
|
||||
toolName: string;
|
||||
input: unknown;
|
||||
result?: unknown;
|
||||
/** True once the corresponding tool_result event has been received. */
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* An ordered item in the streaming event timeline.
|
||||
* Text and tool calls are interleaved in arrival order so the UI renders
|
||||
* them chronologically rather than grouping all tools above all text.
|
||||
*/
|
||||
export type StreamingEventItem =
|
||||
| { kind: 'text'; content: string }
|
||||
| { kind: 'thinking'; content: string }
|
||||
| { kind: 'tool'; toolCall: StreamingToolCall };
|
||||
|
||||
/** Data from an SSE `approval` event — user must approve or reject before the stream continues. */
|
||||
export interface PendingApproval {
|
||||
approvalId: string;
|
||||
executionId: string;
|
||||
actionType: string;
|
||||
resourceType: string;
|
||||
summary: string;
|
||||
diff: { before: unknown; after: unknown } | null;
|
||||
}
|
||||
|
||||
/** A single field in a clarification form. */
|
||||
export interface ClarificationField {
|
||||
id: string;
|
||||
/** 'text' | 'number' | 'select' | 'checkbox' | 'radio' */
|
||||
type: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
options?: string[] | null;
|
||||
default?: string | string[] | null;
|
||||
}
|
||||
|
||||
/** Data from an SSE `clarification` event — user must submit answers before the stream continues. */
|
||||
export interface PendingClarification {
|
||||
clarificationId: string;
|
||||
executionId: string;
|
||||
message: string;
|
||||
discoveredContext: Record<string, unknown> | null;
|
||||
fields: ClarificationField[];
|
||||
}
|
||||
|
||||
/** Per-conversation streaming state. Present in the store's `streams` map only while active. */
|
||||
export interface ConversationStreamState {
|
||||
isStreaming: boolean;
|
||||
streamingContent: string;
|
||||
streamingStatus: string;
|
||||
streamingEvents: StreamingEventItem[];
|
||||
streamingMessageId: string | null;
|
||||
pendingApproval: PendingApproval | null;
|
||||
pendingClarification: PendingClarification | null;
|
||||
}
|
||||
@@ -48,13 +48,12 @@
|
||||
}
|
||||
|
||||
.app-content {
|
||||
width: calc(100% - 54px); // width of the sidebar
|
||||
flex: 1;
|
||||
min-width: 0; // allow shrinking below natural width when AI panel is open
|
||||
z-index: 0;
|
||||
|
||||
margin: 0 auto;
|
||||
|
||||
&.full-screen-content {
|
||||
width: 100% !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content-container {
|
||||
@@ -66,12 +65,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&.side-nav-pinned {
|
||||
.app-content {
|
||||
width: calc(100% - 240px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-support-gateway {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useMutation, useQueries } from 'react-query';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Toaster } from '@signozhq/ui';
|
||||
import { Toaster, TooltipProvider } from '@signozhq/ui';
|
||||
import { Flex } from 'antd';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
@@ -36,6 +36,8 @@ import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { GlobalShortcuts } from 'constants/shortcuts/globalShortcuts';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import AIAssistantModal from 'container/AIAssistant/AIAssistantModal';
|
||||
import AIAssistantPanel from 'container/AIAssistant/AIAssistantPanel';
|
||||
import SideNav from 'container/SideNav';
|
||||
import TopNav from 'container/TopNav';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -392,6 +394,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
const pageTitle = t(routeKey);
|
||||
|
||||
const isPublicDashboard = pathname.startsWith('/public/dashboard/');
|
||||
const isAIAssistantPage = pathname.startsWith('/ai-assistant/');
|
||||
|
||||
const renderFullScreen =
|
||||
pathname === ROUTES.GET_STARTED ||
|
||||
@@ -767,110 +770,121 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
!showTrialExpiryBanner && showPaymentFailedWarning;
|
||||
|
||||
return (
|
||||
<Layout className={cx(isDarkMode ? 'darkMode dark' : 'lightMode')}>
|
||||
<Helmet>
|
||||
<title>{pageTitle}</title>
|
||||
</Helmet>
|
||||
<TooltipProvider>
|
||||
<Layout className={cx(isDarkMode ? 'darkMode dark' : 'lightMode')}>
|
||||
<Helmet>
|
||||
<title>{pageTitle}</title>
|
||||
</Helmet>
|
||||
|
||||
{isLoggedIn && (
|
||||
<div className={cx('app-banner-wrapper')}>
|
||||
{SHOW_TRIAL_EXPIRY_BANNER && (
|
||||
<div className="trial-expiry-banner">
|
||||
You are in free trial period. Your free trial will end on{' '}
|
||||
<span>{getFormattedDate(trialInfo?.trialEnd || Date.now())}.</span>
|
||||
{user.role === USER_ROLES.ADMIN ? (
|
||||
<span>
|
||||
{' '}
|
||||
Please{' '}
|
||||
<a className="upgrade-link" onClick={handleUpgrade}>
|
||||
upgrade
|
||||
</a>
|
||||
to continue using SigNoz features.
|
||||
<span className="refresh-payment-status">
|
||||
{isLoggedIn && (
|
||||
<div className={cx('app-banner-wrapper')}>
|
||||
{SHOW_TRIAL_EXPIRY_BANNER && (
|
||||
<div className="trial-expiry-banner">
|
||||
You are in free trial period. Your free trial will end on{' '}
|
||||
<span>{getFormattedDate(trialInfo?.trialEnd || Date.now())}.</span>
|
||||
{user.role === USER_ROLES.ADMIN ? (
|
||||
<span>
|
||||
{' '}
|
||||
| Already upgraded? <RefreshPaymentStatus type="text" />
|
||||
Please{' '}
|
||||
<a className="upgrade-link" onClick={handleUpgrade}>
|
||||
upgrade
|
||||
</a>
|
||||
to continue using SigNoz features.
|
||||
<span className="refresh-payment-status">
|
||||
{' '}
|
||||
| Already upgraded? <RefreshPaymentStatus type="text" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
'Please contact your administrator for upgrading to a paid plan.'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{SHOW_WORKSPACE_RESTRICTED_BANNER && renderWorkspaceRestrictedBanner()}
|
||||
|
||||
{SHOW_PAYMENT_FAILED_BANNER && (
|
||||
<div className="payment-failed-banner">
|
||||
Your bill payment has failed. Your workspace will get suspended on{' '}
|
||||
<span>
|
||||
{getFormattedDateWithMinutes(
|
||||
dayjs(activeLicense?.event_queue?.scheduled_at).unix() || Date.now(),
|
||||
) : (
|
||||
'Please contact your administrator for upgrading to a paid plan.'
|
||||
)}
|
||||
.
|
||||
</span>
|
||||
{user.role === USER_ROLES.ADMIN ? (
|
||||
</div>
|
||||
)}
|
||||
|
||||
{SHOW_WORKSPACE_RESTRICTED_BANNER && renderWorkspaceRestrictedBanner()}
|
||||
|
||||
{SHOW_PAYMENT_FAILED_BANNER && (
|
||||
<div className="payment-failed-banner">
|
||||
Your bill payment has failed. Your workspace will get suspended on{' '}
|
||||
<span>
|
||||
{' '}
|
||||
Please{' '}
|
||||
<a className="upgrade-link" onClick={handleFailedPayment}>
|
||||
pay the bill
|
||||
</a>
|
||||
to continue using SigNoz features.
|
||||
<span className="refresh-payment-status">
|
||||
{' '}
|
||||
| Already paid? <RefreshPaymentStatus type="text" />
|
||||
</span>
|
||||
{getFormattedDateWithMinutes(
|
||||
dayjs(activeLicense?.event_queue?.scheduled_at).unix() || Date.now(),
|
||||
)}
|
||||
.
|
||||
</span>
|
||||
) : (
|
||||
' Please contact your administrator to pay the bill.'
|
||||
)}
|
||||
</div>
|
||||
{user.role === USER_ROLES.ADMIN ? (
|
||||
<span>
|
||||
{' '}
|
||||
Please{' '}
|
||||
<a className="upgrade-link" onClick={handleFailedPayment}>
|
||||
pay the bill
|
||||
</a>
|
||||
to continue using SigNoz features.
|
||||
<span className="refresh-payment-status">
|
||||
{' '}
|
||||
| Already paid? <RefreshPaymentStatus type="text" />
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
' Please contact your administrator to pay the bill.'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Flex
|
||||
className={cx(
|
||||
'app-layout',
|
||||
isDarkMode ? 'darkMode dark' : 'lightMode',
|
||||
isSideNavPinned ? 'side-nav-pinned' : '',
|
||||
SHOW_WORKSPACE_RESTRICTED_BANNER ? 'isWorkspaceRestricted' : '',
|
||||
SHOW_TRIAL_EXPIRY_BANNER ? 'isTrialExpired' : '',
|
||||
SHOW_PAYMENT_FAILED_BANNER ? 'isPaymentFailed' : '',
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Flex
|
||||
className={cx(
|
||||
'app-layout',
|
||||
isDarkMode ? 'darkMode dark' : 'lightMode',
|
||||
isSideNavPinned ? 'side-nav-pinned' : '',
|
||||
SHOW_WORKSPACE_RESTRICTED_BANNER ? 'isWorkspaceRestricted' : '',
|
||||
SHOW_TRIAL_EXPIRY_BANNER ? 'isTrialExpired' : '',
|
||||
SHOW_PAYMENT_FAILED_BANNER ? 'isPaymentFailed' : '',
|
||||
)}
|
||||
>
|
||||
{isToDisplayLayout && !renderFullScreen && (
|
||||
<SideNav isPinned={isSideNavPinned} />
|
||||
)}
|
||||
<div
|
||||
className={cx('app-content', {
|
||||
'full-screen-content': renderFullScreen,
|
||||
})}
|
||||
data-overlayscrollbars-initialize
|
||||
>
|
||||
<Sentry.ErrorBoundary
|
||||
fallback={<ErrorBoundaryFallback />}
|
||||
ref={errorBoundaryRef}
|
||||
{isToDisplayLayout && !renderFullScreen && (
|
||||
<SideNav isPinned={isSideNavPinned} />
|
||||
)}
|
||||
<div
|
||||
className={cx('app-content', {
|
||||
'full-screen-content': renderFullScreen,
|
||||
})}
|
||||
data-overlayscrollbars-initialize
|
||||
>
|
||||
<LayoutContent data-overlayscrollbars-initialize>
|
||||
<OverlayScrollbar>
|
||||
<ChildrenContainer>
|
||||
{isToDisplayLayout && !renderFullScreen && <TopNav />}
|
||||
{children}
|
||||
</ChildrenContainer>
|
||||
</OverlayScrollbar>
|
||||
</LayoutContent>
|
||||
</Sentry.ErrorBoundary>
|
||||
</div>
|
||||
</Flex>
|
||||
<Sentry.ErrorBoundary
|
||||
fallback={<ErrorBoundaryFallback />}
|
||||
ref={errorBoundaryRef}
|
||||
>
|
||||
<LayoutContent data-overlayscrollbars-initialize>
|
||||
<OverlayScrollbar>
|
||||
<ChildrenContainer>
|
||||
{isToDisplayLayout && !renderFullScreen && !isAIAssistantPage && (
|
||||
<TopNav />
|
||||
)}
|
||||
{children}
|
||||
</ChildrenContainer>
|
||||
</OverlayScrollbar>
|
||||
</LayoutContent>
|
||||
</Sentry.ErrorBoundary>
|
||||
</div>
|
||||
|
||||
{showAddCreditCardModal && <ChatSupportGateway />}
|
||||
{showChangelogModal && changelog && (
|
||||
<ChangelogModal changelog={changelog} onClose={toggleChangelogModal} />
|
||||
)}
|
||||
{isLoggedIn && (
|
||||
<>
|
||||
<AIAssistantPanel />
|
||||
<AIAssistantModal />
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<Toaster />
|
||||
</Layout>
|
||||
{showAddCreditCardModal && <ChatSupportGateway />}
|
||||
{showChangelogModal && changelog && (
|
||||
<ChangelogModal changelog={changelog} onClose={toggleChangelogModal} />
|
||||
)}
|
||||
|
||||
<Toaster />
|
||||
</Layout>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
126
frontend/src/pages/AIAssistantPage/AIAssistantPage.tsx
Normal file
126
frontend/src/pages/AIAssistantPage/AIAssistantPage.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Tooltip } from '@signozhq/tooltip';
|
||||
import ROUTES from 'constants/routes';
|
||||
import AIAssistantIcon from 'container/AIAssistant/components/AIAssistantIcon';
|
||||
import HistorySidebar from 'container/AIAssistant/components/HistorySidebar';
|
||||
import ConversationView from 'container/AIAssistant/ConversationView';
|
||||
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { Eraser, Minimize2, Plus } from 'lucide-react';
|
||||
|
||||
import 'container/AIAssistant/AIAssistant.styles.scss';
|
||||
|
||||
interface RouteParams {
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
export default function AIAssistantPage(): JSX.Element {
|
||||
const history = useHistory();
|
||||
const { conversationId } = useParams<RouteParams>();
|
||||
|
||||
const conversations = useAIAssistantStore((s) => s.conversations);
|
||||
const setActiveConversation = useAIAssistantStore(
|
||||
(s) => s.setActiveConversation,
|
||||
);
|
||||
const startNewConversation = useAIAssistantStore(
|
||||
(s) => s.startNewConversation,
|
||||
);
|
||||
const clearConversation = useAIAssistantStore((s) => s.clearConversation);
|
||||
const openDrawer = useAIAssistantStore((s) => s.openDrawer);
|
||||
|
||||
// Keep a ref so the effect can read latest conversations without re-firing
|
||||
// when startNewConversation mutates the store mid-effect.
|
||||
const conversationsRef = useRef(conversations);
|
||||
conversationsRef.current = conversations;
|
||||
|
||||
useEffect(() => {
|
||||
if (conversationsRef.current[conversationId]) {
|
||||
setActiveConversation(conversationId);
|
||||
} else {
|
||||
const newId = startNewConversation();
|
||||
history.replace(ROUTES.AI_ASSISTANT.replace(':conversationId', newId));
|
||||
}
|
||||
// Only re-run when the URL param changes, not when conversations mutates.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [conversationId]);
|
||||
|
||||
const handleMinimize = useCallback(() => {
|
||||
openDrawer();
|
||||
history.goBack();
|
||||
}, [openDrawer, history]);
|
||||
|
||||
const handleNewConversation = useCallback(() => {
|
||||
const newId = startNewConversation();
|
||||
history.push(ROUTES.AI_ASSISTANT.replace(':conversationId', newId));
|
||||
}, [startNewConversation, history]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
clearConversation(conversationId);
|
||||
}, [clearConversation, conversationId]);
|
||||
|
||||
// When history sidebar selects a conversation, navigate to it
|
||||
const handleHistorySelect = useCallback(
|
||||
(id: string) => {
|
||||
history.push(ROUTES.AI_ASSISTANT.replace(':conversationId', id));
|
||||
},
|
||||
[history],
|
||||
);
|
||||
|
||||
const activeId = conversations[conversationId] ? conversationId : null;
|
||||
|
||||
return (
|
||||
<div className="ai-assistant-page">
|
||||
<div className="ai-assistant-page__header">
|
||||
<div className="ai-assistant-page__title">
|
||||
<AIAssistantIcon size={22} />
|
||||
<span>AI Assistant</span>
|
||||
</div>
|
||||
|
||||
<div className="ai-assistant-page__actions">
|
||||
<Tooltip title="Clear chat">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleClear}
|
||||
disabled={!activeId}
|
||||
aria-label="Clear chat"
|
||||
>
|
||||
<Eraser size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="New conversation">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
prefixIcon={<Plus size={14} />}
|
||||
onClick={handleNewConversation}
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Minimize to panel">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleMinimize}
|
||||
aria-label="Minimize to panel"
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ai-assistant-page__body">
|
||||
<HistorySidebar onSelect={handleHistorySelect} />
|
||||
|
||||
<div className="ai-assistant-page__chat">
|
||||
{activeId && <ConversationView conversationId={activeId} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
frontend/src/pages/LogsExplorer/aiActions.ts
Normal file
250
frontend/src/pages/LogsExplorer/aiActions.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* AI Assistant page-action factories for the Logs Explorer.
|
||||
*
|
||||
* Each factory closes over live page state/callbacks so that `execute()`
|
||||
* always operates on the current query. The page component instantiates these
|
||||
* with `useMemo` and passes them to `usePageActions`.
|
||||
*
|
||||
* IMPORTANT: filters are driven by the URL param `compositeQuery`, not by
|
||||
* React state alone. To make filter changes visible in the WHERE clause UI,
|
||||
* we must call `redirectWithQueryBuilderData(updatedQuery)` which syncs the
|
||||
* URL and triggers the component to re-read and display the new filters.
|
||||
* Calling only `handleSetQueryData` updates React state but not the URL,
|
||||
* so the query builder UI never reflects the change.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { PageAction } from 'container/AIAssistant/pageActions/types';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
// ─── Shared param shape emitted by the AI ─────────────────────────────────────
|
||||
|
||||
interface AIFilter {
|
||||
key: string;
|
||||
op: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface RunQueryParams {
|
||||
filters: AIFilter[];
|
||||
}
|
||||
|
||||
interface AddFilterParams {
|
||||
key: string;
|
||||
op: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ChangeViewParams {
|
||||
view: 'list' | 'timeseries' | 'table';
|
||||
}
|
||||
|
||||
interface SaveViewParams {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function aiFilterToTagFilterItem(f: AIFilter): TagFilterItem {
|
||||
const key: BaseAutocompleteData = {
|
||||
key: f.key,
|
||||
type: null,
|
||||
dataType: undefined,
|
||||
};
|
||||
return { id: uuidv4(), key, op: f.op, value: f.value };
|
||||
}
|
||||
|
||||
/** Return a new Query with the first builder queryData entry replaced. */
|
||||
function replaceFirstQueryData(query: Query, updated: IBuilderQuery): Query {
|
||||
const queryData = [...query.builder.queryData];
|
||||
queryData[0] = updated;
|
||||
return {
|
||||
...query,
|
||||
builder: { ...query.builder, queryData },
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Deps types ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface FilterDeps {
|
||||
currentQuery: Query;
|
||||
redirectWithQueryBuilderData: (query: Query) => void;
|
||||
}
|
||||
|
||||
// ─── Action factories ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Replace all active filters and navigate to the updated query URL
|
||||
* (which makes the WHERE clause reflect the new filters and triggers a re-run).
|
||||
*/
|
||||
export function logsRunQueryAction(
|
||||
deps: FilterDeps,
|
||||
): PageAction<RunQueryParams> {
|
||||
return {
|
||||
id: 'logs.runQuery',
|
||||
description: 'Replace the active log filters and re-run the query',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
filters: {
|
||||
type: 'array',
|
||||
description: 'Replacement filter list',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: {
|
||||
type: 'string',
|
||||
description: 'Attribute key, e.g. severity_text',
|
||||
},
|
||||
op: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'=',
|
||||
'!=',
|
||||
'IN',
|
||||
'NOT_IN',
|
||||
'CONTAINS',
|
||||
'NOT_CONTAINS',
|
||||
],
|
||||
},
|
||||
value: { type: 'string' },
|
||||
},
|
||||
required: ['key', 'op', 'value'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['filters'],
|
||||
},
|
||||
autoApply: true,
|
||||
execute: async ({ filters }) => {
|
||||
const baseQuery = deps.currentQuery.builder.queryData[0];
|
||||
if (!baseQuery) throw new Error('No active query found in Logs Explorer.');
|
||||
|
||||
const tagItems = filters.map(aiFilterToTagFilterItem);
|
||||
const updatedBuilderQuery: IBuilderQuery = {
|
||||
...baseQuery,
|
||||
filters: { items: tagItems, op: 'AND' },
|
||||
};
|
||||
|
||||
deps.redirectWithQueryBuilderData(
|
||||
replaceFirstQueryData(deps.currentQuery, updatedBuilderQuery),
|
||||
);
|
||||
|
||||
return {
|
||||
summary: `Query updated with ${filters.length} filter(s) and re-run.`,
|
||||
};
|
||||
},
|
||||
getContext: () => ({
|
||||
filters:
|
||||
deps.currentQuery.builder.queryData[0]?.filters?.items?.map(
|
||||
(f: TagFilterItem) => ({
|
||||
key: f.key?.key,
|
||||
op: f.op,
|
||||
value: f.value,
|
||||
}),
|
||||
) ?? [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a single filter to the existing query and navigate to the updated URL.
|
||||
*/
|
||||
export function logsAddFilterAction(
|
||||
deps: FilterDeps,
|
||||
): PageAction<AddFilterParams> {
|
||||
return {
|
||||
id: 'logs.addFilter',
|
||||
description: 'Add a single filter to the current log query and re-run',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: {
|
||||
type: 'string',
|
||||
description: 'Attribute key, e.g. severity_text',
|
||||
},
|
||||
op: {
|
||||
type: 'string',
|
||||
enum: ['=', '!=', 'IN', 'NOT_IN', 'CONTAINS', 'NOT_CONTAINS'],
|
||||
},
|
||||
value: { type: 'string' },
|
||||
},
|
||||
required: ['key', 'op', 'value'],
|
||||
},
|
||||
autoApply: true,
|
||||
execute: async ({ key, op, value }) => {
|
||||
const baseQuery = deps.currentQuery.builder.queryData[0];
|
||||
if (!baseQuery) throw new Error('No active query found in Logs Explorer.');
|
||||
|
||||
const existing = baseQuery.filters?.items ?? [];
|
||||
const newItem = aiFilterToTagFilterItem({ key, op, value });
|
||||
const updatedBuilderQuery: IBuilderQuery = {
|
||||
...baseQuery,
|
||||
filters: { items: [...existing, newItem], op: 'AND' },
|
||||
};
|
||||
|
||||
deps.redirectWithQueryBuilderData(
|
||||
replaceFirstQueryData(deps.currentQuery, updatedBuilderQuery),
|
||||
);
|
||||
|
||||
return { summary: `Filter added: ${key} ${op} "${value}". Query re-run.` };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the explorer between list / timeseries / table views.
|
||||
*/
|
||||
export function logsChangeViewAction(deps: {
|
||||
onChangeView: (view: 'list' | 'timeseries' | 'table') => void;
|
||||
}): PageAction<ChangeViewParams> {
|
||||
return {
|
||||
id: 'logs.changeView',
|
||||
description:
|
||||
'Switch the Logs Explorer between list, timeseries, and table views',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
view: {
|
||||
type: 'string',
|
||||
enum: ['list', 'timeseries', 'table'],
|
||||
description: 'The panel view to switch to',
|
||||
},
|
||||
},
|
||||
required: ['view'],
|
||||
},
|
||||
execute: async ({ view }) => {
|
||||
deps.onChangeView(view);
|
||||
return { summary: `Switched to the "${view}" view.` };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current query as a named view (stub — wires to real API when available).
|
||||
*/
|
||||
export function logsSaveViewAction(deps: {
|
||||
onSaveView: (name: string) => Promise<void>;
|
||||
}): PageAction<SaveViewParams> {
|
||||
return {
|
||||
id: 'logs.saveView',
|
||||
description: 'Save the current log query as a named view',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Name for the saved view' },
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
execute: async ({ name }) => {
|
||||
await deps.onSaveView(name);
|
||||
return { summary: `View "${name}" saved.` };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
|
||||
import LogExplorerQuerySection from 'container/LogExplorerQuerySection';
|
||||
import LogsExplorerViewsContainer from 'container/LogsExplorerViews';
|
||||
import {
|
||||
@@ -39,6 +40,12 @@ import {
|
||||
panelTypeToExplorerView,
|
||||
} from 'utils/explorerUtils';
|
||||
|
||||
import {
|
||||
logsAddFilterAction,
|
||||
logsChangeViewAction,
|
||||
logsRunQueryAction,
|
||||
logsSaveViewAction,
|
||||
} from './aiActions';
|
||||
import { ExplorerViews } from './utils';
|
||||
|
||||
import './LogsExplorer.styles.scss';
|
||||
@@ -65,7 +72,12 @@ function LogsExplorer(): JSX.Element {
|
||||
return true;
|
||||
});
|
||||
|
||||
const { handleRunQuery, handleSetConfig } = useQueryBuilder();
|
||||
const {
|
||||
handleRunQuery,
|
||||
handleSetConfig,
|
||||
currentQuery,
|
||||
redirectWithQueryBuilderData,
|
||||
} = useQueryBuilder();
|
||||
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
|
||||
@@ -96,6 +108,35 @@ function LogsExplorer(): JSX.Element {
|
||||
[handleSetConfig, handleExplorerTabChange, setSelectedView],
|
||||
);
|
||||
|
||||
// ─── AI Assistant page actions ─────────────────────────────────────────────
|
||||
const aiActions = useMemo(
|
||||
() => [
|
||||
logsRunQueryAction({
|
||||
currentQuery,
|
||||
redirectWithQueryBuilderData,
|
||||
}),
|
||||
logsAddFilterAction({
|
||||
currentQuery,
|
||||
redirectWithQueryBuilderData,
|
||||
}),
|
||||
logsChangeViewAction({
|
||||
onChangeView: (view) =>
|
||||
handleChangeSelectedView(view as ExplorerViews),
|
||||
}),
|
||||
logsSaveViewAction({
|
||||
// POC stub — logs a save request; wire to real API when available
|
||||
onSaveView: async (name) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.info('[AI Assistant] Save view requested:', name);
|
||||
},
|
||||
}),
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[currentQuery, redirectWithQueryBuilderData, handleChangeSelectedView],
|
||||
);
|
||||
usePageActions('logs-explorer', aiActions);
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const handleFilterVisibilityChange = (): void => {
|
||||
setLocalStorageApi(
|
||||
LOCALSTORAGE.SHOW_LOGS_QUICK_FILTERS,
|
||||
|
||||
@@ -131,4 +131,6 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
METER_EXPLORER_VIEWS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
PUBLIC_DASHBOARD: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ALERT_TYPE_SELECTION: ['ADMIN', 'EDITOR'],
|
||||
AI_ASSISTANT: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
AI_ASSISTANT_ICON_PREVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
};
|
||||
|
||||
5228
frontend/yarn.lock
5228
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
1
go.mod
1
go.mod
@@ -64,7 +64,6 @@ require (
|
||||
github.com/uptrace/bun/dialect/pgdialect v1.2.9
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.2.9
|
||||
github.com/uptrace/bun/extra/bunotel v1.2.9
|
||||
github.com/yuin/goldmark v1.7.16
|
||||
go.opentelemetry.io/collector/confmap v1.51.0
|
||||
go.opentelemetry.io/collector/otelcol v0.144.0
|
||||
go.opentelemetry.io/collector/pdata v1.51.0
|
||||
|
||||
2
go.sum
2
go.sum
@@ -1144,8 +1144,6 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
github.com/zeebo/assert v1.3.1 h1:vukIABvugfNMZMQO1ABsyQDJDTVQbn+LWSMy1ol1h6A=
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package markdownrenderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
func (r *renderer) renderHTML(_ context.Context, markdown string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := newHTMLRenderer().Convert([]byte(markdown), &buf); err != nil {
|
||||
return "", errors.WrapInternalf(err, errors.CodeInternal, "failed to convert markdown to HTML")
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
package markdownrenderer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var (
|
||||
testMarkdown = `# 🔥 FIRING: High CPU Usage on api-gateway
|
||||
|
||||
https://signoz.example.com/alerts/123
|
||||
https://runbooks.example.com/cpu-high
|
||||
|
||||
## Alert Details
|
||||
|
||||
**Status:** **FIRING** | *api-gateway* service is experiencing high CPU usage. ~~resolved~~ previously.
|
||||
|
||||
Alert triggered because ` + "`cpu_usage_percent`" + ` exceeded threshold ` + "`90`" + `.
|
||||
|
||||
[View Alert in SigNoz](https://signoz.example.com/alerts/123) | [View Logs](https://signoz.example.com/logs?service=api-gateway) | [View Traces](https://signoz.example.com/traces?service=api-gateway)
|
||||
|
||||

|
||||
|
||||
## Alert Labels
|
||||
|
||||
| Label | Value |
|
||||
| -------- | ----------- |
|
||||
| service | api-gateway |
|
||||
| instance | pod-5a8b3c |
|
||||
| severity | critical |
|
||||
| region | us-east-1 |
|
||||
|
||||
## Remediation Steps
|
||||
|
||||
1. Check current CPU usage on the pod
|
||||
2. Review recent deployments for regressions
|
||||
3. Scale horizontally if load-related
|
||||
1. Increase replica count
|
||||
2. Verify HPA configuration
|
||||
|
||||
## Affected Services
|
||||
|
||||
* api-gateway
|
||||
* auth-service
|
||||
* payment-service
|
||||
* payment-processor
|
||||
* payment-validator
|
||||
|
||||
## Incident Checklist
|
||||
|
||||
- [x] Alert acknowledged
|
||||
- [x] On-call notified
|
||||
- [ ] Root cause identified
|
||||
- [ ] Fix deployed
|
||||
|
||||
## Alert Rule Description
|
||||
|
||||
> This alert fires when CPU usage exceeds 90% for more than 5 minutes on any pod in the api-gateway service.
|
||||
>
|
||||
>> For capacity planning guidelines, see the infrastructure runbook section on horizontal pod autoscaling.
|
||||
|
||||
## Triggered Query
|
||||
` + "```promql\navg(rate(container_cpu_usage_seconds_total{service=\"api-gateway\"}[5m])) by (pod) > 0.9\n```" + `
|
||||
|
||||
## Inline Details
|
||||
|
||||
This alert was generated by SigNoz using ` + "`alertmanager`" + ` rules engine.
|
||||
`
|
||||
)
|
||||
|
||||
func newTestRenderer() Renderer {
|
||||
return NewRenderer()
|
||||
}
|
||||
|
||||
func TestRenderHTML_Composite(t *testing.T) {
|
||||
renderer := newTestRenderer()
|
||||
|
||||
html, err := renderer.Render(context.Background(), testMarkdown, MarkdownFormatHTML)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Full expected output for exact match
|
||||
expected := "<h1>🔥 FIRING: High CPU Usage on api-gateway</h1>\n" +
|
||||
"<p><a href=\"https://signoz.example.com/alerts/123\">https://signoz.example.com/alerts/123</a>\n<a href=\"https://runbooks.example.com/cpu-high\">https://runbooks.example.com/cpu-high</a></p>\n" +
|
||||
"<h2>Alert Details</h2>\n" +
|
||||
"<p><strong>Status:</strong> <strong>FIRING</strong> | <em>api-gateway</em> service is experiencing high CPU usage. <del>resolved</del> previously.</p>\n" +
|
||||
"<p>Alert triggered because <code>cpu_usage_percent</code> exceeded threshold <code>90</code>.</p>\n" +
|
||||
"<p><a href=\"https://signoz.example.com/alerts/123\">View Alert in SigNoz</a> | <a href=\"https://signoz.example.com/logs?service=api-gateway\">View Logs</a> | <a href=\"https://signoz.example.com/traces?service=api-gateway\">View Traces</a></p>\n" +
|
||||
"<p><img src=\"https://signoz.example.com/badges/critical.svg\" alt=\"critical\" title=\"Critical Alert\"></p>\n" +
|
||||
"<h2>Alert Labels</h2>\n" +
|
||||
"<table>\n<thead>\n<tr>\n<th>Label</th>\n<th>Value</th>\n</tr>\n</thead>\n" +
|
||||
"<tbody>\n<tr>\n<td>service</td>\n<td>api-gateway</td>\n</tr>\n" +
|
||||
"<tr>\n<td>instance</td>\n<td>pod-5a8b3c</td>\n</tr>\n" +
|
||||
"<tr>\n<td>severity</td>\n<td>critical</td>\n</tr>\n" +
|
||||
"<tr>\n<td>region</td>\n<td>us-east-1</td>\n</tr>\n</tbody>\n</table>\n" +
|
||||
"<h2>Remediation Steps</h2>\n" +
|
||||
"<ol>\n<li>Check current CPU usage on the pod</li>\n<li>Review recent deployments for regressions</li>\n<li>Scale horizontally if load-related\n" +
|
||||
"<ol>\n<li>Increase replica count</li>\n<li>Verify HPA configuration</li>\n</ol>\n</li>\n</ol>\n" +
|
||||
"<h2>Affected Services</h2>\n" +
|
||||
"<ul>\n<li>api-gateway</li>\n<li>auth-service</li>\n<li>payment-service\n" +
|
||||
"<ul>\n<li>payment-processor</li>\n<li>payment-validator</li>\n</ul>\n</li>\n</ul>\n" +
|
||||
"<h2>Incident Checklist</h2>\n" +
|
||||
"<ul>\n<li><input checked=\"\" disabled=\"\" type=\"checkbox\"> Alert acknowledged</li>\n" +
|
||||
"<li><input checked=\"\" disabled=\"\" type=\"checkbox\"> On-call notified</li>\n" +
|
||||
"<li><input disabled=\"\" type=\"checkbox\"> Root cause identified</li>\n" +
|
||||
"<li><input disabled=\"\" type=\"checkbox\"> Fix deployed</li>\n</ul>\n" +
|
||||
"<h2>Alert Rule Description</h2>\n" +
|
||||
"<blockquote>\n<p>This alert fires when CPU usage exceeds 90% for more than 5 minutes on any pod in the api-gateway service.</p>\n" +
|
||||
"<blockquote>\n<p>For capacity planning guidelines, see the infrastructure runbook section on horizontal pod autoscaling.</p>\n</blockquote>\n</blockquote>\n" +
|
||||
"<h2>Triggered Query</h2>\n" +
|
||||
"<pre><code class=\"language-promql\">avg(rate(container_cpu_usage_seconds_total{service="api-gateway"}[5m])) by (pod) > 0.9\n</code></pre>\n" +
|
||||
"<h2>Inline Details</h2>\n" +
|
||||
"<p>This alert was generated by SigNoz using <code>alertmanager</code> rules engine.</p>\n"
|
||||
|
||||
assert.Equal(t, expected, html)
|
||||
}
|
||||
|
||||
func TestRenderHTML_InlineFormatting(t *testing.T) {
|
||||
renderer := newTestRenderer()
|
||||
|
||||
input := `# 🔥 FIRING: High CPU on api-gateway
|
||||
## Alert Status
|
||||
|
||||
**FIRING** alert for *api-gateway* service — ~~resolved~~ previously.
|
||||
|
||||
Metric ` + "`cpu_usage_percent`" + ` exceeded threshold. [View in SigNoz](https://signoz.example.com/alerts/123)
|
||||
|
||||
`
|
||||
|
||||
html, err := renderer.Render(context.Background(), input, MarkdownFormatHTML)
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := "<h1>🔥 FIRING: High CPU on api-gateway</h1>\n<h2>Alert Status</h2>\n" +
|
||||
"<p><strong>FIRING</strong> alert for <em>api-gateway</em> service — <del>resolved</del> previously.</p>\n" +
|
||||
"<p>Metric <code>cpu_usage_percent</code> exceeded threshold. <a href=\"https://signoz.example.com/alerts/123\">View in SigNoz</a></p>\n" +
|
||||
"<p><img src=\"https://signoz.example.com/badges/critical.svg\" alt=\"critical\" title=\"Critical Alert\"></p>\n"
|
||||
|
||||
assert.Equal(t, expected, html)
|
||||
}
|
||||
|
||||
func TestRenderHTML_BlockElements(t *testing.T) {
|
||||
renderer := newTestRenderer()
|
||||
|
||||
input := `1. Check CPU usage on the pod
|
||||
2. Review recent deployments
|
||||
3. Scale horizontally if needed
|
||||
|
||||
* api-gateway
|
||||
* auth-service
|
||||
* payment-service
|
||||
|
||||
- [x] Alert acknowledged
|
||||
- [ ] Root cause identified
|
||||
|
||||
> This alert fires when CPU usage exceeds 90% for more than 5 minutes.
|
||||
|
||||
| Label | Value |
|
||||
| -------- | ----------- |
|
||||
| service | api-gateway |
|
||||
| severity | <no value> |
|
||||
|
||||
` + "```promql\navg(rate(container_cpu_usage_seconds_total{service=\"api-gateway\"}[5m])) by (pod) > 0.9\n```"
|
||||
|
||||
html, err := renderer.Render(context.Background(), input, MarkdownFormatHTML)
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := "<ol>\n<li>Check CPU usage on the pod</li>\n<li>Review recent deployments</li>\n<li>Scale horizontally if needed</li>\n</ol>\n" +
|
||||
"<ul>\n<li>api-gateway</li>\n<li>auth-service</li>\n<li>payment-service</li>\n</ul>\n" +
|
||||
"<ul>\n<li><input checked=\"\" disabled=\"\" type=\"checkbox\"> Alert acknowledged</li>\n" +
|
||||
"<li><input disabled=\"\" type=\"checkbox\"> Root cause identified</li>\n</ul>\n" +
|
||||
"<blockquote>\n<p>This alert fires when CPU usage exceeds 90% for more than 5 minutes.</p>\n</blockquote>\n" +
|
||||
"<table>\n<thead>\n<tr>\n<th>Label</th>\n<th>Value</th>\n</tr>\n</thead>\n" +
|
||||
"<tbody>\n<tr>\n<td>service</td>\n<td>api-gateway</td>\n</tr>\n" +
|
||||
"<tr>\n<td>severity</td>\n<td><no value></td>\n</tr>\n</tbody>\n</table>\n" +
|
||||
"<pre><code class=\"language-promql\">avg(rate(container_cpu_usage_seconds_total{service="api-gateway"}[5m])) by (pod) > 0.9\n</code></pre>\n"
|
||||
|
||||
assert.Equal(t, expected, html)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package markdownrenderer
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/templating/slackblockkitrenderer"
|
||||
"github.com/SigNoz/signoz/pkg/templating/slackmrkdwnrenderer"
|
||||
"github.com/SigNoz/signoz/pkg/templating/templatingextensions"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
)
|
||||
|
||||
// newHTMLRenderer creates a new goldmark.Markdown instance for HTML rendering.
|
||||
func newHTMLRenderer() goldmark.Markdown {
|
||||
return goldmark.New(
|
||||
goldmark.WithExtensions(extension.GFM),
|
||||
goldmark.WithExtensions(templatingextensions.EscapeNoValue),
|
||||
)
|
||||
}
|
||||
|
||||
// newSlackBlockKitRenderer creates a new goldmark.Markdown instance for Slack Block Kit rendering.
|
||||
func newSlackBlockKitRenderer() goldmark.Markdown {
|
||||
return goldmark.New(
|
||||
goldmark.WithExtensions(slackblockkitrenderer.BlockKitV2),
|
||||
)
|
||||
}
|
||||
|
||||
// newSlackMrkdwnRenderer creates a new goldmark.Markdown instance for Slack mrkdwn rendering.
|
||||
func newSlackMrkdwnRenderer() goldmark.Markdown {
|
||||
return goldmark.New(
|
||||
goldmark.WithExtensions(slackmrkdwnrenderer.SlackMrkdwn),
|
||||
)
|
||||
}
|
||||
|
||||
type OutputFormat int
|
||||
|
||||
const (
|
||||
MarkdownFormatHTML OutputFormat = iota
|
||||
MarkdownFormatSlackBlockKit
|
||||
MarkdownFormatSlackMrkdwn
|
||||
MarkdownFormatNoop
|
||||
)
|
||||
|
||||
// Renderer is the interface for rendering markdown to different formats.
|
||||
type Renderer interface {
|
||||
// Render renders the markdown to the given output format.
|
||||
Render(ctx context.Context, markdown string, outputFormat OutputFormat) (string, error)
|
||||
}
|
||||
|
||||
type renderer struct {
|
||||
}
|
||||
|
||||
func NewRenderer() Renderer {
|
||||
return &renderer{}
|
||||
}
|
||||
|
||||
func (r *renderer) Render(ctx context.Context, markdown string, outputFormat OutputFormat) (string, error) {
|
||||
switch outputFormat {
|
||||
case MarkdownFormatHTML:
|
||||
return r.renderHTML(ctx, markdown)
|
||||
case MarkdownFormatSlackBlockKit:
|
||||
return r.renderSlackBlockKit(ctx, markdown)
|
||||
case MarkdownFormatSlackMrkdwn:
|
||||
return r.renderSlackMrkdwn(ctx, markdown)
|
||||
case MarkdownFormatNoop:
|
||||
return markdown, nil
|
||||
default:
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown output format: %v", outputFormat)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package markdownrenderer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRenderNoop(t *testing.T) {
|
||||
renderer := newTestRenderer()
|
||||
|
||||
output, err := renderer.Render(context.Background(), testMarkdown, MarkdownFormatNoop)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testMarkdown, output)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package markdownrenderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
func (r *renderer) renderSlackBlockKit(_ context.Context, markdown string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := newSlackBlockKitRenderer().Convert([]byte(markdown), &buf); err != nil {
|
||||
return "", errors.WrapInternalf(err, errors.CodeInternal, "failed to convert markdown to Slack Block Kit")
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func (r *renderer) renderSlackMrkdwn(_ context.Context, markdown string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := newSlackMrkdwnRenderer().Convert([]byte(markdown), &buf); err != nil {
|
||||
return "", errors.WrapInternalf(err, errors.CodeInternal, "failed to convert markdown to Slack Mrkdwn")
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package markdownrenderer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func jsonEqual(a, b string) bool {
|
||||
var va, vb any
|
||||
if err := json.Unmarshal([]byte(a), &va); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal([]byte(b), &vb); err != nil {
|
||||
return false
|
||||
}
|
||||
ja, _ := json.Marshal(va)
|
||||
jb, _ := json.Marshal(vb)
|
||||
return string(ja) == string(jb)
|
||||
}
|
||||
|
||||
func prettyJSON(s string) string {
|
||||
var v any
|
||||
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
||||
return s
|
||||
}
|
||||
b, _ := json.MarshalIndent(v, "", " ")
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestRenderSlackBlockKit(t *testing.T) {
|
||||
renderer := NewRenderer()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
markdown string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple paragraph",
|
||||
markdown: "Hello world",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "Hello world" }
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "alert-themed with heading, list, and code block",
|
||||
markdown: `# Alert Triggered
|
||||
|
||||
- Service: **checkout-api**
|
||||
- Status: _critical_
|
||||
|
||||
` + "```" + `
|
||||
error: connection timeout after 30s
|
||||
` + "```",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "*Alert Triggered*" }
|
||||
},
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [
|
||||
{ "type": "text", "text": "Service: " },
|
||||
{ "type": "text", "text": "checkout-api", "style": { "bold": true } }
|
||||
]},
|
||||
{ "type": "rich_text_section", "elements": [
|
||||
{ "type": "text", "text": "Status: " },
|
||||
{ "type": "text", "text": "critical", "style": { "italic": true } }
|
||||
]}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_preformatted",
|
||||
"border": 0,
|
||||
"elements": [
|
||||
{ "type": "text", "text": "error: connection timeout after 30s" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := renderer.Render(context.Background(), tt.markdown, MarkdownFormatSlackBlockKit)
|
||||
if err != nil {
|
||||
t.Fatalf("Render error: %v", err)
|
||||
}
|
||||
|
||||
// Verify output is valid JSON
|
||||
if !json.Valid([]byte(got)) {
|
||||
t.Fatalf("output is not valid JSON:\n%s", got)
|
||||
}
|
||||
|
||||
if !jsonEqual(got, tt.expected) {
|
||||
t.Errorf("JSON mismatch\n\nMarkdown:\n%s\n\nExpected:\n%s\n\nGot:\n%s",
|
||||
tt.markdown, prettyJSON(tt.expected), prettyJSON(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSlackMrkdwn(t *testing.T) {
|
||||
renderer := NewRenderer()
|
||||
|
||||
markdown := `# Alert Triggered
|
||||
|
||||
- Service: **checkout-api**
|
||||
- Status: _critical_
|
||||
- Dashboard: [View Dashboard](https://example.com/dashboard)
|
||||
|
||||
| Metric | Value | Threshold |
|
||||
| --- | --- | --- |
|
||||
| Latency | 250ms | 100ms |
|
||||
| Error Rate | 5.2% | 1% |
|
||||
|
||||
` + "```" + `
|
||||
error: connection timeout after 30s
|
||||
` + "```"
|
||||
|
||||
expected := "*Alert Triggered*\n\n" +
|
||||
"• Service: *checkout-api*\n" +
|
||||
"• Status: _critical_\n" +
|
||||
"• Dashboard: <https://example.com/dashboard|View Dashboard>\n\n" +
|
||||
"```\nMetric | Value | Threshold\n-----------|-------|----------\nLatency | 250ms | 100ms \nError Rate | 5.2% | 1% \n```\n\n" +
|
||||
"```\nerror: connection timeout after 30s\n```\n\n"
|
||||
|
||||
got, err := renderer.Render(context.Background(), markdown, MarkdownFormatSlackMrkdwn)
|
||||
if err != nil {
|
||||
t.Fatalf("Render error: %v", err)
|
||||
}
|
||||
|
||||
if got != expected {
|
||||
t.Errorf("mrkdwn mismatch\n\nExpected:\n%q\n\nGot:\n%q", expected, got)
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package slackblockkitrenderer
|
||||
|
||||
import (
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
type blockKitV2 struct{}
|
||||
|
||||
// BlockKitV2 is a goldmark.Extender that configures the Slack Block Kit v2 renderer.
|
||||
var BlockKitV2 = &blockKitV2{}
|
||||
|
||||
// Extend implements goldmark.Extender.
|
||||
func (e *blockKitV2) Extend(m goldmark.Markdown) {
|
||||
extension.Table.Extend(m)
|
||||
extension.Strikethrough.Extend(m)
|
||||
extension.TaskList.Extend(m)
|
||||
m.Renderer().AddOptions(
|
||||
renderer.WithNodeRenderers(util.Prioritized(NewRenderer(), 1)),
|
||||
)
|
||||
}
|
||||
@@ -1,542 +0,0 @@
|
||||
package slackblockkitrenderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
)
|
||||
|
||||
func jsonEqual(a, b string) bool {
|
||||
var va, vb interface{}
|
||||
if err := json.Unmarshal([]byte(a), &va); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal([]byte(b), &vb); err != nil {
|
||||
return false
|
||||
}
|
||||
ja, _ := json.Marshal(va)
|
||||
jb, _ := json.Marshal(vb)
|
||||
return string(ja) == string(jb)
|
||||
}
|
||||
|
||||
func prettyJSON(s string) string {
|
||||
var v interface{}
|
||||
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
||||
return s
|
||||
}
|
||||
b, _ := json.MarshalIndent(v, "", " ")
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestRenderer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
markdown string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "empty input",
|
||||
markdown: "",
|
||||
expected: `[]`,
|
||||
},
|
||||
{
|
||||
name: "simple paragraph",
|
||||
markdown: "Hello world",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "Hello world" }
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "heading",
|
||||
markdown: "# My Heading",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "*My Heading*" }
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "multiple paragraphs",
|
||||
markdown: "First paragraph\n\nSecond paragraph",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "First paragraph\nSecond paragraph" }
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "todo list ",
|
||||
markdown: "- [ ] item 1\n- [x] item 2",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"border": 0,
|
||||
"elements": [
|
||||
{ "elements": [ { "text": "[ ] ", "type": "text" }, { "text": "item 1", "type": "text" } ], "type": "rich_text_section" },
|
||||
{ "elements": [ { "text": "[x] ", "type": "text" }, { "text": "item 2", "type": "text" } ], "type": "rich_text_section" }
|
||||
],
|
||||
"indent": 0,
|
||||
"style": "bullet",
|
||||
"type": "rich_text_list"
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "thematic break between paragraphs",
|
||||
markdown: "Before\n\n---\n\nAfter",
|
||||
expected: `[
|
||||
{ "type": "section", "text": { "type": "mrkdwn", "text": "Before" } },
|
||||
{ "type": "divider" },
|
||||
{ "type": "section", "text": { "type": "mrkdwn", "text": "After" } }
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "fenced code block with language",
|
||||
markdown: "```go\nfmt.Println(\"hello\")\n```",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_preformatted",
|
||||
"border": 0,
|
||||
"language": "go",
|
||||
"elements": [
|
||||
{ "type": "text", "text": "fmt.Println(\"hello\")" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "indented code block",
|
||||
markdown: " code line 1\n code line 2",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_preformatted",
|
||||
"border": 0,
|
||||
"elements": [
|
||||
{ "type": "text", "text": "code line 1\ncode line 2" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "empty fenced code block",
|
||||
markdown: "```\n```",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_preformatted",
|
||||
"border": 0,
|
||||
"elements": [
|
||||
{ "type": "text", "text": " " }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "simple bullet list",
|
||||
markdown: "- item 1\n- item 2\n- item 3",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 1" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 2" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 3" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "simple ordered list",
|
||||
markdown: "1. first\n2. second\n3. third",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "first" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "second" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "third" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "nested bullet list (2 levels)",
|
||||
markdown: "- item 1\n- item 2\n - sub a\n - sub b\n- item 3",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 1" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 2" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 1, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "sub a" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "sub b" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 3" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "nested ordered list with offset",
|
||||
markdown: "1. first\n 1. nested-a\n 2. nested-b\n2. second\n3. third",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "first" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rich_text_list", "style": "ordered", "indent": 1, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "nested-a" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "nested-b" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 1,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "second" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "third" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "mixed ordered/bullet nesting",
|
||||
markdown: "1. ordered\n - bullet child\n2. ordered again",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "ordered" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 1, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "bullet child" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 1,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "ordered again" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "list items with bold/italic/link/code",
|
||||
markdown: "- **bold item**\n- _italic item_\n- [link](http://example.com)\n- `code item`",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "bold item", "style": { "bold": true } }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "italic item", "style": { "italic": true } }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "link", "url": "http://example.com", "text": "link" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "code item", "style": { "code": true } }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "table with header and body",
|
||||
markdown: "| Name | Age |\n|------|-----|\n| Alice | 30 |",
|
||||
expected: `[
|
||||
{
|
||||
"type": "table",
|
||||
"rows": [
|
||||
[
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "Name", "style": { "bold": true } }] }
|
||||
]},
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "Age", "style": { "bold": true } }] }
|
||||
]}
|
||||
],
|
||||
[
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "Alice" }] }
|
||||
]},
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "30" }] }
|
||||
]}
|
||||
]
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "blockquote",
|
||||
markdown: "> quoted text",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "> quoted text" }
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "blockquote with nested list",
|
||||
markdown: "> item 1\n> > item 2\n> > item 3",
|
||||
expected: `[
|
||||
{
|
||||
"text": {
|
||||
"text": "> item 1\n> > item 2\n> > item 3",
|
||||
"type": "mrkdwn"
|
||||
},
|
||||
"type": "section"
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "inline formatting in paragraph",
|
||||
markdown: "This is **bold** and _italic_ and ~strike~ and `code`",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "This is *bold* and _italic_ and ~strike~ and ` + "`code`" + `" }
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "link in paragraph",
|
||||
markdown: "Visit [Google](http://google.com)",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "Visit <http://google.com|Google>" }
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "image is skipped",
|
||||
markdown: "",
|
||||
// For image skip the block and return empty array
|
||||
expected: `[]`,
|
||||
},
|
||||
{
|
||||
name: "paragraph then list then paragraph",
|
||||
markdown: "Before\n\n- item\n\nAfter",
|
||||
expected: `[
|
||||
{ "type": "section", "text": { "type": "mrkdwn", "text": "Before" } },
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "type": "section", "text": { "type": "mrkdwn", "text": "After" } }
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "ordered list with start > 1",
|
||||
markdown: "5. fifth\n6. sixth",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 4,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "fifth" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "sixth" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "deeply nested ordered list (3 levels) with offsets",
|
||||
markdown: "1. Some things\n\t1. are best left\n2. to the fate\n\t1. of the world\n\t\t1. and then\n\t\t2. this is how\n3. it turns out to be",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{ "type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0,
|
||||
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "Some things" }] }] },
|
||||
|
||||
{ "type": "rich_text_list", "style": "ordered", "indent": 1, "border": 0,
|
||||
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "are best left" }] }] },
|
||||
|
||||
{ "type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 1,
|
||||
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "to the fate" }] }] },
|
||||
|
||||
{ "type": "rich_text_list", "style": "ordered", "indent": 1, "border": 0,
|
||||
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "of the world" }] }] },
|
||||
|
||||
{ "type": "rich_text_list", "style": "ordered", "indent": 2, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "and then" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "this is how" }] }
|
||||
]
|
||||
},
|
||||
|
||||
{ "type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 2,
|
||||
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "it turns out to be" }] }] }
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "link with bold label in list item",
|
||||
markdown: "- [**docs**](http://example.com)",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "link", "url": "http://example.com", "text": "docs" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "table with empty cell",
|
||||
markdown: "| A | B |\n|---|---|\n| 1 | |",
|
||||
expected: `[
|
||||
{
|
||||
"type": "table",
|
||||
"rows": [
|
||||
[
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "A", "style": { "bold": true } }] }
|
||||
]},
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "B", "style": { "bold": true } }] }
|
||||
]}
|
||||
],
|
||||
[
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "1" }] }
|
||||
]},
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": " " }] }
|
||||
]}
|
||||
]
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "table with missing column in row",
|
||||
markdown: "| A | B |\n|---|---|\n| 1 |",
|
||||
expected: `[
|
||||
{
|
||||
"type": "table",
|
||||
"rows": [
|
||||
[
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "A", "style": { "bold": true } }] }
|
||||
]},
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "B", "style": { "bold": true } }] }
|
||||
]}
|
||||
],
|
||||
[
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "1" }] }
|
||||
]},
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": " " }] }
|
||||
]}
|
||||
]
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
md := goldmark.New(
|
||||
goldmark.WithExtensions(BlockKitV2),
|
||||
)
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert([]byte(tt.markdown), &buf); err != nil {
|
||||
t.Fatalf("convert error: %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
if !jsonEqual(got, tt.expected) {
|
||||
t.Errorf("JSON mismatch\n\nMarkdown:\n%s\n\nExpected:\n%s\n\nGot:\n%s",
|
||||
tt.markdown, prettyJSON(tt.expected), prettyJSON(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,737 +0,0 @@
|
||||
package slackblockkitrenderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark/ast"
|
||||
extensionast "github.com/yuin/goldmark/extension/ast"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// listFrame tracks state for a single level of list nesting.
|
||||
type listFrame struct {
|
||||
style string // "bullet" or "ordered"
|
||||
indent int
|
||||
itemCount int
|
||||
}
|
||||
|
||||
// listContext holds all state while processing a list tree.
|
||||
type listContext struct {
|
||||
result []RichTextList
|
||||
stack []listFrame
|
||||
current *RichTextList
|
||||
currentItemInlines []interface{}
|
||||
}
|
||||
|
||||
// tableContext holds state while processing a table.
|
||||
type tableContext struct {
|
||||
rows [][]TableCell
|
||||
currentRow []TableCell
|
||||
currentCellInlines []interface{}
|
||||
isHeader bool
|
||||
}
|
||||
|
||||
// Renderer converts Markdown AST to Slack Block Kit JSON.
|
||||
type Renderer struct {
|
||||
blocks []interface{}
|
||||
mrkdwn strings.Builder
|
||||
// holds active styles for the current rich text element
|
||||
styleStack []RichTextStyle
|
||||
// holds the current list context while processing a list tree.
|
||||
listCtx *listContext
|
||||
// holds the current table context while processing a table.
|
||||
tableCtx *tableContext
|
||||
// stores the current blockquote depth while processing a blockquote.
|
||||
// so blockquote with nested list can be rendered correctly.
|
||||
blockquoteDepth int
|
||||
}
|
||||
|
||||
// NewRenderer returns a new block kit renderer.
|
||||
func NewRenderer() renderer.NodeRenderer {
|
||||
return &Renderer{}
|
||||
}
|
||||
|
||||
// RegisterFuncs registers node rendering functions.
|
||||
func (r *Renderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||
// Blocks
|
||||
reg.Register(ast.KindDocument, r.renderDocument)
|
||||
reg.Register(ast.KindHeading, r.renderHeading)
|
||||
reg.Register(ast.KindParagraph, r.renderParagraph)
|
||||
reg.Register(ast.KindThematicBreak, r.renderThematicBreak)
|
||||
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
|
||||
reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock)
|
||||
reg.Register(ast.KindBlockquote, r.renderBlockquote)
|
||||
reg.Register(ast.KindList, r.renderList)
|
||||
reg.Register(ast.KindListItem, r.renderListItem)
|
||||
reg.Register(ast.KindImage, r.renderImage)
|
||||
|
||||
// Inlines
|
||||
reg.Register(ast.KindText, r.renderText)
|
||||
reg.Register(ast.KindEmphasis, r.renderEmphasis)
|
||||
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
|
||||
reg.Register(ast.KindLink, r.renderLink)
|
||||
|
||||
// Extensions
|
||||
reg.Register(extensionast.KindStrikethrough, r.renderStrikethrough)
|
||||
reg.Register(extensionast.KindTable, r.renderTable)
|
||||
reg.Register(extensionast.KindTableHeader, r.renderTableHeader)
|
||||
reg.Register(extensionast.KindTableRow, r.renderTableRow)
|
||||
reg.Register(extensionast.KindTableCell, r.renderTableCell)
|
||||
reg.Register(extensionast.KindTaskCheckBox, r.renderTaskCheckBox)
|
||||
}
|
||||
|
||||
// inRichTextMode returns true when we're inside a list or table context
|
||||
// in slack blockkit list and table items are rendered as rich_text elements
|
||||
// if more cases are found in future those needs to be added here.
|
||||
func (r *Renderer) inRichTextMode() bool {
|
||||
return r.listCtx != nil || r.tableCtx != nil
|
||||
}
|
||||
|
||||
// currentStyle merges the stored style stack into RichTextStyle
|
||||
// which can be applied on rich text elements.
|
||||
func (r *Renderer) currentStyle() *RichTextStyle {
|
||||
s := RichTextStyle{}
|
||||
for _, f := range r.styleStack {
|
||||
s.Bold = s.Bold || f.Bold
|
||||
s.Italic = s.Italic || f.Italic
|
||||
s.Strike = s.Strike || f.Strike
|
||||
s.Code = s.Code || f.Code
|
||||
}
|
||||
if s == (RichTextStyle{}) {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
// flushMrkdwn collects markdown text and adds it as a SectionBlock with mrkdwn text
|
||||
// whenever starting a new block we flush markdown to render it as a separate block.
|
||||
func (r *Renderer) flushMrkdwn() {
|
||||
text := strings.TrimSpace(r.mrkdwn.String())
|
||||
if text != "" {
|
||||
r.blocks = append(r.blocks, SectionBlock{
|
||||
Type: "section",
|
||||
Text: &TextObject{
|
||||
Type: "mrkdwn",
|
||||
Text: text,
|
||||
},
|
||||
})
|
||||
}
|
||||
r.mrkdwn.Reset()
|
||||
}
|
||||
|
||||
// addInline adds an inline element to the appropriate context.
|
||||
func (r *Renderer) addInline(el interface{}) {
|
||||
if r.listCtx != nil {
|
||||
r.listCtx.currentItemInlines = append(r.listCtx.currentItemInlines, el)
|
||||
} else if r.tableCtx != nil {
|
||||
r.tableCtx.currentCellInlines = append(r.tableCtx.currentCellInlines, el)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Document ---
|
||||
|
||||
func (r *Renderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.blocks = nil
|
||||
r.mrkdwn.Reset()
|
||||
r.styleStack = nil
|
||||
r.listCtx = nil
|
||||
r.tableCtx = nil
|
||||
r.blockquoteDepth = 0
|
||||
} else {
|
||||
// on exiting the document node write the json for the collected blocks.
|
||||
r.flushMrkdwn()
|
||||
var data []byte
|
||||
var err error
|
||||
if len(r.blocks) > 0 {
|
||||
data, err = json.Marshal(r.blocks)
|
||||
if err != nil {
|
||||
return ast.WalkStop, err
|
||||
}
|
||||
} else {
|
||||
// if no blocks are collected, write an empty array.
|
||||
data = []byte("[]")
|
||||
}
|
||||
_, err = w.Write(data)
|
||||
if err != nil {
|
||||
return ast.WalkStop, err
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Heading ---
|
||||
|
||||
func (r *Renderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.mrkdwn.WriteString("*")
|
||||
} else {
|
||||
r.mrkdwn.WriteString("*\n")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Paragraph ---
|
||||
|
||||
func (r *Renderer) renderParagraph(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
if r.mrkdwn.Len() > 0 {
|
||||
text := r.mrkdwn.String()
|
||||
if !strings.HasSuffix(text, "\n") {
|
||||
r.mrkdwn.WriteString("\n")
|
||||
}
|
||||
}
|
||||
// handling of nested blockquotes
|
||||
if r.blockquoteDepth > 0 {
|
||||
r.mrkdwn.WriteString(strings.Repeat("> ", r.blockquoteDepth))
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- ThematicBreak ---
|
||||
|
||||
func (r *Renderer) renderThematicBreak(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.flushMrkdwn()
|
||||
r.blocks = append(r.blocks, DividerBlock{Type: "divider"})
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- CodeBlock (indented) ---
|
||||
|
||||
func (r *Renderer) renderCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
r.flushMrkdwn()
|
||||
|
||||
var buf bytes.Buffer
|
||||
lines := node.Lines()
|
||||
for i := 0; i < lines.Len(); i++ {
|
||||
line := lines.At(i)
|
||||
buf.Write(line.Value(source))
|
||||
}
|
||||
|
||||
text := buf.String()
|
||||
// Remove trailing newline
|
||||
text = strings.TrimRight(text, "\n")
|
||||
// Slack API rejects empty text in rich_text_preformatted elements
|
||||
if text == "" {
|
||||
text = " "
|
||||
}
|
||||
|
||||
elements := []interface{}{
|
||||
RichTextInline{Type: "text", Text: text},
|
||||
}
|
||||
|
||||
r.blocks = append(r.blocks, RichTextBlock{
|
||||
Type: "rich_text",
|
||||
Elements: []interface{}{
|
||||
RichTextPreformatted{
|
||||
Type: "rich_text_preformatted",
|
||||
Elements: elements,
|
||||
Border: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- FencedCodeBlock ---
|
||||
|
||||
func (r *Renderer) renderFencedCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
r.flushMrkdwn()
|
||||
|
||||
n := node.(*ast.FencedCodeBlock)
|
||||
var buf bytes.Buffer
|
||||
lines := node.Lines()
|
||||
for i := 0; i < lines.Len(); i++ {
|
||||
line := lines.At(i)
|
||||
buf.Write(line.Value(source))
|
||||
}
|
||||
|
||||
text := buf.String()
|
||||
text = strings.TrimRight(text, "\n")
|
||||
// Slack API rejects empty text in rich_text_preformatted elements
|
||||
if text == "" {
|
||||
text = " "
|
||||
}
|
||||
|
||||
elements := []interface{}{
|
||||
RichTextInline{Type: "text", Text: text},
|
||||
}
|
||||
|
||||
// If language is specified, collect it.
|
||||
var language string
|
||||
lang := n.Language(source)
|
||||
if len(lang) > 0 {
|
||||
language = string(lang)
|
||||
}
|
||||
// Add the preformatted block to the blocks slice with the collected language.
|
||||
r.blocks = append(r.blocks, RichTextBlock{
|
||||
Type: "rich_text",
|
||||
Elements: []interface{}{
|
||||
RichTextPreformatted{
|
||||
Type: "rich_text_preformatted",
|
||||
Elements: elements,
|
||||
Border: 0,
|
||||
Language: language,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// --- Blockquote ---
|
||||
|
||||
func (r *Renderer) renderBlockquote(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.blockquoteDepth++
|
||||
} else {
|
||||
r.blockquoteDepth--
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- List ---
|
||||
|
||||
func (r *Renderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
list := node.(*ast.List)
|
||||
|
||||
if entering {
|
||||
style := "bullet"
|
||||
if list.IsOrdered() {
|
||||
style = "ordered"
|
||||
}
|
||||
|
||||
if r.listCtx == nil {
|
||||
// Top-level list: flush mrkdwn and create context
|
||||
r.flushMrkdwn()
|
||||
r.listCtx = &listContext{}
|
||||
} else {
|
||||
// Nested list: check if we already have some collected list items that needs to be flushed.
|
||||
// in slack blockkit, list items with different levels of indentation are added as different rich_text_list blocks.
|
||||
if len(r.listCtx.currentItemInlines) > 0 {
|
||||
sec := RichTextBlock{
|
||||
Type: "rich_text_section",
|
||||
Elements: r.listCtx.currentItemInlines,
|
||||
}
|
||||
if r.listCtx.current != nil {
|
||||
r.listCtx.current.Elements = append(r.listCtx.current.Elements, sec)
|
||||
}
|
||||
r.listCtx.currentItemInlines = nil
|
||||
// Increment parent's itemCount
|
||||
if len(r.listCtx.stack) > 0 {
|
||||
r.listCtx.stack[len(r.listCtx.stack)-1].itemCount++
|
||||
}
|
||||
}
|
||||
// Finalize current list to result only if items were collected
|
||||
if r.listCtx.current != nil && len(r.listCtx.current.Elements) > 0 {
|
||||
r.listCtx.result = append(r.listCtx.result, *r.listCtx.current)
|
||||
}
|
||||
}
|
||||
|
||||
// the stack accumulated till this level derives hte indentation
|
||||
// the stack get's collected as we go in more nested levels of list
|
||||
// and as we get our of the nesting we remove the items from the slack
|
||||
indent := len(r.listCtx.stack)
|
||||
r.listCtx.stack = append(r.listCtx.stack, listFrame{
|
||||
style: style,
|
||||
indent: indent,
|
||||
itemCount: 0,
|
||||
})
|
||||
|
||||
newList := &RichTextList{
|
||||
Type: "rich_text_list",
|
||||
Style: style,
|
||||
Indent: indent,
|
||||
Border: 0,
|
||||
Elements: []interface{}{},
|
||||
}
|
||||
|
||||
// Handle ordered list with start > 1
|
||||
if list.IsOrdered() && list.Start > 1 {
|
||||
newList.Offset = list.Start - 1
|
||||
}
|
||||
|
||||
r.listCtx.current = newList
|
||||
|
||||
} else {
|
||||
// Leaving list: finalize current list
|
||||
if r.listCtx.current != nil && len(r.listCtx.current.Elements) > 0 {
|
||||
r.listCtx.result = append(r.listCtx.result, *r.listCtx.current)
|
||||
}
|
||||
|
||||
// Pop stack to so upcoming indentations can be handled correctly.
|
||||
r.listCtx.stack = r.listCtx.stack[:len(r.listCtx.stack)-1]
|
||||
|
||||
if len(r.listCtx.stack) > 0 {
|
||||
// Resume parent: start a new list segment at parent indent/style
|
||||
parent := &r.listCtx.stack[len(r.listCtx.stack)-1]
|
||||
newList := &RichTextList{
|
||||
Type: "rich_text_list",
|
||||
Style: parent.style,
|
||||
Indent: parent.indent,
|
||||
Border: 0,
|
||||
Elements: []interface{}{},
|
||||
}
|
||||
// Set offset for ordered parent continuation
|
||||
if parent.style == "ordered" && parent.itemCount > 0 {
|
||||
newList.Offset = parent.itemCount
|
||||
}
|
||||
r.listCtx.current = newList
|
||||
} else {
|
||||
// Top-level list is done since all stack are popped: build RichTextBlock if non-empty
|
||||
if len(r.listCtx.result) > 0 {
|
||||
elements := make([]interface{}, len(r.listCtx.result))
|
||||
for i, l := range r.listCtx.result {
|
||||
elements[i] = l
|
||||
}
|
||||
r.blocks = append(r.blocks, RichTextBlock{
|
||||
Type: "rich_text",
|
||||
Elements: elements,
|
||||
})
|
||||
}
|
||||
r.listCtx = nil
|
||||
}
|
||||
}
|
||||
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- ListItem ---
|
||||
|
||||
func (r *Renderer) renderListItem(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.listCtx.currentItemInlines = nil
|
||||
} else {
|
||||
// Only add if there are inlines (might be empty after nested list consumed them)
|
||||
if len(r.listCtx.currentItemInlines) > 0 {
|
||||
sec := RichTextBlock{
|
||||
Type: "rich_text_section",
|
||||
Elements: r.listCtx.currentItemInlines,
|
||||
}
|
||||
if r.listCtx.current != nil {
|
||||
r.listCtx.current.Elements = append(r.listCtx.current.Elements, sec)
|
||||
}
|
||||
r.listCtx.currentItemInlines = nil
|
||||
// Increment parent frame's itemCount
|
||||
if len(r.listCtx.stack) > 0 {
|
||||
r.listCtx.stack[len(r.listCtx.stack)-1].itemCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Table ---
|
||||
// when table is encountered, we flush the markdown and create a table context.
|
||||
// when header row is encountered, we set the isHeader flag to true
|
||||
// when each row ends in renderTableRow we add that row to rows array of table context.
|
||||
// when table cell is encountered, we apply header related styles to the collected inline items,
|
||||
// all inline items are parsed as separate AST items like list item, links, text, etc. are collected
|
||||
// using the addInline function and wrapped in a rich_text_section block.
|
||||
|
||||
func (r *Renderer) renderTable(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.flushMrkdwn()
|
||||
r.tableCtx = &tableContext{}
|
||||
} else {
|
||||
// Pad short rows to match header column count for valid Block Kit payload
|
||||
// without this slack blockkit attachment is invalid and the API fails
|
||||
rows := r.tableCtx.rows
|
||||
if len(rows) > 0 {
|
||||
maxCols := len(rows[0])
|
||||
for i, row := range rows {
|
||||
for len(row) < maxCols {
|
||||
emptySec := RichTextBlock{
|
||||
Type: "rich_text_section",
|
||||
Elements: []interface{}{RichTextInline{Type: "text", Text: " "}},
|
||||
}
|
||||
row = append(row, TableCell{
|
||||
Type: "rich_text",
|
||||
Elements: []interface{}{emptySec},
|
||||
})
|
||||
}
|
||||
rows[i] = row
|
||||
}
|
||||
}
|
||||
r.blocks = append(r.blocks, TableBlock{
|
||||
Type: "table",
|
||||
Rows: rows,
|
||||
})
|
||||
r.tableCtx = nil
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderTableHeader(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.tableCtx.isHeader = true
|
||||
r.tableCtx.currentRow = nil
|
||||
} else {
|
||||
r.tableCtx.rows = append(r.tableCtx.rows, r.tableCtx.currentRow)
|
||||
r.tableCtx.currentRow = nil
|
||||
r.tableCtx.isHeader = false
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderTableRow(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.tableCtx.currentRow = nil
|
||||
} else {
|
||||
r.tableCtx.rows = append(r.tableCtx.rows, r.tableCtx.currentRow)
|
||||
r.tableCtx.currentRow = nil
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderTableCell(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.tableCtx.currentCellInlines = nil
|
||||
} else {
|
||||
// If header, make text bold for the collected inline items.
|
||||
if r.tableCtx.isHeader {
|
||||
for i, el := range r.tableCtx.currentCellInlines {
|
||||
if inline, ok := el.(RichTextInline); ok {
|
||||
if inline.Style == nil {
|
||||
inline.Style = &RichTextStyle{Bold: true}
|
||||
} else {
|
||||
inline.Style.Bold = true
|
||||
}
|
||||
r.tableCtx.currentCellInlines[i] = inline
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ensure cell has at least one element for valid Block Kit payload
|
||||
if len(r.tableCtx.currentCellInlines) == 0 {
|
||||
r.tableCtx.currentCellInlines = []interface{}{
|
||||
RichTextInline{Type: "text", Text: " "},
|
||||
}
|
||||
}
|
||||
// All inline items that are collected for a table cell are wrapped in a rich_text_section block.
|
||||
sec := RichTextBlock{
|
||||
Type: "rich_text_section",
|
||||
Elements: r.tableCtx.currentCellInlines,
|
||||
}
|
||||
// The rich_text_section block is wrapped in a rich_text block.
|
||||
cell := TableCell{
|
||||
Type: "rich_text",
|
||||
Elements: []interface{}{sec},
|
||||
}
|
||||
r.tableCtx.currentRow = append(r.tableCtx.currentRow, cell)
|
||||
r.tableCtx.currentCellInlines = nil
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- TaskCheckBox ---
|
||||
|
||||
func (r *Renderer) renderTaskCheckBox(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*extensionast.TaskCheckBox)
|
||||
text := "[ ] "
|
||||
if n.IsChecked {
|
||||
text = "[x] "
|
||||
}
|
||||
if r.inRichTextMode() {
|
||||
r.addInline(RichTextInline{Type: "text", Text: text})
|
||||
} else {
|
||||
r.mrkdwn.WriteString(text)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Inline: Text ---
|
||||
|
||||
func (r *Renderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*ast.Text)
|
||||
value := string(n.Segment.Value(source))
|
||||
|
||||
if r.inRichTextMode() {
|
||||
r.addInline(RichTextInline{
|
||||
Type: "text",
|
||||
Text: value,
|
||||
Style: r.currentStyle(),
|
||||
})
|
||||
if n.HardLineBreak() || n.SoftLineBreak() {
|
||||
r.addInline(RichTextInline{Type: "text", Text: "\n"})
|
||||
}
|
||||
} else {
|
||||
r.mrkdwn.WriteString(value)
|
||||
if n.HardLineBreak() || n.SoftLineBreak() {
|
||||
r.mrkdwn.WriteString("\n")
|
||||
if r.blockquoteDepth > 0 {
|
||||
r.mrkdwn.WriteString(strings.Repeat("> ", r.blockquoteDepth))
|
||||
}
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Inline: Emphasis ---
|
||||
|
||||
func (r *Renderer) renderEmphasis(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
n := node.(*ast.Emphasis)
|
||||
if r.inRichTextMode() {
|
||||
if entering {
|
||||
s := RichTextStyle{}
|
||||
if n.Level == 1 {
|
||||
s.Italic = true
|
||||
} else {
|
||||
s.Bold = true
|
||||
}
|
||||
r.styleStack = append(r.styleStack, s)
|
||||
} else {
|
||||
// the collected style gets used by the rich text element using currentStyle()
|
||||
// so we remove this style from the stack.
|
||||
if len(r.styleStack) > 0 {
|
||||
r.styleStack = r.styleStack[:len(r.styleStack)-1]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if n.Level == 1 {
|
||||
r.mrkdwn.WriteString("_")
|
||||
} else {
|
||||
r.mrkdwn.WriteString("*")
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Inline: Strikethrough ---
|
||||
|
||||
func (r *Renderer) renderStrikethrough(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if r.inRichTextMode() {
|
||||
if entering {
|
||||
r.styleStack = append(r.styleStack, RichTextStyle{Strike: true})
|
||||
} else {
|
||||
// the collected style gets used by the rich text element using currentStyle()
|
||||
// so we remove this style from the stack.
|
||||
if len(r.styleStack) > 0 {
|
||||
r.styleStack = r.styleStack[:len(r.styleStack)-1]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
r.mrkdwn.WriteString("~")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Inline: CodeSpan ---
|
||||
|
||||
func (r *Renderer) renderCodeSpan(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
if r.inRichTextMode() {
|
||||
// Collect all child text
|
||||
var buf bytes.Buffer
|
||||
for c := node.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
if t, ok := c.(*ast.Text); ok {
|
||||
v := t.Segment.Value(source)
|
||||
if bytes.HasSuffix(v, []byte("\n")) {
|
||||
buf.Write(v[:len(v)-1])
|
||||
buf.WriteByte(' ')
|
||||
} else {
|
||||
buf.Write(v)
|
||||
}
|
||||
} else if s, ok := c.(*ast.String); ok {
|
||||
buf.Write(s.Value)
|
||||
}
|
||||
}
|
||||
style := r.currentStyle()
|
||||
if style == nil {
|
||||
style = &RichTextStyle{Code: true}
|
||||
} else {
|
||||
style.Code = true
|
||||
}
|
||||
r.addInline(RichTextInline{
|
||||
Type: "text",
|
||||
Text: buf.String(),
|
||||
Style: style,
|
||||
})
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
// mrkdwn mode
|
||||
r.mrkdwn.WriteByte('`')
|
||||
for c := node.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
if t, ok := c.(*ast.Text); ok {
|
||||
v := t.Segment.Value(source)
|
||||
if bytes.HasSuffix(v, []byte("\n")) {
|
||||
r.mrkdwn.Write(v[:len(v)-1])
|
||||
r.mrkdwn.WriteByte(' ')
|
||||
} else {
|
||||
r.mrkdwn.Write(v)
|
||||
}
|
||||
} else if s, ok := c.(*ast.String); ok {
|
||||
r.mrkdwn.Write(s.Value)
|
||||
}
|
||||
}
|
||||
r.mrkdwn.WriteByte('`')
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// --- Inline: Link ---
|
||||
|
||||
func (r *Renderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
n := node.(*ast.Link)
|
||||
if r.inRichTextMode() {
|
||||
if entering {
|
||||
// Walk the entire subtree to collect text from all descendants,
|
||||
// including nested inline nodes like emphasis, strong, code spans, etc.
|
||||
var buf bytes.Buffer
|
||||
_ = ast.Walk(node, func(child ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering || child == node {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
if t, ok := child.(*ast.Text); ok {
|
||||
buf.Write(t.Segment.Value(source))
|
||||
} else if s, ok := child.(*ast.String); ok {
|
||||
buf.Write(s.Value)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
// Once we've collected the text for the link (given it was present)
|
||||
// let's add the link to the rich text block.
|
||||
r.addInline(RichTextLink{
|
||||
Type: "link",
|
||||
URL: string(n.Destination),
|
||||
Text: buf.String(),
|
||||
Style: r.currentStyle(),
|
||||
})
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
} else {
|
||||
if entering {
|
||||
r.mrkdwn.WriteString("<")
|
||||
r.mrkdwn.Write(n.Destination)
|
||||
r.mrkdwn.WriteString("|")
|
||||
} else {
|
||||
r.mrkdwn.WriteString(">")
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Image (skip) ---
|
||||
|
||||
func (r *Renderer) renderImage(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package slackblockkitrenderer
|
||||
|
||||
// SectionBlock represents a Slack section block with mrkdwn text.
|
||||
type SectionBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text *TextObject `json:"text"`
|
||||
}
|
||||
|
||||
// DividerBlock represents a Slack divider block.
|
||||
type DividerBlock struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// RichTextBlock is a container for rich text elements (lists, code blocks, table and cell blocks).
|
||||
type RichTextBlock struct {
|
||||
Type string `json:"type"`
|
||||
Elements []interface{} `json:"elements"`
|
||||
}
|
||||
|
||||
// TableBlock represents a Slack table rendered as a rich_text block with preformatted text.
|
||||
type TableBlock struct {
|
||||
Type string `json:"type"`
|
||||
Rows [][]TableCell `json:"rows"`
|
||||
}
|
||||
|
||||
// TableCell is a cell in a table block.
|
||||
type TableCell struct {
|
||||
Type string `json:"type"`
|
||||
Elements []interface{} `json:"elements"`
|
||||
}
|
||||
|
||||
// TextObject is the text field inside a SectionBlock.
|
||||
type TextObject struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// RichTextList represents an ordered or unordered list.
|
||||
type RichTextList struct {
|
||||
Type string `json:"type"`
|
||||
Style string `json:"style"`
|
||||
Indent int `json:"indent"`
|
||||
Border int `json:"border"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
Elements []interface{} `json:"elements"`
|
||||
}
|
||||
|
||||
// RichTextPreformatted represents a code block.
|
||||
type RichTextPreformatted struct {
|
||||
Type string `json:"type"`
|
||||
Elements []interface{} `json:"elements"`
|
||||
Border int `json:"border"`
|
||||
Language string `json:"language,omitempty"`
|
||||
}
|
||||
|
||||
// RichTextInline represents inline text with optional styling
|
||||
// ex: text inside list, table cell
|
||||
type RichTextInline struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Style *RichTextStyle `json:"style,omitempty"`
|
||||
}
|
||||
|
||||
// RichTextLink represents a link inside rich text
|
||||
// ex: link inside list, table cell
|
||||
type RichTextLink struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Style *RichTextStyle `json:"style,omitempty"`
|
||||
}
|
||||
|
||||
// RichTextStyle holds boolean style flags for inline elements
|
||||
// these bools can toggle different styles for a rich text element at once.
|
||||
type RichTextStyle struct {
|
||||
Bold bool `json:"bold,omitempty"`
|
||||
Italic bool `json:"italic,omitempty"`
|
||||
Strike bool `json:"strike,omitempty"`
|
||||
Code bool `json:"code,omitempty"`
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package slackmrkdwnrenderer
|
||||
|
||||
import (
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
type slackMrkdwn struct{}
|
||||
|
||||
// SlackMrkdwn is a goldmark.Extender that configures the Slack mrkdwn renderer.
|
||||
var SlackMrkdwn = &slackMrkdwn{}
|
||||
|
||||
// Extend implements goldmark.Extender.
|
||||
func (e *slackMrkdwn) Extend(m goldmark.Markdown) {
|
||||
extension.Table.Extend(m)
|
||||
extension.Strikethrough.Extend(m)
|
||||
m.Renderer().AddOptions(
|
||||
renderer.WithNodeRenderers(util.Prioritized(NewRenderer(), 1)),
|
||||
)
|
||||
}
|
||||
@@ -1,383 +0,0 @@
|
||||
package slackmrkdwnrenderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/yuin/goldmark/ast"
|
||||
extensionast "github.com/yuin/goldmark/extension/ast"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// Renderer renders nodes as Slack mrkdwn.
|
||||
type Renderer struct {
|
||||
prefixes []string
|
||||
}
|
||||
|
||||
// NewRenderer returns a new Renderer with given options.
|
||||
func NewRenderer() renderer.NodeRenderer {
|
||||
return &Renderer{}
|
||||
}
|
||||
|
||||
// RegisterFuncs implements NodeRenderer.RegisterFuncs.
|
||||
func (r *Renderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||
// Blocks
|
||||
reg.Register(ast.KindDocument, r.renderDocument)
|
||||
reg.Register(ast.KindHeading, r.renderHeading)
|
||||
reg.Register(ast.KindBlockquote, r.renderBlockquote)
|
||||
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
|
||||
reg.Register(ast.KindFencedCodeBlock, r.renderCodeBlock)
|
||||
reg.Register(ast.KindList, r.renderList)
|
||||
reg.Register(ast.KindListItem, r.renderListItem)
|
||||
reg.Register(ast.KindParagraph, r.renderParagraph)
|
||||
reg.Register(ast.KindTextBlock, r.renderTextBlock)
|
||||
reg.Register(ast.KindRawHTML, r.renderRawHTML)
|
||||
reg.Register(ast.KindThematicBreak, r.renderThematicBreak)
|
||||
|
||||
// Inlines
|
||||
reg.Register(ast.KindAutoLink, r.renderAutoLink)
|
||||
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
|
||||
reg.Register(ast.KindEmphasis, r.renderEmphasis)
|
||||
reg.Register(ast.KindImage, r.renderImage)
|
||||
reg.Register(ast.KindLink, r.renderLink)
|
||||
reg.Register(ast.KindText, r.renderText)
|
||||
|
||||
// Extensions
|
||||
reg.Register(extensionast.KindStrikethrough, r.renderStrikethrough)
|
||||
reg.Register(extensionast.KindTable, r.renderTable)
|
||||
}
|
||||
|
||||
func (r *Renderer) writePrefix(w util.BufWriter) {
|
||||
for _, p := range r.prefixes {
|
||||
_, _ = w.WriteString(p)
|
||||
}
|
||||
}
|
||||
|
||||
// writeLineSeparator writes a newline followed by the current prefix.
|
||||
// Used for tight separations (e.g., between list items or text blocks).
|
||||
func (r *Renderer) writeLineSeparator(w util.BufWriter) {
|
||||
_ = w.WriteByte('\n')
|
||||
r.writePrefix(w)
|
||||
}
|
||||
|
||||
// writeBlockSeparator writes a blank line separator between block-level elements,
|
||||
// respecting any active prefixes for proper nesting (e.g., inside blockquotes).
|
||||
func (r *Renderer) writeBlockSeparator(w util.BufWriter) {
|
||||
r.writeLineSeparator(w)
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
|
||||
// separateFromPrevious writes a block separator if the node has a previous sibling.
|
||||
func (r *Renderer) separateFromPrevious(w util.BufWriter, n ast.Node) {
|
||||
if n.PreviousSibling() != nil {
|
||||
r.writeBlockSeparator(w)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Renderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
_, _ = w.WriteString("\n\n")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, node)
|
||||
}
|
||||
_, _ = w.WriteString("*")
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderBlockquote(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, n)
|
||||
r.prefixes = append(r.prefixes, "> ")
|
||||
_, _ = w.WriteString("> ")
|
||||
} else {
|
||||
r.prefixes = r.prefixes[:len(r.prefixes)-1]
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderCodeBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, n)
|
||||
// start code block and write code line by line
|
||||
_, _ = w.WriteString("```\n")
|
||||
l := n.Lines().Len()
|
||||
for i := 0; i < l; i++ {
|
||||
line := n.Lines().At(i)
|
||||
v := line.Value(source)
|
||||
_, _ = w.Write(v)
|
||||
}
|
||||
} else {
|
||||
_, _ = w.WriteString("```")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
if node.PreviousSibling() != nil {
|
||||
r.writeLineSeparator(w)
|
||||
// another line break if not a nested list item and starting another block
|
||||
if node.Parent() == nil || node.Parent().Kind() != ast.KindListItem {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderListItem(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
if n.PreviousSibling() != nil {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
parent := n.Parent().(*ast.List)
|
||||
// compute and write the prefix based on list type and index
|
||||
var prefixStr string
|
||||
if parent.IsOrdered() {
|
||||
index := parent.Start
|
||||
for c := parent.FirstChild(); c != nil && c != n; c = c.NextSibling() {
|
||||
index++
|
||||
}
|
||||
prefixStr = fmt.Sprintf("%d. ", index)
|
||||
} else {
|
||||
prefixStr = "• "
|
||||
}
|
||||
_, _ = w.WriteString(prefixStr)
|
||||
r.prefixes = append(r.prefixes, "\t") // add tab for nested list items
|
||||
} else {
|
||||
r.prefixes = r.prefixes[:len(r.prefixes)-1]
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderParagraph(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, n)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderTextBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering && n.PreviousSibling() != nil {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderRawHTML(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
n := n.(*ast.RawHTML)
|
||||
l := n.Segments.Len()
|
||||
for i := 0; i < l; i++ {
|
||||
segment := n.Segments.At(i)
|
||||
_, _ = w.Write(segment.Value(source))
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderThematicBreak(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, n)
|
||||
_, _ = w.WriteString("---")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderAutoLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*ast.AutoLink)
|
||||
url := string(n.URL(source))
|
||||
label := string(n.Label(source))
|
||||
|
||||
if n.AutoLinkType == ast.AutoLinkEmail && !strings.HasPrefix(strings.ToLower(url), "mailto:") {
|
||||
url = "mailto:" + url
|
||||
}
|
||||
|
||||
if url == label {
|
||||
_, _ = fmt.Fprintf(w, "<%s>", url)
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(w, "<%s|%s>", url, label)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderCodeSpan(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
_ = w.WriteByte('`')
|
||||
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
segment := c.(*ast.Text).Segment
|
||||
value := segment.Value(source)
|
||||
if bytes.HasSuffix(value, []byte("\n")) { // replace newline with space
|
||||
_, _ = w.Write(value[:len(value)-1])
|
||||
_ = w.WriteByte(' ')
|
||||
} else {
|
||||
_, _ = w.Write(value)
|
||||
}
|
||||
}
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
_ = w.WriteByte('`')
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderEmphasis(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
n := node.(*ast.Emphasis)
|
||||
mark := "_"
|
||||
if n.Level == 2 {
|
||||
mark = "*"
|
||||
}
|
||||
_, _ = w.WriteString(mark)
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
n := node.(*ast.Link)
|
||||
if entering {
|
||||
_, _ = w.WriteString("<")
|
||||
_, _ = w.Write(util.URLEscape(n.Destination, true))
|
||||
_, _ = w.WriteString("|")
|
||||
} else {
|
||||
_, _ = w.WriteString(">")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderImage(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*ast.Image)
|
||||
_, _ = w.WriteString("<")
|
||||
_, _ = w.Write(util.URLEscape(n.Destination, true))
|
||||
_, _ = w.WriteString("|")
|
||||
|
||||
// Write the alt text directly
|
||||
var altBuf bytes.Buffer
|
||||
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
if textNode, ok := c.(*ast.Text); ok {
|
||||
altBuf.Write(textNode.Segment.Value(source))
|
||||
}
|
||||
}
|
||||
_, _ = w.Write(altBuf.Bytes())
|
||||
|
||||
_, _ = w.WriteString(">")
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*ast.Text)
|
||||
segment := n.Segment
|
||||
value := segment.Value(source)
|
||||
_, _ = w.Write(value)
|
||||
if n.HardLineBreak() || n.SoftLineBreak() {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderStrikethrough(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
_, _ = w.WriteString("~")
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderTable(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
r.separateFromPrevious(w, node)
|
||||
|
||||
// Collect cells and max widths
|
||||
var rows [][]string
|
||||
var colWidths []int
|
||||
|
||||
for c := node.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
if c.Kind() == extensionast.KindTableHeader || c.Kind() == extensionast.KindTableRow {
|
||||
var row []string
|
||||
colIdx := 0
|
||||
for cc := c.FirstChild(); cc != nil; cc = cc.NextSibling() {
|
||||
if cc.Kind() == extensionast.KindTableCell {
|
||||
cellText := extractPlainText(cc, source)
|
||||
row = append(row, cellText)
|
||||
runeLen := utf8.RuneCountInString(cellText)
|
||||
if colIdx >= len(colWidths) {
|
||||
colWidths = append(colWidths, runeLen)
|
||||
} else if runeLen > colWidths[colIdx] {
|
||||
colWidths[colIdx] = runeLen
|
||||
}
|
||||
colIdx++
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
|
||||
// writing table in code block
|
||||
_, _ = w.WriteString("```\n")
|
||||
for i, row := range rows {
|
||||
for colIdx, cellText := range row {
|
||||
width := 0
|
||||
if colIdx < len(colWidths) {
|
||||
width = colWidths[colIdx]
|
||||
}
|
||||
runeLen := utf8.RuneCountInString(cellText)
|
||||
padding := max(0, width-runeLen)
|
||||
|
||||
_, _ = w.WriteString(cellText)
|
||||
_, _ = w.WriteString(strings.Repeat(" ", padding))
|
||||
if colIdx < len(row)-1 {
|
||||
_, _ = w.WriteString(" | ")
|
||||
}
|
||||
}
|
||||
_ = w.WriteByte('\n')
|
||||
|
||||
// Print separator after header
|
||||
if i == 0 {
|
||||
for colIdx := range row {
|
||||
width := 0
|
||||
if colIdx < len(colWidths) {
|
||||
width = colWidths[colIdx]
|
||||
}
|
||||
_, _ = w.WriteString(strings.Repeat("-", width))
|
||||
if colIdx < len(row)-1 {
|
||||
_, _ = w.WriteString("-|-")
|
||||
}
|
||||
}
|
||||
_ = w.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
_, _ = w.WriteString("```")
|
||||
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// extractPlainText extracts all the text content from the given node.
|
||||
func extractPlainText(n ast.Node, source []byte) string {
|
||||
var buf bytes.Buffer
|
||||
_ = ast.Walk(n, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
if textNode, ok := node.(*ast.Text); ok {
|
||||
buf.Write(textNode.Segment.Value(source))
|
||||
} else if strNode, ok := node.(*ast.String); ok {
|
||||
buf.Write(strNode.Value)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
return strings.TrimSpace(buf.String())
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package slackmrkdwnrenderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
)
|
||||
|
||||
func TestRenderer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
markdown string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Heading with Thematic Break",
|
||||
markdown: "# Title 1\n# Hello World\n---\nthis is sometext",
|
||||
expected: "*Title 1*\n\n*Hello World*\n\n---\n\nthis is sometext\n\n",
|
||||
},
|
||||
{
|
||||
name: "Blockquote",
|
||||
markdown: "> This is a quote\n> It continues",
|
||||
expected: "> This is a quote\n> It continues\n\n",
|
||||
},
|
||||
{
|
||||
name: "Fenced Code Block",
|
||||
markdown: "```go\npackage main\nfunc main() {}\n```",
|
||||
expected: "```\npackage main\nfunc main() {}\n```\n\n",
|
||||
},
|
||||
{
|
||||
name: "Unordered List",
|
||||
markdown: "- item 1\n- item 2\n- item 3",
|
||||
expected: "• item 1\n• item 2\n• item 3\n\n",
|
||||
},
|
||||
{
|
||||
name: "nested unordered list",
|
||||
markdown: "- item 1\n- item 2\n\t- item 2.1\n\t\t- item 2.1.1\n\t\t- item 2.1.2\n\t- item 2.2\n- item 3",
|
||||
expected: "• item 1\n• item 2\n\t• item 2.1\n\t\t• item 2.1.1\n\t\t• item 2.1.2\n\t• item 2.2\n• item 3\n\n",
|
||||
},
|
||||
{
|
||||
name: "Ordered List",
|
||||
markdown: "1. item 1\n2. item 2\n3. item 3",
|
||||
expected: "1. item 1\n2. item 2\n3. item 3\n\n",
|
||||
},
|
||||
{
|
||||
name: "nested ordered list",
|
||||
markdown: "1. item 1\n2. item 2\n\t1. item 2.1\n\t\t1. item 2.1.1\n\t\t2. item 2.1.2\n\t2. item 2.2\n\t3. item 2.3\n3. item 3\n4. item 4",
|
||||
expected: "1. item 1\n2. item 2\n\t1. item 2.1\n\t\t1. item 2.1.1\n\t\t2. item 2.1.2\n\t2. item 2.2\n\t3. item 2.3\n3. item 3\n4. item 4\n\n",
|
||||
},
|
||||
{
|
||||
name: "Links and AutoLinks",
|
||||
markdown: "This is a [link](https://example.com) and an autolink <https://test.com>",
|
||||
expected: "This is a <https://example.com|link> and an autolink <https://test.com>\n\n",
|
||||
},
|
||||
{
|
||||
name: "Images",
|
||||
markdown: "An image ",
|
||||
expected: "An image <https://example.com/image.png|alt text>\n\n",
|
||||
},
|
||||
{
|
||||
name: "Emphasis",
|
||||
markdown: "This is **bold** and *italic* and __bold__ and _italic_",
|
||||
expected: "This is *bold* and _italic_ and *bold* and _italic_\n\n",
|
||||
},
|
||||
{
|
||||
name: "Strikethrough",
|
||||
markdown: "This is ~~strike~~",
|
||||
expected: "This is ~strike~\n\n",
|
||||
},
|
||||
{
|
||||
name: "Code Span",
|
||||
markdown: "This is `inline code` embedded.",
|
||||
expected: "This is `inline code` embedded.\n\n",
|
||||
},
|
||||
{
|
||||
name: "Table",
|
||||
markdown: "Col 1 | Col 2 | Col 3\n--- | --- | ---\nVal 1 | Long Value 2 | 3\nShort | V | 1000",
|
||||
expected: "```\nCol 1 | Col 2 | Col 3\n------|--------------|------\nVal 1 | Long Value 2 | 3 \nShort | V | 1000 \n```\n\n",
|
||||
},
|
||||
{
|
||||
name: "Mixed Nested Lists",
|
||||
markdown: "1. first\n\t- nested bullet\n\t- another bullet\n2. second",
|
||||
expected: "1. first\n\t• nested bullet\n\t• another bullet\n2. second\n\n",
|
||||
},
|
||||
{
|
||||
name: "Email AutoLink",
|
||||
markdown: "<user@example.com>",
|
||||
expected: "<mailto:user@example.com|user@example.com>\n\n",
|
||||
},
|
||||
{
|
||||
name: "No value string parsed as is",
|
||||
markdown: "Service: <no value>",
|
||||
expected: "Service: <no value>\n\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
md := goldmark.New(goldmark.WithExtensions(SlackMrkdwn))
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert([]byte(tt.markdown), &buf); err != nil {
|
||||
t.Fatalf("failed to convert: %v", err)
|
||||
}
|
||||
|
||||
// Do exact string matching
|
||||
actual := buf.String()
|
||||
if actual != tt.expected {
|
||||
t.Errorf("\nExpected:\n%q\nGot:\n%q\nRaw Expected:\n%s\nRaw Got:\n%s",
|
||||
tt.expected, actual, tt.expected, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package templatingextensions
|
||||
|
||||
import (
|
||||
"github.com/yuin/goldmark"
|
||||
gast "github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// NoValueHTMLRenderer is a renderer.NodeRenderer implementation that
|
||||
// renders <no value> as escaped visible text instead of omitting it.
|
||||
type NoValueHTMLRenderer struct {
|
||||
html.Config
|
||||
}
|
||||
|
||||
// NewNoValueHTMLRenderer returns a new NoValueHTMLRenderer.
|
||||
func NewNoValueHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
|
||||
r := &NoValueHTMLRenderer{
|
||||
Config: html.NewConfig(),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt.SetHTMLOption(&r.Config)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs.
|
||||
func (r *NoValueHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||
reg.Register(gast.KindRawHTML, r.renderRawHTML)
|
||||
}
|
||||
|
||||
func (r *NoValueHTMLRenderer) renderRawHTML(
|
||||
w util.BufWriter, source []byte, node gast.Node, entering bool) (gast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return gast.WalkSkipChildren, nil
|
||||
}
|
||||
if r.Unsafe {
|
||||
n := node.(*gast.RawHTML)
|
||||
for i := 0; i < n.Segments.Len(); i++ {
|
||||
segment := n.Segments.At(i)
|
||||
_, _ = w.Write(segment.Value(source))
|
||||
}
|
||||
return gast.WalkSkipChildren, nil
|
||||
}
|
||||
n := node.(*gast.RawHTML)
|
||||
raw := string(n.Segments.Value(source))
|
||||
if raw == "<no value>" {
|
||||
_, _ = w.WriteString("<no value>")
|
||||
return gast.WalkSkipChildren, nil
|
||||
}
|
||||
_, _ = w.WriteString("<!-- raw HTML omitted -->")
|
||||
return gast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
type escapeNoValue struct{}
|
||||
|
||||
// EscapeNoValue is an extension that renders <no value> as visible
|
||||
// escaped text instead of omitting it as raw HTML.
|
||||
var EscapeNoValue = &escapeNoValue{}
|
||||
|
||||
func (e *escapeNoValue) Extend(m goldmark.Markdown) {
|
||||
m.Renderer().AddOptions(renderer.WithNodeRenderers(
|
||||
util.Prioritized(NewNoValueHTMLRenderer(), 500),
|
||||
))
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package templatingextensions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
)
|
||||
|
||||
func TestEscapeNoValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
markdown string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "plain text",
|
||||
markdown: "Service: <no value>",
|
||||
expected: "<p>Service: <no value></p>\n",
|
||||
},
|
||||
{
|
||||
name: "inside strong",
|
||||
markdown: "Service: **<no value>**",
|
||||
expected: "<p>Service: <strong><no value></strong></p>\n",
|
||||
},
|
||||
{
|
||||
name: "inside emphasis",
|
||||
markdown: "Service: *<no value>*",
|
||||
expected: "<p>Service: <em><no value></em></p>\n",
|
||||
},
|
||||
{
|
||||
name: "inside strikethrough",
|
||||
markdown: "Service: ~~<no value>~~",
|
||||
expected: "<p>Service: <del><no value></del></p>\n",
|
||||
},
|
||||
{
|
||||
name: "real html still omitted",
|
||||
markdown: "hello <div>world</div>",
|
||||
expected: "<p>hello <!-- raw HTML omitted -->world<!-- raw HTML omitted --></p>\n",
|
||||
},
|
||||
{
|
||||
name: "inside heading",
|
||||
markdown: "# Title <no value>",
|
||||
expected: "<h1>Title <no value></h1>\n",
|
||||
},
|
||||
{
|
||||
name: "inside list item",
|
||||
markdown: "- item <no value>",
|
||||
expected: "<ul>\n<li>item <no value></li>\n</ul>\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gm := goldmark.New(goldmark.WithExtensions(EscapeNoValue, extension.Strikethrough))
|
||||
var buf bytes.Buffer
|
||||
if err := gm.Convert([]byte(tt.markdown), &buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if buf.String() != tt.expected {
|
||||
t.Errorf("expected:\n%s\ngot:\n%s", tt.expected, buf.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user