diff --git a/src/app/hooks/aiContext.tsx b/src/app/hooks/aiContext.tsx new file mode 100644 index 0000000..3b1e151 --- /dev/null +++ b/src/app/hooks/aiContext.tsx @@ -0,0 +1,39 @@ +import { OpenAI } from 'openai' +import React, { useEffect } from 'react' +import { useAppSelector } from './reduxHooks' +import { selectApiKey, selectOrganization } from '../../features/wizard/credentialsSlice' +import NiceModal from '@ebay/nice-modal-react' +import { OpenAIKeyModal } from '../../features/modals/OpenAIKeyModal' + +type AIContextType = { + openAIInstance?: OpenAI +} + +export const AIContext = React.createContext({}) + +export const AIProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const organizationStore = useAppSelector(selectOrganization) + const apiKeyStore = useAppSelector(selectApiKey) + const organization = process.env.REACT_APP_OPENAI_ORGANIZATION || organizationStore + const apiKey = process.env.REACT_APP_OPENAI_API_KEY || apiKeyStore + const [openAIInstance, setOpenAIInstance] = React.useState(undefined) + useEffect(() => { + if (organization && apiKey && organization !== '' && apiKey !== '') { + setOpenAIInstance( + new OpenAI({ + dangerouslyAllowBrowser: true, + organization, + apiKey, + }) + ) + } + }, [organization, apiKey, setOpenAIInstance]) + useEffect(() => { + if (!openAIInstance) { + NiceModal.show(OpenAIKeyModal, { onConfirm: (instance) => setOpenAIInstance(instance), onReject: () => {} }) + } + }, [openAIInstance, setOpenAIInstance]) + return {children} +} + +export const useAI = () => React.useContext(AIContext) diff --git a/src/app/store.ts b/src/app/store.ts index cf67fe1..f18cc55 100644 --- a/src/app/store.ts +++ b/src/app/store.ts @@ -2,13 +2,15 @@ import { configureStore, ThunkAction, Action } from '@reduxjs/toolkit' import { persistStore, persistReducer } from 'redux-persist' import counterReducer from '../features/counter/counterSlice' import jsonFormsEditReducer from '../features/wizard/WizardSlice' -import templateSlice from '../features/wizard/TemplateSlice' +import templateSlice, { restoreTemplates } from '../features/wizard/TemplateSlice' import storage from 'redux-persist/lib/storage' -import { formsDataReducer } from '../features/wizard/FormDataSlice' // defaults to localStorage for web +import { formsDataReducer, initialFormState, restoreForms } from '../features/wizard/FormDataSlice' +import { credentialsReducer } from '../features/wizard/credentialsSlice' // defaults to localStorage for web export const store = configureStore({ reducer: { counter: counterReducer, jsonFormsEdit: jsonFormsEditReducer, + credentials: credentialsReducer, template: persistReducer( { key: 'template', @@ -30,3 +32,36 @@ export const persistor = persistStore(store) export type AppDispatch = typeof store.dispatch export type RootState = ReturnType export type AppThunk = ThunkAction> + +export const appVersion = process.env.REACT_APP_VERSION || process.env.REACT_APP_COMMIT || new Date().toISOString() + +type StoreBackup = { appVersion: string; store: { template: RootState['template']; formsData: RootState['formsData'] } } +export const downloadBackup: () => string = () => { + const template = store.getState().template + const formsData = store.getState().formsData + const appVersion = process.env.REACT_APP_VERSION || process.env.REACT_APP_COMMIT || new Date().toISOString() + const backup: StoreBackup = { + appVersion, + store: { + template, + formsData, + }, + } + const blob = new Blob([JSON.stringify(backup)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + return url +} + +export const uploadBackup = (backup: StoreBackup) => { + console.log( + `uploading backup from App Version: ${backup.appVersion} to current instance of version ${appVersion}`, + backup + ) + store.dispatch(restoreTemplates(backup.store.template)) + store.dispatch(restoreForms(backup.store.formsData)) +} + +export const resetStore = () => { + store.dispatch(restoreTemplates({ templates: [] })) + store.dispatch(restoreForms(initialFormState)) +} diff --git a/src/features/dragAndDrop/DropTargetFormsPreview.tsx b/src/features/dragAndDrop/DropTargetFormsPreview.tsx index 5904f72..3aae21e 100644 --- a/src/features/dragAndDrop/DropTargetFormsPreview.tsx +++ b/src/features/dragAndDrop/DropTargetFormsPreview.tsx @@ -3,7 +3,10 @@ import { JsonForms } from '@jsonforms/react' import { materialCells, materialRenderers } from '@jsonforms/material-renderers' import { JsonSchema7, UISchemaElement } from '@jsonforms/core' -const DropTargetFormsPreview: React.FC<{ metadata: DraggableComponent }> = ({ metadata }) => ( +const DropTargetFormsPreview: React.FC<{ metadata: DraggableComponent; topLevelUISchema?: boolean }> = ({ + metadata, + topLevelUISchema, +}) => ( <> {metadata.jsonSchemaElement && ( = ({ me renderers={materialRenderers} cells={materialCells} uischema={ - { - type: 'VerticalLayout', - elements: [ - { - type: 'Control', - scope: `#/properties/${metadata.name}`, - ...(metadata.uiSchema || {}), - }, - ], - } as UISchemaElement + topLevelUISchema && metadata.uiSchema + ? metadata.uiSchema + : ({ + type: 'VerticalLayout', + elements: [ + { + type: 'Control', + scope: `#/properties/${metadata.name}`, + ...(metadata.uiSchema || {}), + }, + ], + } as UISchemaElement) } schema={ - { - type: 'object', - properties: { - [metadata?.name]: metadata.jsonSchemaElement, - }, - } as JsonSchema7 + topLevelUISchema && metadata.jsonSchemaElement + ? metadata.jsonSchemaElement + : ({ + type: 'object', + properties: { + [metadata?.name]: metadata.jsonSchemaElement, + }, + } as JsonSchema7) } /> )} diff --git a/src/features/home/ClickBox.tsx b/src/features/home/ClickBox.tsx index d157586..b1dc735 100644 --- a/src/features/home/ClickBox.tsx +++ b/src/features/home/ClickBox.tsx @@ -1,20 +1,34 @@ import { Avatar, Button, Card, CardActionArea, CardContent, CardHeader, CardMedia, Typography } from '@mui/material' import React, { useCallback } from 'react' -import { useAppDispatch } from '../../app/hooks/reduxHooks' -import { loadForm } from '../wizard/FormDataSlice' +import { useAppDispatch, useAppSelector } from '../../app/hooks/reduxHooks' +import { listFormData, loadForm, removeForm } from '../wizard/FormDataSlice' import { red } from '@mui/material/colors' +import { replaceSchema, replaceUISchema } from '../wizard/WizardSlice' type ClickBoxProps = { id: string title: string avatar?: string + disableActions?: boolean } -export const ClickBox = ({ id, title, avatar }: ClickBoxProps) => { +export const ClickBox = ({ id, title, avatar, disableActions }: ClickBoxProps) => { const dispatch = useAppDispatch() + const formList = useAppSelector(listFormData) const handleLoad = useCallback(() => { dispatch(loadForm({ id })) }, [id]) + const handleLoadFormData = useCallback(() => { + const { jsonSchema, uiSchema } = formList.find((f) => f.id === id) || { jsonSchema: {}, uiSchema: undefined } + dispatch(replaceSchema(jsonSchema)) + dispatch(replaceUISchema(uiSchema)) + dispatch(loadForm({ id })) + }, [id, formList]) + + const handleRemove = useCallback(() => { + dispatch(removeForm({ id: id })) + }, [id]) + return ( { title={title} > {avatar && } - - - + + {!disableActions && ( + + + + + + )} ) } diff --git a/src/features/home/DragBox.tsx b/src/features/home/DragBox.tsx index d4ad390..ef9a4f7 100644 --- a/src/features/home/DragBox.tsx +++ b/src/features/home/DragBox.tsx @@ -1,15 +1,18 @@ -import React, { useCallback, useEffect, useLayoutEffect, useState } from 'react' +import React, { useCallback } from 'react' import { useDrag } from 'react-dnd' -import { Button, Card, CardActionArea, CardActions, CardContent, Typography } from '@mui/material' -import { DraggableComponent, replaceSchema } from '../wizard/WizardSlice' +import { Button, Card, CardActionArea, CardContent, Typography } from '@mui/material' +import { DraggableComponent, replaceSchema, replaceUISchema } from '../wizard/WizardSlice' import { useAppDispatch } from '../../app/hooks/reduxHooks' +import { newForm } from '../wizard/FormDataSlice' +import { removeTemplate } from '../wizard/TemplateSlice' type DragBoxProps = { name: string img?: string componentMeta: DraggableComponent + disableActions?: boolean } -const DragBox = ({ name = 'Eingabefeld', img = '', componentMeta }: DragBoxProps) => { +const DragBox = ({ name = 'Eingabefeld', img = '', componentMeta, disableActions }: DragBoxProps) => { const dispatch = useAppDispatch() const [, dragRef] = useDrag( () => ({ @@ -29,6 +32,12 @@ const DragBox = ({ name = 'Eingabefeld', img = '', componentMeta }: DragBoxProps const handleReplace = useCallback(() => { dispatch(replaceSchema(componentMeta.jsonSchemaElement)) + dispatch(replaceUISchema(componentMeta.uiSchema)) + dispatch(newForm({ jsonSchema: componentMeta.jsonSchemaElement, uiSchema: componentMeta.uiSchema })) + }, [dispatch, componentMeta]) + + const handleRemove = useCallback(() => { + dispatch(removeTemplate({ element: componentMeta })) }, [dispatch, componentMeta]) return ( @@ -39,9 +48,12 @@ const DragBox = ({ name = 'Eingabefeld', img = '', componentMeta }: DragBoxProps {name} - - - + {!disableActions && ( + + + + + )} ) diff --git a/src/features/home/LeftDrawer.tsx b/src/features/home/LeftDrawer.tsx index f9f3406..f498551 100644 --- a/src/features/home/LeftDrawer.tsx +++ b/src/features/home/LeftDrawer.tsx @@ -17,7 +17,7 @@ import { ClickBox } from './ClickBox' import { listFormData, newForm } from '../wizard/FormDataSlice' import { CreateRounded } from '@mui/icons-material' -const drawerWidth = 240 +const drawerWidth = 340 export const basicDraggableComponents: DraggableComponent[] = [ { @@ -51,6 +51,26 @@ export const basicDraggableComponents: DraggableComponent[] = [ }, }, }, + { + name: 'Auswahlfeld', + jsonSchemaElement: { + type: 'string', + enum: ['DE', 'IT', 'JP', 'US', 'RU', 'Other'], + }, + }, + { + name: 'Zahleneingabe', + jsonSchemaElement: { + type: 'number', + }, + }, + { + name: 'E-Mail', + jsonSchemaElement: { + type: 'string', + format: 'email', + }, + }, ] export const advancedDraggableComponents: DraggableComponent[] = [ @@ -161,12 +181,12 @@ export default function LeftDrawer() { - - + + - + {basicDraggableComponents.map((component, index) => { - return + return ( + + ) })} - + KI gestützte Formulargenerierung {advancedDraggableComponents.map((component, index) => { return diff --git a/src/features/home/RightDrawer.tsx b/src/features/home/RightDrawer.tsx index 5720e9d..a4fec43 100644 --- a/src/features/home/RightDrawer.tsx +++ b/src/features/home/RightDrawer.tsx @@ -21,6 +21,7 @@ import { useAppDispatch } from '../../app/hooks/reduxHooks' import { pathToScope } from '../../utils/uiSchemaHelpers' import RecursiveTreeView, { RenderTree } from '../wizard/JSONSchemaTreeView' import { JsonSchema7 } from '@jsonforms/core' +import { selectCurrentForm, selectFormData } from '../wizard/FormDataSlice' const drawerWidth = 240 @@ -112,14 +113,47 @@ const jsonSchema2RenderTreeView: (key: string, jsonSchema: JsonSchema7) => Rende } } +const jsonDataObj2RenderTreeView: (key: string, jsonData: object) => RenderTree = (key, jsonData) => { + return { + id: key, + name: key, + children: Object.keys(jsonData || {}).map((innerKey) => + jsonData2RenderTreeView(`${innerKey}`, jsonData[innerKey] as any) + ), + } +} + +const jsonDataArray2RenderTreeView: (key: string, jsonData: any[]) => RenderTree = (key, jsonData) => { + return { + id: key, + name: key, + children: jsonData.map((item, index) => jsonData2RenderTreeView(`[${index}]`, item)), + } +} + +const jsonData2RenderTreeView: (key: string, jsonData: any) => RenderTree = (key, jsonData) => { + if (Array.isArray(jsonData)) { + return jsonDataArray2RenderTreeView(key, jsonData) + } else if (typeof jsonData === 'object') { + return jsonDataObj2RenderTreeView(key, jsonData) + } else { + return { + id: key, + name: `${key} = ${String(jsonData)}`, + children: [], + } + } +} export default function RightDrawer() { const selectedKey = useSelector(selectSelectedElementKey) const uiSchema = useSelector(selectUIElementFromSelection) + const formData = useSelector(selectCurrentForm) const jsonSchema = useSelector(selectJsonSchema) const jsonSchemaTree = useMemo( () => jsonSchema2RenderTreeView('Schema', jsonSchema as JsonSchema7), [jsonSchema] ) + const formDataTree = useMemo(() => jsonData2RenderTreeView('Data', formData), [formData]) const dispatch = useAppDispatch() const handleLabelChange = useCallback( (e) => { @@ -164,6 +198,10 @@ export default function RightDrawer() { + + + + ) } diff --git a/src/features/input/UploadAnalyzeGPT.tsx b/src/features/input/UploadAnalyzeGPT.tsx deleted file mode 100644 index 93a0750..0000000 --- a/src/features/input/UploadAnalyzeGPT.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { useState } from 'react' -import openAIInstance, { model } from '../../utils/openai' -import { threadId } from 'worker_threads' - -export const UploadAnalyzeGPT = () => { - const [summary, setSummary] = useState('') - const [loading, setLoading] = useState(false) - - const handleFileUpload = async (event) => { - const file = event.target.files[0] - - if (!file) { - return - } - - setLoading(true) - - try { - const client = openAIInstance - - // Upload the file to OpenAI - /*const response = await client.files.create({ - file: new File([file], file.name), - purpose: 'assistants', // Change the purpose according to your need - });*/ - - const vectorStore = await client.beta.vectorStores.create({ name: 'File Upload' }) - console.log('Vector Store ID:', vectorStore.id) - - const batch = await client.beta.vectorStores.fileBatches.uploadAndPoll(vectorStore.id, { - files: [new File([file], file.name)], - }) - console.log({ batch }) - - const assistant = await client.beta.assistants.create({ - model: model, - instructions: 'You are a knowledgeable assistant that uses the provided files to answer questions.', - tools: [{ type: 'file_search' }], - tool_resources: { - file_search: { - vector_store_ids: [vectorStore.id], // Attach vector store containing your files - }, - }, - }) - - console.log('Assistant ID:', assistant.id) - console.log({ assistant }) - - const thread = await client.beta.threads.create() - console.log('Thread ID:', thread.id) - - const message = await client.beta.threads.messages.create(thread.id, { - role: 'user', - content: 'Summarize what can be seen at the image', - }) - - const run = await client.beta.threads.runs.create(thread.id, { - assistant_id: assistant.id, - }) - - console.log({ run }) - - let interval = setInterval(async () => { - const status = await client.beta.threads.runs.retrieve(thread.id, run.id) - if (status.status === 'completed') { - clearInterval(interval) - const messages = await client.beta.threads.messages.list(thread.id) - console.log({ messages }) - } - }, 1000) - - //setSummary(run.choices[0].message.content.trim()); - } catch (error) { - console.error('Error uploading file:', error) - setSummary('An error occurred while summarizing the file.') - } finally { - setLoading(false) - } - } - - return ( -
- - {loading &&

Uploading and summarizing the file...

} - {summary &&

Summary: {summary}

} -
- ) -} diff --git a/src/features/modals/ChatGptModal.tsx b/src/features/modals/ChatGptModal.tsx index 409d9d3..86a4550 100644 --- a/src/features/modals/ChatGptModal.tsx +++ b/src/features/modals/ChatGptModal.tsx @@ -10,8 +10,8 @@ import NiceModal, { useModal } from '@ebay/nice-modal-react' import { FormattedMessage } from 'react-intl' import { TransitionProps } from '@mui/material/transitions' -import openaiInstance, { model } from '../../utils/openai' -import { Grid, Tab, TextField } from '@mui/material' +import { model } from '../../utils/openai' +import { Backdrop, CircularProgress, Grid, Tab, TextField } from '@mui/material' import { useAppDispatch } from '../../app/hooks/reduxHooks' import { addTemplate } from '../wizard/TemplateSlice' import { generateDefaultUISchema } from '@jsonforms/core' @@ -22,6 +22,7 @@ import { LoadingButton, TabContext, TabList, TabPanel } from '@mui/lab' import DropTargetFormsPreview from '../dragAndDrop/DropTargetFormsPreview' import Vocal from '@untemps/react-vocal' import { AndroidRounded } from '@mui/icons-material' +import { useAI } from '../../app/hooks/aiContext' interface ConfirmModalProps { onConfirm?: () => void @@ -48,6 +49,7 @@ const ChatGptModal = NiceModal.create(({ onConfirm = () => nu const dispatch = useAppDispatch() const [activeTab, setActiveTab] = useState('2') const [loading, setLoading] = useState(false) + const { openAIInstance } = useAI() const onVocalResult = useCallback( (result) => { setMessage((msg) => `${msg} ${result}`) @@ -61,12 +63,12 @@ const ChatGptModal = NiceModal.create(({ onConfirm = () => nu const element = { name: formTitle, jsonSchemaElement: jsonSchema, - uiSchema: updateScopeOfUISchemaElement('#', `#/properties/${formTitle}`, { + uiSchema: { type: 'Group', //@ts-ignore label: formTitle, elements: [generateDefaultUISchema(jsonSchema)], - }), + }, } setNewElement(element) } catch (e) { @@ -77,7 +79,11 @@ const ChatGptModal = NiceModal.create(({ onConfirm = () => nu const talkToAI = useCallback( async (text) => { // Insert message at first element. - const response = await openaiInstance.chat.completions.create({ + let client = openAIInstance + if (!client) return + + setLoading(true) + const response = await client.chat.completions.create({ model: model, messages: [ { @@ -90,7 +96,8 @@ const ChatGptModal = NiceModal.create(({ onConfirm = () => nu console.log(response) // Append AI message. const res = response.choices[0].message.content - setResponse(res) + res && setResponse(res) + if (!res) return let formTitle: string | null = null try { const schema = JSON.parse(res) @@ -98,10 +105,10 @@ const ChatGptModal = NiceModal.create(({ onConfirm = () => nu formTitle = schema.title } } catch (e) { - console.warn(e.message) + console.warn('Could not parse JSON Schema title', e) } if (!formTitle) { - const titleResponse = await openaiInstance.chat.completions.create({ + const titleResponse = await client.chat.completions.create({ model: model, messages: [ { role: 'user', content: `A short headline or title that summarizes the following form: ${text}` }, @@ -111,14 +118,13 @@ const ChatGptModal = NiceModal.create(({ onConfirm = () => nu formTitle = titleResponse.choices[0].message.content } - setFormTitle(formTitle) + formTitle && setFormTitle(formTitle) setLoading(false) }, - [setResponse, setFormTitle, setLoading] + [setResponse, setFormTitle, setLoading, openAIInstance] ) const onSubmit = useCallback( (event) => { - setLoading(true) talkToAI(message) }, [talkToAI, message, setLoading] @@ -191,7 +197,7 @@ const ChatGptModal = NiceModal.create(({ onConfirm = () => nu />
- {newElement && } + {newElement && }
@@ -201,7 +207,7 @@ const ChatGptModal = NiceModal.create(({ onConfirm = () => nu @@ -214,10 +220,12 @@ export function ConfirmButton({ children, ...props }: ConfirmModalProps & { children: React.ReactNode }) { + const { openAIInstance } = useAI() return ( werden. Wenn Sie die Anwendung jedoch mit OpenAI + verwenden möchten, müssen Sie Ihre OpenAI-Organisation und Ihren OpenAI-API-Schlüssel eingeben. Bei KI + gestützten Anfragen, werden dann Daten an OpenAI gesendet. Die Daten, die an OpenAI gesendet werden, können + sie im Netzwerk-Tab der Entwicklerwerkzeuge (Strg+Shift+I) einsehen. Ihre eingegebenen Daten bleiben + grundsätzlich auf Ihrem Gerät und werden nicht an Dritte weitergegeben. Da dies eine Demo-Anwendung ist, + können wir jedoch keine Garantie für die Sicherheit Ihrer Daten übernehmen. + + setOrganizationTemp(e.target.value)} + /> + setApiKeyTemp(e.target.value)} + /> + + + + + + + + ) +}) diff --git a/src/features/wizard/DownloadBackupButton.tsx b/src/features/wizard/DownloadBackupButton.tsx new file mode 100644 index 0000000..7d61bb8 --- /dev/null +++ b/src/features/wizard/DownloadBackupButton.tsx @@ -0,0 +1,15 @@ +import { Button } from '@mui/material' +import { Download } from '@mui/icons-material' +import { downloadBackup } from '../../app/store' +import React, { useMemo } from 'react' + +export const DownloadBackupButton = ({ children }: { children: string | undefined }) => { + const src = useMemo(() => downloadBackup(), []) + const isoDate = useMemo(() => new Date().toISOString(), []) + const appVersion = useMemo(() => process.env.REACT_APP_VERSION || '', []) + return ( + + ) +} diff --git a/src/features/wizard/FormDataSlice.ts b/src/features/wizard/FormDataSlice.ts index 18a0f97..b706b5e 100644 --- a/src/features/wizard/FormDataSlice.ts +++ b/src/features/wizard/FormDataSlice.ts @@ -1,5 +1,3 @@ -//now we create a reducer for formData including an ID and a JSON Schema - import { JsonSchema } from '@jsonforms/core' import { ScopableUISchemaElement } from '../../types' import { RootState } from '../../app/store' @@ -28,31 +26,43 @@ export const selectFormData = (state: RootState) => state.formsData.formData[sta export const selectCurrentForm = (state: RootState) => state.formsData.formData[state.formsData.currentID] export const listFormData = (state: RootState) => Object.values(state.formsData.formData) -export const formsContentSlice = createSlice({ - name: 'formsContent', - initialState: { - currentID: initialID, - formData: { - [initialID]: { - id: initialID, - title: 'Showcase', - jsonSchema: { - type: 'object', - properties: { - name: { type: 'string' }, - }, - }, - uiSchema: null, - formData: { - name: 'Showcase', + +export const initialFormState: FormsContentReducer = { + currentID: initialID, + formData: { + [initialID]: { + id: initialID, + title: 'Showcase', + jsonSchema: { + type: 'object', + properties: { + name: { type: 'string' }, }, }, + uiSchema: null, + formData: { + name: 'Showcase', + }, }, - } as FormsContentReducer, + }, +} +export const formsContentSlice = createSlice({ + name: 'formsContent', + initialState: initialFormState, reducers: { + restoreForms: (state: FormsContentReducer, action: PayloadAction) => { + state.formData = action.payload.formData + state.currentID = action.payload.currentID + }, setData: (state: FormsContentReducer, action: PayloadAction) => { state.formData[state.currentID].formData = action.payload }, + removeForm: (state: FormsContentReducer, action: PayloadAction<{ id: string }>) => { + delete state.formData[action.payload.id] + if (state.currentID === action.payload.id) { + state.currentID = Object.keys(state.formData)[0] + } + }, loadForm: (state: FormsContentReducer, action: PayloadAction<{ id: string }>) => { state.currentID = action.payload.id }, @@ -76,5 +86,5 @@ export const formsContentSlice = createSlice({ }, }) -export const { newForm, setData, setAvatar, setTitle, loadForm } = formsContentSlice.actions +export const { newForm, setData, setAvatar, setTitle, loadForm, removeForm, restoreForms } = formsContentSlice.actions export const formsDataReducer = formsContentSlice.reducer diff --git a/src/features/wizard/JSONSchemaTreeView.tsx b/src/features/wizard/JSONSchemaTreeView.tsx index 1d969fb..1180b87 100644 --- a/src/features/wizard/JSONSchemaTreeView.tsx +++ b/src/features/wizard/JSONSchemaTreeView.tsx @@ -1,10 +1,11 @@ import React, { useCallback } from 'react' import { TreeItem, TreeView } from '@mui/lab' -import { Checkbox, FormControlLabel } from '@mui/material' +import { Checkbox, FormControlLabel, Typography } from '@mui/material' import { ChevronRight, ExpandMore } from '@mui/icons-material' import { useAppDispatch } from '../../app/hooks/reduxHooks' import { selectElement, selectSelectedElementKey } from './WizardSlice' import { useSelector } from 'react-redux' +import { OverflowContainer } from './OverflowContainer' export interface RenderTree { id: string @@ -92,7 +93,9 @@ export default function RecursiveTreeView({ data, checkboxes, omitString }: Prop key={key} /> ) : ( - <>{nodes.name} + + {nodes.name}{' '} + ) } > diff --git a/src/features/wizard/OverflowContainer.tsx b/src/features/wizard/OverflowContainer.tsx new file mode 100644 index 0000000..181a11d --- /dev/null +++ b/src/features/wizard/OverflowContainer.tsx @@ -0,0 +1,50 @@ +import React, { useState, MouseEvent, useCallback } from 'react' +import { Box, Tooltip, TypographyProps } from '@mui/material' + +type Props = { + children: React.ReactNode + tooltip?: React.ReactNode + density?: 'comfortable' | 'compact' | 'spacious' + useParentTarget?: boolean +} + +export type OverflowContainerProps = Props & Partial +export const OverflowContainer = ({ + children, + tooltip, + useParentTarget, + density, + ...props +}: OverflowContainerProps) => { + const [tooltipEnabled, setTooltipEnabled] = useState(false) + + const handleShouldShow = useCallback( + ({ currentTarget }: MouseEvent) => { + const cTarget = useParentTarget ? currentTarget.parentElement : currentTarget + if ( + tooltip || + (density === 'spacious' + ? cTarget.scrollHeight > cTarget.clientHeight + : cTarget.scrollWidth > cTarget.clientWidth) + ) { + setTooltipEnabled(true) + } + }, + [setTooltipEnabled, tooltip, density, useParentTarget] + ) + + return ( + setTooltipEnabled(false)}> + + {children} + + + ) +} diff --git a/src/features/wizard/TemplateSlice.ts b/src/features/wizard/TemplateSlice.ts index 2ec5a12..66ec65f 100644 --- a/src/features/wizard/TemplateSlice.ts +++ b/src/features/wizard/TemplateSlice.ts @@ -15,14 +15,23 @@ export const templateSlice = createSlice({ name: 'template', initialState, reducers: { + restoreTemplates: (state: TemplateState, action: PayloadAction<{ templates: DraggableComponent[] }>) => { + const { templates } = action.payload + state.templates = templates + }, addTemplate: (state: TemplateState, action: PayloadAction<{ element: DraggableComponent }>) => { const { element } = action.payload // @ts-ignore state.templates = [...state.templates, element] }, + removeTemplate: (state: TemplateState, action: PayloadAction<{ element: DraggableComponent }>) => { + const { element } = action.payload + // @ts-ignore + state.templates = state.templates.filter((template) => template.name !== element.name) + }, }, }) -export const { addTemplate } = templateSlice.actions +export const { addTemplate, removeTemplate, restoreTemplates } = templateSlice.actions export default templateSlice.reducer diff --git a/src/features/wizard/UploadBackupButton.tsx b/src/features/wizard/UploadBackupButton.tsx new file mode 100644 index 0000000..bdd2792 --- /dev/null +++ b/src/features/wizard/UploadBackupButton.tsx @@ -0,0 +1,29 @@ +import { UploadFile } from '@mui/icons-material' +import { Button } from '@mui/material' +import { uploadBackup } from '../../app/store' + +export const UploadBackupButton = ({ children }: { children: string | undefined }) => { + const handleFileUpload = (event: React.ChangeEvent) => { + const file = (event.target as any).files?.[0] + if (!file) { + return + } + const reader = new FileReader() + reader.onload = (e) => { + const content = e.target?.result + if (!content) { + return + } + const json = JSON.parse(content.toString()) + //we should check via ajv for correctness + uploadBackup(json) + } + reader.readAsText(file) + } + return ( + + ) +} diff --git a/src/features/wizard/Wizard.tsx b/src/features/wizard/Wizard.tsx index ea58533..5d70c21 100644 --- a/src/features/wizard/Wizard.tsx +++ b/src/features/wizard/Wizard.tsx @@ -13,10 +13,14 @@ import { horizontalLayoutTester } from '../../renderer/HorizontalLayoutWithDropZ import HorizontalLayoutWithDropZoneRenderer from '../../renderer/HorizontalLayoutWithDropZoneRenderer' import RightDrawer from '../home/RightDrawer' import LeftDrawer from '../home/LeftDrawer' -import { TextField } from '@mui/material' -import openAIInstance, { model } from '../../utils/openai' +import { Backdrop, CircularProgress, TextField } from '@mui/material' +import { model } from '../../utils/openai' import Button from '@mui/material/Button' import { selectFormData, setAvatar, setData, setTitle } from './FormDataSlice' +import { useAI } from '../../app/hooks/aiContext' +import NiceModal from '@ebay/nice-modal-react' +import { OpenAIKeyModal } from '../modals/OpenAIKeyModal' +import { OpenAI } from 'openai' const renderers = [ ...materialRenderers, @@ -32,6 +36,7 @@ const renderers = [ function Wizard() { const data = useAppSelector(selectFormData) + const [inProgress, setInProgress] = useState(false) const jsonSchema = useAppSelector(selectJsonSchema) const dispatch = useAppDispatch() const handleFormChange = useCallback( @@ -43,37 +48,55 @@ function Wizard() { const uiSchema = useAppSelector(selectUiSchema) const editMode = useAppSelector(selectEditMode) const [imageURL, setImageURL] = useState('') + const { openAIInstance } = useAI() - const getTitleOfData = useCallback(async (d: any) => { - const client = openAIInstance - const response = await client.chat.completions.create({ - model: model, - messages: [ - { - role: 'user', - content: `give a good summarizing title of the following data: \n ${JSON.stringify(d, null, 2)}`, - }, - ], - max_tokens: 300, - }) - console.log(response) - // Append AI message. - const res = response.choices[0].message.content - typeof res === 'string' && dispatch(setTitle(res.replace('"', ''))) - }, [dispatch]) + const getTitleOfData = useCallback( + async (d: any) => { + let client: OpenAI | null = null + if (!client) return + const response = await client.chat.completions.create({ + model: model, + messages: [ + { + role: 'user', + content: `give a good summarizing title of the following data: \n ${JSON.stringify(data, null, 2)}`, + }, + ], + max_tokens: 300, + }) + console.log(response) + // Append AI message. + const res = response.choices[0].message.content + typeof res === 'string' && dispatch(setTitle(res.replace('"', ''))) + }, + [dispatch] + ) const fillData = useCallback(async () => { - const client = openAIInstance + let client: OpenAI | null = openAIInstance + if (!client) return + setInProgress(true) const response = await client.chat.completions.create({ model: model, messages: [ { role: 'user', - content: `Try to analyze the image ${imageURL} for information. Do not improvise but only include data that can be derived from the image. Try to fit the information to the fields of the following JSON-schema and deliver the ready to use JSON without any extra information or markup. Produced result must comply to the given JSON-Schema: \n ${JSON.stringify( - jsonSchema, - null, - 2 - )}`, + content: [ + { + type: 'text', + text: `Try to analyze the image for information. Do not improvise but only include data that can be derived from the image. Try to fit the information to the fields of the following JSON-schema and deliver the ready to use JSON without any extra information or markup. Produced result must comply to the given JSON-Schema: \n ${JSON.stringify( + jsonSchema, + null, + 2 + )}`, + }, + { + type: 'image_url', + image_url: { + url: imageURL, + }, + }, + ], }, ], max_tokens: 3000, @@ -85,19 +108,25 @@ function Wizard() { const d = JSON.parse(res.replace('```json', '').replace('```', '')) dispatch(setData(d)) dispatch(setAvatar(imageURL)) + setInProgress(false) setTimeout(() => getTitleOfData(d), 1000) } catch (e) { console.warn(e) } //setResponse(res) - }, [jsonSchema, dispatch, imageURL, getTitleOfData]) + }, [jsonSchema, dispatch, imageURL, getTitleOfData, setInProgress, openAIInstance]) return ( - setImageURL(e.target.value)} title={'Image URL'} /> - + theme.zIndex.drawer + 1 }} open={inProgress}> + + + setImageURL(e.target.value)} label={'Bild URL'} /> + ) => { + state.uiSchema = action.payload + }, insertControl: ( state: JsonFormsEditState, action: PayloadAction<{ @@ -225,6 +228,7 @@ export const { updateUISchemaByScope, toggleEditMode, replaceSchema, + replaceUISchema, } = jsonFormsEditSlice.actions export default jsonFormsEditSlice.reducer diff --git a/src/features/wizard/credentialsSlice.ts b/src/features/wizard/credentialsSlice.ts new file mode 100644 index 0000000..a8327fb --- /dev/null +++ b/src/features/wizard/credentialsSlice.ts @@ -0,0 +1,35 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { RootState } from '../../app/store' + +export type Credentials = { + apiKey: string + organization: string +} + +export type CredentialsReducer = { + credentials: Credentials +} + +const initialCredentials: Credentials = { + apiKey: '', + organization: '', +} + +export const selectApiKey = (state: RootState) => state.credentials.credentials.apiKey + +export const selectOrganization = (state: RootState) => state.credentials.credentials.organization + +export const credentialsSlice = createSlice({ + name: 'credentials', + initialState: { + credentials: initialCredentials, + } as CredentialsReducer, + reducers: { + onCredentialChange: (state: CredentialsReducer, action: PayloadAction) => { + state.credentials = action.payload + }, + }, +}) + +export const { onCredentialChange } = credentialsSlice.actions +export const credentialsReducer = credentialsSlice.reducer diff --git a/src/index.tsx b/src/index.tsx index dc9cf3c..6af9156 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -14,6 +14,7 @@ import { IntlProvider } from 'react-intl' import { TouchBackend } from 'react-dnd-touch-backend' import reportWebVitals from './reportWebVitals' import { PersistGate } from 'redux-persist/integration/react' +import { AIProvider } from './app/hooks/aiContext' const container = document.getElementById('root')! const root = createRoot(container) console.log('APP running in touch mode: ' + isTouchDevice()) @@ -23,15 +24,17 @@ root.render( - - - - - - - - - + + + + + + + + + + + diff --git a/src/utils/openai.ts b/src/utils/openai.ts index 99a0d6f..e06f977 100644 --- a/src/utils/openai.ts +++ b/src/utils/openai.ts @@ -1,11 +1 @@ -import { OpenAI } from 'openai' -console.log(process.env) -const openAIInstance = new OpenAI({ - dangerouslyAllowBrowser: true, - organization: process.env.REACT_APP_OPENAI_ORGANIZATION, - apiKey: process.env.REACT_APP_OPENAI_API_KEY, -}) - -export default openAIInstance - export const model = 'gpt-4o'