diff --git a/.betterer.results b/.betterer.results index 8e2ee8c27e9..c032091d058 100644 --- a/.betterer.results +++ b/.betterer.results @@ -3132,6 +3132,10 @@ exports[`better eslint`] = { "public/app/features/dashboard/components/GenAI/GenAIButton.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], + "public/app/features/dashboard/components/GenAI/llms/openai.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"] + ], "public/app/features/dashboard/components/HelpWizard/HelpWizard.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], diff --git a/public/app/features/dashboard/components/GenAI/GenAIDashDescriptionButton.tsx b/public/app/features/dashboard/components/GenAI/GenAIDashDescriptionButton.tsx index e6d0c0a5e19..d7067d29d23 100644 --- a/public/app/features/dashboard/components/GenAI/GenAIDashDescriptionButton.tsx +++ b/public/app/features/dashboard/components/GenAI/GenAIDashDescriptionButton.tsx @@ -4,7 +4,7 @@ import { DashboardModel } from '../../state'; import { GenAIButton } from './GenAIButton'; import { EventSource, reportGenerateAIButtonClicked } from './tracking'; -import { Message, Role } from './utils'; +import { getDashboardPanelPrompt, Message, Role } from './utils'; interface GenAIDashDescriptionButtonProps { onGenerate: (description: string) => void; @@ -12,9 +12,15 @@ interface GenAIDashDescriptionButtonProps { } const DESCRIPTION_GENERATION_STANDARD_PROMPT = - 'You are an expert in Grafana dashboards.' + - 'Your goal is to write short, descriptive, and concise dashboards description using the dashboard panels title and descriptions. ' + - 'The description should be shorter than 140 characters.'; + 'You are an expert in creating Grafana Dashboards.\n' + + 'Your goal is to write a descriptive and concise dashboard description.\n' + + "You will be given the title and description of the dashboard's panels.\n" + + 'The dashboard description is meant to explain the purpose of the dashboard and what its panels show.\n' + + 'If the dashboard has no panels, the description should be "Empty dashboard"\n' + + 'There should be no numbers in the description except where they are important.\n' + + 'The dashboard description should not have the dashboard title or any quotation marks in it.\n' + + 'The description should be, at most, 140 characters.\n' + + 'Respond with only the description of the dashboard.'; export const GenAIDashDescriptionButton = ({ onGenerate, dashboard }: GenAIDashDescriptionButtonProps) => { const messages = React.useMemo(() => getMessages(dashboard), [dashboard]); @@ -26,23 +32,15 @@ export const GenAIDashDescriptionButton = ({ onGenerate, dashboard }: GenAIDashD }; function getMessages(dashboard: DashboardModel): Message[] { + const panelPrompt = getDashboardPanelPrompt(dashboard); + return [ { content: DESCRIPTION_GENERATION_STANDARD_PROMPT, role: Role.system, }, { - content: `The title of the dashboard is "${ - dashboard.title - }" and the the panels in the dashboard are: ${dashboard.panels - .map( - (panel, idx) => ` - - Panel ${idx} - - Title: ${panel.title} - ${panel.description ? `- Description: ${panel.description}` : ''} - ` - ) - .join('\n')}`, + content: `The title of the dashboard is "${dashboard.title}"\n` + `${panelPrompt}`, role: Role.system, }, ]; diff --git a/public/app/features/dashboard/components/GenAI/GenAIDashTitleButton.tsx b/public/app/features/dashboard/components/GenAI/GenAIDashTitleButton.tsx index 2daa2fd2e8a..67165119c79 100644 --- a/public/app/features/dashboard/components/GenAI/GenAIDashTitleButton.tsx +++ b/public/app/features/dashboard/components/GenAI/GenAIDashTitleButton.tsx @@ -4,17 +4,23 @@ import { DashboardModel } from '../../state'; import { GenAIButton } from './GenAIButton'; import { EventSource, reportGenerateAIButtonClicked } from './tracking'; -import { Message, Role } from './utils'; +import { getDashboardPanelPrompt, Message, Role } from './utils'; interface GenAIDashTitleButtonProps { dashboard: DashboardModel; onGenerate: (description: string) => void; } -const DESCRIPTION_GENERATION_STANDARD_PROMPT = - 'You are an expert in Grafana dashboards.' + - 'Your goal is to write the dashboard title inspired by the title and descriptions for the dashboard panels. ' + - 'The title must be shorter than 50 characters.'; +const TITLE_GENERATION_STANDARD_PROMPT = + 'You are an expert in creating Grafana Dashboards.\n' + + 'Your goal is to write a concise dashboard title.\n' + + "You will be given the title and description of the dashboard's panels.\n" + + 'The dashboard title is meant to say what it shows on one line for users to navigate to it.\n' + + 'If the dashboard has no panels, the title should be "Empty dashboard"\n' + + 'There should be no numbers in the title.\n' + + 'The dashboard title should not have quotation marks in it.\n' + + 'The title should be, at most, 50 characters.\n' + + 'Respond with only the title of the dashboard.'; export const GenAIDashTitleButton = ({ onGenerate, dashboard }: GenAIDashTitleButtonProps) => { const messages = React.useMemo(() => getMessages(dashboard), [dashboard]); @@ -26,19 +32,11 @@ export const GenAIDashTitleButton = ({ onGenerate, dashboard }: GenAIDashTitleBu function getMessages(dashboard: DashboardModel): Message[] { return [ { - content: DESCRIPTION_GENERATION_STANDARD_PROMPT, + content: TITLE_GENERATION_STANDARD_PROMPT, role: Role.system, }, { - content: `The panels in the dashboard are: ${dashboard.panels - .map( - (panel, idx) => ` - - Panel ${idx} - - Title: ${panel.title} - ${panel.description ? `- Description: ${panel.description}` : ''} - ` - ) - .join('\n')}`, + content: `The panels in the dashboard are: ${getDashboardPanelPrompt(dashboard)}`, role: Role.system, }, ]; diff --git a/public/app/features/dashboard/components/GenAI/GenAIPanelDescriptionButton.tsx b/public/app/features/dashboard/components/GenAI/GenAIPanelDescriptionButton.tsx index 06cf54ef0a2..59f88567102 100644 --- a/public/app/features/dashboard/components/GenAI/GenAIPanelDescriptionButton.tsx +++ b/public/app/features/dashboard/components/GenAI/GenAIPanelDescriptionButton.tsx @@ -13,9 +13,12 @@ interface GenAIPanelDescriptionButtonProps { } const DESCRIPTION_GENERATION_STANDARD_PROMPT = - 'You are an expert in creating Grafana Panels.' + - 'Your goal is to write short, descriptive, and concise panel description using a JSON object with the panel declaration ' + - 'The description should be shorter than 140 characters.'; + 'You are an expert in creating Grafana Panels.\n' + + 'You will be given the title and description of the dashboard the panel is in as well as the JSON for the panel.\n' + + 'Your goal is to write a descriptive and concise panel description.\n' + + 'The panel description is meant to explain the purpose of the panel, not just its attributes.\n' + + 'There should be no numbers in the description except for thresholds.\n' + + 'The description should be, at most, 140 characters.'; export const GenAIPanelDescriptionButton = ({ onGenerate, panel }: GenAIPanelDescriptionButtonProps) => { const messages = React.useMemo(() => getMessages(panel), [panel]); @@ -43,7 +46,7 @@ function getMessages(panel: PanelModel): Message[] { role: Role.system, }, { - content: `Use this JSON object which defines the panel: ${JSON.stringify(panel.getSaveModel())}`, + content: `This is the JSON which defines the panel: ${JSON.stringify(panel.getSaveModel())}`, role: Role.user, }, ]; diff --git a/public/app/features/dashboard/components/GenAI/hooks.ts b/public/app/features/dashboard/components/GenAI/hooks.ts index c7b95598228..0b7b7bdd5ac 100644 --- a/public/app/features/dashboard/components/GenAI/hooks.ts +++ b/public/app/features/dashboard/components/GenAI/hooks.ts @@ -2,13 +2,12 @@ import { useState } from 'react'; import { useAsync } from 'react-use'; import { Subscription } from 'rxjs'; -import { llms } from '@grafana/experimental'; - +import { openai } from './llms'; import { isLLMPluginEnabled, OPEN_AI_MODEL } from './utils'; // Declared instead of imported from utils to make this hook modular // Ideally we will want to move the hook itself to a different scope later. -type Message = llms.openai.Message; +type Message = openai.Message; // TODO: Add tests export function useOpenAIStream( @@ -50,7 +49,7 @@ export function useOpenAIStream( setIsGenerating(true); // Stream the completions. Each element is the next stream chunk. - const stream = llms.openai + const stream = openai .streamChatCompletions({ model, temperature, @@ -59,7 +58,7 @@ export function useOpenAIStream( .pipe( // Accumulate the stream content into a stream of strings, where each // element contains the accumulated message so far. - llms.openai.accumulateContent() + openai.accumulateContent() // The stream is just a regular Observable, so we can use standard rxjs // functionality to update state, e.g. recording when the stream // has completed. @@ -71,6 +70,10 @@ export function useOpenAIStream( enabled, stream: stream.subscribe({ next: setReply, + error: (e) => { + console.log('The backend for the stream returned an error and nobody has implemented error handling yet!'); + console.log(e); + }, complete: () => { setIsGenerating(false); setMessages([]); diff --git a/public/app/features/dashboard/components/GenAI/llms/constants.ts b/public/app/features/dashboard/components/GenAI/llms/constants.ts new file mode 100644 index 00000000000..30a2ea75651 --- /dev/null +++ b/public/app/features/dashboard/components/GenAI/llms/constants.ts @@ -0,0 +1,19 @@ +import { SemVer } from 'semver'; + +import { logWarning } from '@grafana/runtime'; + +export const LLM_PLUGIN_ID = 'grafana-llm-app'; +export const LLM_PLUGIN_ROUTE = `/api/plugins/${LLM_PLUGIN_ID}`; + +// The LLM app was at version 0.2.0 before we added the health check. +// If the health check fails, or the details don't exist on the response, +// we should assume it's this older version. +export let LLM_PLUGIN_VERSION = new SemVer('0.2.0'); + +export function setLLMPluginVersion(version: string) { + try { + LLM_PLUGIN_VERSION = new SemVer(version); + } catch (e) { + logWarning('Failed to parse version of grafana-llm-app; assuming old version is present.'); + } +} diff --git a/public/app/features/dashboard/components/GenAI/llms/index.ts b/public/app/features/dashboard/components/GenAI/llms/index.ts new file mode 100644 index 00000000000..5f583a9ec6d --- /dev/null +++ b/public/app/features/dashboard/components/GenAI/llms/index.ts @@ -0,0 +1,2 @@ +// This is here to mimic the structure of imports from @grafana/experimental while switching over +export * as openai from './openai'; diff --git a/public/app/features/dashboard/components/GenAI/llms/openai.ts b/public/app/features/dashboard/components/GenAI/llms/openai.ts new file mode 100644 index 00000000000..6ffd77a708d --- /dev/null +++ b/public/app/features/dashboard/components/GenAI/llms/openai.ts @@ -0,0 +1,394 @@ +/** + * OpenAI API client. + * + * This module contains functions used to make requests to the OpenAI API via + * the Grafana LLM app plugin. That plugin must be installed, enabled and configured + * in order for these functions to work. + * + * The {@link enabled} function can be used to check if the plugin is enabled and configured. + */ + +import { pipe, Observable, UnaryFunction } from 'rxjs'; +import { filter, map, scan, takeWhile, tap } from 'rxjs/operators'; + +import { + isLiveChannelMessageEvent, + LiveChannelAddress, + LiveChannelMessageEvent, + LiveChannelScope, +} from '@grafana/data'; +import { getBackendSrv, getGrafanaLiveSrv, logDebug } from '@grafana/runtime'; + +import { LLM_PLUGIN_ID, LLM_PLUGIN_ROUTE, setLLMPluginVersion } from './constants'; +import { LLMAppHealthCheck } from './types'; + +const OPENAI_CHAT_COMPLETIONS_PATH = 'openai/v1/chat/completions'; + +/** The role of a message's author. */ +export type Role = 'system' | 'user' | 'assistant' | 'function'; + +/** A message in a conversation. */ +export interface Message { + /** The role of the message's author. */ + role: Role; + + /** The contents of the message. content is required for all messages, and may be null for assistant messages with function calls. */ + content: string; + + /** + * The name of the author of this message. + * + * This is required if role is 'function', and it should be the name of the function whose response is in the content. + * + * May contain a-z, A-Z, 0-9, and underscores, with a maximum length of 64 characters. + */ + name?: string; + + /** + * The name and arguments of a function that should be called, as generated by the model. + */ + function_call?: Object; +} + +/** A function the model may generate JSON inputs for. */ +export interface Function { + /** + * The name of the function to be called. + * + * Must be a-z, A-Z, 0-9, or contain underscores and dashes, with a maximum length of 64. + */ + name: string; + /** + * A description of what the function does, used by the model to choose when and how to call the function. + */ + description?: string; + /* + * The parameters the functions accepts, described as a JSON Schema object. See the OpenAI guide for examples, and the JSON Schema reference for documentation about the format. + * + * To describe a function that accepts no parameters, provide the value {"type": "object", "properties": {}}. + */ + parameters: Object; +} + +export interface ChatCompletionsRequest { + /** + * ID of the model to use. + * + * See the model endpoint compatibility table for details on which models work with the Chat Completions API. + */ + model: string; + /** A list of messages comprising the conversation so far. */ + messages: Message[]; + /** A list of functions the model may generate JSON inputs for. */ + functions?: Function[]; + /** + * Controls how the model responds to function calls. + * + * "none" means the model does not call a function, and responds to the end-user. + * "auto" means the model can pick between an end-user or calling a function. + * Specifying a particular function via {"name": "my_function"} forces the model to call that function. + * + * "none" is the default when no functions are present. "auto" is the default if functions are present. + */ + function_call?: 'none' | 'auto' | { name: string }; + /** + * What sampling temperature to use, between 0 and 2. + * Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic. + * + * We generally recommend altering this or top_p but not both. + */ + temperature?: number; + /** + * An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. + * So 0.1 means only the tokens comprising the top 10% probability mass are considered. + * + * We generally recommend altering this or temperature but not both. + */ + top_p?: number; + /** + * How many chat completion choices to generate for each input message. + */ + n?: number; + /** + * Up to 4 sequences where the API will stop generating further tokens. + */ + stop?: string | string[]; + /** + * The maximum number of tokens to generate in the chat completion. + * + * The total length of input tokens and generated tokens is limited by the model's context length. Example Python code for counting tokens. + */ + max_tokens?: number; + /** + * Number between -2.0 and 2.0. + * + * Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics. + */ + presence_penalty?: number; + /** + * Number between -2.0 and 2.0. + * + * Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim. + */ + frequency_penalty?: number; + /** + * Modify the likelihood of specified tokens appearing in the completion. + * + * Accepts a json object that maps tokens (specified by their token ID in the tokenizer) to an associated bias value from -100 to 100. + * Mathematically, the bias is added to the logits generated by the model prior to sampling. The exact effect will vary per model, + * but values between -1 and 1 should decrease or increase likelihood of selection; values like -100 or 100 should result in a ban + * or exclusive selection of the relevant token. + */ + logit_bias?: { [key: string]: number }; + /** + * A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse. + */ + user?: string; +} + +/** A completion object from an OpenAI model. */ +export interface Choice { + /** The message object generated by the model. */ + message: Message; + /** + * The reason the model stopped generating text. + * + * This may be one of: + * - stop: API returned complete message, or a message terminated by one of the stop sequences provided via the stop parameter + * - length: incomplete model output due to max_tokens parameter or token limit + * - function_call: the model decided to call a function + * - content_filter: omitted content due to a flag from our content filters + * - null: API response still in progress or incomplete + */ + finish_reason: string; + /** The index of the completion in the list of choices. */ + index: number; +} + +/** The usage statistics for a request to OpenAPI. */ +export interface Usage { + /** The number of tokens in the prompt. */ + prompt_tokens: number; + /** The number of tokens in the completion. */ + completion_tokens: number; + /** The total number of tokens. */ + total_tokens: number; +} + +/** The error response from the Grafana LLM app when trying to call the chat completions API. */ +interface ChatCompletionsErrorResponse { + /** The error message. */ + error: string; +} + +/** A response from the OpenAI Chat Completions API. */ +export interface ChatCompletionsResponse { + /** The ID of the request. */ + id: string; + /** The type of object returned (e.g. 'chat.completion'). */ + object: string; + /** The timestamp of the request, as a UNIX timestamp. */ + created: number; + /** The name of the model used to generate the response. */ + model: string; + /** A list of completion objects (only one, unless `n > 1` in the request). */ + choices: T[]; + /** The number of tokens used to generate the replies, counting prompt, completion, and total. */ + usage: Usage; +} + +/** A content message returned from the model. */ +export interface ContentMessage { + /** The content of the message. */ + content: string; +} + +/** A message returned from the model indicating that it is done. */ +export interface DoneMessage { + done: boolean; +} + +/** A function call message returned from the model. */ +export interface FunctionCallMessage { + /** The name of the function to call. */ + name: string; + /** JSON string for the arguments to the function call. */ + arguments: string; +} + +/** + * A delta returned from a stream of chat completion responses. + * + * In practice this will be either a content message or a function call; + * done messages are filtered out by the `streamChatCompletions` function. + */ +export type ChatCompletionsDelta = ContentMessage | FunctionCallMessage | DoneMessage; + +/** A chunk included in a chat completion response. */ +export interface ChatCompletionsChunk { + /** The delta since the previous chunk. */ + delta: ChatCompletionsDelta; +} + +/** Return true if the message is a 'content' message. */ +export function isContentMessage(message: unknown): message is ContentMessage { + return typeof message === 'object' && message !== null && 'content' in message; +} + +/** Return true if the message is a 'done' message. */ +export function isDoneMessage(message: unknown): message is DoneMessage { + return typeof message === 'object' && message !== null && 'done' in message; +} + +/** Return true if the response is an error response. */ +export function isErrorResponse(response: unknown): response is ChatCompletionsErrorResponse { + return typeof response === 'object' && response !== null && 'error' in response; +} + +/** + * An rxjs operator that extracts the content messages from a stream of chat completion responses. + * + * @returns An observable that emits the content messages. Each emission will be a string containing the + * token emitted by the model. + * @example Example of reading all tokens in a stream. + * const stream = streamChatCompletions({ model: 'gpt-3.5-turbo', messages: [ + * { role: 'system', content: 'You are a great bot.' }, + * { role: 'user', content: 'Hello, bot.' }, + * ]}).pipe(extractContent()); + * stream.subscribe({ next: console.log, error: console.error }); + * // Output: + * // ['Hello', '? ', 'How ', 'are ', 'you', '?'] + */ +export function extractContent(): UnaryFunction< + Observable>, + Observable +> { + return pipe( + filter((response: ChatCompletionsResponse) => isContentMessage(response.choices[0].delta)), + // The type assertion is needed here because the type predicate above doesn't seem to propagate. + map( + (response: ChatCompletionsResponse) => (response.choices[0].delta as ContentMessage).content + ) + ); +} + +/** + * An rxjs operator that accumulates the content messages from a stream of chat completion responses. + * + * @returns An observable that emits the accumulated content messages. Each emission will be a string containing the + * content of all messages received so far. + * @example + * const stream = streamChatCompletions({ model: 'gpt-3.5-turbo', messages: [ + * { role: 'system', content: 'You are a great bot.' }, + * { role: 'user', content: 'Hello, bot.' }, + * ]}).pipe(accumulateContent()); + * stream.subscribe({ next: console.log, error: console.error }); + * // Output: + * // ['Hello', 'Hello! ', 'Hello! How ', 'Hello! How are ', 'Hello! How are you', 'Hello! How are you?'] + */ +export function accumulateContent(): UnaryFunction< + Observable>, + Observable +> { + return pipe( + extractContent(), + scan((acc, curr) => acc + curr, '') + ); +} + +/** + * Make a request to OpenAI's chat-completions API via the Grafana LLM plugin proxy. + */ +export async function chatCompletions(request: ChatCompletionsRequest): Promise { + const response = await getBackendSrv().post( + '/api/plugins/grafana-llm-app/resources/openai/v1/chat/completions', + request, + { + headers: { 'Content-Type': 'application/json' }, + } + ); + return response; +} + +/** + * Make a streaming request to OpenAI's chat-completions API via the Grafana LLM plugin proxy. + * + * A stream of tokens will be returned as an `Observable`. Use the `extractContent` operator to + * filter the stream to only content messages, or the `accumulateContent` operator to obtain a stream of + * accumulated content messages. + * + * The 'done' message will not be emitted; the stream will simply end when this message is encountered. + * + * @example Example of reading all tokens in a stream. + * const stream = streamChatCompletions({ model: 'gpt-3.5-turbo', messages: [ + * { role: 'system', content: 'You are a great bot.' }, + * { role: 'user', content: 'Hello, bot.' }, + * ]}).pipe(extractContent()); + * stream.subscribe({ next: console.log, error: console.error }); + * // Output: + * // ['Hello', '? ', 'How ', 'are ', 'you', '?'] + * + * @example Example of accumulating tokens in a stream. + * const stream = streamChatCompletions({ model: 'gpt-3.5-turbo', messages: [ + * { role: 'system', content: 'You are a great bot.' }, + * { role: 'user', content: 'Hello, bot.' }, + * ]}).pipe(accumulateContent()); + * stream.subscribe({ next: console.log, error: console.error }); + * // Output: + * // ['Hello', 'Hello! ', 'Hello! How ', 'Hello! How are ', 'Hello! How are you', 'Hello! How are you?'] + */ +export function streamChatCompletions( + request: ChatCompletionsRequest +): Observable> { + const channel: LiveChannelAddress = { + scope: LiveChannelScope.Plugin, + namespace: LLM_PLUGIN_ID, + path: OPENAI_CHAT_COMPLETIONS_PATH + '/' + self.crypto.randomUUID(), + data: request, + }; + const messages = getGrafanaLiveSrv() + .getStream(channel) + .pipe(filter((event) => isLiveChannelMessageEvent(event))) as Observable< + LiveChannelMessageEvent> + >; + return messages.pipe( + tap((event) => { + if (isErrorResponse(event.message)) { + throw new Error(event.message.error); + } + }), + takeWhile((event) => isErrorResponse(event.message) || !isDoneMessage(event.message.choices[0].delta)), + map((event) => event.message) + ); +} + +let loggedWarning = false; + +/** Check if the OpenAI API is enabled via the LLM plugin. */ +export const enabled = async () => { + // Run a health check to see if the plugin is installed. + let response: LLMAppHealthCheck; + try { + response = await getBackendSrv().get(`${LLM_PLUGIN_ROUTE}/health`, undefined, undefined, { + showSuccessAlert: false, + showErrorAlert: false, + }); + } catch (e) { + if (!loggedWarning) { + logDebug(String(e)); + logDebug( + 'Failed to check if OpenAI is enabled. This is expected if the Grafana LLM plugin is not installed, and the above error can be ignored.' + ); + loggedWarning = true; + } + return false; + } + + const { details } = response; + // Update the version if it's present on the response. + if (details.version !== undefined) { + setLLMPluginVersion(details.version); + } + // If the plugin is installed then check if it is configured. + return details?.openAI ?? false; +}; diff --git a/public/app/features/dashboard/components/GenAI/llms/types.ts b/public/app/features/dashboard/components/GenAI/llms/types.ts new file mode 100644 index 00000000000..84c45dc824b --- /dev/null +++ b/public/app/features/dashboard/components/GenAI/llms/types.ts @@ -0,0 +1,7 @@ +export type LLMAppHealthCheck = { + details: { + openAI?: boolean; + vector?: boolean; + version?: string; + }; +}; diff --git a/public/app/features/dashboard/components/GenAI/utils.test.ts b/public/app/features/dashboard/components/GenAI/utils.test.ts index 391d21ffd7a..896970c8de6 100644 --- a/public/app/features/dashboard/components/GenAI/utils.test.ts +++ b/public/app/features/dashboard/components/GenAI/utils.test.ts @@ -1,17 +1,14 @@ -import { llms } from '@grafana/experimental'; - import { createDashboardModelFixture, createPanelJSONFixture } from '../../state/__fixtures__/dashboardFixtures'; +import { openai } from './llms'; import { getDashboardChanges, isLLMPluginEnabled } from './utils'; // Mock the llms.openai module -jest.mock('@grafana/experimental', () => ({ - llms: { - openai: { - streamChatCompletions: jest.fn(), - accumulateContent: jest.fn(), - enabled: jest.fn(), - }, +jest.mock('./llms', () => ({ + openai: { + streamChatCompletions: jest.fn(), + accumulateContent: jest.fn(), + enabled: jest.fn(), }, })); @@ -69,7 +66,7 @@ describe('getDashboardChanges', () => { describe('isLLMPluginEnabled', () => { it('should return true if LLM plugin is enabled', async () => { // Mock llms.openai.enabled to return true - jest.mocked(llms.openai.enabled).mockResolvedValue(true); + jest.mocked(openai.enabled).mockResolvedValue(true); const enabled = await isLLMPluginEnabled(); @@ -78,7 +75,7 @@ describe('isLLMPluginEnabled', () => { it('should return false if LLM plugin is not enabled', async () => { // Mock llms.openai.enabled to return false - jest.mocked(llms.openai.enabled).mockResolvedValue(false); + jest.mocked(openai.enabled).mockResolvedValue(false); const enabled = await isLLMPluginEnabled(); diff --git a/public/app/features/dashboard/components/GenAI/utils.ts b/public/app/features/dashboard/components/GenAI/utils.ts index bb4e6f45bdd..3a41070c664 100644 --- a/public/app/features/dashboard/components/GenAI/utils.ts +++ b/public/app/features/dashboard/components/GenAI/utils.ts @@ -1,8 +1,8 @@ -import { llms } from '@grafana/experimental'; - -import { DashboardModel } from '../../state'; +import { DashboardModel, PanelModel } from '../../state'; import { Diffs, jsonDiff } from '../VersionHistory/utils'; +import { openai } from './llms'; + export enum Role { // System content cannot be overwritten by user prompts. 'system' = 'system', @@ -11,7 +11,7 @@ export enum Role { 'user' = 'user', } -export type Message = llms.openai.Message; +export type Message = openai.Message; /** * The OpenAI model to be used. @@ -49,5 +49,47 @@ export function getDashboardChanges(dashboard: DashboardModel): { export async function isLLMPluginEnabled() { // Check if the LLM plugin is enabled and configured. // If not, we won't be able to make requests, so return early. - return await llms.openai.enabled(); + return await openai.enabled(); +} + +/** + * + * @param dashboard Dashboard to generate a title or description for + * @returns String for inclusion in prompts stating what the dashboard's panels are + */ +export function getDashboardPanelPrompt(dashboard: DashboardModel): string { + const getPanelString = (panel: PanelModel, idx: number) => ` + - Panel ${idx}\n + - Title: ${panel.title}\n + ${panel.description ? `- Description: ${panel.description}` : ''} + `; + + const panelStrings: string[] = dashboard.panels.map(getPanelString); + let panelPrompt: string; + + if (panelStrings.length <= 10) { + panelPrompt = `The panels in the dashboard are:\n${panelStrings.join('\n')}`; + } else { + const withDescriptions = dashboard.panels.filter((panel) => panel.description); + const withoutDescriptions = dashboard.panels.filter((panel) => !panel.description); + let concatenatedPanelStrings; + if (withDescriptions.length >= 10) { + concatenatedPanelStrings = withDescriptions.slice(10).map(getPanelString).join('\n'); + } else { + concatenatedPanelStrings = withDescriptions.map(getPanelString).join('\n'); + concatenatedPanelStrings += '\n'; + concatenatedPanelStrings += withoutDescriptions + .slice(10 - withDescriptions.length) + .map(getPanelString) + .join('n'); + } + panelPrompt = + `There are ${panelStrings.length} panels.\n` + + 'Due to space constraints, only the information for ten of them is presented.\n' + + 'These ten are not necessarily the first ten, but prioritized to those with descriptions.\n' + + `The panels in the dashboard are:\n${concatenatedPanelStrings}`; + } // This truncation should prevent exceeding the allowed size for GPT calls. + // Additionally, context windows that are too long degrade performance, + // So it is possibly that if we can condense it further it would be better + return panelPrompt; }