diff --git a/plugins/lightspeed-backend/README.md b/plugins/lightspeed-backend/README.md index 542aa5d98f..d5ae1455f9 100644 --- a/plugins/lightspeed-backend/README.md +++ b/plugins/lightspeed-backend/README.md @@ -67,32 +67,22 @@ backend.start(); ### Plugin Configurations -Add the following proxy configurations into your `app-config.yaml` file: +Add the following lightspeed configurations into your `app-config.yaml` file: ```yaml -proxy: - endpoints: - '/lightspeed/api': - target: '' - headers: - content-type: 'application/json' - Authorization: 'Bearer ' - secure: true - changeOrigin: true - credentials: 'dangerously-allow-unauthenticated' # No Backstage credentials are required to access this proxy target +lightspeed: + servers: + - id: + url: + token: # dummy token ``` Example local development configuration: ```yaml -proxy: - endpoints: - '/lightspeed/api': - target: 'https://localhost:443/v1' - headers: - content-type: 'application/json' - Authorization: 'Bearer js92n-ssj28dbdk902' # dummy token - secure: true - changeOrigin: true - credentials: 'dangerously-allow-unauthenticated' # No Backstage credentials are required to access this proxy target +lightspeed: + servers: + - id: 'my-llm-server' + url: 'https://localhost:443/v1' + token: 'js92n-ssj28dbdk902' # dummy token ``` diff --git a/plugins/lightspeed-backend/config.d.ts b/plugins/lightspeed-backend/config.d.ts index 5728ccd9e1..10b47648fb 100644 --- a/plugins/lightspeed-backend/config.d.ts +++ b/plugins/lightspeed-backend/config.d.ts @@ -1 +1,25 @@ -export interface Config {} +export interface Config { + /** + * Configuration required for using lightspeed + * @visibility frontend + */ + lightspeed: { + servers: Array<{ + /** + * The id of the server. + * @visibility frontend + */ + id: string; + /** + * The url of the server. + * @visibility frontend + */ + url: string; + /** + * The access token for authenticating server. + * @visibility secret + */ + token?: string; + }>; + }; +} diff --git a/plugins/lightspeed-backend/package.json b/plugins/lightspeed-backend/package.json index 120b44de51..5afe297651 100644 --- a/plugins/lightspeed-backend/package.json +++ b/plugins/lightspeed-backend/package.json @@ -63,6 +63,7 @@ }, "devDependencies": { "@backstage/cli": "0.26.10", + "@backstage/test-utils": "^1.6.0", "@janus-idp/cli": "1.12.0", "@types/supertest": "2.0.12", "msw": "1.0.0", diff --git a/plugins/lightspeed-backend/src/handlers/chatHistory.test.ts b/plugins/lightspeed-backend/src/handlers/chatHistory.test.ts new file mode 100644 index 0000000000..58266edfd2 --- /dev/null +++ b/plugins/lightspeed-backend/src/handlers/chatHistory.test.ts @@ -0,0 +1,95 @@ +import { AIMessage, HumanMessage } from '@langchain/core/messages'; + +import { Roles } from '../service/types'; +import { deleteHistory, loadHistory, saveHistory } from './chatHistory'; + +const mockConversationId = 'user1+1q2w3e4r-qwer1234'; + +describe('Test History Functions', () => { + afterEach(async () => { + // Clear the history store before each test + await deleteHistory(mockConversationId); + }); + + test('saveHistory should save a human message', async () => { + const message = 'Hello, how are you?'; + + await saveHistory(mockConversationId, Roles.HumanRole, message); + + const history = await loadHistory(mockConversationId, 10); + expect(history.length).toBe(1); + expect(history[0]).toBeInstanceOf(HumanMessage); + expect(history[0].content).toBe(message); + }); + + test('saveHistory should save an AI message', async () => { + const message = 'I am fine, thank you!'; + + await saveHistory(mockConversationId, Roles.AIRole, message); + + const history = await loadHistory(mockConversationId, 10); + + expect(history.length).toBe(1); + expect(history[0]).toBeInstanceOf(AIMessage); + expect(history[0].content).toBe(message); + }); + + test('saveHistory and loadHistory with multiple messages', async () => { + await saveHistory(mockConversationId, Roles.HumanRole, 'Hello'); + await saveHistory( + mockConversationId, + Roles.AIRole, + 'Hi! How can I help you today?', + ); + + const history = await loadHistory(mockConversationId, 10); + expect(history.length).toBe(2); + expect(history[0]).toBeInstanceOf(HumanMessage); + expect(history[0].content).toBe('Hello'); + expect(history[1]).toBeInstanceOf(AIMessage); + expect(history[1].content).toBe('Hi! How can I help you today?'); + }); + + test('saveHistory and loadHistory with exact number of messages', async () => { + await saveHistory(mockConversationId, Roles.HumanRole, 'Hello'); + await saveHistory( + mockConversationId, + Roles.AIRole, + 'Hi! How can I help you today?', + ); + + const history = await loadHistory(mockConversationId, 1); + expect(history.length).toBe(1); + expect(history[0]).toBeInstanceOf(AIMessage); + expect(history[0].content).toBe('Hi! How can I help you today?'); + }); + + test('saveHistory should throw an error for unknown roles', async () => { + await expect( + saveHistory(mockConversationId, 'UnknownRole', 'Message'), + ).rejects.toThrow('Unknown role: UnknownRole'); + }); + + test('deleteHistory should delete specific conversation', async () => { + await saveHistory(mockConversationId, Roles.HumanRole, 'Hello'); + await saveHistory('conv2', Roles.AIRole, 'Hi! How can I help you today?'); + + const history1 = await loadHistory(mockConversationId, 1); + expect(history1.length).toBe(1); + + const history2 = await loadHistory('conv2', 1); + expect(history2.length).toBe(1); + + await deleteHistory('conv2'); + + await expect(loadHistory('conv2', 1)).rejects.toThrow( + 'unknown conversation_id: conv2', + ); + + expect(await loadHistory(mockConversationId, 1)).toBeDefined(); + }); + + test('deleteHistory should not return error with unknown id', async () => { + await expect(() => deleteHistory(mockConversationId)).not.toThrow(); + }); +}); diff --git a/plugins/lightspeed-backend/src/handlers/chatHistory.ts b/plugins/lightspeed-backend/src/handlers/chatHistory.ts new file mode 100644 index 0000000000..d6d5f7dc89 --- /dev/null +++ b/plugins/lightspeed-backend/src/handlers/chatHistory.ts @@ -0,0 +1,58 @@ +import { + AIMessage, + BaseMessage, + HumanMessage, + SystemMessage, +} from '@langchain/core/messages'; +import { InMemoryStore } from '@langchain/core/stores'; + +import { Roles } from '../service/types'; + +const historyStore = new InMemoryStore(); + +export async function saveHistory( + conversation_id: string, + role: string, + message: string, +): Promise { + let newMessage: BaseMessage; + switch (role) { + case Roles.AIRole: { + newMessage = new AIMessage(message); + break; + } + case Roles.HumanRole: { + newMessage = new HumanMessage(message); + break; + } + case Roles.SystemRole: { + newMessage = new SystemMessage(message); + break; + } + default: + throw new Error(`Unknown role: ${role}`); + } + + const sessionHistory = await historyStore.mget([conversation_id]); + let newHistory: BaseMessage[] = []; + if (sessionHistory && sessionHistory[0]) { + newHistory = sessionHistory[0]; + } + newHistory.push(newMessage); + await historyStore.mset([[conversation_id, newHistory]]); +} + +export async function loadHistory( + conversation_id: string, + historyLength: number, +): Promise { + const sessionHistory = await historyStore.mget([conversation_id]); + if (!sessionHistory[0]) { + throw new Error(`unknown conversation_id: ${conversation_id}`); + } + return sessionHistory[0]?.slice(-historyLength); +} + +export async function deleteHistory(conversation_id: string): Promise { + return await historyStore.mdelete([conversation_id]); +} diff --git a/plugins/lightspeed-backend/src/service/router.test.ts b/plugins/lightspeed-backend/src/service/router.test.ts index c25d50c1eb..d276616297 100644 --- a/plugins/lightspeed-backend/src/service/router.test.ts +++ b/plugins/lightspeed-backend/src/service/router.test.ts @@ -1,11 +1,13 @@ import { getVoidLogger } from '@backstage/backend-common'; // import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; -import { ConfigReader } from '@backstage/config'; +import { MockConfigApi } from '@backstage/test-utils'; import { AIMessage } from '@langchain/core/messages'; import express from 'express'; import request from 'supertest'; +import { saveHistory } from '../handlers/chatHistory'; +import { Roles } from '../service/types'; import { createRouter } from './router'; const mockAIMessage = new AIMessage('Mockup AI Message'); @@ -27,6 +29,22 @@ jest.mock('@langchain/core/prompts', () => { }; }); +const mockConversationId = 'user1+1q2w3e4r-qwer1234'; +const mockServerURL = 'http://localhost:7007/api/proxy/lightspeed/api'; +const mockModel = 'test-model'; + +const mockConfiguration = new MockConfigApi({ + lightspeed: { + servers: [ + { + id: 'test-server', + url: mockServerURL, + token: 'dummy-token', + }, + ], + }, +}); + (global.fetch as jest.Mock) = jest.fn(); describe('createRouter', () => { @@ -35,7 +53,7 @@ describe('createRouter', () => { beforeAll(async () => { const router = await createRouter({ logger: getVoidLogger(), - config: new ConfigReader({}), + config: mockConfiguration, // TODO: for user authentication // httpAuth: mockServices.httpAuth({ @@ -62,9 +80,47 @@ describe('createRouter', () => { }); }); - const mockConversationId = 'user1+1q2w3e4r-qwer1234'; - const mockServerURL = 'http://localhost:7007/api/proxy/lightspeed/api'; - const mockModel = 'test-model'; + describe('GET and DELETE /conversations/:conversation_id', () => { + it('load history', async () => { + const humanMessage = 'Hello'; + const aiMessage = 'Hi! How can I help you today?'; + await saveHistory(mockConversationId, Roles.HumanRole, humanMessage); + await saveHistory(mockConversationId, Roles.AIRole, aiMessage); + + const response = await request(app).get( + `/conversations/${mockConversationId}`, + ); + expect(response.statusCode).toEqual(200); + // Parse response body + const responseData = response.body; + + // Check that responseData is an array + expect(Array.isArray(responseData)).toBe(true); + expect(responseData.length).toBe(2); + + expect(responseData[0].id).toContain('HumanMessage'); + expect(responseData[0].kwargs?.content).toBe(humanMessage); + + expect(responseData[1].id).toContain('AIMessage'); + expect(responseData[1].kwargs?.content).toBe(aiMessage); + }); + + it('delete history', async () => { + // delete request + const deleteResponse = await request(app).delete( + `/conversations/${mockConversationId}`, + ); + expect(deleteResponse.statusCode).toEqual(200); + }); + + it('load history with deleted conversation_id', async () => { + const response = await request(app).get( + `/conversations/${mockConversationId}`, + ); + expect(response.statusCode).toEqual(500); + expect(response.body.error).toContain('unknown conversation_id'); + }); + }); describe('POST /v1/query', () => { it('chat completions', async () => { diff --git a/plugins/lightspeed-backend/src/service/router.ts b/plugins/lightspeed-backend/src/service/router.ts index 3355acb520..3e26ac24b3 100644 --- a/plugins/lightspeed-backend/src/service/router.ts +++ b/plugins/lightspeed-backend/src/service/router.ts @@ -9,13 +9,26 @@ import { ChatOpenAI } from '@langchain/openai'; import express from 'express'; import Router from 'express-promise-router'; -import { QueryRequestBody, RouterOptions } from './types'; -import { validateCompletionsRequest } from './validation'; +import { + deleteHistory, + loadHistory, + saveHistory, +} from '../handlers/chatHistory'; +import { + DEFAULT_HISTORY_LENGTH, + QueryRequestBody, + Roles, + RouterOptions, +} from './types'; +import { + validateCompletionsRequest, + validateLoadHistoryRequest, +} from './validation'; export async function createRouter( options: RouterOptions, ): Promise { - const { logger } = options; + const { logger, config } = options; const router = Router(); router.use(express.json()); @@ -24,6 +37,41 @@ export async function createRouter( response.json({ status: 'ok' }); }); + router.get( + '/conversations/:conversation_id', + validateLoadHistoryRequest, + async (request, response) => { + const conversation_id = request.params.conversation_id; + const historyLength = Number(request.query.historyLength); + + const loadhistoryLength: number = historyLength || DEFAULT_HISTORY_LENGTH; + try { + const history = await loadHistory(conversation_id, loadhistoryLength); + response.status(200).json(history); + response.end(); + } catch (error) { + const errormsg = `Error: ${error}`; + logger.error(errormsg); + response.status(500).json({ error: errormsg }); + } + }, + ); + + router.delete( + '/conversations/:conversation_id', + async (request, response) => { + const conversation_id = request.params.conversation_id; + try { + response.status(200).json(await deleteHistory(conversation_id)); + response.end(); + } catch (error) { + const errormsg = `${error}`; + logger.error(errormsg); + response.status(500).json({ error: errormsg }); + } + }, + ); + router.post( '/v1/query', validateCompletionsRequest, @@ -31,18 +79,24 @@ export async function createRouter( const { conversation_id, model, query, serverURL }: QueryRequestBody = request.body; try { + // currently only supports single server + const apiToken = config + .getConfigArray('lightspeed.servers')[0] + .getOptionalString('token'); + const openAIApi = new ChatOpenAI({ - apiKey: 'sk-no-key-required', // authorization token is used + apiKey: apiToken || 'sk-no-key-required', // set to sk-no-key-required if api token is not provided model: model, streaming: false, + // streaming: true, + // streamUsage: false, temperature: 0, configuration: { - // bearer token should already applied in proxy header - // baseOptions: { - // headers: { - // ...(token && { Authorization: `Bearer ` }), - // }, - // }, + baseOptions: { + headers: { + ...(apiToken && { Authorization: `Bearer ${apiToken}` }), + }, + }, baseURL: serverURL, }, }); @@ -60,12 +114,16 @@ export async function createRouter( const res = await chain.invoke({ messages: [new HumanMessage(query)], }); + const data = { conversation_id: conversation_id, response: res.content, }; response.json(data); response.end(); + + await saveHistory(conversation_id, Roles.HumanRole, query); + await saveHistory(conversation_id, Roles.AIRole, String(res.content)); } catch (error) { const errormsg = `Error fetching completions from ${serverURL}: ${error}`; logger.error(errormsg); diff --git a/plugins/lightspeed-backend/src/service/types.ts b/plugins/lightspeed-backend/src/service/types.ts index 1aace7eb05..6ee3a16f40 100644 --- a/plugins/lightspeed-backend/src/service/types.ts +++ b/plugins/lightspeed-backend/src/service/types.ts @@ -32,3 +32,13 @@ export interface QueryRequestBody { // A combination of user_id & session_id in the format of + conversation_id: string; } + +// For create AIMessage, HumanMessage, SystemMessage respectively +export const Roles = { + AIRole: 'ai', + HumanRole: 'human', + SystemRole: 'system', +} as const; + +// default number of message history being loaded +export const DEFAULT_HISTORY_LENGTH = 10; diff --git a/plugins/lightspeed-backend/src/service/validation.ts b/plugins/lightspeed-backend/src/service/validation.ts index b6dff630c6..8927e30572 100644 --- a/plugins/lightspeed-backend/src/service/validation.ts +++ b/plugins/lightspeed-backend/src/service/validation.ts @@ -1,13 +1,18 @@ import { NextFunction, Request, Response } from 'express'; +import { QueryRequestBody } from './types'; + export const validateCompletionsRequest = ( req: Request, res: Response, next: NextFunction, ) => { - const { conversation_id, model, query, serverURL } = req.body; + const reqData: QueryRequestBody = req.body; - if (typeof conversation_id !== 'string' || conversation_id.trim() === '') { + if ( + typeof reqData.conversation_id !== 'string' || + reqData.conversation_id.trim() === '' + ) { return res.status(400).json({ error: 'conversation_id is required and must be a non-empty string', }); @@ -15,19 +20,22 @@ export const validateCompletionsRequest = ( // TODO: Need to extract out the user_id from conversation_id, and verify with the login user entity - if (typeof serverURL !== 'string' || serverURL.trim() === '') { + if ( + typeof reqData.serverURL !== 'string' || + reqData.serverURL.trim() === '' + ) { return res .status(400) .json({ error: 'serverURL is required and must be a non-empty string' }); } - if (typeof model !== 'string' || model.trim() === '') { + if (typeof reqData.model !== 'string' || reqData.model.trim() === '') { return res .status(400) .json({ error: 'model is required and must be a non-empty string' }); } - if (typeof query !== 'string' || query.trim() === '') { + if (typeof reqData.query !== 'string' || reqData.query.trim() === '') { return res .status(400) .json({ error: 'query is required and must be a non-empty string' }); @@ -35,3 +43,19 @@ export const validateCompletionsRequest = ( return next(); }; + +export const validateLoadHistoryRequest = ( + req: Request, + res: Response, + next: NextFunction, +) => { + const historyLength = Number(req.query.historyLength); + + if (historyLength && !Number.isInteger(historyLength)) { + return res.status(400).send('historyLength has to be a valid integer'); + } + + // TODO: Need to extract out the user_id from conversation_id, and verify with the login user entity + + return next(); +}; diff --git a/yarn.lock b/yarn.lock index ec4315155f..7d1688d001 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3747,6 +3747,25 @@ zen-observable "^0.10.0" zod "^3.22.4" +"@backstage/core-app-api@^1.15.0": + version "1.15.0" + resolved "https://registry.yarnpkg.com/@backstage/core-app-api/-/core-app-api-1.15.0.tgz#d121fcb8b93e9043302f23646fb8ec2dcdb8171b" + integrity sha512-Pgw1n3Aqv/TuYI3S9eg18/zrUJYWle19VFskubmPGS4eVF5rmN5VQxIz7R84K/CoJ22W6d/qCvWPJeD3SDwmYw== + dependencies: + "@backstage/config" "^1.2.0" + "@backstage/core-plugin-api" "^1.9.4" + "@backstage/types" "^1.1.1" + "@backstage/version-bridge" "^1.0.9" + "@types/prop-types" "^15.7.3" + "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" + history "^5.0.0" + i18next "^22.4.15" + lodash "^4.17.21" + prop-types "^15.7.2" + react-use "^17.2.4" + zen-observable "^0.10.0" + zod "^3.22.4" + "@backstage/core-compat-api@^0.2.7": version "0.2.7" resolved "https://registry.yarnpkg.com/@backstage/core-compat-api/-/core-compat-api-0.2.7.tgz#5b58f5984e2d54489dc6389fb48296afc2586149" @@ -3812,6 +3831,18 @@ "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" history "^5.0.0" +"@backstage/core-plugin-api@^1.9.4": + version "1.9.4" + resolved "https://registry.yarnpkg.com/@backstage/core-plugin-api/-/core-plugin-api-1.9.4.tgz#3341207f67321f705462d77258ab5ee73e3e8da7" + integrity sha512-YFQKgGmN8cPsPyBpkELWGajVTfVV99IlcGgjggkGE6Qd9vKLyU1Wj76a8cJC8itcS8gw+BDJQ4AvdTKBuW707Q== + dependencies: + "@backstage/config" "^1.2.0" + "@backstage/errors" "^1.2.4" + "@backstage/types" "^1.1.1" + "@backstage/version-bridge" "^1.0.9" + "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" + history "^5.0.0" + "@backstage/dev-utils@1.0.36": version "1.0.36" resolved "https://registry.yarnpkg.com/@backstage/dev-utils/-/dev-utils-1.0.36.tgz#a52b4f54c1eaea42606c58a8e1b66f45deaba041" @@ -4951,6 +4982,17 @@ "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" swr "^2.0.0" +"@backstage/plugin-permission-react@^0.4.26": + version "0.4.26" + resolved "https://registry.yarnpkg.com/@backstage/plugin-permission-react/-/plugin-permission-react-0.4.26.tgz#85e92579c579d090d2acdfa90deb4f1910cdcb89" + integrity sha512-HNXuxUd2xw3nPu2SC7UeMevou2ktNQZd/QQQAZEY3qE/THXpZ+HMA3gAljn5xydf8CiEz2taPlOubVBO+ByQMg== + dependencies: + "@backstage/config" "^1.2.0" + "@backstage/core-plugin-api" "^1.9.4" + "@backstage/plugin-permission-common" "^0.8.1" + "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" + swr "^2.0.0" + "@backstage/plugin-proxy-backend@^0.5.3": version "0.5.3" resolved "https://registry.yarnpkg.com/@backstage/plugin-proxy-backend/-/plugin-proxy-backend-0.5.3.tgz#fc9c12ac160e389913433a33629cb7d09e71e2f7" @@ -5711,6 +5753,25 @@ i18next "^22.4.15" zen-observable "^0.10.0" +"@backstage/test-utils@^1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@backstage/test-utils/-/test-utils-1.6.0.tgz#b79e9a0a0f602aa4cf9a2269c2bfceb9c9a69e89" + integrity sha512-IYjCyJkqoxjvwdvY4GnaBmbdw/2bxdwerOTZop1lUwWyTdlpcK0Cx/6+6cL6yXoms1EUrdwDI4xiT/YQJY0fuA== + dependencies: + "@backstage/config" "^1.2.0" + "@backstage/core-app-api" "^1.15.0" + "@backstage/core-plugin-api" "^1.9.4" + "@backstage/plugin-permission-common" "^0.8.1" + "@backstage/plugin-permission-react" "^0.4.26" + "@backstage/theme" "^0.5.7" + "@backstage/types" "^1.1.1" + "@material-ui/core" "^4.12.2" + "@material-ui/icons" "^4.9.1" + "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" + cross-fetch "^4.0.0" + i18next "^22.4.15" + zen-observable "^0.10.0" + "@backstage/theme@0.5.6", "@backstage/theme@^0.5.6": version "0.5.6" resolved "https://registry.yarnpkg.com/@backstage/theme/-/theme-0.5.6.tgz#18645cbe42fb5667946e0a5dd38f2fb0bb056597" @@ -5720,6 +5781,15 @@ "@emotion/styled" "^11.10.5" "@mui/material" "^5.12.2" +"@backstage/theme@^0.5.7": + version "0.5.7" + resolved "https://registry.yarnpkg.com/@backstage/theme/-/theme-0.5.7.tgz#496b310d436efdb6ce1e240b69cc1ef7e3526db0" + integrity sha512-XztEKnNot3DA4BuLZJocbSYvpYpWm/OF9PP7nOk9pJ4Jg4YIrEzZxOxPorOp7r/UhZhLwnqneIV3RcFBhOt9BA== + dependencies: + "@emotion/react" "^11.10.5" + "@emotion/styled" "^11.10.5" + "@mui/material" "^5.12.2" + "@backstage/types@1.1.1", "@backstage/types@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@backstage/types/-/types-1.1.1.tgz#c9ccb30357005e7fb5fa2ac140198059976eb076" @@ -5732,6 +5802,13 @@ dependencies: "@types/react" "^16.13.1 || ^17.0.0" +"@backstage/version-bridge@^1.0.9": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@backstage/version-bridge/-/version-bridge-1.0.9.tgz#1369e168dce806422134c3bafb9da5bad4776e49" + integrity sha512-UqTBxKgNK5HyVzlyVOESd+9J26rF/OJeTrOMrTiReJMGKf8DUCLNDJ7+DbDxIPwSfl5wNbEBEyFAkk9iLK0OoQ== + dependencies: + "@types/react" "^16.13.1 || ^17.0.0" + "@balena/dockerignore@^1.0.2": version "1.0.2" resolved "https://registry.yarnpkg.com/@balena/dockerignore/-/dockerignore-1.0.2.tgz#9ffe4726915251e8eb69f44ef3547e0da2c03e0d" @@ -15132,9 +15209,9 @@ "@types/react" "*" "@types/react@*", "@types/react@18.3.3", "@types/react@>=16", "@types/react@^16.13.1 || ^17.0.0", "@types/react@^16.13.1 || ^17.0.0 || ^18.0.0", "@types/react@^18": - version "18.3.4" - resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.4.tgz#dfdd534a1d081307144c00e325c06e00312c93a3" - integrity sha512-J7W30FTdfCxDDjmfRM+/JqLHBIyl7xUIp9kwK637FGmY7+mkSFSe6L4jpZzhj5QMfLssSDP4/i75AKkrdC7/Jw== + version "18.3.7" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.7.tgz#6decbfbb01f8d82d56ff5403394121940faa6569" + integrity sha512-KUnDCJF5+AiZd8owLIeVHqmW9yM4sqmDVf2JRJiBMFkGvkoZ4/WyV2lL4zVsoinmRS/W3FeEdZLEWFRofnT2FQ== dependencies: "@types/prop-types" "*" csstype "^3.0.2"