update and refactor actions and interactions
This commit is contained in:
parent
3111f56571
commit
fc403142c2
@ -1,215 +0,0 @@
|
||||
// import React, { useEffect, useState, useRef } from 'react';
|
||||
// import { Button } from 'primereact/button';
|
||||
// import { Toast } from 'primereact/toast';
|
||||
// import { api } from '../services/axios';
|
||||
// import { Dialog } from 'primereact/dialog';
|
||||
// import { useNavigate } from 'react-router-dom';
|
||||
// import { useSettingsStore } from '../state/stores/useSettingsStore';
|
||||
|
||||
// type UserInfo = {
|
||||
// role: string;
|
||||
// tenantId: string | number;
|
||||
// };
|
||||
|
||||
// const ActivateConnectionModal = () => {
|
||||
// const [loading, setLoading] = useState(false);
|
||||
// const [showDialog, setShowDialog] = useState(false);
|
||||
// const toast = useRef<Toast>(null);
|
||||
// const [fetched, setFetched] = useState(false);
|
||||
// const [systemName, setSystemName] = useState('');
|
||||
// const tenantId = localStorage.getItem('tenantId');
|
||||
// const systemId = localStorage.getItem('systemId');
|
||||
|
||||
// const navigate = useNavigate();
|
||||
|
||||
// const [userInfo, setUserInfo] = useState<UserInfo>({
|
||||
// role: '',
|
||||
// tenantId: -1,
|
||||
// });
|
||||
|
||||
// const [messageShown, setMessageShown] = useState<boolean>(false);
|
||||
|
||||
// const { setConnectionId } = useSettingsStore();
|
||||
|
||||
// useEffect(() => {
|
||||
// // TODO: similar logic used in other components, refactor into user context/state/hook
|
||||
// const storedUser = localStorage.getItem('user');
|
||||
// const storedSystemName = localStorage.getItem('systemName');
|
||||
|
||||
// if (storedUser) {
|
||||
// const parsedUser = JSON.parse(storedUser);
|
||||
|
||||
// let role = parsedUser.roles?.includes('admin')
|
||||
// ? 'admin'
|
||||
// : parsedUser.roles?.includes('user')
|
||||
// ? 'user'
|
||||
// : '';
|
||||
|
||||
// let tenantId =
|
||||
// role === 'admin' ? localStorage.getItem('tenantId') || -1 : -1;
|
||||
|
||||
// let userInfo: UserInfo = {
|
||||
// role: role,
|
||||
// tenantId: tenantId,
|
||||
// };
|
||||
|
||||
// setUserInfo(userInfo);
|
||||
// }
|
||||
|
||||
// if (storedSystemName) {
|
||||
// setSystemName(storedSystemName);
|
||||
// }
|
||||
// }, []);
|
||||
|
||||
// useEffect(() => {
|
||||
// if (userInfo.role && userInfo.tenantId !== undefined && !fetched) {
|
||||
// fetchConnection();
|
||||
// }
|
||||
// }, [userInfo]);
|
||||
|
||||
// const fetchConnection = async () => {
|
||||
// setLoading(true);
|
||||
// try {
|
||||
// let response: any;
|
||||
|
||||
// if (userInfo.role === 'user') {
|
||||
// response = await api('get', `/connections?systemName=${systemName}`);
|
||||
// } else if (userInfo.role === 'admin' && userInfo.tenantId) {
|
||||
// response = await api(
|
||||
// 'get',
|
||||
// `/connections?systemName=${systemName}&tenantId=${userInfo.tenantId}`,
|
||||
// );
|
||||
// } else {
|
||||
// throw new Error('Invalid user role or missing tenantId');
|
||||
// }
|
||||
|
||||
// const connections = response.data.data;
|
||||
|
||||
// // Localstorage saves undefined as string
|
||||
// if (tenantId === 'undefined' || systemId === 'undefined') {
|
||||
// navigate('/systems');
|
||||
// }
|
||||
|
||||
// if (!connections || connections.length === 0) {
|
||||
// setShowDialog(true);
|
||||
// } else if (userInfo.role === 'user') {
|
||||
// setUserInfo((prevUserInfo) => ({
|
||||
// ...prevUserInfo,
|
||||
// tenantId: connections[0].tenant_fleks_id,
|
||||
// }));
|
||||
// }
|
||||
// const connectionId = connections[0].id;
|
||||
// localStorage.setItem('connectionId', connectionId);
|
||||
// setConnectionId(connectionId);
|
||||
// navigate(`/dashboard/${systemName}`);
|
||||
// setLoading(false);
|
||||
// setFetched(true);
|
||||
// } catch (error) {
|
||||
// console.error('Error:', error);
|
||||
// setLoading(false);
|
||||
// if (!messageShown) {
|
||||
// setMessageShown(true);
|
||||
// }
|
||||
// }
|
||||
// };
|
||||
|
||||
// const handleActivateClick = async () => {
|
||||
// setLoading(true);
|
||||
// const systemId = localStorage.getItem('systemId') || null;
|
||||
|
||||
// try {
|
||||
// let postResponse: any;
|
||||
// let requestBody: any = {
|
||||
// system_id: Number(systemId),
|
||||
// };
|
||||
|
||||
// if (!systemId) {
|
||||
// throw new Error('Missing systemId');
|
||||
// }
|
||||
|
||||
// if (userInfo.role === 'admin' && userInfo.tenantId) {
|
||||
// requestBody.tenant_id = userInfo.tenantId;
|
||||
// }
|
||||
|
||||
// postResponse = await api('post', '/connections', requestBody);
|
||||
|
||||
// if (postResponse.status === 201) {
|
||||
// const connectionId = postResponse.data.id;
|
||||
// localStorage.setItem('connectionId', connectionId);
|
||||
// setConnectionId(connectionId);
|
||||
|
||||
// navigate(`/dashboard/${systemName}`);
|
||||
|
||||
// setLoading(false);
|
||||
// setShowDialog(false);
|
||||
// toast.current.show({
|
||||
// severity: 'success',
|
||||
// summary: 'Success',
|
||||
// detail: 'Connection established successfully.',
|
||||
// life: 3000,
|
||||
// });
|
||||
// } else {
|
||||
// setLoading(false);
|
||||
// toast.current.show({
|
||||
// severity: 'error',
|
||||
// summary: 'Error',
|
||||
// detail: 'Failed to establish connection.',
|
||||
// life: 3000,
|
||||
// });
|
||||
// }
|
||||
// } catch (error) {
|
||||
// if (error.message === 'Missing systemId') {
|
||||
// toast.current.show({
|
||||
// severity: 'error',
|
||||
// summary: 'Error',
|
||||
// detail: 'Missing system information. Please verify.',
|
||||
// life: 3000,
|
||||
// });
|
||||
// } else {
|
||||
// console.error('Error:', error);
|
||||
// }
|
||||
// }
|
||||
// };
|
||||
|
||||
// const handleCancelClick = () => {
|
||||
// setShowDialog(false);
|
||||
// navigate('/systems');
|
||||
// };
|
||||
|
||||
// return (
|
||||
// <div className="flex items-center justify-center h-screen overflow-y-hidden bg-gradient-to-br from-purple-500 to-indigo-500">
|
||||
// <div className="fixed inset-0 flex items-center justify-center overflow-y-hidden">
|
||||
// <div className="transition-transform" style={{ transform: 'none' }}>
|
||||
// <Dialog
|
||||
// visible={showDialog}
|
||||
// onHide={() => setShowDialog(false)}
|
||||
// header="Activate Connection"
|
||||
// footer={
|
||||
// <div>
|
||||
// <Button
|
||||
// label="Activate"
|
||||
// className="p-button-primary"
|
||||
// onClick={handleActivateClick}
|
||||
// disabled={loading}
|
||||
// />
|
||||
// <Button
|
||||
// label="Cancel"
|
||||
// className="p-button-secondary"
|
||||
// onClick={handleCancelClick}
|
||||
// disabled={loading}
|
||||
// />
|
||||
// </div>
|
||||
// }
|
||||
// >
|
||||
// {loading ? (
|
||||
// <p>Performing the post request, please wait...</p>
|
||||
// ) : (
|
||||
// <p>Are you sure you want to activate the connection?</p>
|
||||
// )}
|
||||
// </Dialog>
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// };
|
||||
// export default ActivateConnectionModal;
|
@ -1,372 +0,0 @@
|
||||
import React from 'react';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { Card } from 'primereact/card';
|
||||
|
||||
interface CanvasData {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface CanvasDataTableProps {
|
||||
data: CanvasData[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const CanvasDataTable: React.FC<CanvasDataTableProps> = ({
|
||||
data,
|
||||
loading,
|
||||
errorimport { Column } from 'primereact/column';
|
||||
import { FilterMatchMode, FilterOperator } from 'primereact/api';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api } from '../../services/api.ts';
|
||||
|
||||
import TableGroupHeader from '../../shared/components/_V1/TableGroupHeader.tsx';
|
||||
import TableGroup from '../../shared/components/_V1/TableGroup.tsx';
|
||||
|
||||
import { Toast } from 'primereact/toast';
|
||||
import ConfirmModal from '../../shared/components/modals/ConfirmModal.tsx';
|
||||
import CreateVoucherModal from './CreateVoucherModal.tsx';
|
||||
// import EditVoucherModal from './AddVoucherCodeModal.tsx';
|
||||
import LoadingPage from '../../shared/components/_V1/LoadingPage.tsx';
|
||||
import DetailModal from './DetailModal.tsx';
|
||||
import AddVoucherCodeModal, { AddVoucherCodeModalProps } from './AddVoucherCodeModal.tsx';
|
||||
import { Voucher, useVoucherStore } from '../../state/stores/Mews/useMewsVoucherStore.ts';
|
||||
import { TSizeOptionValue } from '../../types/DataTable.types.ts';
|
||||
import Button from '../../shared/components/_V2/Button.tsx';
|
||||
|
||||
export default function VoucherTableGroup() {
|
||||
const { createVoucher, fetchVouchers, updateVoucher, vouchers, addVoucherCode } =
|
||||
useVoucherStore();
|
||||
|
||||
const [filters, setFilters] = useState(null);
|
||||
const [globalFilterValue, setGlobalFilterValue] = useState('');
|
||||
|
||||
const [showCreateVoucherModal, setShowCreateVoucherModal] = useState(false);
|
||||
const [showConfirmDeleteModal, setShowConfirmDeleteModal] = useState(false);
|
||||
|
||||
const [showAddCodeModal, setShowAddCodeModal] = useState(false);
|
||||
const [currentVoucherForCode, setCurrentVoucherForCode] = useState(null);
|
||||
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||
const [detailContent, setDetailContent] = useState([]);
|
||||
const [detailTitle, setDetailTitle] = useState('');
|
||||
|
||||
const [selectedVouchers, setSelectedVouchers] = useState<Voucher[]>(null);
|
||||
|
||||
const toast = useRef<Toast>(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Change table row size
|
||||
const sizeOptions: { label: string; value: TSizeOptionValue }[] = [
|
||||
{ label: 'Small', value: 'small' },
|
||||
{ label: 'Normal', value: 'normal' },
|
||||
{ label: 'Large', value: 'large' },
|
||||
];
|
||||
|
||||
const [size, setSize] = useState<TSizeOptionValue>(sizeOptions[0].value);
|
||||
|
||||
useEffect(() => {
|
||||
initFilters();
|
||||
// fetchVouchers;
|
||||
handleRefresh();
|
||||
}, []);
|
||||
|
||||
const showToastMessage = (options) => {
|
||||
toast.current?.show(options);
|
||||
};
|
||||
|
||||
const initFilters = () => {
|
||||
setFilters({
|
||||
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
||||
representative: { value: null, matchMode: FilterMatchMode.IN },
|
||||
createdAt: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{ value: null, matchMode: FilterMatchMode.DATE_IS }],
|
||||
},
|
||||
});
|
||||
setGlobalFilterValue('');
|
||||
};
|
||||
|
||||
const clearFilter = () => {
|
||||
initFilters();
|
||||
};
|
||||
|
||||
const onGlobalFilterChange = (e: { target: { value: any } }) => {
|
||||
const value = e.target.value;
|
||||
// @ts-ignore
|
||||
let _filters = { ...filters };
|
||||
_filters['global'].value = value;
|
||||
setFilters(_filters);
|
||||
setGlobalFilterValue(value);
|
||||
};
|
||||
|
||||
// TODO: refresh data
|
||||
const handleRefresh = async () => {
|
||||
setIsLoading(true);
|
||||
await fetchVouchers();
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const formatDate = (value: string | number | Date) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const dateBodyTemplate = (rowData: { createdAt: string | number | Date }) => {
|
||||
return formatDate(rowData.createdAt);
|
||||
};
|
||||
|
||||
const actionButtons = () => {
|
||||
return (
|
||||
<div className={'flex gap-3'}>
|
||||
<Button text={true} onClick={() => setShowCreateVoucherModal(true)}>
|
||||
Add Voucher
|
||||
</Button>
|
||||
{/* <Button
|
||||
onClick={() => setShowConfirmDeleteModal(true)}
|
||||
severity={'danger'}
|
||||
disabled={selectedVouchers == null || selectedVouchers.length === 0}
|
||||
>
|
||||
Delete selected
|
||||
</Button> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const editButton = (rowData: Voucher) => {
|
||||
return <Button>action</Button>;
|
||||
};
|
||||
|
||||
const openDetailModal = (content, title) => {
|
||||
setDetailContent(content);
|
||||
setDetailTitle(title);
|
||||
setDetailModalVisible(true);
|
||||
};
|
||||
|
||||
const closeDetailModal = () => {
|
||||
setDetailModalVisible(false);
|
||||
};
|
||||
|
||||
const handleCreateVoucher = async (newVoucher: Voucher) => {
|
||||
await createVoucher(newVoucher);
|
||||
handleRefresh();
|
||||
setShowCreateVoucherModal(false);
|
||||
};
|
||||
|
||||
const handleDeleteSelectedVouchers = () => {
|
||||
const URL = '/neppeURL';
|
||||
const body = selectedVouchers.map((voucher) => {
|
||||
return {
|
||||
id: voucher.id,
|
||||
};
|
||||
});
|
||||
api('delete', URL, body);
|
||||
};
|
||||
|
||||
const voucherRatesBodyTemplate = (rowData) => {
|
||||
if (rowData?.voucherCodes.length < 1) {
|
||||
return '';
|
||||
}
|
||||
const details = rowData.rates.map((rate) => `Name: ${rate.name}, Type: ${rate.type}`);
|
||||
return (
|
||||
<Button
|
||||
label='View Rates'
|
||||
onClick={() => openDetailModal(details, 'Voucher Rates')}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const voucherCodesBodyTemplate = (rowData) => {
|
||||
const details = rowData.voucherCodes.join(', ');
|
||||
return (
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
label='View Codes'
|
||||
onClick={() => openDetailModal([details], 'Voucher Codes')}
|
||||
disabled={isLoading || rowData.voucherCodes.length === 0}
|
||||
/>
|
||||
<Button
|
||||
label='Add Code'
|
||||
onClick={() => {
|
||||
setCurrentVoucherForCode(rowData);
|
||||
setShowAddCodeModal(true);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddVoucherCode: AddVoucherCodeModalProps['addVoucherCode'] = async (
|
||||
voucherId,
|
||||
code
|
||||
) => {
|
||||
try {
|
||||
await addVoucherCode(voucherId, code);
|
||||
showToastMessage({
|
||||
severity: 'success',
|
||||
summary: 'Code Added',
|
||||
detail: 'Voucher code has been successfully added.',
|
||||
});
|
||||
setShowAddCodeModal(false);
|
||||
handleRefresh();
|
||||
} catch (error) {
|
||||
showToastMessage({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Failed to add voucher code.',
|
||||
});
|
||||
console.error('Failed to add voucher code:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const renderHeader = () => {
|
||||
return (
|
||||
<TableGroupHeader
|
||||
size={size}
|
||||
setSize={setSize}
|
||||
sizeOptions={sizeOptions}
|
||||
globalFilterValue={globalFilterValue}
|
||||
onGlobalFilterChange={(e: any) => onGlobalFilterChange(e)}
|
||||
onRefresh={handleRefresh}
|
||||
clearFilter={clearFilter}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const voucherCodeSortFunction = (e) => {
|
||||
const result = e.data.sort((data1, data2) => {
|
||||
const codes1 = data1.voucherCodes.length;
|
||||
const codes2 = data2.voucherCodes.length;
|
||||
|
||||
return e.order * (codes1 - codes2);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='w-full h-full flex flex-col'>
|
||||
<CreateVoucherModal
|
||||
visible={showCreateVoucherModal}
|
||||
onHide={() => setShowCreateVoucherModal(false)}
|
||||
// @ts-ignore
|
||||
onConfirm={handleCreateVoucher}
|
||||
/>
|
||||
|
||||
<AddVoucherCodeModal
|
||||
visible={showAddCodeModal}
|
||||
onHide={() => setShowAddCodeModal(false)}
|
||||
addVoucherCode={handleAddVoucherCode} // Pass the function directly
|
||||
voucherId={currentVoucherForCode?.id || ''}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
visible={showConfirmDeleteModal}
|
||||
header={`Delete ${selectedVouchers?.length} voucher(s)`}
|
||||
message={`Are you sure?`}
|
||||
onHide={() => setShowConfirmDeleteModal(false)}
|
||||
onConfirm={handleDeleteSelectedVouchers}
|
||||
confirmLabel={'Confirm'}
|
||||
cancelLabel={'Cancel'}
|
||||
onCancel={() => setShowConfirmDeleteModal(false)}
|
||||
/>
|
||||
|
||||
<DetailModal
|
||||
visible={detailModalVisible}
|
||||
onHide={closeDetailModal}
|
||||
title={detailTitle}
|
||||
details={detailContent}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<LoadingPage />
|
||||
) : (
|
||||
<TableGroup
|
||||
body={vouchers}
|
||||
size={size}
|
||||
header={renderHeader}
|
||||
filters={filters}
|
||||
rows={10}
|
||||
paginator
|
||||
// stripedRows={true}
|
||||
showGridlines={true}
|
||||
removableSort={true}
|
||||
dragSelection={false}
|
||||
selectionMode={'multiple'}
|
||||
selection={selectedVouchers}
|
||||
onSelectionChange={(e: any) => setSelectedVouchers(e.value)}
|
||||
emptyMessage='No vouchers found.'
|
||||
>
|
||||
<Column selectionMode='multiple' headerStyle={{ width: '3rem' }}></Column>
|
||||
|
||||
<Column field='id' header={actionButtons} sortable filter style={{ width: '40%' }} />
|
||||
<Column field='name' header='Name' sortable filter style={{ width: '20%' }} />
|
||||
<Column field='type' header='Type' sortable filter style={{ width: '20%' }} />
|
||||
<Column
|
||||
field='voucherCodes'
|
||||
header='Voucher Codes'
|
||||
body={voucherCodesBodyTemplate}
|
||||
sortable
|
||||
filter
|
||||
sortFunction={voucherCodeSortFunction}
|
||||
className='h-full align-top bg-red'
|
||||
style={{ width: '20%', textAlign: 'center', lineHeight: '20px' }}
|
||||
/>
|
||||
<Column
|
||||
field='rates'
|
||||
header='Voucher Rates'
|
||||
body={voucherRatesBodyTemplate}
|
||||
sortable
|
||||
filter
|
||||
style={{ width: '20%' }}
|
||||
/>
|
||||
{/* <Column
|
||||
field='actions'
|
||||
header='Actions'
|
||||
body={editButton}
|
||||
style={{ width: '15%', textAlign: 'center' }}
|
||||
/> */}
|
||||
</TableGroup>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}) => {
|
||||
// Dynamically generate columns based on the first data item
|
||||
const columns = data.length > 0
|
||||
? Object.keys(data[0]).map(key => (
|
||||
<Column
|
||||
key={key}
|
||||
field={key}
|
||||
header={key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, ' ')}
|
||||
sortable
|
||||
/>
|
||||
))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Card className="flex-grow mb-4">
|
||||
<DataTable
|
||||
value={data}
|
||||
loading={loading}
|
||||
emptyMessage={error || "No data found. Try using the prompt below."}
|
||||
className="p-datatable-sm"
|
||||
paginator
|
||||
rows={10}
|
||||
rowsPerPageOptions={[10, 25, 50]}
|
||||
>
|
||||
{columns}
|
||||
</DataTable>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CanvasDataTable;
|
@ -1,120 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Button } from 'primereact/button';
|
||||
import { ScrollPanel } from 'primereact/scrollpanel';
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface ChatGptModalProps {
|
||||
visible: boolean;
|
||||
onHide: () => void;
|
||||
}
|
||||
|
||||
const ChatGptModal: React.FC<ChatGptModalProps> = ({ visible, onHide }) => {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
const userMessage: Message = { role: 'user', content: prompt };
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
|
||||
try {
|
||||
const response = await fetch('/canvas-api/chatgpt/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
data: prompt,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.errorMessage) {
|
||||
throw new Error(data.errorMessage);
|
||||
}
|
||||
|
||||
const assistantMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: typeof data.responseText === 'string'
|
||||
? data.responseText
|
||||
: JSON.stringify(data.responseText, null, 2)
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, assistantMessage]);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
const errorMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: `Error: ${error instanceof Error ? error.message : 'Failed to get response'}`
|
||||
};
|
||||
setMessages(prev => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setPrompt('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
visible={visible}
|
||||
onHide={onHide}
|
||||
header="ChatGPT Assistant"
|
||||
style={{ width: '50vw' }}
|
||||
className="chatgpt-modal"
|
||||
>
|
||||
<div className="flex flex-col h-[60vh]">
|
||||
<ScrollPanel style={{ width: '100%', height: 'calc(100% - 60px)' }} className="mb-4">
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
{messages.map((message, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`p-3 rounded-lg max-w-[80%] ${message.role === 'user'
|
||||
? 'bg-blue-100 ml-auto'
|
||||
: 'bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollPanel>
|
||||
|
||||
<div className="flex gap-2 mt-auto">
|
||||
<InputText
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Type your message..."
|
||||
className="flex-1"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-send"
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
disabled={!prompt.trim() || loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatGptModal;
|
@ -1,37 +0,0 @@
|
||||
import React from 'react';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Button } from 'primereact/button';
|
||||
|
||||
interface PromptInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const PromptInput: React.FC<PromptInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder = "Type your prompt here"
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-4 border-t">
|
||||
<InputText
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="flex-grow"
|
||||
onKeyPress={(e) => e.key === 'Enter' && onSubmit()}
|
||||
/>
|
||||
<Button
|
||||
label="Send"
|
||||
icon="pi pi-send"
|
||||
onClick={onSubmit}
|
||||
disabled={!value.trim()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PromptInput;
|
@ -1,33 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
|
||||
export type SystemType = 'council' | 'fusemind' | 'canvas';
|
||||
|
||||
interface SystemSelectorProps {
|
||||
value: SystemType | null;
|
||||
onChange: (value: SystemType | null) => void;
|
||||
}
|
||||
|
||||
const systemOptions = [
|
||||
{ label: 'CouncilAI', value: 'council' },
|
||||
{ label: 'FuseMind (AI)', value: 'fusemind' },
|
||||
{ label: 'Canvas API', value: 'canvas' }
|
||||
];
|
||||
|
||||
const SystemSelector: React.FC<SystemSelectorProps> = ({ value, onChange }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-4 border-b w-80">
|
||||
<span className="font-semibold mb-1">Apps & Flows</span>
|
||||
<Dropdown
|
||||
value={value}
|
||||
options={systemOptions}
|
||||
onChange={(e) => onChange(e.value)}
|
||||
placeholder="Select an integration"
|
||||
className="w-full"
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemSelector;
|
@ -1,83 +0,0 @@
|
||||
import { SystemType } from './SystemSelector';
|
||||
|
||||
interface CommandResult {
|
||||
system: SystemType;
|
||||
endpoint: string;
|
||||
params?: Record<string, any>;
|
||||
courseId?: number;
|
||||
}
|
||||
|
||||
export const parseCommand = (prompt: string, system: SystemType): CommandResult | null => {
|
||||
const lowerPrompt = prompt.toLowerCase().trim();
|
||||
|
||||
if (system === 'canvas') {
|
||||
// Extract course ID if present (e.g., "get students in course 123")
|
||||
const courseIdMatch = lowerPrompt.match(/course\s+(\d+)/);
|
||||
const courseId = courseIdMatch ? parseInt(courseIdMatch[1], 10) : 1; // Default to 1 if not specified
|
||||
|
||||
if (lowerPrompt.includes('get all students')) {
|
||||
return {
|
||||
system: 'canvas',
|
||||
endpoint: '/users',
|
||||
params: {
|
||||
enrollment_type: ['student'],
|
||||
per_page: 100
|
||||
},
|
||||
courseId
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerPrompt.includes('get courses')) {
|
||||
return {
|
||||
system: 'canvas',
|
||||
endpoint: '/courses',
|
||||
params: {
|
||||
per_page: 100
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerPrompt.includes('get assignments')) {
|
||||
return {
|
||||
system: 'canvas',
|
||||
endpoint: '/assignments',
|
||||
params: {
|
||||
per_page: 100
|
||||
},
|
||||
courseId
|
||||
};
|
||||
}
|
||||
} else if (system === 'council') {
|
||||
if (lowerPrompt.includes('get all students')) {
|
||||
return {
|
||||
system: 'council',
|
||||
endpoint: '/students',
|
||||
params: {
|
||||
limit: 100
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerPrompt.includes('get courses')) {
|
||||
return {
|
||||
system: 'council',
|
||||
endpoint: '/courses',
|
||||
params: {
|
||||
limit: 100
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerPrompt.includes('get assignments')) {
|
||||
return {
|
||||
system: 'council',
|
||||
endpoint: '/assignments',
|
||||
params: {
|
||||
limit: 100
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
@ -1,90 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Column } from 'primereact/column';
|
||||
import { FilterMatchMode } from 'primereact/api';
|
||||
import { TSizeOptionValue } from '../../types/DataTable.types';
|
||||
import { api } from '../../services/api';
|
||||
import ChatGptModal from './ChatGptModal';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Card } from 'primereact/card';
|
||||
|
||||
const CanvasEndpoints: React.FC = () => {
|
||||
const [showChatGptModal, setShowChatGptModal] = useState(false);
|
||||
const [endpoints, setEndpoints] = useState<any[]>([]);
|
||||
const [filters, setFilters] = useState<any>(null);
|
||||
const [globalFilterValue, setGlobalFilterValue] = useState('');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [newEndpoint, setNewEndpoint] = useState({ name: '', method: '', path: '', description: '' });
|
||||
const sizeOptions: { label: string; value: TSizeOptionValue }[] = [
|
||||
{ label: 'Small', value: 'small' },
|
||||
{ label: 'Normal', value: 'normal' },
|
||||
{ label: 'Large', value: 'large' },
|
||||
];
|
||||
const [size, setSize] = useState<TSizeOptionValue>(sizeOptions[0].value);
|
||||
|
||||
const fetchEndpoints = async () => {
|
||||
try {
|
||||
const response = await api('get', '/api/v1/canvas-api/endpoints');
|
||||
setEndpoints(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch endpoints:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const initFilters = () => {
|
||||
setFilters({
|
||||
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
||||
});
|
||||
setGlobalFilterValue('');
|
||||
};
|
||||
|
||||
const clearFilter = () => {
|
||||
initFilters();
|
||||
};
|
||||
|
||||
const onGlobalFilterChange = (e: { target: { value: any } }) => {
|
||||
const value = e.target.value;
|
||||
let _filters = { ...filters };
|
||||
_filters['global'].value = value;
|
||||
setFilters(_filters);
|
||||
setGlobalFilterValue(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Card className="mb-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-2">Canvas Endpoints</h1>
|
||||
<p className="text-gray-400">Manage and test your Canvas API endpoints</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
label="Create Endpoint"
|
||||
icon="pi pi-plus"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
/>
|
||||
<Button
|
||||
label="Ask ChatGPT"
|
||||
icon="pi pi-comments"
|
||||
onClick={() => setShowChatGptModal(true)}
|
||||
className="p-button-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<DataTable value={endpoints} paginator rows={10} showGridlines responsiveLayout="scroll" emptyMessage="No endpoints found.">
|
||||
<Column field="id" header="ID" sortable filter style={{ width: '10%' }} />
|
||||
<Column field="name" header="Name" sortable filter style={{ width: '20%' }} />
|
||||
<Column field="method" header="Method" sortable filter style={{ width: '10%' }} />
|
||||
<Column field="path" header="Path" sortable filter style={{ width: '40%' }} />
|
||||
</DataTable>
|
||||
<ChatGptModal
|
||||
visible={showChatGptModal}
|
||||
onHide={() => setShowChatGptModal(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CanvasEndpoints;
|
@ -1,110 +0,0 @@
|
||||
// import React, { useState, useEffect, useRef } from 'react';
|
||||
// import { InputText } from 'primereact/inputtext';
|
||||
// import { Button } from 'primereact/button';
|
||||
// import { Toast } from 'primereact/toast';
|
||||
// import { Dialog } from 'primereact/dialog';
|
||||
// import { Card } from 'primereact/card';
|
||||
// import io, { Socket } from 'socket.io-client';
|
||||
|
||||
// interface ChatProps {
|
||||
// serverUrl: string;
|
||||
// }
|
||||
|
||||
// const Chat: React.FC<ChatProps> = ({ serverUrl }) => {
|
||||
// const [messages, setMessages] = useState<string[]>([]);
|
||||
// const [inputText, setInputText] = useState('');
|
||||
// const [showChat, setShowChat] = useState(false);
|
||||
// const socketRef = useRef<Socket | null>(null);
|
||||
// const toastRef = useRef<Toast | null>(null);
|
||||
|
||||
// useEffect(() => {
|
||||
// socketRef.current = io(serverUrl);
|
||||
// socketRef.current.emit('join', 'global');
|
||||
|
||||
// socketRef.current.on('connect', () => {
|
||||
// showToast('success', 'Connected to the chat server');
|
||||
// });
|
||||
|
||||
// socketRef.current.on('disconnect', () => {
|
||||
// showToast('warn', 'Disconnected from the chat server');
|
||||
// });
|
||||
|
||||
// socketRef.current.on('message', (message: string) => {
|
||||
// setMessages((prevMessages) => [...prevMessages, message]);
|
||||
// });
|
||||
|
||||
// return () => {
|
||||
// if (socketRef.current) {
|
||||
// socketRef.current.disconnect();
|
||||
// }
|
||||
// };
|
||||
// }, [serverUrl]);
|
||||
|
||||
// const handleSendMessage = () => {
|
||||
// if (inputText.trim() === '') {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// if (socketRef.current) {
|
||||
// socketRef.current.emit('message', inputText);
|
||||
// }
|
||||
// setInputText('');
|
||||
// };
|
||||
|
||||
// const toggleChat = () => {
|
||||
// setShowChat((prevShowChat) => !prevShowChat);
|
||||
// };
|
||||
|
||||
// const showToast = (severity: any, detail: string) => {
|
||||
// if (toastRef.current) {
|
||||
// toastRef.current.show({
|
||||
// severity,
|
||||
// summary: severity === 'success' ? 'Success' : 'Warning',
|
||||
// detail,
|
||||
// life: 3000,
|
||||
// });
|
||||
// }
|
||||
// };
|
||||
|
||||
// const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// if (event.key === 'Enter') {
|
||||
// handleSendMessage();
|
||||
// }
|
||||
// };
|
||||
|
||||
// return (
|
||||
// <>
|
||||
// <div className="chat-icon" onClick={toggleChat}>
|
||||
// <h3>Click to chat</h3>
|
||||
// </div>
|
||||
// <Dialog
|
||||
// visible={showChat}
|
||||
// onHide={toggleChat}
|
||||
// className="chat-dialog"
|
||||
// modal
|
||||
// >
|
||||
// <Card title="Chat" className="chat-card">
|
||||
// <div className="chat-container">
|
||||
// {messages.map((message, index) => (
|
||||
// <div key={index} className="message">
|
||||
// <span className="message-content">{message}</span>
|
||||
// </div>
|
||||
// ))}
|
||||
// </div>
|
||||
// <div className="input-container">
|
||||
// <InputText
|
||||
// value={inputText}
|
||||
// onChange={(e) => setInputText(e.target.value)}
|
||||
// onKeyDown={handleInputKeyDown}
|
||||
// placeholder="Type a message..."
|
||||
// />
|
||||
// <Button label="Send" onClick={handleSendMessage} />
|
||||
// </div>
|
||||
// </Card>
|
||||
// </Dialog>
|
||||
// <Toast ref={toastRef} position="bottom-left" className="bottom-0" />{' '}
|
||||
// </>
|
||||
// );
|
||||
// };
|
||||
|
||||
// export default Chat;
|
@ -1,85 +0,0 @@
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useChatStore } from '../state/stores/useChatStore';
|
||||
|
||||
interface ChatGPTModalProps {
|
||||
content?: {
|
||||
prompt: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface TableData {
|
||||
columns: Array<{
|
||||
field: string;
|
||||
header: string;
|
||||
}>;
|
||||
data: any[];
|
||||
}
|
||||
|
||||
export const ChatGPTModal = ({ content }: ChatGPTModalProps) => {
|
||||
const [tableData, setTableData] = useState<TableData>({ columns: [], data: [] });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { sendChatRequest } = useChatStore();
|
||||
|
||||
useEffect(() => {
|
||||
console.log('useEffect triggered with content:', content);
|
||||
if (content?.prompt) {
|
||||
generateTable();
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
const generateTable = async () => {
|
||||
if (!content?.prompt) return;
|
||||
console.log('Generating table for prompt:', content.prompt);
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await sendChatRequest('/chat/completions', {
|
||||
data: `Create a table with data about: ${content.prompt}
|
||||
Return ONLY a JSON object in this format:
|
||||
{
|
||||
"columns": [
|
||||
{"field": "column1", "header": "Column 1"},
|
||||
{"field": "column2", "header": "Column 2"}
|
||||
],
|
||||
"data": [
|
||||
{"column1": "value1", "column2": "value2"},
|
||||
{"column1": "value3", "column2": "value4"}
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
1. Return ONLY the JSON object, no explanations
|
||||
2. Make sure field names in data match column fields exactly
|
||||
3. Choose appropriate column names based on the topic
|
||||
4. Include at least 5 rows of data`,
|
||||
responseFormat: 'json'
|
||||
});
|
||||
console.log('Got raw response:', response);
|
||||
|
||||
if (response?.responseText) {
|
||||
console.log('Got responseText:', response.responseText);
|
||||
setTableData(JSON.parse(response.responseText));
|
||||
} else {
|
||||
console.error('Invalid response format:', response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating table:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-column gap-3 p-4">
|
||||
<DataTable value={tableData.data} loading={loading} className="w-full">
|
||||
{tableData.columns.map(col => (
|
||||
<Column key={col.field} field={col.field} header={col.header} />
|
||||
))}
|
||||
</DataTable>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatGPTModal;
|
443
frontend/src/components/CanvasEndpoints.tsx → frontend/src/components/canvas-api/CanvasEndpoints.tsx
443
frontend/src/components/CanvasEndpoints.tsx → frontend/src/components/canvas-api/CanvasEndpoints.tsx
@ -1,10 +1,10 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Column } from 'primereact/column';
|
||||
import { FilterMatchMode } from 'primereact/api';
|
||||
import TableGroupHeader from '../shared/components/_V1/TableGroupHeader';
|
||||
import TableGroup from '../shared/components/_V1/TableGroup';
|
||||
import { TSizeOptionValue } from '../types/DataTable.types';
|
||||
import { api } from '../services/api';
|
||||
import TableGroupHeader from '../../shared/components/_V1/TableGroupHeader';
|
||||
import TableGroup from '../../shared/components/_V1/TableGroup';
|
||||
import { TSizeOptionValue } from './DataTable.types';
|
||||
import { api } from '../../services/api';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
@ -12,13 +12,15 @@ import { Dropdown } from 'primereact/dropdown';
|
||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||
import ChatGptModal from './ChatGPTModal';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { useAuthStore } from '../../state/stores/useAuthStore';
|
||||
import SettingsMenu, { SettingsMenuItem } from '../../shared/components/SettingsMenu';
|
||||
|
||||
export default function CanvasEndpoints() {
|
||||
const [endpoints, setEndpoints] = useState<any[]>([]);
|
||||
const [filters, setFilters] = useState<any>(null);
|
||||
const [globalFilterValue, setGlobalFilterValue] = useState('');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [newEndpoint, setNewEndpoint] = useState({ name: '', method: '', path: '', description: '' });
|
||||
const [newEndpoint, setNewEndpoint] = useState({ name: '', method: '', path: '', description: '', publicPath: '' });
|
||||
const sizeOptions: { label: string; value: TSizeOptionValue }[] = [
|
||||
{ label: 'Small', value: 'small' },
|
||||
{ label: 'Normal', value: 'normal' },
|
||||
@ -42,8 +44,29 @@ export default function CanvasEndpoints() {
|
||||
const [chatError, setChatError] = useState<string | null>(null);
|
||||
const toast = useRef(null);
|
||||
const [editEndpointId, setEditEndpointId] = useState<number | null>(null);
|
||||
const [apiKey, setApiKey] = useState<string | null>(null);
|
||||
const [apiKeyLoading, setApiKeyLoading] = useState(false);
|
||||
const [apiKeyError, setApiKeyError] = useState<string | null>(null);
|
||||
const [appId, setAppId] = useState<number | null>(null);
|
||||
const [showApiKeyModal, setShowApiKeyModal] = useState(false);
|
||||
const [editByVoiceEndpoint, setEditByVoiceEndpoint] = useState<any>(null);
|
||||
const [showVoiceModal, setShowVoiceModal] = useState(false);
|
||||
const [liveTranscript, setLiveTranscript] = useState('');
|
||||
const [showVoiceInfoModal, setShowVoiceInfoModal] = useState(false);
|
||||
const [fullTranscript, setFullTranscript] = useState('');
|
||||
const [showCogMenu, setShowCogMenu] = useState(false);
|
||||
const cogMenuRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchAppId() {
|
||||
try {
|
||||
const response = await api('get', '/api/v1/app/by-name/Canvas');
|
||||
setAppId(response.data.id);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch Canvas app id');
|
||||
}
|
||||
}
|
||||
fetchAppId();
|
||||
initFilters();
|
||||
fetchEndpoints();
|
||||
}, []);
|
||||
@ -86,7 +109,7 @@ export default function CanvasEndpoints() {
|
||||
const sanitizedEndpoint = { ...newEndpoint, path: sanitizeCanvasPath(newEndpoint.path) };
|
||||
await api('post', '/api/v1/canvas-api/endpoints', sanitizedEndpoint);
|
||||
setShowCreateModal(false);
|
||||
setNewEndpoint({ name: '', method: '', path: '', description: '' });
|
||||
setNewEndpoint({ name: '', method: '', path: '', description: '', publicPath: '' });
|
||||
fetchEndpoints();
|
||||
} catch (error) {
|
||||
console.error('Failed to create endpoint:', error);
|
||||
@ -133,15 +156,14 @@ export default function CanvasEndpoints() {
|
||||
setCallModalGlobalFilterValue(value);
|
||||
};
|
||||
|
||||
const handleChatPromptSend = async () => {
|
||||
if (!chatPrompt.trim()) return;
|
||||
const handleChatPromptSend = async (promptOverride?: string) => {
|
||||
const prompt = promptOverride !== undefined ? promptOverride : chatPrompt;
|
||||
if (!prompt.trim()) return;
|
||||
setChatMessages(prev => [...prev, { role: 'user', content: prompt }]);
|
||||
setChatLoading(true);
|
||||
setChatError(null);
|
||||
setChatMessages(prev => [...prev, { role: 'user', content: chatPrompt }]);
|
||||
try {
|
||||
const formattedRequest = {
|
||||
data: chatPrompt,
|
||||
};
|
||||
const formattedRequest = { data: prompt };
|
||||
const response = await api(
|
||||
'post',
|
||||
'/api/v1/canvas-api/chatgpt/completions',
|
||||
@ -211,6 +233,7 @@ export default function CanvasEndpoints() {
|
||||
method: endpoint.method,
|
||||
path: endpoint.path,
|
||||
description: endpoint.description || '',
|
||||
publicPath: endpoint.publicPath || '',
|
||||
});
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
@ -224,7 +247,7 @@ export default function CanvasEndpoints() {
|
||||
await api('post', '/api/v1/canvas-api/endpoints', sanitizedEndpoint);
|
||||
}
|
||||
setShowCreateModal(false);
|
||||
setNewEndpoint({ name: '', method: '', path: '', description: '' });
|
||||
setNewEndpoint({ name: '', method: '', path: '', description: '', publicPath: '' });
|
||||
setEditEndpointId(null);
|
||||
fetchEndpoints();
|
||||
} catch (error) {
|
||||
@ -234,10 +257,280 @@ export default function CanvasEndpoints() {
|
||||
|
||||
const handleModalHide = () => {
|
||||
setShowCreateModal(false);
|
||||
setNewEndpoint({ name: '', method: '', path: '', description: '' });
|
||||
setNewEndpoint({ name: '', method: '', path: '', description: '', publicPath: '' });
|
||||
setEditEndpointId(null);
|
||||
};
|
||||
|
||||
const handleApiKey = async () => {
|
||||
console.log('handleApiKey called');
|
||||
setApiKeyLoading(true);
|
||||
setApiKeyError(null);
|
||||
try {
|
||||
const user = useAuthStore.getState().user;
|
||||
console.log('User:', user, 'AppId:', appId);
|
||||
if (!user) throw new Error('User not logged in');
|
||||
if (!appId) throw new Error('App ID not loaded');
|
||||
const response = await api('post', '/api/v1/app/apikey/generate', { userId: user.id, appId });
|
||||
setApiKey(response.data.apiKey);
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.error || error.message || 'Failed to fetch or generate API key';
|
||||
setApiKeyError(errorMessage);
|
||||
console.error('API Key Error:', error);
|
||||
} finally {
|
||||
setApiKeyLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchApiKey = async () => {
|
||||
try {
|
||||
const user = useAuthStore.getState().user;
|
||||
if (!user || !appId) return;
|
||||
const response = await api('post', '/api/v1/app/apikey/get', { userId: user.id, appId });
|
||||
setApiKey(response.data.apiKey || null);
|
||||
setApiKeyError(null);
|
||||
} catch (error: any) {
|
||||
setApiKey(null);
|
||||
setApiKeyError(error.response?.data?.error || error.message || 'Failed to fetch API key');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (showApiKeyModal) {
|
||||
fetchApiKey();
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
}, [showApiKeyModal, appId]);
|
||||
|
||||
const tryDeleteFromSpeech = async (transcript) => {
|
||||
const lower = transcript.toLowerCase();
|
||||
if (lower.startsWith('create')) return false; // let normal create/chat logic handle it
|
||||
|
||||
if (lower.includes('delete')) {
|
||||
// Try to match "delete endpoint 5" or "remove endpoint 5"
|
||||
const idMatch = transcript.match(/delete endpoint (\d+)/i) || transcript.match(/remove endpoint (\d+)/i);
|
||||
if (idMatch) {
|
||||
const id = parseInt(idMatch[1], 10);
|
||||
await handleDeleteEndpoint({ id });
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `Endpoint with ID ${id} deleted (if it existed).` }]);
|
||||
return true;
|
||||
}
|
||||
// Try to match "delete endpoint called X" or "remove endpoint named X"
|
||||
const nameMatch = transcript.match(/delete endpoint (called|named) (.+)/i) || transcript.match(/remove endpoint (called|named) (.+)/i);
|
||||
if (nameMatch) {
|
||||
const name = nameMatch[2].trim();
|
||||
const endpoint = endpoints.find(e => e.name.toLowerCase() === name.toLowerCase());
|
||||
if (endpoint) {
|
||||
await handleDeleteEndpoint(endpoint);
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `Endpoint \"${name}\" deleted.` }]);
|
||||
} else {
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `No endpoint found with name \"${name}\".` }]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Try to match "delete endpoint [name]"
|
||||
const nameAfterEndpointMatch = transcript.match(/delete endpoint ([a-zA-Z0-9 _-]+)/i);
|
||||
if (nameAfterEndpointMatch) {
|
||||
const name = nameAfterEndpointMatch[1].trim();
|
||||
const endpoint = endpoints.find(e => e.name.toLowerCase() === name.toLowerCase());
|
||||
if (endpoint) {
|
||||
await handleDeleteEndpoint(endpoint);
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `Endpoint \"${name}\" deleted.` }]);
|
||||
} else {
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `No endpoint found with name \"${name}\".` }]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// If just "delete endpoint" with no id or name, show a message
|
||||
if (lower.includes('delete endpoint')) {
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `Please specify the endpoint ID or name to delete.` }]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const tryShowFromSpeech = async (transcript) => {
|
||||
const lower = transcript.toLowerCase();
|
||||
const viewKeywords = ['show', 'view', 'open'];
|
||||
// Find which keyword (if any) is present
|
||||
const action = viewKeywords.find(kw => lower.includes(kw));
|
||||
if (!action) return false;
|
||||
|
||||
// Try "<action> endpoint 5"
|
||||
const idMatch = lower.match(/(?:show|view|open) endpoint (\d+)/i);
|
||||
if (idMatch) {
|
||||
const id = parseInt(idMatch[1], 10);
|
||||
const endpoint = endpoints.find(e => e.id === id);
|
||||
if (endpoint) {
|
||||
handleCallEndpoint(endpoint);
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `${action.charAt(0).toUpperCase() + action.slice(1)}ing endpoint with ID ${id}.` }]);
|
||||
} else {
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `No endpoint found with ID ${id}.` }]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try "<action> endpoint [name]"
|
||||
const nameAfterEndpointMatch = lower.match(/(?:show|view|open) endpoint ([a-zA-Z0-9 _-]+)/i);
|
||||
if (nameAfterEndpointMatch) {
|
||||
const name = nameAfterEndpointMatch[1].trim();
|
||||
const endpoint = endpoints.find(e => e.name.toLowerCase() === name.toLowerCase());
|
||||
if (endpoint) {
|
||||
handleCallEndpoint(endpoint);
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `${action.charAt(0).toUpperCase() + action.slice(1)}ing endpoint \"${name}\".` }]);
|
||||
} else {
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `No endpoint found with name \"${name}\".` }]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try "<action> [name] endpoint"
|
||||
const nameEndpointMatch = lower.match(/(?:show|view|open) (.+) endpoint/i);
|
||||
if (nameEndpointMatch) {
|
||||
const name = nameEndpointMatch[1].trim();
|
||||
const endpoint = endpoints.find(e => e.name.toLowerCase() === name.toLowerCase());
|
||||
if (endpoint) {
|
||||
handleCallEndpoint(endpoint);
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `${action.charAt(0).toUpperCase() + action.slice(1)}ing endpoint \"${name}\".` }]);
|
||||
} else {
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `No endpoint found with name \"${name}\".` }]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try "<action> [name]"
|
||||
const nameMatch = lower.match(/(?:show|view|open) (.+)/i);
|
||||
if (nameMatch) {
|
||||
const name = nameMatch[1].trim();
|
||||
const endpoint = endpoints.find(e => e.name.toLowerCase() === name.toLowerCase());
|
||||
if (endpoint) {
|
||||
handleCallEndpoint(endpoint);
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `${action.charAt(0).toUpperCase() + action.slice(1)}ing endpoint \"${name}\".` }]);
|
||||
} else {
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `No endpoint found with name \"${name}\".` }]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const trySearchFromSpeech = (transcript) => {
|
||||
const lower = transcript.toLowerCase().trim();
|
||||
// Match "search [term]", "find [term]", or "filter by [term]"
|
||||
const searchMatch = lower.match(/^(search|find|filter by)\s+(.+)$/i);
|
||||
if (searchMatch) {
|
||||
const term = searchMatch[2].trim();
|
||||
setGlobalFilterValue(term);
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `Filtered endpoints by: "${term}"` }]);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const tryModifyFromSpeech = (transcript) => {
|
||||
const lower = transcript.toLowerCase();
|
||||
const match = lower.match(/modify endpoint (\d+)/i);
|
||||
if (match) {
|
||||
const id = parseInt(match[1], 10);
|
||||
const endpoint = endpoints.find(e => e.id === id);
|
||||
if (endpoint) {
|
||||
setEditByVoiceEndpoint({ ...endpoint });
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `Ready to modify endpoint with ID ${id}. Say 'method is GET' or 'path is slash course slash id'.` }]);
|
||||
} else {
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `No endpoint found with ID ${id}.` }]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const tryUpdateEditByVoice = async (transcript) => {
|
||||
if (!editByVoiceEndpoint) return false;
|
||||
let updated = { ...editByVoiceEndpoint };
|
||||
let didUpdate = false;
|
||||
|
||||
// Method update
|
||||
const methodMatch = transcript.match(/method is (get|post|put|delete|patch)/i);
|
||||
if (methodMatch) {
|
||||
updated.method = methodMatch[1].toUpperCase();
|
||||
didUpdate = true;
|
||||
}
|
||||
|
||||
// Path update
|
||||
const pathMatch = transcript.match(/path is (.+)/i);
|
||||
if (pathMatch) {
|
||||
let path = pathMatch[1]
|
||||
.replace(/slash/gi, '/')
|
||||
.replace(/colon id|id/gi, ':id')
|
||||
.replace(/\s+/g, '');
|
||||
updated.path = path;
|
||||
didUpdate = true;
|
||||
}
|
||||
|
||||
if (didUpdate) {
|
||||
setEditByVoiceEndpoint(updated);
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `Updated endpoint: method=${updated.method}, path=${updated.path}` }]);
|
||||
// Auto-save
|
||||
await api('put', `/api/v1/canvas-api/endpoints/${updated.id}`, updated);
|
||||
fetchEndpoints();
|
||||
setEditByVoiceEndpoint(null);
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `Endpoint ${updated.id} saved.` }]);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleVoiceInput = () => {
|
||||
// @ts-ignore
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
if (!SpeechRecognition) {
|
||||
alert('Speech recognition not supported in this browser.');
|
||||
return;
|
||||
}
|
||||
setShowVoiceModal(true);
|
||||
setLiveTranscript('');
|
||||
setFullTranscript('');
|
||||
const recognition = new SpeechRecognition();
|
||||
recognition.lang = 'en-US';
|
||||
recognition.interimResults = true;
|
||||
recognition.maxAlternatives = 1;
|
||||
|
||||
recognition.onresult = async (event: any) => {
|
||||
const transcript = event.results[0][0].transcript;
|
||||
setLiveTranscript(transcript);
|
||||
setFullTranscript(prev => prev + (prev && !prev.endsWith(' ') ? ' ' : '') + transcript);
|
||||
setChatPrompt(transcript);
|
||||
if (event.results[0].isFinal) {
|
||||
setShowVoiceModal(false);
|
||||
setLiveTranscript('');
|
||||
setFullTranscript('');
|
||||
if (transcript.toLowerCase().includes('open command list')) {
|
||||
setShowVoiceInfoModal(true);
|
||||
return;
|
||||
}
|
||||
if (await tryDeleteFromSpeech(transcript)) return;
|
||||
if (await tryShowFromSpeech(transcript)) return;
|
||||
if (trySearchFromSpeech(transcript)) return;
|
||||
if (tryModifyFromSpeech(transcript)) return;
|
||||
if (await tryUpdateEditByVoice(transcript)) return;
|
||||
handleChatPromptSend(transcript);
|
||||
}
|
||||
};
|
||||
recognition.onerror = (event: any) => {
|
||||
setShowVoiceModal(false);
|
||||
setLiveTranscript('');
|
||||
setFullTranscript('');
|
||||
alert('Speech recognition error: ' + event.error);
|
||||
};
|
||||
recognition.onend = () => {
|
||||
setShowVoiceModal(false);
|
||||
setLiveTranscript('');
|
||||
setFullTranscript('');
|
||||
};
|
||||
recognition.start();
|
||||
};
|
||||
|
||||
const renderHeader = () => (
|
||||
<TableGroupHeader
|
||||
size={size}
|
||||
@ -250,16 +543,36 @@ export default function CanvasEndpoints() {
|
||||
extraButtonsTemplate={() => (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
label="Create Endpoint"
|
||||
icon="pi pi-plus"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="p-button-secondary"
|
||||
/>
|
||||
<Button
|
||||
label="Ask ChatGPT"
|
||||
icon="pi pi-comments"
|
||||
onClick={() => setShowChatGptModal(true)}
|
||||
className="p-button-primary"
|
||||
/>
|
||||
<SettingsMenu
|
||||
items={[
|
||||
{
|
||||
label: 'Manage API Key',
|
||||
icon: 'pi pi-key',
|
||||
command: () => setShowApiKeyModal(true),
|
||||
},
|
||||
{
|
||||
label: 'Voice Command List',
|
||||
icon: 'pi pi-info-circle',
|
||||
command: () => setShowVoiceInfoModal(true),
|
||||
},
|
||||
]}
|
||||
buttonClassName="p-button-secondary"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-volume-up"
|
||||
onClick={handleVoiceInput}
|
||||
className="p-button-secondary"
|
||||
disabled={chatLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
@ -267,9 +580,9 @@ export default function CanvasEndpoints() {
|
||||
|
||||
const renderCallButton = (rowData: any) => (
|
||||
<div className="flex gap-2">
|
||||
<Button label="Call" icon="pi pi-play" onClick={() => handleCallEndpoint(rowData)} />
|
||||
<Button label="Edit" icon="pi pi-pencil" className="p-button-warning" onClick={() => openEditModal(rowData)} />
|
||||
<Button label="Delete" icon="pi pi-trash" className="p-button-danger" onClick={() => handleDeleteEndpoint(rowData)} />
|
||||
<Button icon="pi pi-play" onClick={() => handleCallEndpoint(rowData)} />
|
||||
<Button icon="pi pi-pencil" className="p-button-warning" onClick={() => openEditModal(rowData)} />
|
||||
<Button icon="pi pi-trash" className="p-button-danger" onClick={() => handleDeleteEndpoint(rowData)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -285,12 +598,22 @@ export default function CanvasEndpoints() {
|
||||
/>
|
||||
);
|
||||
|
||||
// Compute filtered endpoints for search-everything
|
||||
const filteredEndpoints = globalFilterValue
|
||||
? endpoints.filter(endpoint =>
|
||||
Object.values(endpoint)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(globalFilterValue.toLowerCase())
|
||||
)
|
||||
: endpoints;
|
||||
|
||||
return (
|
||||
<div className='w-full h-full p-6'>
|
||||
<h2 className='text-xl font-semibold mb-4'>Canvas Endpoints</h2>
|
||||
<Toast ref={toast} position="bottom-right" />
|
||||
<TableGroup
|
||||
body={endpoints}
|
||||
body={filteredEndpoints}
|
||||
size={size}
|
||||
header={renderHeader}
|
||||
filters={filters}
|
||||
@ -304,14 +627,15 @@ export default function CanvasEndpoints() {
|
||||
<Column field='id' header='ID' sortable filter style={{ width: '10%' }} />
|
||||
<Column field='name' header='Name' sortable filter style={{ width: '20%' }} />
|
||||
<Column field='method' header='Method' sortable filter style={{ width: '10%' }} />
|
||||
<Column field='path' header='Path' sortable filter style={{ width: '40%' }} />
|
||||
<Column field='path' header='Path' sortable filter style={{ width: '25%' }} />
|
||||
<Column field='publicPath' header='Public Path' sortable filter style={{ width: '15%' }} />
|
||||
<Column header='Action' body={renderCallButton} style={{ width: '20%' }} />
|
||||
</TableGroup>
|
||||
|
||||
<Dialog
|
||||
visible={showCreateModal}
|
||||
onHide={handleModalHide}
|
||||
header={editEndpointId ? "Edit Endpoint" : "Create New Endpoint"}
|
||||
header={editEndpointId ? "Edit" : "Create"}
|
||||
footer={
|
||||
<div>
|
||||
<Button label="Cancel" icon="pi pi-times" onClick={handleModalHide} className="p-button-text" />
|
||||
@ -343,6 +667,10 @@ export default function CanvasEndpoints() {
|
||||
<label htmlFor="path">Path</label>
|
||||
<InputText id="path" value={newEndpoint.path} onChange={(e) => setNewEndpoint({ ...newEndpoint, path: e.target.value })} />
|
||||
</div>
|
||||
<div className="p-field">
|
||||
<label htmlFor="publicPath">Public Path</label>
|
||||
<InputText id="publicPath" value={newEndpoint.publicPath || ''} onChange={(e) => setNewEndpoint({ ...newEndpoint, publicPath: e.target.value })} />
|
||||
</div>
|
||||
<div className="p-field">
|
||||
<label htmlFor="description">Description</label>
|
||||
<InputText id="description" value={newEndpoint.description} onChange={(e) => setNewEndpoint({ ...newEndpoint, description: e.target.value })} />
|
||||
@ -450,7 +778,7 @@ export default function CanvasEndpoints() {
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-send"
|
||||
onClick={handleChatPromptSend}
|
||||
onClick={() => handleChatPromptSend()}
|
||||
loading={chatLoading}
|
||||
disabled={!chatPrompt.trim() || chatLoading}
|
||||
/>
|
||||
@ -458,6 +786,75 @@ export default function CanvasEndpoints() {
|
||||
{chatError && <div className="text-red-500 mt-2">{chatError}</div>}
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
visible={showApiKeyModal}
|
||||
onHide={() => setShowApiKeyModal(false)}
|
||||
header="API Key Management"
|
||||
footer={
|
||||
<div>
|
||||
<Button
|
||||
label={apiKey ? "Regenerate API Key" : "Create API Key"}
|
||||
icon="pi pi-refresh"
|
||||
onClick={handleApiKey}
|
||||
loading={apiKeyLoading}
|
||||
className="p-button-danger"
|
||||
/>
|
||||
<Button
|
||||
label="Close"
|
||||
icon="pi pi-times"
|
||||
onClick={() => setShowApiKeyModal(false)}
|
||||
className="p-button-text"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{apiKey && (
|
||||
<div>
|
||||
<div className="mb-2 font-bold">Current API Key:</div>
|
||||
<div className="font-mono bg-gray-100 p-2 rounded">{apiKey}</div>
|
||||
<div className="mt-2 text-sm text-red-500">
|
||||
Regenerating will invalidate the old key.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!apiKey && (
|
||||
<div>
|
||||
<div>No API key exists for this app. Click "Create API Key" to generate one.</div>
|
||||
</div>
|
||||
)}
|
||||
{apiKeyError && <div className="text-red-500 mt-2">{apiKeyError}</div>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
visible={showVoiceModal}
|
||||
onHide={() => setShowVoiceModal(false)}
|
||||
header="Listening..."
|
||||
modal
|
||||
style={{ minWidth: 400, textAlign: 'center' }}
|
||||
closable={false}
|
||||
>
|
||||
<div style={{ fontSize: 24, minHeight: 60 }}>{fullTranscript || <span className="text-gray-400">Say something…</span>}</div>
|
||||
<div className="mt-4 text-sm text-gray-500">Speak your command or query.</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
visible={showVoiceInfoModal}
|
||||
onHide={() => setShowVoiceInfoModal(false)}
|
||||
header="Voice Command List"
|
||||
modal
|
||||
style={{ minWidth: 400 }}
|
||||
>
|
||||
<ul style={{ textAlign: 'left', lineHeight: 2 }}>
|
||||
<li><b>show/view/open endpoint [name or id]</b> — Open an endpoint's table view</li>
|
||||
<li><b>delete endpoint [name or id]</b> — Delete an endpoint</li>
|
||||
<li><b>search [term]</b> — Filter endpoints</li>
|
||||
<li><b>modify endpoint [id]</b> — Start editing an endpoint by voice</li>
|
||||
<li><b>method is [GET/POST/PUT/DELETE/PATCH]</b> — Set method (in edit mode)</li>
|
||||
<li><b>path is [your path]</b> — Set path (in edit mode, say 'slash' for /, 'colon id' for :id)</li>
|
||||
<li><b>open command list</b> — Show this help</li>
|
||||
</ul>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
314
frontend/src/components/canvas-api/ChatGPTModal.tsx
Normal file
314
frontend/src/components/canvas-api/ChatGPTModal.tsx
Normal file
@ -0,0 +1,314 @@
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useChatStore } from '../../state/stores/useChatStore';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { generateTablePrompt } from '../../prompts/table-generation';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { useRef } from 'react';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
|
||||
interface ChatGPTModalProps {
|
||||
content?: {
|
||||
prompt: string;
|
||||
id?: string;
|
||||
keywords?: string[];
|
||||
context?: string;
|
||||
tableSchema?: {
|
||||
name: string;
|
||||
fields: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface TableData {
|
||||
columns: Array<{
|
||||
field: string;
|
||||
header: string;
|
||||
}>;
|
||||
data: any[];
|
||||
metadata?: {
|
||||
id?: string;
|
||||
keywords?: string[];
|
||||
context?: string;
|
||||
timestamp: string;
|
||||
actions?: Array<{
|
||||
action: string;
|
||||
target: string;
|
||||
description: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export const ChatGPTModal = ({ content }: ChatGPTModalProps) => {
|
||||
const [tableData, setTableData] = useState<TableData>({
|
||||
columns: [],
|
||||
data: [],
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedRow, setSelectedRow] = useState<any>(null);
|
||||
const [showRowDialog, setShowRowDialog] = useState(false);
|
||||
const [finalPrompt, setFinalPrompt] = useState<string>('');
|
||||
const { sendChatRequest } = useChatStore();
|
||||
const toast = useRef<Toast>(null);
|
||||
|
||||
const constructPath = (id: string | number, pathname: string): string => {
|
||||
// Remove any leading/trailing slashes from pathname
|
||||
const cleanPathname = pathname.replace(/^\/+|\/+$/g, '');
|
||||
// Construct the path with proper slashes
|
||||
return `/${id}/${cleanPathname}`.replace(/\/+/g, '/');
|
||||
};
|
||||
|
||||
const findRowById = (id: string | number) => {
|
||||
const row = tableData.data.find(row => row.id?.toString() === id.toString());
|
||||
if (row) {
|
||||
setSelectedRow(row);
|
||||
setShowRowDialog(true);
|
||||
showToast('info', `Viewing details for ID: ${id}`);
|
||||
} else {
|
||||
showToast('warn', `No row found with ID: ${id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublicPath = (prompt: string) => {
|
||||
// Check for path construction pattern
|
||||
const pathMatch = prompt.match(/add\s+public\s+path\s+to\s+(\d+)\s+slash\s+([^\s]+)/i);
|
||||
if (pathMatch) {
|
||||
const [, id, pathname] = pathMatch;
|
||||
const constructedPath = constructPath(id, pathname);
|
||||
// Find the row with the given ID
|
||||
const rowIndex = tableData.data.findIndex(row => row.id?.toString() === id.toString());
|
||||
if (rowIndex !== -1) {
|
||||
// Update the path of the existing row
|
||||
setTableData(prev => {
|
||||
const newData = [...prev.data];
|
||||
newData[rowIndex] = {
|
||||
...newData[rowIndex],
|
||||
path: constructedPath
|
||||
};
|
||||
// Add an 'add_path' action to metadata
|
||||
return {
|
||||
...prev,
|
||||
data: newData,
|
||||
metadata: {
|
||||
...prev.metadata,
|
||||
actions: [
|
||||
...(prev.metadata?.actions || []),
|
||||
{
|
||||
action: 'add_path',
|
||||
target: constructedPath,
|
||||
description: `added public path ${constructedPath} to endpoint ID ${id}`
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
});
|
||||
showToast('success', `Updated path for ID ${id}: ${constructedPath}`);
|
||||
findRowById(id);
|
||||
} else {
|
||||
showToast('warn', `No endpoint found with ID: ${id}`);
|
||||
}
|
||||
return { id, constructedPath };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (content?.prompt) {
|
||||
setFinalPrompt(content.prompt);
|
||||
// Handle public path update only, not endpoint creation
|
||||
handlePublicPath(content.prompt);
|
||||
generateTable();
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
const showToast = (severity: 'success' | 'info' | 'warn' | 'error', message: string) => {
|
||||
toast.current?.show({
|
||||
severity,
|
||||
summary: severity.charAt(0).toUpperCase() + severity.slice(1),
|
||||
detail: message,
|
||||
life: 3000
|
||||
});
|
||||
};
|
||||
|
||||
const generateTable = async () => {
|
||||
if (!content?.prompt) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const promptText = generateTablePrompt({
|
||||
prompt: content.prompt,
|
||||
context: content.context,
|
||||
keywords: content.keywords,
|
||||
id: content.id,
|
||||
tableSchema: content.tableSchema
|
||||
});
|
||||
|
||||
const response = await sendChatRequest('/chat/completions', {
|
||||
data: promptText,
|
||||
responseFormat: 'json'
|
||||
});
|
||||
|
||||
if (response?.responseText) {
|
||||
try {
|
||||
const parsedData = JSON.parse(response.responseText);
|
||||
setTableData({
|
||||
...parsedData,
|
||||
metadata: {
|
||||
...parsedData.metadata,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
// Show success message based on the primary action
|
||||
const primaryAction = parsedData.metadata?.actions?.[0];
|
||||
if (primaryAction) {
|
||||
const actionMessage = {
|
||||
create: 'Successfully created',
|
||||
delete: 'Successfully deleted',
|
||||
update: 'Successfully updated',
|
||||
show: 'Successfully retrieved',
|
||||
search: 'Search completed',
|
||||
view: 'Viewing details'
|
||||
}[primaryAction.action] || 'Operation completed';
|
||||
|
||||
showToast('success', `${actionMessage} ${primaryAction.target}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing response:', error);
|
||||
showToast('error', 'Error processing the response');
|
||||
}
|
||||
} else {
|
||||
showToast('error', 'Invalid response format');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating table:', error);
|
||||
showToast('error', 'Error generating table');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRowClick = (event: any) => {
|
||||
const rowData = event.data;
|
||||
setSelectedRow(rowData);
|
||||
setShowRowDialog(true);
|
||||
|
||||
// Add view action to metadata if not present
|
||||
if (tableData.metadata?.actions) {
|
||||
const hasViewAction = tableData.metadata.actions.some(
|
||||
action => action.action === 'view' && action.target === rowData.id
|
||||
);
|
||||
|
||||
if (!hasViewAction) {
|
||||
setTableData(prev => ({
|
||||
...prev,
|
||||
metadata: {
|
||||
...prev.metadata,
|
||||
actions: [
|
||||
...(prev.metadata?.actions || []),
|
||||
{
|
||||
action: 'view',
|
||||
target: rowData.id || 'selected row',
|
||||
description: `view details for ${rowData.id || 'selected row'}`
|
||||
}
|
||||
]
|
||||
}
|
||||
}));
|
||||
showToast('info', `Viewing details for ${rowData.id || 'selected row'}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderRowDialog = () => {
|
||||
if (!selectedRow) return null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
visible={showRowDialog}
|
||||
onHide={() => setShowRowDialog(false)}
|
||||
header={`View Details - ${selectedRow.id || 'Selected Row'}`}
|
||||
style={{ width: '50vw' }}
|
||||
>
|
||||
<div className="grid">
|
||||
{tableData.columns.map(col => (
|
||||
<div key={col.field} className="col-12 md:col-6 p-2">
|
||||
<div className="font-bold">{col.header}</div>
|
||||
<div>{selectedRow[col.field] || 'N/A'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toast ref={toast} position="top-right" />
|
||||
<div className="flex flex-column gap-3 p-4 relative">
|
||||
{finalPrompt && (
|
||||
<div className="mb-3">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Your Request
|
||||
</label>
|
||||
<InputText
|
||||
value={finalPrompt}
|
||||
readOnly
|
||||
className="w-full p-2 border rounded"
|
||||
style={{ backgroundColor: '#f8f9fa' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<DataTable
|
||||
value={tableData.data}
|
||||
loading={loading}
|
||||
className="w-full"
|
||||
onRowClick={handleRowClick}
|
||||
selectionMode="single"
|
||||
rowHover
|
||||
>
|
||||
{tableData.columns.map(col => (
|
||||
<Column
|
||||
key={col.field}
|
||||
field={col.field}
|
||||
header={col.header}
|
||||
body={(rowData) => (
|
||||
<div className="cursor-pointer hover:bg-gray-100 p-2">
|
||||
{rowData[col.field] || 'N/A'}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</DataTable>
|
||||
{tableData.metadata && (
|
||||
<div className="text-sm text-gray-500 mt-2">
|
||||
<p>ID: {tableData.metadata.id}</p>
|
||||
<p>Keywords: {tableData.metadata.keywords?.join(', ')}</p>
|
||||
<p>Context: {tableData.metadata.context}</p>
|
||||
<p>Generated: {new Date(tableData.metadata.timestamp).toLocaleString()}</p>
|
||||
{tableData.metadata.actions && tableData.metadata.actions.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="font-bold">Detected Actions:</p>
|
||||
{tableData.metadata.actions.map((action, index) => (
|
||||
<div key={index} className="ml-2">
|
||||
<span className="font-semibold">{action.action}:</span> {action.target}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{renderRowDialog()}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatGPTModal;
|
65
frontend/src/components/canvas-api/ParseDocsModal.tsx
Normal file
65
frontend/src/components/canvas-api/ParseDocsModal.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Button } from 'primereact/button';
|
||||
|
||||
interface ParseDocsModalProps {
|
||||
visible: boolean;
|
||||
onHide: () => void;
|
||||
onParsed?: (endpoints: any[]) => void;
|
||||
}
|
||||
|
||||
export function ParseDocsModal({ visible, onHide, onParsed }: ParseDocsModalProps) {
|
||||
const [url, setUrl] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [results, setResults] = useState<any>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleParse = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResults(null);
|
||||
try {
|
||||
const response = await fetch('/api/parse-docs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url }),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch or parse docs');
|
||||
const data = await response.json();
|
||||
setResults(data);
|
||||
if (onParsed && data.restructured) onParsed(data.restructured);
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog visible={visible} onHide={onHide} header="Parse API Docs" style={{ width: '50vw' }}>
|
||||
<div>
|
||||
<InputText
|
||||
value={url}
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
placeholder="Enter documentation URL"
|
||||
className="w-full mb-3"
|
||||
/>
|
||||
<Button label="Parse" onClick={handleParse} loading={loading} disabled={!url} />
|
||||
{error && <div className="text-red-500 mt-2">{error}</div>}
|
||||
</div>
|
||||
{results && (
|
||||
<div className="mt-4">
|
||||
<h4>Raw HTML (first 500 chars):</h4>
|
||||
<pre style={{ maxHeight: 200, overflow: 'auto', background: '#f8f9fa', padding: 8 }}>{results.rawHtml?.slice(0, 500)}...</pre>
|
||||
<h4>AI Output:</h4>
|
||||
<pre style={{ maxHeight: 200, overflow: 'auto', background: '#f8f9fa', padding: 8 }}>{JSON.stringify(results.aiOutput, null, 2)}</pre>
|
||||
<h4>Restructured Output:</h4>
|
||||
<pre style={{ maxHeight: 200, overflow: 'auto', background: '#f8f9fa', padding: 8 }}>{JSON.stringify(results.restructured, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default ParseDocsModal;
|
0
frontend/src/state/stores/useCanvasStore.ts → frontend/src/components/canvas-api/useCanvasStore.ts
0
frontend/src/state/stores/useCanvasStore.ts → frontend/src/components/canvas-api/useCanvasStore.ts
75
frontend/src/constants/actions.ts
Normal file
75
frontend/src/constants/actions.ts
Normal file
@ -0,0 +1,75 @@
|
||||
export interface ActionKeyword {
|
||||
action: string;
|
||||
target: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const ACTION_KEYWORDS: Record<string, string[]> = {
|
||||
create: ['create', 'new', 'make'],
|
||||
delete: ['delete', 'remove', 'drop'],
|
||||
update: ['update', 'modify', 'change', 'edit'],
|
||||
show: ['show', 'display', 'list', 'get', 'fetch', 'view'],
|
||||
search: ['search', 'find', 'query', 'filter'],
|
||||
view: ['view', 'details', 'info', 'expand']
|
||||
};
|
||||
|
||||
export const detectActions = (prompt: string, keywords: string[]): ActionKeyword[] => {
|
||||
const actions: ActionKeyword[] = [];
|
||||
const promptLower = prompt.toLowerCase().trim();
|
||||
|
||||
// If prompt starts with 'add', treat as 'add' action
|
||||
if (promptLower.startsWith('add')) {
|
||||
// Try to match 'add ... to [id] slash [path]'
|
||||
const match = promptLower.match(/add.*to\s+(\d+)\s+slash\s+([^\s]+)/i);
|
||||
if (match) {
|
||||
const [, id, pathname] = match;
|
||||
actions.push({
|
||||
action: 'add',
|
||||
target: `id:${id} path:/${pathname.replace(/^\/+|\/+$/g, '')}`,
|
||||
description: `add /${pathname.replace(/^\/+|\/+$/g, '')} to endpoint ID ${id}`
|
||||
});
|
||||
} else {
|
||||
// Fallback: just mark as a generic add action
|
||||
actions.push({
|
||||
action: 'add',
|
||||
target: '',
|
||||
description: 'add action'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// First check for explicit action-target pairs
|
||||
const words = promptLower.split(' ');
|
||||
for (let i = 0; i < words.length - 1; i++) {
|
||||
const word = words[i];
|
||||
const nextWord = words[i + 1];
|
||||
for (const [action, triggers] of Object.entries(ACTION_KEYWORDS)) {
|
||||
if (triggers.includes(word)) {
|
||||
actions.push({
|
||||
action,
|
||||
target: nextWord,
|
||||
description: `${action} ${nextWord}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then check keywords for additional context
|
||||
keywords.forEach(keyword => {
|
||||
const keywordWords = keyword.toLowerCase().split(' ');
|
||||
for (const [action, triggers] of Object.entries(ACTION_KEYWORDS)) {
|
||||
if (triggers.some(trigger => keywordWords.includes(trigger))) {
|
||||
const target = keywordWords.find(word => !triggers.includes(word));
|
||||
if (target) {
|
||||
actions.push({
|
||||
action,
|
||||
target,
|
||||
description: `${action} ${target}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return actions;
|
||||
};
|
75
frontend/src/prompts/table-generation.ts
Normal file
75
frontend/src/prompts/table-generation.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { ActionKeyword, detectActions } from '../constants/actions';
|
||||
|
||||
interface TableGenerationPromptParams {
|
||||
prompt: string;
|
||||
context?: string;
|
||||
keywords?: string[];
|
||||
id?: string;
|
||||
tableSchema?: {
|
||||
name: string;
|
||||
fields: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export const generateTablePrompt = (params: TableGenerationPromptParams): string => {
|
||||
const {
|
||||
prompt,
|
||||
context = 'General information',
|
||||
keywords = [],
|
||||
id = 'auto-generated',
|
||||
tableSchema
|
||||
} = params;
|
||||
|
||||
const detectedActions = detectActions(prompt, keywords);
|
||||
|
||||
return `Create a table with data about: ${prompt}
|
||||
Context: ${context}
|
||||
Keywords: ${keywords.join(', ') || 'None specified'}
|
||||
${tableSchema ? `
|
||||
Table Schema:
|
||||
Name: ${tableSchema.name}
|
||||
Fields:
|
||||
${tableSchema.fields.map(f => `- ${f.name} (${f.type})${f.description ? `: ${f.description}` : ''}`).join('\n')}
|
||||
` : ''}
|
||||
|
||||
Detected Actions:
|
||||
${detectedActions.map(action => `- ${action.description}`).join('\n')}
|
||||
|
||||
Return ONLY a JSON object in this format:
|
||||
{
|
||||
"columns": [
|
||||
{"field": "column1", "header": "Column 1"},
|
||||
{"field": "column2", "header": "Column 2"}
|
||||
],
|
||||
"data": [
|
||||
{"column1": "value1", "column2": "value2"},
|
||||
{"column1": "value3", "column2": "value4"}
|
||||
],
|
||||
"metadata": {
|
||||
"id": "${id}",
|
||||
"keywords": ${JSON.stringify(keywords)},
|
||||
"context": "${context}",
|
||||
"timestamp": "${new Date().toISOString()}",
|
||||
"actions": ${JSON.stringify(detectedActions)}
|
||||
}
|
||||
}
|
||||
|
||||
Rules:
|
||||
1. Return ONLY the JSON object, no explanations
|
||||
2. Make sure field names in data match column fields exactly
|
||||
3. Choose appropriate column names based on the topic
|
||||
4. Include at least 5 rows of data
|
||||
5. Ensure all data is relevant to the context and keywords
|
||||
6. Format dates in ISO format if present
|
||||
7. Use consistent data types within columns
|
||||
8. If table schema is provided, strictly follow the field names and types
|
||||
9. Ensure all required fields from the schema are present in the data
|
||||
10. Format the data according to the specified types in the schema
|
||||
11. For multiple actions, prioritize the first action mentioned
|
||||
12. When creating endpoints, include all related actions in the endpoint description
|
||||
13. For row click actions, use the 'view' action type`;
|
||||
};
|
@ -1,10 +1,11 @@
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import DualModalComponent from './layouts/DualModal';
|
||||
|
||||
import CouncilAI from './components/CouncilAI/CouncilAI';
|
||||
import FuseMindHome from './components/FuseMind/FuseMindHome';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import CanvasEndpoints from './components/CanvasEndpoints';
|
||||
import CanvasEndpoints from './components/canvas-api/CanvasEndpoints';
|
||||
|
||||
const FuseMindHomeWrapper = () => {
|
||||
const { systemId } = useParams();
|
||||
|
37
frontend/src/shared/components/SettingsMenu.tsx
Normal file
37
frontend/src/shared/components/SettingsMenu.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { useRef } from 'react';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Menu } from 'primereact/menu';
|
||||
|
||||
export interface SettingsMenuItem {
|
||||
label: string;
|
||||
icon?: string;
|
||||
command?: () => void;
|
||||
}
|
||||
|
||||
interface SettingsMenuProps {
|
||||
items: SettingsMenuItem[];
|
||||
buttonClassName?: string;
|
||||
}
|
||||
|
||||
const SettingsMenu: React.FC<SettingsMenuProps> = ({ items, buttonClassName }) => {
|
||||
const menuRef = useRef<any>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
icon="pi pi-cog"
|
||||
aria-label="Settings"
|
||||
onClick={e => menuRef.current.toggle(e)}
|
||||
className={buttonClassName || ''}
|
||||
tooltip="Settings"
|
||||
/>
|
||||
<Menu
|
||||
model={items}
|
||||
popup
|
||||
ref={menuRef}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsMenu;
|
@ -3,7 +3,7 @@ import { SelectButton } from 'primereact/selectbutton';
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import Button from '../_V2/Button';
|
||||
import { TSizeOptionValue } from '../../../types/DataTable.types';
|
||||
import { TSizeOptionValue } from '../../../components/canvas-api/DataTable.types';
|
||||
|
||||
interface HeaderProps {
|
||||
size: any;
|
||||
|
@ -24,14 +24,18 @@ export const useChatStore = create<ChatStore>(() => ({
|
||||
|
||||
const formattedRequest = {
|
||||
data: requestData.data,
|
||||
// model: requestData.model || 'gpt-4o-mini',
|
||||
model: requestData.model || 'o4-mini',
|
||||
model: requestData.model || 'gpt-4-mini',
|
||||
response_format: requestData.responseFormat === 'json' ? { type: 'json_object' } : undefined
|
||||
};
|
||||
|
||||
console.log('Sending formatted request:', formattedRequest);
|
||||
const response = await api('post', `/council${endpoint}`, formattedRequest, undefined, 'json', 60, true);
|
||||
const response = await api('post', endpoint, formattedRequest, undefined, 'json', 60, true);
|
||||
console.log('Got API response:', response);
|
||||
|
||||
if (!response || !response.data) {
|
||||
throw new Error('Invalid response from server');
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error(`Failed to send chat request to ${endpoint}:`, error);
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { Entity, Property, ManyToOne } from '@mikro-orm/core';
|
||||
import { BaseEntity } from '../_BaseEntity';
|
||||
import { User } from '../user/_User';
|
||||
import { App } from '../app/_App';
|
||||
import { APIKeyRepository } from '../../repositories/APIKeyRepository';
|
||||
|
||||
@Entity()
|
||||
export class APIKey extends BaseEntity {
|
||||
@ -9,4 +11,9 @@ export class APIKey extends BaseEntity {
|
||||
|
||||
@ManyToOne(() => User)
|
||||
user!: User;
|
||||
|
||||
@ManyToOne(() => App)
|
||||
app!: App;
|
||||
|
||||
// Each APIKey is now linked to both a user and an app (system)
|
||||
}
|
||||
|
59
src/apps/_app/http/controllers/APIKeyController.ts
Normal file
59
src/apps/_app/http/controllers/APIKeyController.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { APIKeyService } from '../../services/APIKeyService';
|
||||
import { App } from '../../entities/app/_App';
|
||||
import { User } from '../../entities/user/_User';
|
||||
import { APIKey } from '../../entities/apikey/_APIKey';
|
||||
import crypto from 'crypto';
|
||||
|
||||
export class APIKeyController {
|
||||
private apiKeyService: APIKeyService;
|
||||
constructor(apiKeyService: APIKeyService) {
|
||||
this.apiKeyService = apiKeyService;
|
||||
}
|
||||
|
||||
async getOrCreateApiKey(request: FastifyRequest<{ Body: { userId: number; appId: number } }>, reply: FastifyReply) {
|
||||
try {
|
||||
const { userId, appId } = request.body;
|
||||
if (!userId || !appId) {
|
||||
return reply.status(400).send({ error: 'userId and appId are required' });
|
||||
}
|
||||
|
||||
let apiKeyEntity = await this.apiKeyService.getAPIKeyEntityForUserAndApp(userId, appId);
|
||||
if (!apiKeyEntity) {
|
||||
// Generate a new API key
|
||||
const key = crypto.randomBytes(32).toString('hex');
|
||||
const em = request.em;
|
||||
|
||||
// Check if user exists
|
||||
const user = await em.findOne(User, { id: userId });
|
||||
if (!user) {
|
||||
return reply.status(404).send({ error: `User with ID ${userId} not found` });
|
||||
}
|
||||
|
||||
// Check if app exists
|
||||
const app = await em.findOne(App, { id: appId });
|
||||
if (!app) {
|
||||
return reply.status(404).send({ error: `App with ID ${appId} not found` });
|
||||
}
|
||||
|
||||
// Create new API key
|
||||
apiKeyEntity = new APIKey();
|
||||
apiKeyEntity.key = key;
|
||||
apiKeyEntity.user = user;
|
||||
apiKeyEntity.app = app;
|
||||
|
||||
try {
|
||||
await em.persistAndFlush(apiKeyEntity);
|
||||
} catch (dbError) {
|
||||
console.error('Database error while creating API key:', dbError);
|
||||
return reply.status(500).send({ error: 'Failed to create API key in database' });
|
||||
}
|
||||
}
|
||||
|
||||
return reply.send({ apiKey: apiKeyEntity.key });
|
||||
} catch (error) {
|
||||
console.error('Error in getOrCreateApiKey:', error);
|
||||
return reply.status(500).send({ error: 'Internal server error while processing API key request' });
|
||||
}
|
||||
}
|
||||
}
|
@ -5,4 +5,8 @@ export class APIKeyRepository extends EntityRepository<APIKey> {
|
||||
async findAPIKeyByUserId(userId: number): Promise<APIKey | null> {
|
||||
return this.findOne({ user: userId });
|
||||
}
|
||||
|
||||
async findAPIKeyByUserAndApp(userId: number, appId: number): Promise<APIKey | null> {
|
||||
return this.findOne({ user: userId, app: appId });
|
||||
}
|
||||
}
|
||||
|
20
src/apps/_app/routes/APIKeyRoutes.ts
Normal file
20
src/apps/_app/routes/APIKeyRoutes.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { APIKeyService } from '../services/APIKeyService';
|
||||
import { APIKeyController } from '../http/controllers/APIKeyController';
|
||||
|
||||
const apiKeyRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post('/generate', async (request: FastifyRequest<{ Body: { userId: number; appId: number } }>, reply: FastifyReply) => {
|
||||
const apiKeyService = new APIKeyService(request.em);
|
||||
const controller = new APIKeyController(apiKeyService);
|
||||
return controller.getOrCreateApiKey(request, reply);
|
||||
});
|
||||
|
||||
app.post('/get', async (request: FastifyRequest<{ Body: { userId: number; appId: number } }>, reply: FastifyReply) => {
|
||||
const apiKeyService = new APIKeyService(request.em);
|
||||
const apiKey = await apiKeyService.getAPIKeyForUserAndApp(request.body.userId, request.body.appId);
|
||||
if (!apiKey) return reply.send({ apiKey: null });
|
||||
return reply.send({ apiKey });
|
||||
});
|
||||
};
|
||||
|
||||
export default apiKeyRoutes;
|
13
src/apps/_app/routes/AppRoutes.ts
Normal file
13
src/apps/_app/routes/AppRoutes.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { App } from '../entities/app/_App';
|
||||
|
||||
const appRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/by-name/:name', async (request: FastifyRequest<{ Params: { name: string } }>, reply: FastifyReply) => {
|
||||
const { name } = request.params;
|
||||
const appEntity = await request.em.findOne(App, { name });
|
||||
if (!appEntity) return reply.status(404).send({ error: 'App not found' });
|
||||
return reply.send({ id: appEntity.id, name: appEntity.name });
|
||||
});
|
||||
};
|
||||
|
||||
export default appRoutes;
|
@ -15,4 +15,13 @@ export class APIKeyService {
|
||||
const apiKey = await this.apiKeyRepository.findAPIKeyByUserId(userId);
|
||||
return apiKey ? apiKey.key : null;
|
||||
}
|
||||
|
||||
async getAPIKeyForUserAndApp(userId: number, appId: number): Promise<string | null> {
|
||||
const apiKey = await this.apiKeyRepository.findOne({ user: userId, app: appId });
|
||||
return apiKey ? apiKey.key : null;
|
||||
}
|
||||
|
||||
async getAPIKeyEntityForUserAndApp(userId: number, appId: number): Promise<APIKey | null> {
|
||||
return this.apiKeyRepository.findOne({ user: userId, app: appId });
|
||||
}
|
||||
}
|
||||
|
@ -18,4 +18,7 @@ export class CanvasApiEndpoint extends BaseEntity {
|
||||
|
||||
@ManyToOne(() => User, { nullable: true })
|
||||
user?: User;
|
||||
|
||||
@Property({ nullable: true })
|
||||
publicPath?: string;
|
||||
}
|
@ -1,14 +1,29 @@
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { CanvasApiEndpoint } from '../../entities/CanvasApiEndpoint';
|
||||
|
||||
interface CreateEndpointBody {
|
||||
name: string;
|
||||
method: string;
|
||||
path: string;
|
||||
description?: string;
|
||||
user?: any;
|
||||
}
|
||||
|
||||
interface UpdateEndpointBody {
|
||||
name?: string;
|
||||
method?: string;
|
||||
path?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class CanvasApiEndpointController {
|
||||
async getAll(request: FastifyRequest, reply: FastifyReply) {
|
||||
const endpoints = await request.em.find(CanvasApiEndpoint, {});
|
||||
return reply.send(endpoints);
|
||||
}
|
||||
|
||||
async create(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { name, method, path, description, user } = request.body as any;
|
||||
async create(request: FastifyRequest<{ Body: CreateEndpointBody }>, reply: FastifyReply) {
|
||||
const { name, method, path, description, user } = request.body;
|
||||
if (!name || !method || !path) {
|
||||
return reply.status(400).send({ error: 'name, method, and path are required' });
|
||||
}
|
||||
@ -31,15 +46,15 @@ export class CanvasApiEndpointController {
|
||||
return reply.send({ success: true });
|
||||
}
|
||||
|
||||
async update(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
|
||||
async update(request: FastifyRequest<{ Params: { id: string }; Body: UpdateEndpointBody }>, reply: FastifyReply) {
|
||||
const id = Number(request.params.id);
|
||||
if (!id) return reply.status(400).send({ error: 'Missing id' });
|
||||
const endpoint = await request.em.findOne(CanvasApiEndpoint, { id });
|
||||
if (!endpoint) return reply.status(404).send({ error: 'Not found' });
|
||||
const { name, method, path, description } = request.body as any;
|
||||
const { name, method, path, description } = request.body;
|
||||
if (name) endpoint.name = name;
|
||||
if (method) endpoint.method = method;
|
||||
if (path) endpoint.path = (path as string).replace(/^\/api\/v1/, '');
|
||||
if (path) endpoint.path = path.replace(/^\/api\/v1/, '');
|
||||
if (description !== undefined) endpoint.description = description;
|
||||
await request.em.persistAndFlush(endpoint);
|
||||
return reply.send(endpoint);
|
||||
|
@ -11,6 +11,21 @@ interface CanvasProxyRequestInput {
|
||||
params?: any;
|
||||
}
|
||||
|
||||
interface CreateEndpointBody {
|
||||
name: string;
|
||||
method: string;
|
||||
path: string;
|
||||
description?: string;
|
||||
user?: any;
|
||||
}
|
||||
|
||||
interface UpdateEndpointBody {
|
||||
name?: string;
|
||||
method?: string;
|
||||
path?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const endpointsRoutes: FastifyPluginAsync = async (app) => {
|
||||
const controller = new CanvasApiEndpointController();
|
||||
|
||||
@ -18,26 +33,26 @@ const endpointsRoutes: FastifyPluginAsync = async (app) => {
|
||||
return controller.getAll(request, reply);
|
||||
});
|
||||
|
||||
app.post('/endpoints', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
app.post('/endpoints', async (request: FastifyRequest<{ Body: CreateEndpointBody }>, reply: FastifyReply) => {
|
||||
return controller.create(request, reply);
|
||||
});
|
||||
|
||||
app.delete('/endpoints/:id', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
app.delete('/endpoints/:id', async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
||||
return controller.delete(request, reply);
|
||||
});
|
||||
|
||||
app.put('/endpoints/:id', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
app.put('/endpoints/:id', async (request: FastifyRequest<{ Params: { id: string }; Body: UpdateEndpointBody }>, reply: FastifyReply) => {
|
||||
return controller.update(request, reply);
|
||||
});
|
||||
|
||||
// Dynamic proxy route for external Canvas REST API
|
||||
app.post('/proxy-external', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
app.post('/proxy-external', async (request: FastifyRequest<{ Body: CanvasProxyRequestInput }>, reply: FastifyReply) => {
|
||||
try {
|
||||
let baseUrl = process.env.CANVAS_API_URL || 'https://talnet.instructure.com';
|
||||
// Remove trailing /api/v1 if present
|
||||
baseUrl = baseUrl.replace(/\/api\/v1$/, '');
|
||||
const apiKey = process.env.CANVAS_API_KEY || '';
|
||||
const { path, method = 'GET', params } = request.body as CanvasProxyRequestInput;
|
||||
const { path, method = 'GET', params } = request.body;
|
||||
if (!path) return reply.status(400).send({ error: 'Missing path' });
|
||||
if (!ALLOWED_METHODS.includes(method as Method)) {
|
||||
return reply.status(405).send({ error: `Method ${method} not allowed` });
|
||||
|
@ -509,6 +509,16 @@
|
||||
"primary": false,
|
||||
"nullable": true,
|
||||
"mappedType": "integer"
|
||||
},
|
||||
"public_path": {
|
||||
"name": "public_path",
|
||||
"type": "varchar(255)",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": true,
|
||||
"length": 255,
|
||||
"mappedType": "string"
|
||||
}
|
||||
},
|
||||
"name": "canvas_api_endpoints",
|
||||
@ -592,6 +602,15 @@
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"mappedType": "integer"
|
||||
},
|
||||
"app_id": {
|
||||
"name": "app_id",
|
||||
"type": "int",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"mappedType": "integer"
|
||||
}
|
||||
},
|
||||
"name": "apikey",
|
||||
@ -621,6 +640,18 @@
|
||||
],
|
||||
"referencedTableName": "public.user",
|
||||
"updateRule": "cascade"
|
||||
},
|
||||
"apikey_app_id_foreign": {
|
||||
"constraintName": "apikey_app_id_foreign",
|
||||
"columnNames": [
|
||||
"app_id"
|
||||
],
|
||||
"localTableName": "public.apikey",
|
||||
"referencedColumnNames": [
|
||||
"id"
|
||||
],
|
||||
"referencedTableName": "public.app",
|
||||
"updateRule": "cascade"
|
||||
}
|
||||
},
|
||||
"nativeEnums": {}
|
||||
|
@ -11,13 +11,14 @@ export class Migration20240415134130 extends Migration {
|
||||
|
||||
this.addSql('create table "tenant_app" ("id" serial primary key, "created_at" timestamptz not null, "updated_at" timestamptz not null, "tenant_id" int not null, "app_id" int not null, "user_id" int not null);');
|
||||
|
||||
this.addSql('create table "apikey" ("id" serial primary key, "created_at" timestamptz not null, "updated_at" timestamptz not null, "key" varchar(255) not null, "user_id" int not null);');
|
||||
this.addSql('create table "apikey" ("id" serial primary key, "created_at" timestamptz not null, "updated_at" timestamptz not null, "key" varchar(255) not null, "user_id" int not null, "app_id" int not null);');
|
||||
|
||||
this.addSql('alter table "tenant_app" add constraint "tenant_app_tenant_id_foreign" foreign key ("tenant_id") references "tenant" ("id") on update cascade;');
|
||||
this.addSql('alter table "tenant_app" add constraint "tenant_app_app_id_foreign" foreign key ("app_id") references "app" ("id") on update cascade;');
|
||||
this.addSql('alter table "tenant_app" add constraint "tenant_app_user_id_foreign" foreign key ("user_id") references "user" ("id") on update cascade;');
|
||||
|
||||
this.addSql('alter table "apikey" add constraint "apikey_user_id_foreign" foreign key ("user_id") references "user" ("id") on update cascade;');
|
||||
this.addSql('alter table "apikey" add constraint "apikey_app_id_foreign" foreign key ("app_id") references "app" ("id") on update cascade;');
|
||||
}
|
||||
|
||||
async down(): Promise<void> {
|
||||
@ -28,6 +29,7 @@ export class Migration20240415134130 extends Migration {
|
||||
this.addSql('alter table "tenant_app" drop constraint "tenant_app_user_id_foreign";');
|
||||
|
||||
this.addSql('alter table "apikey" drop constraint "apikey_user_id_foreign";');
|
||||
this.addSql('alter table "apikey" drop constraint "apikey_app_id_foreign";');
|
||||
|
||||
this.addSql('drop table if exists "app" cascade;');
|
||||
|
||||
|
@ -6,6 +6,8 @@ export class Migration20250502161232 extends Migration {
|
||||
this.addSql(`create table "canvas_api_endpoints" ("id" serial primary key, "created_at" timestamptz not null, "updated_at" timestamptz not null, "name" varchar(255) not null, "method" varchar(255) not null, "path" varchar(255) not null, "description" varchar(255) null, "user_id" int null);`);
|
||||
|
||||
this.addSql(`alter table "canvas_api_endpoints" add constraint "canvas_api_endpoints_user_id_foreign" foreign key ("user_id") references "user" ("id") on update cascade on delete set null;`);
|
||||
|
||||
this.addSql('alter table "canvas_api_endpoints" add column "public_path" varchar(255) null;');
|
||||
}
|
||||
|
||||
override async down(): Promise<void> {
|
||||
|
@ -1,13 +0,0 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
export class Migration20250505072850 extends Migration {
|
||||
|
||||
override async up(): Promise<void> {
|
||||
this.addSql(`alter table "canvas_api_endpoints" drop column "dummy";`);
|
||||
}
|
||||
|
||||
override async down(): Promise<void> {
|
||||
this.addSql(`alter table "canvas_api_endpoints" add column "dummy" varchar(255) null;`);
|
||||
}
|
||||
|
||||
}
|
@ -1,14 +1,17 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import userRoutes from './apps/_app/routes/UserRoutes';
|
||||
import canvasRoutes from './apps/canvas-api/canvasRouter';
|
||||
import apiKeyRoutes from './apps/_app/routes/APIKeyRoutes';
|
||||
import appRoutes from './apps/_app/routes/AppRoutes';
|
||||
|
||||
const routesPlugin: FastifyPluginAsync = async (app) => {
|
||||
try {
|
||||
///////////////////////////////////////
|
||||
// TODO: Define routes in separate list
|
||||
await app.register(userRoutes, { prefix: '/app/users' });
|
||||
await app.register(apiKeyRoutes, { prefix: '/app/apikey' });
|
||||
await app.register(canvasRoutes, { prefix: '/canvas-api' });
|
||||
|
||||
await app.register(appRoutes, { prefix: '/app' });
|
||||
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
if (!reply.sent) {
|
||||
|
Loading…
Reference in New Issue
Block a user