diff --git a/server/src/main/kotlin/com/xebia/functional/xef/server/Server.kt b/server/src/main/kotlin/com/xebia/functional/xef/server/Server.kt index 5583fb122..851293ac4 100644 --- a/server/src/main/kotlin/com/xebia/functional/xef/server/Server.kt +++ b/server/src/main/kotlin/com/xebia/functional/xef/server/Server.kt @@ -47,6 +47,7 @@ object Server { server(factory = Netty, port = 8081, host = "0.0.0.0") { install(CORS) { allowNonSimpleContentTypes = true + allowHeader("Authorization") anyHost() } install(ContentNegotiation) { json() } diff --git a/server/web/package-lock.json b/server/web/package-lock.json index 85099e4ae..6f5076a13 100644 --- a/server/web/package-lock.json +++ b/server/web/package-lock.json @@ -15,7 +15,7 @@ "@mui/x-data-grid": "6.10.2", "react": "18.2.0", "react-dom": "18.2.0", - "react-router-dom": "^6.15.0" + "react-router-dom": "6.15.0" }, "devDependencies": { "@csstools/postcss-global-data": "2.0.1", diff --git a/server/web/src/components/App/App.module.css b/server/web/src/components/App/App.module.css index e60d0c6d1..6615ded95 100644 --- a/server/web/src/components/App/App.module.css +++ b/server/web/src/components/App/App.module.css @@ -1,11 +1,10 @@ .container { overflow-x: hidden; margin-top: 64px; - margin-bottom: 56px; + padding-bottom: 56px; } .mainContainer { - /* background: red; */ padding: 1rem; transition: margin 225ms cubic-bezier(0, 0, 0.2, 1) 0ms; } diff --git a/server/web/src/components/ErrorPage/ErrorPage.module.css b/server/web/src/components/Pages/ErrorPage/ErrorPage.module.css similarity index 100% rename from server/web/src/components/ErrorPage/ErrorPage.module.css rename to server/web/src/components/Pages/ErrorPage/ErrorPage.module.css diff --git a/server/web/src/components/ErrorPage/ErrorPage.tsx b/server/web/src/components/Pages/ErrorPage/ErrorPage.tsx similarity index 100% rename from server/web/src/components/ErrorPage/ErrorPage.tsx rename to server/web/src/components/Pages/ErrorPage/ErrorPage.tsx diff --git a/server/web/src/components/ErrorPage/index.ts b/server/web/src/components/Pages/ErrorPage/index.ts similarity index 100% rename from server/web/src/components/ErrorPage/index.ts rename to server/web/src/components/Pages/ErrorPage/index.ts diff --git a/server/web/src/components/Features/FeatureOne/FeatureOne.tsx b/server/web/src/components/Pages/FeatureOne/FeatureOne.tsx similarity index 100% rename from server/web/src/components/Features/FeatureOne/FeatureOne.tsx rename to server/web/src/components/Pages/FeatureOne/FeatureOne.tsx diff --git a/server/web/src/components/Features/FeatureOne/index.ts b/server/web/src/components/Pages/FeatureOne/index.ts similarity index 100% rename from server/web/src/components/Features/FeatureOne/index.ts rename to server/web/src/components/Pages/FeatureOne/index.ts diff --git a/server/web/src/components/Features/FeatureTwo/FeatureTwo.tsx b/server/web/src/components/Pages/FeatureTwo/FeatureTwo.tsx similarity index 100% rename from server/web/src/components/Features/FeatureTwo/FeatureTwo.tsx rename to server/web/src/components/Pages/FeatureTwo/FeatureTwo.tsx diff --git a/server/web/src/components/Features/FeatureTwo/index.ts b/server/web/src/components/Pages/FeatureTwo/index.ts similarity index 100% rename from server/web/src/components/Features/FeatureTwo/index.ts rename to server/web/src/components/Pages/FeatureTwo/index.ts diff --git a/server/web/src/components/Pages/GenericQuestion/GenericQuestion.module.css b/server/web/src/components/Pages/GenericQuestion/GenericQuestion.module.css new file mode 100644 index 000000000..5a565c0f9 --- /dev/null +++ b/server/web/src/components/Pages/GenericQuestion/GenericQuestion.module.css @@ -0,0 +1,4 @@ +.response { + margin-top: 1rem; + white-space: break-spaces; +} \ No newline at end of file diff --git a/server/web/src/components/Pages/GenericQuestion/GenericQuestion.tsx b/server/web/src/components/Pages/GenericQuestion/GenericQuestion.tsx new file mode 100644 index 000000000..19b9b364f --- /dev/null +++ b/server/web/src/components/Pages/GenericQuestion/GenericQuestion.tsx @@ -0,0 +1,198 @@ +import { ChangeEvent, KeyboardEvent, useContext, useState } from 'react'; +import { + Alert, + Box, + IconButton, + InputAdornment, + Snackbar, + TextField, + Typography, +} from '@mui/material'; +import { SendRounded } from '@mui/icons-material'; + +import { HourglassLoader } from '@/components/HourglassLoader'; + +import { LoadingContext } from '@/state/Loading'; +import { SettingsContext } from '@/state/Settings'; + +import { + ApiOptions, + EndpointsEnum, + apiConfigConstructor, + apiFetch, + defaultApiServer, +} from '@/utils/api'; + +import styles from './GenericQuestion.module.css'; + +const baseHeaders = { + Accept: 'application/json', + 'Content-Type': 'application/json', +}; + +const chatCompletionsBaseRequest: ChatCompletionsRequest = { + model: 'gpt-3.5-turbo-16k', + messages: [ + { + role: 'user', + content: '', + name: 'USER', + }, + ], + temperature: 0.4, + top_p: 1.0, + n: 1, + max_tokens: 12847, + presence_penalty: 0.0, + frequency_penalty: 0.0, + logit_bias: {}, + user: 'USER', +}; + +const chatCompletionsApiBaseOptions: ApiOptions = { + endpointServer: defaultApiServer, + endpointPath: EndpointsEnum.chatCompletions, + endpointValue: '', + requestOptions: { + method: 'POST', + headers: baseHeaders, + }, +}; + +export function GenericQuestion() { + const [loading, setLoading] = useContext(LoadingContext); + const [settings] = useContext(SettingsContext); + const [prompt, setPrompt] = useState(''); + const [showAlert, setShowAlert] = useState(''); + const [responseMessage, setResponseMessage] = + useState(''); + + const handleClick = async () => { + if (!loading) { + try { + setLoading(true); + console.group(`🖱️ Generic question form used:`); + + const chatCompletionsRequest: ChatCompletionsRequest = { + ...chatCompletionsBaseRequest, + messages: [ + { + ...chatCompletionsBaseRequest.messages[0], + content: prompt, + }, + ], + }; + const chatCompletionsApiOptions: ApiOptions = { + ...chatCompletionsApiBaseOptions, + body: JSON.stringify(chatCompletionsRequest), + requestOptions: { + ...chatCompletionsApiBaseOptions.requestOptions, + headers: { + ...chatCompletionsApiBaseOptions.requestOptions?.headers, + Authorization: `Bearer ${settings.apiKey}`, + }, + }, + }; + const chatCompletionsApiConfig = apiConfigConstructor( + chatCompletionsApiOptions, + ); + const chatCompletionResponse = await apiFetch( + chatCompletionsApiConfig, + ); + const { content } = chatCompletionResponse.choices[0].message; + + setResponseMessage(content); + + console.info(`Chat completions request completed`); + } catch (error) { + const userFriendlyError = `Chat completions request couldn't be completed`; + console.info(userFriendlyError); + setShowAlert(` + ${userFriendlyError}, is the API key set?`); + } finally { + console.groupEnd(); + setLoading(false); + } + } + }; + + const handleKey = (event: KeyboardEvent) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + handleClick(); + } + }; + + const handleChange = (event: ChangeEvent) => { + setPrompt(event.target.value); + }; + + const disabledButton = loading || !prompt.trim(); + + return ( + <> + + + Generic question + + + This is an example of a generic call to the xef-server API. + + + Please check that you have your OpenAI key set in the Settings page. + Then ask any question in the form below: + + + + + + {loading ? : } + + + ), + }} + inputProps={{ + cols: 40, + }} + /> + + {responseMessage && ( + <> + Response: + + {responseMessage} + + + )} + reason !== 'clickaway' && setShowAlert('')} + autoHideDuration={3000}> + {showAlert} + + + ); +} diff --git a/server/web/src/components/Pages/GenericQuestion/index.tsx b/server/web/src/components/Pages/GenericQuestion/index.tsx new file mode 100644 index 000000000..391d9e401 --- /dev/null +++ b/server/web/src/components/Pages/GenericQuestion/index.tsx @@ -0,0 +1 @@ +export * from './GenericQuestion'; diff --git a/server/web/src/components/Features/Root/Root.tsx b/server/web/src/components/Pages/Root/Root.tsx similarity index 100% rename from server/web/src/components/Features/Root/Root.tsx rename to server/web/src/components/Pages/Root/Root.tsx diff --git a/server/web/src/components/Features/Root/index.ts b/server/web/src/components/Pages/Root/index.ts similarity index 100% rename from server/web/src/components/Features/Root/index.ts rename to server/web/src/components/Pages/Root/index.ts diff --git a/server/web/src/components/Pages/SettingsPage/SettingsPage.tsx b/server/web/src/components/Pages/SettingsPage/SettingsPage.tsx new file mode 100644 index 000000000..1c76ba32d --- /dev/null +++ b/server/web/src/components/Pages/SettingsPage/SettingsPage.tsx @@ -0,0 +1,57 @@ +import { ChangeEvent, useContext, useState } from 'react'; +import { Box, Button, TextField, Typography } from '@mui/material'; +import { SaveRounded } from '@mui/icons-material'; + +import { SettingsContext } from '@/state/Settings'; + +export function SettingsPage() { + const [settings, setSettings] = useContext(SettingsContext); + const [apiKeyInput, setApiKeyInput] = useState(settings.apiKey || ''); + + const handleSaving = () => { + setSettings((settings) => ({ ...settings, apiKey: apiKeyInput })); + }; + + const handleChange = (event: ChangeEvent) => { + setApiKeyInput(event.target.value); + }; + + const disabledButton = settings.apiKey === apiKeyInput?.trim(); + + return ( + <> + + + Settings + + These are xef-server settings. + + + + + + + ); +} diff --git a/server/web/src/components/Pages/SettingsPage/index.tsx b/server/web/src/components/Pages/SettingsPage/index.tsx new file mode 100644 index 000000000..f533f5abe --- /dev/null +++ b/server/web/src/components/Pages/SettingsPage/index.tsx @@ -0,0 +1 @@ +export * from './SettingsPage'; diff --git a/server/web/src/components/Sidebar/Sidebar.tsx b/server/web/src/components/Sidebar/Sidebar.tsx index 004bf1d22..b96c450ff 100644 --- a/server/web/src/components/Sidebar/Sidebar.tsx +++ b/server/web/src/components/Sidebar/Sidebar.tsx @@ -48,6 +48,16 @@ export function Sidebar({ drawerWidth, open }: SidebarProps) { + + + + + + + + + + diff --git a/server/web/src/main.css b/server/web/src/main.css index 49e559c4e..d9afa719f 100644 --- a/server/web/src/main.css +++ b/server/web/src/main.css @@ -19,7 +19,7 @@ html { html, body { - height: 100%; + height: calc(100% - 64px); margin: 0; } diff --git a/server/web/src/main.tsx b/server/web/src/main.tsx index 2a3df7cc5..90d5c1763 100644 --- a/server/web/src/main.tsx +++ b/server/web/src/main.tsx @@ -5,12 +5,15 @@ import { CssBaseline, StyledEngineProvider } from '@mui/material'; import { ThemeProvider } from '@emotion/react'; import { App } from '@/components/App'; -import { Root } from '@/components/Features/Root'; -import { ErrorPage } from '@/components/ErrorPage'; -import { FeatureOne } from '@/components/Features/FeatureOne'; -import { FeatureTwo } from '@/components/Features/FeatureTwo'; +import { Root } from '@/components/Pages/Root'; +import { ErrorPage } from '@/components/Pages/ErrorPage'; +import { FeatureOne } from '@/components/Pages/FeatureOne'; +import { FeatureTwo } from '@/components/Pages/FeatureTwo'; +import { GenericQuestion } from '@/components/Pages/GenericQuestion'; +import { SettingsPage } from '@/components/Pages/SettingsPage'; import { LoadingProvider } from '@/state/Loading'; +import { SettingsProvider } from '@/state/Settings'; import { theme } from '@/styles/theme'; @@ -34,6 +37,14 @@ const router = createBrowserRouter([ path: '2', element: , }, + { + path: 'generic-question', + element: , + }, + { + path: 'settings', + element: , + }, ], }, ]); @@ -44,7 +55,9 @@ createRoot(document.getElementById('root') as HTMLElement).render( - + + + diff --git a/server/web/src/state/Settings/SettingsContext.tsx b/server/web/src/state/Settings/SettingsContext.tsx new file mode 100644 index 000000000..6ed4c21e0 --- /dev/null +++ b/server/web/src/state/Settings/SettingsContext.tsx @@ -0,0 +1,37 @@ +import { + createContext, + useState, + ReactNode, + Dispatch, + SetStateAction, + useEffect, +} from 'react'; + +import { noop } from '@/utils/constants'; + +type SettingsContextType = [Settings, Dispatch>]; + +export const initialSettings: Settings = { + apiKey: undefined, +}; + +const SettingsContext = createContext([ + initialSettings, + noop, +]); + +const SettingsProvider = ({ children }: { children: ReactNode }) => { + const [settings, setSettings] = useState(initialSettings); + + useEffect(() => { + console.info('Settings changed', { ...settings }); + }, [settings]); + + return ( + + {children} + + ); +}; + +export { SettingsContext, SettingsProvider }; diff --git a/server/web/src/state/Settings/index.ts b/server/web/src/state/Settings/index.ts new file mode 100644 index 000000000..f171b804d --- /dev/null +++ b/server/web/src/state/Settings/index.ts @@ -0,0 +1 @@ +export * from './SettingsContext'; diff --git a/server/web/src/styles/theme.ts b/server/web/src/styles/theme.ts index f42cf5a12..9b5e8dabc 100644 --- a/server/web/src/styles/theme.ts +++ b/server/web/src/styles/theme.ts @@ -94,13 +94,11 @@ const themeOptions: ThemeOptions = { MuiInputBase: { defaultProps: { margin: 'dense', - size: 'medium', }, }, MuiInputLabel: { defaultProps: { margin: 'dense', - size: 'normal', }, }, MuiList: { @@ -161,6 +159,9 @@ const themeOptions: ThemeOptions = { h6: { fontWeight: 600, }, + button: { + fontWeight: 600, + }, }, }, }, diff --git a/server/web/src/utils/api.ts b/server/web/src/utils/api.ts index ab6f88159..cf201439d 100644 --- a/server/web/src/utils/api.ts +++ b/server/web/src/utils/api.ts @@ -1,6 +1,6 @@ import { toSnakeCase } from '@/utils/strings'; -export const defaultApiServer = 'http://localhost:8080/'; +export const defaultApiServer = 'http://localhost:8081/'; export type ApiConfig = { url: URL; @@ -17,15 +17,11 @@ export type ApiOptions = { }; export enum EndpointsEnum { - test = 'test', + chatCompletions = 'chat/completions', } -export type TestResponse = { - message: string; -}; - export type EndpointsTypes = { - test: TestResponse; + chatCompletions: ChatCompletionsResponse; }; export type EndpointEnumKey = keyof typeof EndpointsEnum; @@ -44,7 +40,7 @@ export function apiConfigConstructor(userApiOptions: ApiOptions): ApiConfig { const options: RequestInit = { ...userApiOptions.requestOptions, - body: JSON.stringify(userApiOptions.body), + body: userApiOptions.body || userApiOptions.requestOptions?.body, }; const config = { diff --git a/server/web/src/vite-env.d.ts b/server/web/src/vite-env.d.ts index 11f02fe2a..59d99639b 100644 --- a/server/web/src/vite-env.d.ts +++ b/server/web/src/vite-env.d.ts @@ -1 +1,296 @@ /// + +type Settings = { + apiKey?: string; +}; + +/** + * For all the OpenAI API based typings, plase check: + * + * https://github.com/openai/openai-node/ + * + **/ + +type FunctionCallOption = { + /** + * The name of the function to call. + */ + name: string; +}; + +type FunctionCall = { + /** + * 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; + + /** + * The parameters the functions accepts, described as a JSON Schema object. See the + * [guide](/docs/guides/gpt/function-calling) for examples, and the + * [JSON Schema reference](https://json-schema.org/understanding-json-schema/) for + * documentation about the format. + * + * To describe a function that accepts no parameters, provide the value + * `{"type": "object", "properties": {}}`. + */ + parameters: Record; + + /** + * A description of what the function does, used by the model to choose when and + * how to call the function. + */ + description?: string; +}; + +type ChatCompletionsRequest = { + /** + * A list of messages comprising the conversation so far. + * [Example Python code](https://github.com/openai/openai-cookbook/blob/main/examples/How_to_format_inputs_to_ChatGPT_models.ipynb). + */ + messages: Array; + + /** + * ID of the model to use. See the + * [model endpoint compatibility](/docs/models/model-endpoint-compatibility) table + * for details on which models work with the Chat API. + */ + model: + | 'gpt-4' + | 'gpt-4-0314' + | 'gpt-4-0613' + | 'gpt-4-32k' + | 'gpt-4-32k-0314' + | 'gpt-4-32k-0613' + | 'gpt-3.5-turbo' + | 'gpt-3.5-turbo-16k' + | 'gpt-3.5-turbo-0301' + | 'gpt-3.5-turbo-0613' + | 'gpt-3.5-turbo-16k-0613'; + + /** + * 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. + * + * [See more information about frequency and presence penalties.](/docs/api-reference/parameter-details) + */ + frequency_penalty?: number | null; + + /** + * 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' | FunctionCallOption; + + /** + * A list of functions the model may generate JSON inputs for. + */ + functions?: Array; + + /** + * 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?: Record | null; + + /** + * The maximum number of [tokens](/tokenizer) 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](https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb) + * for counting tokens. + */ + max_tokens?: number; + + /** + * How many chat completion choices to generate for each input message. + */ + n?: number | null; + + /** + * 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. + * + * [See more information about frequency and presence penalties.](/docs/api-reference/parameter-details) + */ + presence_penalty?: number | null; + + /** + * Up to 4 sequences where the API will stop generating further tokens. + */ + stop?: string | null | Array; + + /** + * If set, partial message deltas will be sent, like in ChatGPT. Tokens will be + * sent as data-only + * [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format) + * as they become available, with the stream terminated by a `data: [DONE]` + * message. + * [Example Python code](https://github.com/openai/openai-cookbook/blob/main/examples/How_to_stream_completions.ipynb). + */ + stream?: boolean | null; + + /** + * 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 | null; + + /** + * 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 | null; + + /** + * A unique identifier representing your end-user, which can help OpenAI to monitor + * and detect abuse. [Learn more](/docs/guides/safety-best-practices/end-user-ids). + */ + user?: string; +}; + +/** + * The name and arguments of a function that should be called, as generated by the + * model. + */ +type FunctionCall = { + /** + * The arguments to call the function with, as generated by the model in JSON + * format. Note that the model does not always generate valid JSON, and may + * hallucinate parameters not defined by your function schema. Validate the + * arguments in your code before calling your function. + */ + arguments: string; + + /** + * The name of the function to call. + */ + name: string; +}; + +/** + * A chat completion message generated by the model. + */ +type ChatCompletionMessage = { + /** + * The contents of the message. `content` is required for all messages, and may be + * null for assistant messages with function calls. + */ + content: string | null; + + /** + * The role of the author of this message. + */ + role: 'system' | 'user' | 'assistant' | 'function'; + + /** + * The name and arguments of a function that should be called, as generated by the + * model. + */ + function_call?: FunctionCall; + + /** + * The name of the author of this message. `name` 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; +}; + +type Choice = { + /** + * The reason the model stopped generating tokens. This will be `stop` if the model + * hit a natural stop point or a provided stop sequence, `length` if the maximum + * number of tokens specified in the request was reached, or `function_call` if the + * model called a function. + */ + finish_reason: 'stop' | 'length' | 'function_call'; + + /** + * The index of the choice in the list of choices. + */ + index: number; + + /** + * A chat completion message generated by the model. + */ + message: ChatCompletionMessage; +}; + +/** + * Usage statistics for the completion request. + */ +type CompletionUsage = { + /** + * Number of tokens in the generated completion. + */ + completion_tokens: number; + + /** + * Number of tokens in the prompt. + */ + prompt_tokens: number; + + /** + * Total number of tokens used in the request (prompt + completion). + */ + total_tokens: number; +}; + +/** + * Check: + * https://github.com/openai/openai-node/blob/master/src/resources/chat/completions.ts + * + **/ +type ChatCompletionsResponse = { + /** + * A unique identifier for the chat completion. + */ + id: string; + + /** + * The object type, which is always `chat.completion`. + */ + object: 'chat.completion'; + + /** + * A unix timestamp of when the chat completion was created. + */ + created: number; + + /** + * The model used for the chat completion. + */ + model: string; + + /** + * A list of chat completion choices. Can be more than one if `n` is greater + * than 1. + */ + choices: Array; + + /** + * Usage statistics for the completion request. + */ + usage?: CompletionUsage; +};