Compare commits
23 Commits
develop
...
scribe-exi
Author | SHA1 | Date | |
---|---|---|---|
![]() |
c44651b104 | ||
![]() |
8fa21e0651 | ||
![]() |
e1b61c7ba7 | ||
![]() |
bb9aa58880 | ||
![]() |
73b4345f5a | ||
![]() |
a8127b91b5 | ||
![]() |
09ea4c8f7f | ||
![]() |
23cc76d474 | ||
![]() |
cd0a983934 | ||
![]() |
5dc08bdd34 | ||
![]() |
fb6f4b72bf | ||
![]() |
a47ae9d370 | ||
![]() |
247a81bf50 | ||
![]() |
322dbc16b5 | ||
![]() |
ea67333f3c | ||
![]() |
ac0eea4d70 | ||
![]() |
47c4b7465d | ||
![]() |
099515df47 | ||
![]() |
0fc4c1ab06 | ||
![]() |
67fdf2d27e | ||
![]() |
34a1df5037 | ||
![]() |
23b4f6e464 | ||
![]() |
c50d0ea163 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -32,7 +32,6 @@ dist-ssr
|
||||
*.sw?
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
|
21
components.json
Normal file
21
components.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/style/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": false,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "radix"
|
||||
}
|
28
eslint.config.js
Normal file
28
eslint.config.js
Normal file
@ -0,0 +1,28 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
4235
package-lock.json
generated
Normal file
4235
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
package.json
52
package.json
@ -1,9 +1,14 @@
|
||||
{
|
||||
"name": "care-scribe",
|
||||
"version": "0.0.1",
|
||||
"main": "index.js",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"dev": "vite preview",
|
||||
"build:watch": "tsc -b && vite build --watch",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@ -16,15 +21,42 @@
|
||||
},
|
||||
"homepage": "https://github.com/ohcnetwork/care_scribe_fe#readme",
|
||||
"description": "",
|
||||
"peerDependencies": {
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@originjs/vite-plugin-federation": "^1.3.6",
|
||||
"@types/node": "^22.10.3",
|
||||
"@types/react": "^18.3.17",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^15.13.0",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "^8.18.1",
|
||||
"vite": "^6.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"lottie-react": "^2.4.0"
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-toast": "^1.2.4",
|
||||
"@tanstack/react-query": "^5.62.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"i18next": "^24.2.0",
|
||||
"jotai": "^2.10.4",
|
||||
"lottie-react": "^2.4.0",
|
||||
"lucide-react": "^0.469.0",
|
||||
"raviger": "^4.1.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^15.4.0",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-keyboard-shortcut": "^1.1.6"
|
||||
}
|
||||
}
|
||||
|
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
57
src/App.tsx
Normal file
57
src/App.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { useEffect } from "react";
|
||||
import { Controller } from "./components/Controller";
|
||||
import { usePath } from "raviger";
|
||||
import { useFeatureFlags } from "./hooks/useFeatureFlags";
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import translation from "./locale/en.json";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
|
||||
export default function App(props: {
|
||||
formState: unknown;
|
||||
setFormState: unknown;
|
||||
}) {
|
||||
const path = usePath();
|
||||
const facilityId = path?.includes("/facility/")
|
||||
? path.split("/facility/")[1].split("/")[0]
|
||||
: undefined;
|
||||
const featureFlags = useFeatureFlags(facilityId);
|
||||
const SCRIBE_ENABLED = featureFlags.includes("SCRIBE_ENABLED");
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
en: {
|
||||
translation,
|
||||
},
|
||||
},
|
||||
lng: localStorage.getItem("i18nextLng") || "en",
|
||||
fallbackLng: "en",
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
saveMissing: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!SCRIBE_ENABLED) return;
|
||||
const pageElement = document.querySelector(
|
||||
'[data-cui-page="true"]',
|
||||
) as HTMLElement;
|
||||
if (pageElement) {
|
||||
pageElement.style.setProperty("padding-bottom", "100px", "important");
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pageElement) {
|
||||
pageElement.style.paddingBottom = "";
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Toaster />
|
||||
{SCRIBE_ENABLED && <Controller {...props} />}
|
||||
</div>
|
||||
);
|
||||
}
|
18
src/Providers.tsx
Normal file
18
src/Providers.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import App from "@/App";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { FeatureFlagsProvider } from "./hooks/useFeatureFlags";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export default function Providers(props: {
|
||||
formState: unknown;
|
||||
setFormState: unknown;
|
||||
}) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<FeatureFlagsProvider>
|
||||
<App {...props} />
|
||||
</FeatureFlagsProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
@ -1,41 +0,0 @@
|
||||
import {
|
||||
CreateFileRequest,
|
||||
CreateFileResponse,
|
||||
FileUploadModel,
|
||||
} from "@/components/Patient/models";
|
||||
import { ScribeModel } from "../types";
|
||||
import { Type } from "@/Utils/request/api";
|
||||
|
||||
const routes = {
|
||||
createScribe: {
|
||||
path: "/api/care_scribe/scribe/",
|
||||
method: "POST",
|
||||
TReq: Type<ScribeModel>(),
|
||||
TRes: Type<ScribeModel>(),
|
||||
},
|
||||
getScribe: {
|
||||
path: "/api/care_scribe/scribe/{external_id}/",
|
||||
method: "GET",
|
||||
TRes: Type<ScribeModel>(),
|
||||
},
|
||||
updateScribe: {
|
||||
path: "/api/care_scribe/scribe/{external_id}/",
|
||||
method: "PUT",
|
||||
TReq: Type<ScribeModel>(),
|
||||
TRes: Type<ScribeModel>(),
|
||||
},
|
||||
createScribeFileUpload: {
|
||||
path: "/api/care_scribe/scribe_file/",
|
||||
method: "POST",
|
||||
TBody: Type<CreateFileRequest>(),
|
||||
TRes: Type<CreateFileResponse>(),
|
||||
},
|
||||
editScribeFileUpload: {
|
||||
path: "/api/care_scribe/scribe_file/{id}/?file_type={fileType}&associating_id={associatingId}",
|
||||
method: "PATCH",
|
||||
TBody: Type<Partial<FileUploadModel>>(),
|
||||
TRes: Type<FileUploadModel>(),
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default routes;
|
@ -1,22 +1,29 @@
|
||||
import { useState } from "react";
|
||||
import { ScribeField, ScribeFieldSuggestion, ScribeStatus } from "../types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useSegmentedRecording from "@/Utils/useSegmentedRecorder";
|
||||
import request from "@/Utils/request/request";
|
||||
import routes from "../api/api";
|
||||
import uploadFile from "@/Utils/request/uploadFile";
|
||||
import TextAreaFormField from "@/components/Form/FormFields/TextAreaFormField";
|
||||
import CareIcon from "@/CAREUI/icons/CareIcon";
|
||||
import { getFieldsToReview, scrapeFields, SCRIBE_PROMPT_MAP } from "../utils";
|
||||
import * as Notify from "@/Utils/Notifications";
|
||||
import { getFieldsToReview, getQuestionInputs } from "../utils/utils";
|
||||
import ScribeButton from "./ScribeButton";
|
||||
import animationData from "../assets/animation.json";
|
||||
import Lottie from "lottie-react";
|
||||
import ScribeReview from "./Review";
|
||||
import useSegmentedRecording from "@/hooks/useSegmentedRecorder";
|
||||
import { useTimer } from "@/hooks/useTimer";
|
||||
import ButtonV2 from "@/components/Common/ButtonV2";
|
||||
import { Button } from "./ui/button";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { API } from "@/utils/api";
|
||||
import uploadFile from "@/utils/uploadFile";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { SCRIBE_PROMPT_MAP, STRUCTURED_INPUT_PROMPTS } from "@/utils/prompts";
|
||||
import {
|
||||
ChevronUpIcon,
|
||||
Cross1Icon,
|
||||
CrossCircledIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
|
||||
export function Controller() {
|
||||
export function Controller(props: {
|
||||
formState: unknown;
|
||||
setFormState: unknown;
|
||||
}) {
|
||||
const [status, setStatus] = useState<ScribeStatus>("IDLE");
|
||||
const { t } = useTranslation();
|
||||
const [transcript, setTranscript] = useState<string>();
|
||||
@ -27,11 +34,15 @@ export function Controller() {
|
||||
const [openEditTranscript, setOpenEditTranscript] = useState(false);
|
||||
|
||||
//Use this to test scribe
|
||||
const SCRIBE_TEST_INPUT = `The patients category is mild. The physical examination info of the patient is that the patient is wounded.
|
||||
Blood pressure is 10 systolic and 14 diastolic. Pulse is 40 bpm. Temperature is 98.
|
||||
Respiratory rate is 43. Spo2 is 14 percent. Heartbeat is irregular.
|
||||
The rhythm description is dhuk dhuk dhuk dhuk.
|
||||
Level of consciousness is alert. The action to be taken is plan for home care. Please review after 15 minutes.`;
|
||||
const SCRIBE_TEST_INPUT = `The patient's encounter status is currently on hold, classified as an emergency with a priority of “as needed,”
|
||||
under hospital identifier 245. The patient was admitted from a nursing home with a diet preference of vegetarian. The care team consists of physical therapists,
|
||||
and the encounter started yesterday at 12 a.m., ending today at 5 p.m. The care plan focuses on stabilizing the patient's blood pressure,
|
||||
with a follow-up frequency of two times weekly. The next visit is scheduled for January 3, 2025.
|
||||
The patient's current vital signs indicate a systolic blood pressure of 20, diastolic blood pressure of 40, pulse of 84, SpO2 at 78%,
|
||||
and a blood sugar level of 59. Pain is reported as mild, and the patient is bed-bound, unable to move.
|
||||
An acute symptom of left-sided ulcerative colitis has been added, with differential verification, moderate severity, beginning yesterday.
|
||||
Update the existing symptom's verification to confirmed, and all existing diagnoses should be removed. Nurse John Doe is filling the allergy intolerance form.
|
||||
A resolved allergy to isomaltose has been detected.`;
|
||||
|
||||
const {
|
||||
startRecording: startSegmentedRecording,
|
||||
@ -40,6 +51,8 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
|
||||
audioBlobs,
|
||||
} = useSegmentedRecording();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
// Keeps polling the scribe endpoint to check if transcript or ai response has been generated
|
||||
const poller = async (
|
||||
scribeInstanceId: string,
|
||||
@ -48,38 +61,34 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
|
||||
return new Promise((resolve, reject) => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const res = await request(routes.getScribe, {
|
||||
pathParams: {
|
||||
external_id: scribeInstanceId,
|
||||
},
|
||||
});
|
||||
const res = await API.scribe.get(scribeInstanceId);
|
||||
const { status, transcript, ai_response } = res;
|
||||
|
||||
if (!res.data || res.error)
|
||||
throw new Error("Error getting scribe instance");
|
||||
|
||||
const { status, transcript, ai_response } = res.data;
|
||||
if (status === "FAILED") {
|
||||
toast({ title: "Transcription failed", variant: "destructive" });
|
||||
clearInterval(interval);
|
||||
return reject(new Error("Transcription failed"));
|
||||
}
|
||||
|
||||
if (
|
||||
status === "GENERATING_AI_RESPONSE" ||
|
||||
status === "COMPLETED" ||
|
||||
status === "FAILED"
|
||||
type === "transcript" &&
|
||||
["GENERATING_AI_RESPONSE", "COMPLETED"].includes(status) &&
|
||||
transcript !== null
|
||||
) {
|
||||
clearInterval(interval);
|
||||
if (status === "FAILED") {
|
||||
Notify.Error({ msg: "Transcription failed" });
|
||||
return reject(new Error("Transcription failed"));
|
||||
}
|
||||
|
||||
if (type === "transcript" && transcript !== null) {
|
||||
return resolve(transcript);
|
||||
}
|
||||
|
||||
if (type === "ai_response" && ai_response !== null) {
|
||||
return resolve(ai_response);
|
||||
}
|
||||
|
||||
return reject(new Error(`Failed to resolve response`));
|
||||
return resolve(transcript);
|
||||
}
|
||||
|
||||
if (
|
||||
type === "ai_response" &&
|
||||
status === "COMPLETED" &&
|
||||
ai_response !== null
|
||||
) {
|
||||
clearInterval(interval);
|
||||
return resolve(ai_response);
|
||||
}
|
||||
|
||||
// return reject(new Error(`Failed to resolve response`));
|
||||
} catch (error) {
|
||||
clearInterval(interval);
|
||||
reject(error);
|
||||
@ -112,30 +121,23 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
|
||||
.reduce((acc, curr) => ({ ...acc, ...curr }), {});
|
||||
return changedData;
|
||||
} catch (e) {
|
||||
Notify.Error({ msg: t("scribe_error") });
|
||||
toast({ title: t("scribe_error"), variant: "destructive" });
|
||||
setStatus("FAILED");
|
||||
}
|
||||
};
|
||||
|
||||
// gets the audio transcription
|
||||
const getTranscript = async (scribeInstanceId: string) => {
|
||||
const res = await request(routes.updateScribe, {
|
||||
body: {
|
||||
status: "READY",
|
||||
},
|
||||
pathParams: {
|
||||
external_id: scribeInstanceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.error || !res.data) throw Error("Error updating scribe instance");
|
||||
try {
|
||||
await API.scribe.update(scribeInstanceId, {
|
||||
status: "READY",
|
||||
});
|
||||
const transcript = await poller(scribeInstanceId, "transcript");
|
||||
setLastTranscript(transcript);
|
||||
setTranscript(transcript);
|
||||
return transcript;
|
||||
} catch (error) {
|
||||
Notify.Error({ msg: t("scribe_error") });
|
||||
toast({ title: t("scribe_error"), variant: "destructive" });
|
||||
setStatus("FAILED");
|
||||
}
|
||||
};
|
||||
@ -146,20 +148,18 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
|
||||
const name = "audio.mp3";
|
||||
const filename = Date.now().toString();
|
||||
|
||||
const response = await request(routes.createScribeFileUpload, {
|
||||
body: {
|
||||
original_name: name,
|
||||
file_type: 1,
|
||||
name: filename,
|
||||
associating_id: scribeInstanceId,
|
||||
file_category: category,
|
||||
mime_type: audioBlob?.type?.split(";")?.[0],
|
||||
},
|
||||
const data = await API.scribe.createFileUpload({
|
||||
original_name: name,
|
||||
file_type: 1,
|
||||
name: filename,
|
||||
associating_id: scribeInstanceId,
|
||||
file_category: category,
|
||||
mime_type: audioBlob?.type?.split(";")?.[0],
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const url = response.data?.signed_url;
|
||||
const internal_name = response.data?.internal_name;
|
||||
const url = data?.signed_url;
|
||||
const internal_name = data?.internal_name;
|
||||
const f = audioBlob;
|
||||
if (f === undefined) {
|
||||
reject(Error("No file to upload"));
|
||||
@ -182,57 +182,67 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
|
||||
);
|
||||
});
|
||||
|
||||
const res = request(routes.editScribeFileUpload, {
|
||||
body: { upload_completed: true },
|
||||
pathParams: {
|
||||
id: response.data?.id || "",
|
||||
fileType: "SCRIBE",
|
||||
associatingId: scribeInstanceId,
|
||||
},
|
||||
});
|
||||
return res;
|
||||
return await API.scribe.editFileUpload(
|
||||
data.id,
|
||||
"SCRIBE",
|
||||
scribeInstanceId,
|
||||
{ upload_completed: true },
|
||||
);
|
||||
};
|
||||
|
||||
// Sets up a scribe instance with the available recordings. Returns the instance ID.
|
||||
const createScribeInstance = async (fields: ScribeField[]) => {
|
||||
const hfields = await getHydratedFields(fields);
|
||||
const response = await request(routes.createScribe, {
|
||||
body: {
|
||||
status: "CREATED",
|
||||
form_data: hfields,
|
||||
},
|
||||
const data = await API.scribe.create({
|
||||
status: "CREATED",
|
||||
form_data: hfields as any,
|
||||
// system_prompt: "...",
|
||||
// json_prompt: "...",
|
||||
});
|
||||
if (response.error) throw Error("Error creating scribe instance");
|
||||
if (!response.data) throw Error("Response did not return any data");
|
||||
|
||||
await Promise.all(
|
||||
audioBlobs.map((blob) =>
|
||||
uploadAudio(blob, response.data?.external_id ?? ""),
|
||||
),
|
||||
audioBlobs.map((blob) => uploadAudio(blob, data?.external_id ?? "")),
|
||||
);
|
||||
|
||||
return response.data.external_id;
|
||||
return data.external_id;
|
||||
};
|
||||
|
||||
const getHydratedFields = async (fields: ScribeField[]) => {
|
||||
return fields.map((field, i) => ({
|
||||
friendlyName: field.label || "Unlabled Field",
|
||||
current: field.value,
|
||||
id: `${i}`,
|
||||
description:
|
||||
field.customPrompt ||
|
||||
SCRIBE_PROMPT_MAP[field.type]?.prompt ||
|
||||
SCRIBE_PROMPT_MAP["default"]?.prompt,
|
||||
type: "string",
|
||||
example:
|
||||
field.customExample ||
|
||||
SCRIBE_PROMPT_MAP[field.type]?.example ||
|
||||
SCRIBE_PROMPT_MAP["default"]?.example,
|
||||
options: field.options?.map((opt) => ({
|
||||
id: opt.value || "NONE",
|
||||
text: opt.text,
|
||||
})),
|
||||
}));
|
||||
return fields.map((field, i) => {
|
||||
const structuredType = field.question.structured_type;
|
||||
|
||||
const structuredPrompt =
|
||||
structuredType &&
|
||||
Object.keys(STRUCTURED_INPUT_PROMPTS).includes(structuredType)
|
||||
? STRUCTURED_INPUT_PROMPTS[
|
||||
structuredType as keyof typeof STRUCTURED_INPUT_PROMPTS
|
||||
]
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
friendlyName: field.question.text || "Unlabled Field",
|
||||
current: field.value,
|
||||
id: `${i}`,
|
||||
description:
|
||||
structuredPrompt?.prompt ||
|
||||
SCRIBE_PROMPT_MAP[field.question.type]?.prompt ||
|
||||
SCRIBE_PROMPT_MAP["default"]?.prompt,
|
||||
type: typeof (
|
||||
structuredPrompt?.example ||
|
||||
SCRIBE_PROMPT_MAP[field.question.type]?.example ||
|
||||
SCRIBE_PROMPT_MAP["default"]?.example
|
||||
),
|
||||
example: JSON.stringify(
|
||||
structuredPrompt?.example ||
|
||||
SCRIBE_PROMPT_MAP[field.question.type]?.example ||
|
||||
SCRIBE_PROMPT_MAP["default"]?.example,
|
||||
),
|
||||
options: field.question.answer_option?.map((opt) => ({
|
||||
id: opt.value,
|
||||
text: opt.value,
|
||||
})),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// updates the transcript and fetches a new AI response
|
||||
@ -241,19 +251,17 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
|
||||
if (!instanceId) throw Error("Cannot find scribe instance");
|
||||
setToReview(undefined);
|
||||
setLastTranscript(updatedTranscript);
|
||||
const res = await request(routes.updateScribe, {
|
||||
body: {
|
||||
try {
|
||||
await API.scribe.update(instanceId, {
|
||||
status: "READY",
|
||||
transcript: updatedTranscript,
|
||||
ai_response: null,
|
||||
},
|
||||
pathParams: {
|
||||
external_id: instanceId,
|
||||
},
|
||||
});
|
||||
if (res.error || !res.data) throw Error("Error updating scribe instance");
|
||||
//ai_response: null,
|
||||
});
|
||||
} catch (error) {
|
||||
throw Error("Error updating Scribe Instance");
|
||||
}
|
||||
setStatus("THINKING");
|
||||
const fields = scrapeFields(null, false);
|
||||
const fields = getQuestionInputs(props.formState);
|
||||
const aiResponse = await getAIResponse(instanceId, fields);
|
||||
if (!aiResponse) return;
|
||||
setStatus("REVIEWING");
|
||||
@ -268,7 +276,7 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
|
||||
timer.start();
|
||||
setStatus("RECORDING");
|
||||
} catch (error) {
|
||||
Notify.Error({ msg: t("audio__permission_message") });
|
||||
toast({ title: t("audio__permission_message") });
|
||||
setStatus("IDLE");
|
||||
}
|
||||
};
|
||||
@ -278,7 +286,7 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
|
||||
timer.reset();
|
||||
setStatus("UPLOADING");
|
||||
stopSegmentedRecording();
|
||||
const fields = scrapeFields(null, false);
|
||||
const fields = getQuestionInputs(props.formState);
|
||||
const instanceId = await createScribeInstance(fields);
|
||||
setInstanceId(instanceId);
|
||||
setStatus("TRANSCRIBING");
|
||||
@ -301,7 +309,7 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
|
||||
className={`fixed bottom-5 right-5 z-40 flex flex-col items-end gap-4 transition-all`}
|
||||
>
|
||||
<div
|
||||
className={`${status === "IDLE" ? "max-h-0 opacity-0" : "max-h-[400px]"} w-full overflow-hidden rounded-2xl ${status === "REVIEWING" && !(openEditTranscript || (toReview && !toReview.length)) ? "" : "border border-secondary-400"} bg-white transition-all delay-100`}
|
||||
className={`${status === "IDLE" ? "max-h-0 opacity-0" : "max-h-[400px]"} w-full overflow-hidden rounded-2xl ${status === "REVIEWING" && !(openEditTranscript || (toReview && !toReview.length)) ? "" : "border-secondary-400 border"} bg-white transition-all delay-100`}
|
||||
>
|
||||
{status === "RECORDING" && (
|
||||
<div className="flex items-center justify-center p-4 py-10">
|
||||
@ -318,7 +326,7 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
|
||||
<div className="w-32">
|
||||
<Lottie animationData={animationData} loop autoPlay />
|
||||
</div>
|
||||
<div className="-translate-y-4 text-sm text-secondary-700">
|
||||
<div className="text-secondary-700 -translate-y-4 text-sm">
|
||||
{t("copilot_thinking")}
|
||||
</div>
|
||||
</div>
|
||||
@ -334,7 +342,7 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
|
||||
)}
|
||||
{audioBlobs.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="rounded border border-secondary-400 bg-secondary-200">
|
||||
<div className="border-secondary-400 bg-secondary-200 rounded border">
|
||||
<audio controls className="plain-audio w-full">
|
||||
{audioBlobs.map((blob, index) => (
|
||||
<source
|
||||
@ -345,15 +353,12 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
|
||||
))}
|
||||
</audio>
|
||||
</div>
|
||||
<ButtonV2
|
||||
variant="primary"
|
||||
ghost
|
||||
border
|
||||
<Button
|
||||
className="mt-2 w-full"
|
||||
onClick={handleStopRecording}
|
||||
>
|
||||
{t("transcribe_again")}
|
||||
</ButtonV2>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-base font-semibold">
|
||||
@ -369,16 +374,17 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
|
||||
>
|
||||
Test
|
||||
</button>
|
||||
<TextAreaFormField
|
||||
<Textarea
|
||||
name="transcript"
|
||||
disabled={status !== "REVIEWING"}
|
||||
value={transcript}
|
||||
onChange={(e) => setTranscript(e.value)}
|
||||
errorClassName="hidden"
|
||||
onChange={(e) => setTranscript(e.target.value)}
|
||||
className="h-20 resize-none"
|
||||
// errorClassName="hidden"
|
||||
placeholder="Transcript"
|
||||
/>
|
||||
<ButtonV2
|
||||
loading={status !== "REVIEWING"}
|
||||
<Button
|
||||
// loading={status !== "REVIEWING"}
|
||||
disabled={transcript === lastTranscript}
|
||||
className="mt-4 w-full"
|
||||
onClick={() =>
|
||||
@ -386,7 +392,7 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
|
||||
}
|
||||
>
|
||||
{t("process_transcript")}
|
||||
</ButtonV2>
|
||||
</Button>
|
||||
{!(toReview && !toReview.length) && (
|
||||
<button
|
||||
className="absolute -top-6 right-4 text-xs text-gray-100 hover:text-gray-200"
|
||||
@ -399,7 +405,7 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
|
||||
)}
|
||||
{status === "FAILED" && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 px-4 py-10 text-red-500">
|
||||
<CareIcon icon="l-times-circle" className="text-4xl" />
|
||||
<CrossCircledIcon className="text-4xl" />
|
||||
{t("scribe_error")}
|
||||
</div>
|
||||
)}
|
||||
@ -412,17 +418,17 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
|
||||
className="flex max-h-[50px] w-40 items-center gap-2 overflow-hidden rounded-lg bg-black/20 p-2 text-left text-xs text-white transition-all hover:bg-black/40 md:max-h-[100px]"
|
||||
>
|
||||
<div>{transcript}</div>
|
||||
<CareIcon icon="l-angle-up" className="text-xl" />
|
||||
<ChevronUpIcon className="text-xl" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{status === "REVIEWING" && (
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="flex aspect-square h-full items-center justify-center rounded-full border border-secondary-400 bg-secondary-300 p-4 text-xl transition-all hover:bg-secondary-400"
|
||||
className="border-secondary-400 bg-secondary-300 hover:bg-secondary-400 flex aspect-square h-full items-center justify-center rounded-full border p-4 text-xl transition-all"
|
||||
title={t("cancel")}
|
||||
>
|
||||
<CareIcon icon="l-times" />
|
||||
<Cross1Icon />
|
||||
</button>
|
||||
)}
|
||||
<ScribeButton
|
||||
@ -437,10 +443,14 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
|
||||
</div>
|
||||
{!!toReview && !!toReview.length && (
|
||||
<ScribeReview
|
||||
{...props}
|
||||
toReview={toReview}
|
||||
onReviewComplete={async (approvedFields) => {
|
||||
const approved = approvedFields.filter((a) => a.approved);
|
||||
approved && Notify.Success({ msg: t("autofilled_fields") });
|
||||
approved &&
|
||||
toast({
|
||||
title: t("autofilled_fields"),
|
||||
});
|
||||
setToReview(undefined);
|
||||
setStatus("IDLE");
|
||||
}}
|
||||
|
@ -1,21 +1,18 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ScribeFieldReviewedSuggestion, ScribeFieldSuggestion } from "../types";
|
||||
import CareIcon from "@/CAREUI/icons/CareIcon";
|
||||
import {
|
||||
previewFieldUpdate,
|
||||
renderFieldValue,
|
||||
sleep,
|
||||
updateFieldValue,
|
||||
} from "../utils";
|
||||
import { renderFieldValue, sleep, updateFieldValue } from "../utils/utils";
|
||||
import useKeyboardShortcut from "use-keyboard-shortcut";
|
||||
import { KeyboardShortcutKey } from "@/CAREUI/interactive/KeyboardShortcut";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { KeyboardShortcutKey } from "./ui/keyboard-shortcut";
|
||||
import { ChevronLeftIcon } from "@radix-ui/react-icons";
|
||||
|
||||
export default function ScribeReview(props: {
|
||||
formState: unknown;
|
||||
setFormState: unknown;
|
||||
toReview: ScribeFieldSuggestion[];
|
||||
onReviewComplete: (accepted: ScribeFieldReviewedSuggestion[]) => void;
|
||||
}) {
|
||||
const { toReview, onReviewComplete } = props;
|
||||
const { toReview, onReviewComplete, formState, setFormState } = props;
|
||||
const initialReviewIndex = toReview.length > 1 ? -1 : 0;
|
||||
const [reviewIndex, setReviewIndex] = useState(initialReviewIndex);
|
||||
const [acceptedSuggestions, setAcceptedSuggestions] = useState<
|
||||
@ -77,7 +74,8 @@ export default function ScribeReview(props: {
|
||||
suggestionIndex: reviewIndex,
|
||||
},
|
||||
];
|
||||
if (!approved && reviewingField) updateFieldValue(reviewingField);
|
||||
if (!approved && reviewingField)
|
||||
updateFieldValue(reviewingField, false, formState, setFormState);
|
||||
await sleep(150);
|
||||
setAcceptedSuggestions(accepted);
|
||||
handleForward(accepted);
|
||||
@ -91,7 +89,7 @@ export default function ScribeReview(props: {
|
||||
}));
|
||||
for (const f of toReview) {
|
||||
await sleep(100);
|
||||
updateFieldValue(f, true);
|
||||
updateFieldValue(f, true, formState, setFormState);
|
||||
}
|
||||
setAcceptedSuggestions(accepted);
|
||||
handleReviewComplete(accepted);
|
||||
@ -99,8 +97,7 @@ export default function ScribeReview(props: {
|
||||
|
||||
useEffect(() => {
|
||||
if (reviewingField) {
|
||||
updateFieldValue(reviewingField, true);
|
||||
previewFieldUpdate(reviewingField);
|
||||
updateFieldValue(reviewingField, true, formState, setFormState);
|
||||
}
|
||||
}, [reviewingField]);
|
||||
|
||||
@ -124,7 +121,9 @@ export default function ScribeReview(props: {
|
||||
key={index}
|
||||
className="flex flex-col items-start rounded-lg bg-black/20 px-4 py-2"
|
||||
>
|
||||
<div className="text-xs text-secondary-400">{field.label}</div>
|
||||
<div className="text-secondary-400 text-xs">
|
||||
{field.question.text}
|
||||
</div>
|
||||
<div className="font-bold">{renderFieldValue(field, true)}</div>
|
||||
</div>
|
||||
))}
|
||||
@ -133,14 +132,14 @@ export default function ScribeReview(props: {
|
||||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
<button
|
||||
onClick={handleAcceptAll}
|
||||
className="flex w-full items-center gap-2 rounded-full bg-primary-500 px-4 py-2 text-lg font-semibold transition-all hover:bg-primary-600 md:w-auto"
|
||||
className="bg-primary-500 hover:bg-primary-600 flex w-full items-center gap-2 rounded-full px-4 py-2 text-lg font-semibold transition-all md:w-auto"
|
||||
>
|
||||
<KeyboardShortcutKey shortcut={["E"]} />
|
||||
{t("accept_all")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleForward()}
|
||||
className="flex w-full items-center gap-2 rounded-full bg-white px-4 py-2 text-lg font-semibold text-black transition-all hover:bg-secondary-100 md:w-auto"
|
||||
className="hover:bg-secondary-100 flex w-full items-center gap-2 rounded-full bg-white px-4 py-2 text-lg font-semibold text-black transition-all md:w-auto"
|
||||
>
|
||||
<KeyboardShortcutKey shortcut={["A"]} />
|
||||
{t("start_review")}
|
||||
@ -179,18 +178,18 @@ export default function ScribeReview(props: {
|
||||
onClick={handleBack}
|
||||
className="flex aspect-square items-center justify-center rounded-full border border-white p-2 text-2xl font-semibold text-white"
|
||||
>
|
||||
<CareIcon icon="l-angle-left" />
|
||||
<ChevronLeftIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleVerdict(false)}
|
||||
className="flex items-center gap-2 rounded-full bg-white px-4 py-2 text-lg font-semibold text-black transition-all hover:bg-secondary-100"
|
||||
className="hover:bg-secondary-100 flex items-center gap-2 rounded-full bg-white px-4 py-2 text-lg font-semibold text-black transition-all"
|
||||
>
|
||||
<KeyboardShortcutKey shortcut={["R"]} />
|
||||
{t("reject")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleVerdict(true)}
|
||||
className="flex items-center gap-2 rounded-full bg-primary-500 px-4 py-2 text-lg font-semibold transition-all hover:bg-primary-600"
|
||||
className="bg-primary-500 hover:bg-primary-600 flex items-center gap-2 rounded-full px-4 py-2 text-lg font-semibold transition-all"
|
||||
>
|
||||
<KeyboardShortcutKey shortcut={["A"]} />
|
||||
{t("accept")}
|
||||
@ -203,7 +202,7 @@ export default function ScribeReview(props: {
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{toReview.map((r, i) => (
|
||||
{toReview.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`aspect-square w-4 rounded-full ${acceptedSuggestions.find((s) => s.suggestionIndex === i)?.approved === true ? "bg-primary-500" : acceptedSuggestions.find((s) => s.suggestionIndex === i)?.approved === false ? "bg-red-500" : "bg-white"} ${reviewIndex === i ? "opacity-100" : "opacity-50"} transition-all`}
|
||||
|
@ -1,7 +1,8 @@
|
||||
import CareIcon from "@/CAREUI/icons/CareIcon";
|
||||
import { ReloadIcon } from "@radix-ui/react-icons";
|
||||
import { ScribeStatus } from "../types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useKeyboardShortcut from "use-keyboard-shortcut";
|
||||
import { MicrophoneIcon, MicrophoneSlashIcon } from "@/utils/icons";
|
||||
|
||||
export default function ScribeButton(props: {
|
||||
status: ScribeStatus;
|
||||
@ -15,21 +16,19 @@ export default function ScribeButton(props: {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`group z-10 flex items-center rounded-full ${status === "IDLE" ? "bg-primary-500 text-white hover:bg-primary-600" : "border border-secondary-400 bg-secondary-200 hover:bg-secondary-300"} transition-all disabled:bg-secondary-300`}
|
||||
className={`group z-10 flex items-center rounded-full ${status === "IDLE" ? "bg-primary-500 hover:bg-primary-600 text-white" : "border-secondary-400 bg-secondary-200 hover:bg-secondary-300 border"} disabled:bg-secondary-300 transition-all`}
|
||||
disabled={["TRANSCRIBING", "THINKING"].includes(status)}
|
||||
>
|
||||
<div
|
||||
className={`flex aspect-square h-full items-center justify-center rounded-full ${status === "IDLE" ? "bg-primary-600 group-hover:bg-primary-700" : "bg-secondary-300 group-hover:bg-secondary-400"} p-4 text-xl`}
|
||||
>
|
||||
<CareIcon
|
||||
icon={
|
||||
status === "IDLE"
|
||||
? "l-microphone"
|
||||
: status === "RECORDING"
|
||||
? "l-microphone-slash"
|
||||
: "l-redo"
|
||||
}
|
||||
/>
|
||||
{status === "IDLE" ? (
|
||||
<MicrophoneIcon className="w-4 invert" />
|
||||
) : status === "RECORDING" ? (
|
||||
<MicrophoneSlashIcon className="w-5" />
|
||||
) : (
|
||||
<ReloadIcon />
|
||||
)}
|
||||
</div>
|
||||
<div className="pl-2 pr-6 font-semibold">
|
||||
{status === "IDLE"
|
||||
|
57
src/components/ui/button.tsx
Normal file
57
src/components/ui/button.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/utils/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-neutral-300",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-neutral-900 text-neutral-50 shadow hover:bg-neutral-900/90 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-neutral-50/90",
|
||||
destructive:
|
||||
"bg-red-500 text-neutral-50 shadow-sm hover:bg-red-500/90 dark:bg-red-900 dark:text-neutral-50 dark:hover:bg-red-900/90",
|
||||
outline:
|
||||
"border border-neutral-200 bg-white shadow-sm hover:bg-neutral-100 hover:text-neutral-900 dark:border-neutral-800 dark:bg-neutral-950 dark:hover:bg-neutral-800 dark:hover:text-neutral-50",
|
||||
secondary:
|
||||
"bg-neutral-100 text-neutral-900 shadow-sm hover:bg-neutral-100/80 dark:bg-neutral-800 dark:text-neutral-50 dark:hover:bg-neutral-800/80",
|
||||
ghost:
|
||||
"hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-neutral-800 dark:hover:text-neutral-50",
|
||||
link: "text-neutral-900 underline-offset-4 hover:underline dark:text-neutral-50",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
38
src/components/ui/keyboard-shortcut.tsx
Normal file
38
src/components/ui/keyboard-shortcut.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { Fragment } from "react/jsx-runtime";
|
||||
|
||||
const SHORTCUT_KEY_MAP = {
|
||||
Meta: "⌘",
|
||||
Shift: "⇧Shift",
|
||||
Alt: "⌥Alt",
|
||||
Control: "Ctrl",
|
||||
ArrowUp: "↑",
|
||||
ArrowDown: "↓",
|
||||
ArrowLeft: "←",
|
||||
ArrowRight: "→",
|
||||
} as Record<string, string>;
|
||||
|
||||
export function KeyboardShortcutKey(props: {
|
||||
shortcut: string[];
|
||||
useShortKeys?: boolean;
|
||||
}) {
|
||||
const { shortcut, useShortKeys } = props;
|
||||
|
||||
return (
|
||||
<div className="hidden shrink-0 items-center md:flex">
|
||||
{shortcut.map((key, idx, keys) => (
|
||||
<Fragment key={idx}>
|
||||
<kbd className="border-secondary-400 bg-secondary-100 relative z-10 flex h-6 min-w-6 shrink-0 items-center justify-center rounded-md border border-b-4 px-2 text-xs text-black">
|
||||
{SHORTCUT_KEY_MAP[key]
|
||||
? useShortKeys
|
||||
? SHORTCUT_KEY_MAP[key][0]
|
||||
: SHORTCUT_KEY_MAP[key]
|
||||
: key}
|
||||
</kbd>
|
||||
{idx !== keys.length - 1 && (
|
||||
<span className="px-1 text-zinc-300/60"> + </span>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
21
src/components/ui/textarea.tsx
Normal file
21
src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { cn } from "@/utils/utils";
|
||||
import * as React from "react";
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-neutral-200 bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-neutral-500 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-800 dark:placeholder:text-neutral-400 dark:focus-visible:ring-neutral-300 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
127
src/components/ui/toast.tsx
Normal file
127
src/components/ui/toast.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import * as React from "react";
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Cross2Icon } from "@radix-ui/react-icons";
|
||||
import { cn } from "@/utils/utils";
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider;
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border border-neutral-200 p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full dark:border-neutral-800",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border bg-white text-neutral-950 dark:bg-neutral-950 dark:text-neutral-50",
|
||||
destructive:
|
||||
"destructive group border-red-500 bg-red-500 text-neutral-50 dark:border-red-900 dark:bg-red-900 dark:text-neutral-50",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border border-neutral-200 bg-transparent px-3 text-sm font-medium transition-colors hover:bg-neutral-100 focus:outline-none focus:ring-1 focus:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-neutral-100/40 group-[.destructive]:hover:border-red-500/30 group-[.destructive]:hover:bg-red-500 group-[.destructive]:hover:text-neutral-50 group-[.destructive]:focus:ring-red-500 dark:border-neutral-800 dark:hover:bg-neutral-800 dark:focus:ring-neutral-300 dark:group-[.destructive]:border-neutral-800/40 dark:group-[.destructive]:hover:border-red-900/30 dark:group-[.destructive]:hover:bg-red-900 dark:group-[.destructive]:hover:text-neutral-50 dark:group-[.destructive]:focus:ring-red-900",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-1 top-1 rounded-md p-1 text-neutral-950/50 opacity-0 transition-opacity hover:text-neutral-950 focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600 dark:text-neutral-50/50 dark:hover:text-neutral-50",
|
||||
className,
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
));
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
};
|
33
src/components/ui/toaster.tsx
Normal file
33
src/components/ui/toaster.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
194
src/hooks/use-toast.ts
Normal file
194
src/hooks/use-toast.ts
Normal file
@ -0,0 +1,194 @@
|
||||
"use client"
|
||||
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react"
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
78
src/hooks/useFeatureFlags.tsx
Normal file
78
src/hooks/useFeatureFlags.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { API } from "../utils/api";
|
||||
import { FacilityModel } from "../types";
|
||||
export type FeatureFlag = "SCRIBE_ENABLED";
|
||||
|
||||
export interface FeatureFlagsResponse {
|
||||
user_flags: FeatureFlag[];
|
||||
facility_flags: {
|
||||
facility: string;
|
||||
features: FeatureFlag[];
|
||||
}[];
|
||||
}
|
||||
|
||||
const defaultFlags: FeatureFlag[] = [];
|
||||
|
||||
const FeatureFlagsContext = createContext<FeatureFlagsResponse>({
|
||||
user_flags: defaultFlags,
|
||||
facility_flags: [],
|
||||
});
|
||||
|
||||
export const FeatureFlagsProvider = (props: { children: React.ReactNode }) => {
|
||||
const [featureFlags, setFeatureFlags] = useState<FeatureFlagsResponse>({
|
||||
user_flags: defaultFlags,
|
||||
facility_flags: [],
|
||||
});
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ["authuser"],
|
||||
queryFn: API.users.current,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.user_flags) {
|
||||
setFeatureFlags((ff) => ({
|
||||
...ff,
|
||||
user_flags: [...defaultFlags, ...(user.user_flags || [])],
|
||||
}));
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<FeatureFlagsContext.Provider value={featureFlags}>
|
||||
{props.children}
|
||||
</FeatureFlagsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useFeatureFlags = (facility?: FacilityModel | string) => {
|
||||
const [facilityObject] = useState<FacilityModel | undefined>(
|
||||
typeof facility === "string" ? undefined : facility,
|
||||
);
|
||||
|
||||
const context = useContext(FeatureFlagsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
"useFeatureFlags must be used within a FeatureFlagsProvider",
|
||||
);
|
||||
}
|
||||
|
||||
// const facilityQuery = useQuery({
|
||||
// queryKey: ["facility", facility],
|
||||
// queryFn: () =>
|
||||
// API.facilities.getPermitted(typeof facility === "string" ? facility : ""),
|
||||
// });
|
||||
|
||||
// useEffect(() => {
|
||||
// facilityQuery.data && setFacilityObject(facilityQuery.data);
|
||||
// }, [facilityQuery.data]);
|
||||
|
||||
const facilityFlags = facilityObject?.facility_flags || [];
|
||||
|
||||
// useEffect(() => {
|
||||
// facilityQuery.refetch();
|
||||
// }, [facility]);
|
||||
|
||||
return [...context.user_flags, ...facilityFlags];
|
||||
};
|
148
src/hooks/useSegmentedRecorder.ts
Normal file
148
src/hooks/useSegmentedRecorder.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const useSegmentedRecording = () => {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [recorder, setRecorder] = useState<MediaRecorder | null>(null);
|
||||
const [audioBlobs, setAudioBlobs] = useState<Blob[]>([]);
|
||||
const [restart, setRestart] = useState(false);
|
||||
const [microphoneAccess, setMicrophoneAccess] = useState(false); // New state
|
||||
const { t } = useTranslation();
|
||||
|
||||
const bufferInterval = 1 * 1000;
|
||||
const splitSizeLimit = 20 * 1000000; // 20MB
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRecording && recorder && audioBlobs.length > 0) {
|
||||
setRecorder(null);
|
||||
}
|
||||
}, [isRecording, recorder, audioBlobs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (recorder === null) {
|
||||
if (isRecording || restart) {
|
||||
requestRecorder().then(
|
||||
(newRecorder) => {
|
||||
setRecorder(newRecorder);
|
||||
setMicrophoneAccess(true); // Set access to true on success
|
||||
if (restart) {
|
||||
setIsRecording(true);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
window.alert({
|
||||
msg: t("audio__permission_message"),
|
||||
});
|
||||
setIsRecording(false);
|
||||
setMicrophoneAccess(false); // Set access to false on failure
|
||||
},
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRecording) {
|
||||
recorder.state === "inactive" && recorder.start(bufferInterval);
|
||||
} else {
|
||||
if (restart) {
|
||||
setIsRecording(true);
|
||||
} else {
|
||||
recorder?.stream?.getTracks()?.forEach((i) => i?.stop());
|
||||
recorder.stop();
|
||||
}
|
||||
recorder.state === "recording" && recorder.stop();
|
||||
}
|
||||
|
||||
// Obtain the audio when ready.
|
||||
const handleData = (e: { data: Blob }) => {
|
||||
const newChunk = e.data;
|
||||
let currentBlob: Blob | undefined = audioBlobs[audioBlobs.length - 1];
|
||||
if (restart) {
|
||||
currentBlob = undefined;
|
||||
}
|
||||
if ((currentBlob?.size || 0) + newChunk.size < splitSizeLimit) {
|
||||
// Audio size is less than 20MB, appending to current blob
|
||||
if (!currentBlob) {
|
||||
// Current blob is null, setting new blob
|
||||
if (restart) {
|
||||
setAudioBlobs((prev) => [
|
||||
...prev,
|
||||
new Blob([newChunk], { type: recorder.mimeType }),
|
||||
]);
|
||||
setRestart(false);
|
||||
return;
|
||||
}
|
||||
setAudioBlobs([new Blob([newChunk], { type: recorder.mimeType })]);
|
||||
return;
|
||||
}
|
||||
// Appending new chunk to current blob
|
||||
const newBlob = new Blob([currentBlob, newChunk], {
|
||||
type: recorder.mimeType,
|
||||
});
|
||||
setAudioBlobs((prev) => [...prev.slice(0, prev.length - 1), newBlob]);
|
||||
} else {
|
||||
// Audio size exceeded 20MB, starting new recording
|
||||
if (currentBlob)
|
||||
setAudioBlobs((prev) => [
|
||||
...prev.slice(0, prev.length - 1),
|
||||
new Blob([currentBlob ?? new Blob([]), newChunk], {
|
||||
type: recorder.mimeType,
|
||||
}),
|
||||
]);
|
||||
recorder.stop();
|
||||
setRecorder(null);
|
||||
setRestart(true);
|
||||
setIsRecording(false);
|
||||
}
|
||||
};
|
||||
recorder.addEventListener("dataavailable", handleData);
|
||||
return () => recorder.removeEventListener("dataavailable", handleData);
|
||||
}, [recorder, isRecording, bufferInterval, audioBlobs, restart]);
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const newRecorder = await requestRecorder();
|
||||
setRecorder(newRecorder);
|
||||
setMicrophoneAccess(true);
|
||||
setIsRecording(true);
|
||||
} catch (error) {
|
||||
setMicrophoneAccess(false);
|
||||
throw new Error("Microphone access denied");
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
setIsRecording(false);
|
||||
};
|
||||
|
||||
const resetRecording = () => {
|
||||
setAudioBlobs([]);
|
||||
};
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
resetRecording,
|
||||
audioBlobs,
|
||||
microphoneAccess, // Return microphoneAccess
|
||||
};
|
||||
};
|
||||
|
||||
async function requestRecorder() {
|
||||
return new Promise<MediaRecorder>((resolve, reject) => {
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({ audio: true })
|
||||
.then((stream) => {
|
||||
const recorder = new MediaRecorder(stream, {
|
||||
audioBitsPerSecond: 128000,
|
||||
});
|
||||
resolve(recorder);
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default useSegmentedRecording;
|
53
src/hooks/useTimer.tsx
Normal file
53
src/hooks/useTimer.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Custom hook to manage a timer in MM:SS format. This can be useful for tracking time during recording sessions, user actions, or any other timed event.
|
||||
*
|
||||
* @returns {Object} A set of properties and methods to control and display the timer:
|
||||
*
|
||||
* @property {number} seconds - The total elapsed time in seconds.
|
||||
* @property {JSX.Element} time - A JSX element displaying the current time in MM:SS format.
|
||||
* @property {function} start - Function to start the timer.
|
||||
* @property {function} stop - Function to stop the timer.
|
||||
*
|
||||
* @example
|
||||
* const { time, start, stop } = useTimer();
|
||||
*
|
||||
* // To start the timer:
|
||||
* start();
|
||||
*
|
||||
* // To stop the timer:
|
||||
* stop();
|
||||
*
|
||||
* // To display the timer in your component:
|
||||
* <div>{time}</div>
|
||||
*/
|
||||
export const useTimer = (autoStart = false) => {
|
||||
const [running, setRunning] = useState(autoStart);
|
||||
const [time, setTime] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let interval: ReturnType<typeof setInterval>;
|
||||
if (running) {
|
||||
interval = setInterval(() => {
|
||||
setTime((prevTime) => prevTime + 1);
|
||||
}, 10);
|
||||
} else {
|
||||
setTime(0);
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [running]);
|
||||
|
||||
return {
|
||||
seconds: time / 100,
|
||||
time: (
|
||||
<span>
|
||||
{("0" + Math.floor((time / 6000) % 60)).slice(-2)}:
|
||||
{("0" + Math.floor((time / 100) % 60)).slice(-2)}
|
||||
</span>
|
||||
),
|
||||
start: () => setRunning(true),
|
||||
stop: () => setRunning(false),
|
||||
reset: () => setTime(0),
|
||||
};
|
||||
};
|
@ -1,50 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller } from "./components/Controller";
|
||||
import { useFeatureFlags } from "@/Utils/featureFlags";
|
||||
import { usePath } from "raviger";
|
||||
|
||||
export { default as manifest } from "./manifest";
|
||||
|
||||
export default function Entry() {
|
||||
const path = usePath();
|
||||
const facilityId = path?.includes("/facility/")
|
||||
? path.split("/facility/")[1].split("/")[0]
|
||||
: undefined;
|
||||
const [forms, setForms] = useState<NodeListOf<Element>>();
|
||||
const featureFlags = useFeatureFlags(facilityId);
|
||||
const SCRIBE_ENABLED = featureFlags.includes("SCRIBE_ENABLED");
|
||||
|
||||
useEffect(() => {
|
||||
if (!SCRIBE_ENABLED) return;
|
||||
const observer = new MutationObserver((mutationsList, observer) => {
|
||||
for (const mutation of mutationsList) {
|
||||
if (mutation.type === "childList") {
|
||||
const forms = document.querySelectorAll('[data-scribe-form="true"]');
|
||||
setForms(forms);
|
||||
}
|
||||
}
|
||||
});
|
||||
const config = { childList: true, subtree: true };
|
||||
observer.observe(document.body, config);
|
||||
return () => observer.disconnect();
|
||||
}, [SCRIBE_ENABLED]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!forms || forms.length === 0) return;
|
||||
|
||||
const pageElement = document.querySelector(
|
||||
'[data-cui-page="true"]',
|
||||
) as HTMLElement;
|
||||
if (pageElement) {
|
||||
pageElement.style.setProperty("padding-bottom", "100px", "important");
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pageElement) {
|
||||
pageElement.style.paddingBottom = "";
|
||||
}
|
||||
};
|
||||
}, [forms]);
|
||||
|
||||
return <div>{forms?.length && <Controller />}</div>;
|
||||
}
|
19
src/locale/en.json
Normal file
19
src/locale/en.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"voice_autofill": "Voice Autofill",
|
||||
"hearing": "We are hearing you...",
|
||||
"copilot_thinking": "Copilot is thinking...",
|
||||
"could_not_autofill": "We could not autofill any fields from what you said",
|
||||
"transcribe_again": "Transcribe Again",
|
||||
"transcript_edit_info": "You can update this if we made an error",
|
||||
"transcript_information": "This is what we heard",
|
||||
"process_transcript": "Process Again",
|
||||
"retake_recording": "Retake Recording",
|
||||
"scribe__reviewing_field": "Reviewing field {{currentField}} / {{totalFields}}",
|
||||
"scribe_error": "Could not autofill fields",
|
||||
"accept": "Accept",
|
||||
"accept_all": "Accept All",
|
||||
"reject": "Reject",
|
||||
"stop_recording": "Stop Recording",
|
||||
"autofilled_fields": "Autofilled Fields",
|
||||
"start_review": "Start Review"
|
||||
}
|
1
src/main.tsx
Normal file
1
src/main.tsx
Normal file
@ -0,0 +1 @@
|
||||
export { default as manifest } from "./manifest";
|
@ -1,13 +1,12 @@
|
||||
import { PluginManifest } from "@/pluginTypes";
|
||||
import { lazy } from "react";
|
||||
|
||||
const manifest: PluginManifest = {
|
||||
const manifest = {
|
||||
plugin: "care-scribe",
|
||||
routes: {},
|
||||
extends: [],
|
||||
components: {
|
||||
Scribe: lazy(
|
||||
() => import("./index"),
|
||||
() => import("./Providers"),
|
||||
)
|
||||
},
|
||||
navItems: [],
|
||||
|
8
src/style/index.css
Normal file
8
src/style/index.css
Normal file
@ -0,0 +1,8 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@layer base {
|
||||
:root {
|
||||
--radius: 0.5rem
|
||||
}
|
||||
}
|
141
src/types.ts
141
src/types.ts
@ -1,4 +1,39 @@
|
||||
import { UserModel } from "@/components/Users/models";
|
||||
export type FeatureFlag = "SCRIBE_ENABLED"; // "HCX_ENABLED" | "ABDM_ENABLED" |
|
||||
|
||||
export type UserBareMinimum = {
|
||||
id: number;
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
user_type: unknown;
|
||||
last_login: string | undefined;
|
||||
read_profile_picture_url?: string;
|
||||
external_id: string;
|
||||
};
|
||||
|
||||
export type GenderType = "Male" | "Female" | "Transgender";
|
||||
|
||||
export type UserModel = UserBareMinimum & {
|
||||
external_id: string;
|
||||
local_body?: number;
|
||||
district?: number;
|
||||
state?: number;
|
||||
video_connect_link: string;
|
||||
phone_number?: string;
|
||||
alt_phone_number?: string;
|
||||
gender?: GenderType;
|
||||
read_profile_picture_url?: string;
|
||||
date_of_birth: Date | null | string;
|
||||
is_superuser?: boolean;
|
||||
verified?: boolean;
|
||||
home_facility?: string;
|
||||
qualification?: string;
|
||||
doctor_experience_commenced_on?: string;
|
||||
doctor_medical_council_registration?: string;
|
||||
weekly_working_hours?: string | null;
|
||||
user_flags?: FeatureFlag[];
|
||||
};
|
||||
|
||||
export type ScribeModel = {
|
||||
external_id: string;
|
||||
@ -21,6 +56,8 @@ export type ScribeModel = {
|
||||
| "GENERATING_AI_RESPONSE"
|
||||
| "COMPLETED"
|
||||
| "FAILED";
|
||||
system_prompt?: string;
|
||||
json_prompt?: string;
|
||||
};
|
||||
|
||||
export type ScribeStatus =
|
||||
@ -38,22 +75,108 @@ export type ScribeFieldOption = {
|
||||
text: string
|
||||
}
|
||||
|
||||
export type ScribeFieldTypes = "string" | "number" | "date" | "datetime-local" | "select" | "cui-select" | "cui-multi-select" | "cui-date" | "radio" | "checkbox" | "sub-form"
|
||||
|
||||
export type ScribeField = {
|
||||
type: ScribeFieldTypes
|
||||
question: FormQuestion,
|
||||
fieldElement: Element,
|
||||
label: string;
|
||||
options?: ScribeFieldOption[];
|
||||
value: string | null;
|
||||
customPrompt?: string,
|
||||
customExample?: string
|
||||
}
|
||||
|
||||
export type ScribeAIResponse = {
|
||||
[field_number: number]: unknown
|
||||
}
|
||||
|
||||
export type ScribePromptMap = {
|
||||
[key in QuestionType | "default"]?: { prompt: string; example: unknown };
|
||||
}
|
||||
|
||||
export type ScribeFieldSuggestion = ScribeField & { newValue: unknown }
|
||||
|
||||
export type ScribeFieldReviewedSuggestion = ScribeFieldSuggestion & { suggestionIndex: number, approved?: boolean }
|
||||
export type ScribeFieldReviewedSuggestion = ScribeFieldSuggestion & { suggestionIndex: number, approved?: boolean }
|
||||
|
||||
export type FileCategory = "UNSPECIFIED" | "XRAY" | "AUDIO" | "IDENTITY_PROOF";
|
||||
|
||||
export interface CreateFileRequest {
|
||||
file_type: string | number;
|
||||
file_category: FileCategory;
|
||||
name: string;
|
||||
associating_id: string;
|
||||
original_name: string;
|
||||
mime_type: string;
|
||||
}
|
||||
|
||||
export interface CreateFileResponse {
|
||||
id: string;
|
||||
file_type: string;
|
||||
file_category: FileCategory;
|
||||
signed_url: string;
|
||||
internal_name: string;
|
||||
}
|
||||
|
||||
export interface FileUploadModel {
|
||||
id?: string;
|
||||
name?: string;
|
||||
associating_id?: string;
|
||||
created_date?: string;
|
||||
upload_completed?: boolean;
|
||||
uploaded_by?: UserBareMinimum;
|
||||
file_category?: FileCategory;
|
||||
read_signed_url?: string;
|
||||
is_archived?: boolean;
|
||||
archive_reason?: string;
|
||||
extension?: string;
|
||||
archived_by?: UserBareMinimum;
|
||||
archived_datetime?: string;
|
||||
}
|
||||
|
||||
export interface FacilityModel {
|
||||
id?: string;
|
||||
name?: string;
|
||||
read_cover_image_url?: string;
|
||||
facility_type?: string;
|
||||
address?: string;
|
||||
features?: number[];
|
||||
location?: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
phone_number?: string;
|
||||
middleware_address?: string;
|
||||
modified_date?: string;
|
||||
created_date?: string;
|
||||
state?: number;
|
||||
district?: number;
|
||||
local_body?: number;
|
||||
ward?: number;
|
||||
pincode?: string;
|
||||
facility_flags?: FeatureFlag[];
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
kasp_empanelled?: boolean;
|
||||
patient_count?: number;
|
||||
bed_count?: number;
|
||||
}
|
||||
|
||||
export type QuestionType =
|
||||
| "group"
|
||||
| "display"
|
||||
| "boolean"
|
||||
| "decimal"
|
||||
| "integer"
|
||||
| "date"
|
||||
| "dateTime"
|
||||
| "time"
|
||||
| "string"
|
||||
| "text"
|
||||
| "url"
|
||||
| "choice"
|
||||
| "quantity"
|
||||
| "structured";
|
||||
|
||||
export interface FormQuestion {
|
||||
id: string;
|
||||
structured_type?: string;
|
||||
answer_option?: { value: string }[];
|
||||
text: string;
|
||||
required?: boolean;
|
||||
type: QuestionType
|
||||
}
|
401
src/utils.tsx
401
src/utils.tsx
@ -1,401 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
ScribeAIResponse,
|
||||
ScribeField,
|
||||
ScribeFieldSuggestion,
|
||||
ScribeFieldTypes,
|
||||
} from "./types";
|
||||
|
||||
const isVisible = (elem: HTMLElement, allowSubform: boolean) => {
|
||||
// Ignore fields that are hidden in the viewport
|
||||
return (
|
||||
!!(
|
||||
elem.offsetWidth ||
|
||||
elem.offsetHeight ||
|
||||
elem.getClientRects().length ||
|
||||
window.getComputedStyle(elem).visibility !== "hidden"
|
||||
) &&
|
||||
// Intentionally ignored fields
|
||||
!elem.closest('[data-scribe-ignore="true"]') &&
|
||||
// Check if field is not in a subform
|
||||
(allowSubform ? true : !elem.closest("[data-scribe-subform]"))
|
||||
);
|
||||
};
|
||||
|
||||
export const scrapeFields = (
|
||||
initFormElement: HTMLElement | null,
|
||||
isSubform: boolean,
|
||||
) => {
|
||||
const formElement =
|
||||
initFormElement ||
|
||||
(document.querySelector(`[data-scribe-form="true"]`) as HTMLElement);
|
||||
if (!formElement || !isVisible(formElement, isSubform))
|
||||
throw Error(
|
||||
'Cannot find a scribeable form. Make sure to mark forms with the "data-scribe-form" attribute',
|
||||
);
|
||||
const inputElements = [
|
||||
...formElement.querySelectorAll(
|
||||
'input:not([type="submit"]):not([role="combobox"])',
|
||||
),
|
||||
].filter((ele) =>
|
||||
isVisible(ele as HTMLElement, isSubform),
|
||||
) as HTMLInputElement[];
|
||||
const textAreaElements = [...formElement.querySelectorAll("textarea")].filter(
|
||||
(ele) => isVisible(ele as HTMLElement, isSubform),
|
||||
) as HTMLTextAreaElement[];
|
||||
const selectElements = [...formElement.querySelectorAll(`select`)].filter(
|
||||
(ele) => isVisible(ele as HTMLElement, isSubform),
|
||||
) as HTMLSelectElement[];
|
||||
// Care UI (Headless UI) does not use the traditional <select> field for dropdowns.
|
||||
const careUISelectElements = [
|
||||
...formElement.querySelectorAll(`[data-cui-listbox]`),
|
||||
].filter((ele) => isVisible(ele as HTMLElement, isSubform));
|
||||
const careUIDateElements = [
|
||||
...formElement.querySelectorAll(`[data-cui-dateinput]`),
|
||||
].filter((ele) => isVisible(ele as HTMLElement, isSubform));
|
||||
|
||||
// temp disable subforms
|
||||
// const subFormElements = scrapeSubForms(formElement);
|
||||
|
||||
const getInputType: (t: string | null) => ScribeField["type"] = (
|
||||
type: string | null,
|
||||
) =>
|
||||
type &&
|
||||
[
|
||||
"string",
|
||||
"number",
|
||||
"date",
|
||||
"datetime-local",
|
||||
"radio",
|
||||
"checkbox",
|
||||
].includes(type)
|
||||
? (type as ScribeField["type"])
|
||||
: "string";
|
||||
|
||||
const inputs: ScribeField[] = inputElements
|
||||
.filter(
|
||||
(ele) => !["radio", "checkbox"].includes(ele.getAttribute("type") || ""),
|
||||
)
|
||||
.map((ele) => ({
|
||||
type: getInputType(ele.getAttribute("type")),
|
||||
fieldElement: ele,
|
||||
label: ele.labels?.[0]?.innerText || ele.name || "",
|
||||
value: ele.value,
|
||||
}));
|
||||
|
||||
const checkBoxesAndRadios: ScribeField[] = Array.from(
|
||||
new Map(
|
||||
inputElements
|
||||
.filter((ele) =>
|
||||
["radio", "checkbox"].includes(ele.getAttribute("type") || ""),
|
||||
)
|
||||
.map((ele) => [
|
||||
ele.getAttribute("name"), // use the `name` attribute as the key
|
||||
{
|
||||
type: getInputType(ele.getAttribute("type")),
|
||||
fieldElement: ele.parentElement?.parentElement || ele,
|
||||
label:
|
||||
(
|
||||
document.querySelector(
|
||||
`label[for=${ele.getAttribute("name")}]`,
|
||||
) as HTMLLabelElement
|
||||
)?.innerText || "",
|
||||
options: [
|
||||
...(document.querySelectorAll(
|
||||
`input[name=${ele.getAttribute("name")}]`,
|
||||
) as NodeListOf<HTMLInputElement>),
|
||||
].map((inp) => ({
|
||||
text: (
|
||||
document.querySelector(
|
||||
`label[for="${inp.id}"]`,
|
||||
) as HTMLLabelElement
|
||||
).innerText,
|
||||
value: inp.value,
|
||||
})),
|
||||
value:
|
||||
[
|
||||
...(document.querySelectorAll(
|
||||
`input[name=${ele.getAttribute("name")}]`,
|
||||
) as NodeListOf<HTMLInputElement>),
|
||||
].find((radio) => radio.checked)?.value || null,
|
||||
},
|
||||
]),
|
||||
).values(),
|
||||
);
|
||||
|
||||
const textareas: ScribeField[] = textAreaElements.map((ele) => ({
|
||||
type: "string",
|
||||
fieldElement: ele,
|
||||
label: ele.labels?.[0]?.innerText || "",
|
||||
value: ele.value,
|
||||
customPrompt: ele.getAttribute("data-scribe-prompt") || undefined,
|
||||
customExample: ele.getAttribute("data-scribe-example") || undefined,
|
||||
}));
|
||||
|
||||
const selects: ScribeField[] = selectElements.map((ele) => ({
|
||||
type: "select",
|
||||
fieldElement: ele,
|
||||
label: ele.labels?.[0]?.innerText || "",
|
||||
options: [...ele.querySelectorAll("option")].map((option) => ({
|
||||
value: option?.value || "",
|
||||
text: option?.innerText,
|
||||
})),
|
||||
value: ele.value,
|
||||
customPrompt: ele.getAttribute("data-scribe-prompt") || undefined,
|
||||
customExample: ele.getAttribute("data-scribe-example") || undefined,
|
||||
}));
|
||||
|
||||
const cuiSelects: ScribeField[] = careUISelectElements.map((ele) => ({
|
||||
type: Array.isArray(
|
||||
JSON.parse(ele.getAttribute("data-cui-listbox-value") || `""`),
|
||||
)
|
||||
? "cui-multi-select"
|
||||
: "cui-select",
|
||||
fieldElement: ele,
|
||||
label:
|
||||
(
|
||||
ele.parentElement?.parentElement?.querySelector(
|
||||
"label:not([data-headlessui-state])",
|
||||
) as HTMLLabelElement
|
||||
)?.innerText || ele.id,
|
||||
options: (
|
||||
JSON.parse(ele.getAttribute("data-cui-listbox-options") || "[]") as [
|
||||
string,
|
||||
string,
|
||||
][]
|
||||
).map(([value, text]) => ({ text, value })),
|
||||
value: JSON.parse(ele.getAttribute("data-cui-listbox-value") || `""`),
|
||||
customPrompt: ele.getAttribute("data-scribe-prompt") || undefined,
|
||||
customExample: ele.getAttribute("data-scribe-example") || undefined,
|
||||
}));
|
||||
|
||||
const cuiDateInput: ScribeField[] = careUIDateElements.map((ele) => ({
|
||||
type: "cui-date",
|
||||
fieldElement: ele,
|
||||
label:
|
||||
(
|
||||
ele.parentElement?.parentElement?.parentElement?.querySelector(
|
||||
"label",
|
||||
) as HTMLLabelElement
|
||||
)?.innerText ||
|
||||
ele.querySelector("input[readonly]")?.id ||
|
||||
"",
|
||||
value: JSON.parse(ele.getAttribute("data-cui-dateinput-value") || `""`),
|
||||
customPrompt: ele.getAttribute("data-scribe-prompt") || undefined,
|
||||
customExample: ele.getAttribute("data-scribe-example") || undefined,
|
||||
}));
|
||||
|
||||
// const subForms: ScribeField[] = subFormElements.map((form) => ({
|
||||
// type: "sub-form",
|
||||
// fieldElement: form.element,
|
||||
// label: form.label,
|
||||
// value: JSON.stringify(
|
||||
// form.entries.map((row, id) => ({
|
||||
// id,
|
||||
// action: "NONE",
|
||||
// fields: row.map((field) => ({ [field.label]: field.value })),
|
||||
// })),
|
||||
// ),
|
||||
// customPrompt: `A complex array of objects.
|
||||
// If there are any additions to the field, please add to the array in the following example format:
|
||||
// ${JSON.stringify({ id: null, action: "ADD", fields: form.creator.map((field) => ({ [field.label]: field.options ? (field.type === "cui-multi-select" ? [field.options[0].value, field.options[1].value] : field.options[0].value) : SCRIBE_PROMPT_MAP[field.type]?.example })) })}.
|
||||
// ${
|
||||
// form.creator.filter((f) => f.options).length
|
||||
// ? `
|
||||
// NOTE : Refer to the following as option values for the fields. Make sure you select only the value of the option for the corresponding field.
|
||||
// ${JSON.stringify(form.creator.filter((f) => f.options).map((f) => ({ [f.label]: f.options })))}
|
||||
// `
|
||||
// : ``
|
||||
// }
|
||||
// If a row is being added, action should be "ADD". If an existing row is being updated, action should be "UPDATE", and if the row is being deleted or removed, action should be "DELETE". If there is no action, the action should be "NONE". No other action value is allowed.`,
|
||||
// customExample: `${JSON.stringify({ id: null, action: "ADD", fields: form.creator.map((field) => ({ [field.label]: field.options ? (field.type === "cui-multi-select" ? [field.options[0].value, field.options[1].value] : field.options[0].value) : SCRIBE_PROMPT_MAP[field.type]?.example })) })}.`,
|
||||
// }));
|
||||
|
||||
//console.log(subForms[0]?.customPrompt);
|
||||
|
||||
const fields = [
|
||||
...inputs,
|
||||
...textareas,
|
||||
...selects,
|
||||
...cuiSelects,
|
||||
...checkBoxesAndRadios,
|
||||
...cuiDateInput,
|
||||
//...subForms,
|
||||
];
|
||||
|
||||
return fields;
|
||||
};
|
||||
|
||||
export const scrapeSubForms = (formElement: HTMLElement) => {
|
||||
const subforms = [
|
||||
...formElement.querySelectorAll("[data-scribe-subform]"),
|
||||
] as HTMLElement[];
|
||||
const subformsData = subforms.map((form) => ({
|
||||
element: form,
|
||||
label: form.getAttribute("data-scribe-subform") || "Sub Form",
|
||||
entries: (
|
||||
[
|
||||
...form.querySelectorAll(`[data-scribe-subform-entry="true"]`),
|
||||
] as HTMLElement[]
|
||||
).map((entry) => scrapeFields(entry, true)),
|
||||
creator: scrapeFields(
|
||||
form.querySelector(`[data-scribe-subform-creator="true"]`) as HTMLElement,
|
||||
true,
|
||||
),
|
||||
}));
|
||||
return subformsData;
|
||||
};
|
||||
|
||||
export const getFieldsToReview = (
|
||||
aiResponse: ScribeAIResponse,
|
||||
scrapedFields: ScribeField[],
|
||||
) => {
|
||||
return scrapedFields
|
||||
.map((f, i) => ({ ...f, newValue: aiResponse[i] }))
|
||||
.filter((f) => f.newValue);
|
||||
};
|
||||
|
||||
export const renderFieldValue = (
|
||||
field: ScribeFieldSuggestion,
|
||||
useNewValue?: boolean,
|
||||
) => {
|
||||
const val = useNewValue ? field.newValue : field.value;
|
||||
if (field.type === "sub-form") {
|
||||
return <div className="italic text-gray-200">Multiple Updates</div>;
|
||||
}
|
||||
if (!["string", "number"].includes(typeof field.value)) return "N/A";
|
||||
return field.options
|
||||
? field.options.find((o) => String(o.value) === String(val))?.text
|
||||
: (val as string | number);
|
||||
};
|
||||
|
||||
export const sleep = async (seconds: number) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, seconds);
|
||||
});
|
||||
};
|
||||
|
||||
export const updateFieldValue = (
|
||||
field: ScribeFieldSuggestion,
|
||||
useNewValue?: boolean,
|
||||
) => {
|
||||
const val = (useNewValue ? field.newValue : field.value) as string;
|
||||
const element = field.fieldElement as HTMLElement;
|
||||
switch (field.type) {
|
||||
case "cui-select":
|
||||
case "cui-multi-select":
|
||||
element.setAttribute("data-cui-listbox-value", JSON.stringify(val || ""));
|
||||
break;
|
||||
|
||||
case "cui-date":
|
||||
element.setAttribute(
|
||||
"data-cui-dateinput-value",
|
||||
JSON.stringify(val || ""),
|
||||
);
|
||||
break;
|
||||
|
||||
case "radio":
|
||||
case "checkbox":
|
||||
const toCheck = element.querySelector(
|
||||
`input[value=${val || "__NULL__"}]`,
|
||||
) as HTMLInputElement;
|
||||
element.querySelectorAll(`input`).forEach((e) => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"checked",
|
||||
);
|
||||
|
||||
if (descriptor?.set) {
|
||||
descriptor.set.call(e, toCheck.value === e.value);
|
||||
e.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
toCheck.value === e.value && e.click();
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
case "sub-form":
|
||||
//console.log("Subform", getSubFormValues(val));
|
||||
break;
|
||||
|
||||
default:
|
||||
const input = field.fieldElement as
|
||||
| HTMLInputElement
|
||||
| HTMLTextAreaElement;
|
||||
// input.value = x won't do the trick as it will just update the DOM value, and not trigger the onChange for the state to update.
|
||||
const descriptor = Object.getOwnPropertyDescriptor(
|
||||
Object.getPrototypeOf(element),
|
||||
"value",
|
||||
);
|
||||
if (descriptor?.set) {
|
||||
descriptor.set.call(input, val as string);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getSubFormValues = (value?: string) => {
|
||||
try {
|
||||
const values: {
|
||||
id: null | number;
|
||||
action: "ADD" | "UPDATE" | "DELETE" | "NONE";
|
||||
fields: unknown;
|
||||
}[] = JSON.parse(value || "[]");
|
||||
return {
|
||||
updated: values.filter((v) => v.action === "UPDATE"),
|
||||
deleted: values.filter((v) => v.action === "DELETE"),
|
||||
added: values.filter((v) => v.action === "ADD"),
|
||||
all: values,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Could not parse sub form data from scribe response");
|
||||
}
|
||||
};
|
||||
|
||||
export const previewFieldUpdate = (field: ScribeFieldSuggestion) => {
|
||||
switch (field.type) {
|
||||
case "sub-form":
|
||||
//console.log(field.newValue);
|
||||
// const newFields = getSubFormValues(field.newValue as string);
|
||||
//console.log(newFields);
|
||||
|
||||
// TODO: Update styling to reflect changes
|
||||
// entries.forEach((entry, i) => {
|
||||
// const updatedEntry = value.find(v => v.id === i);
|
||||
// entry.style.background = updatedEntry?.action === 'DELETE' ? "red" : updatedEntry?.action === "UPDATE" ? "yellow" : ""
|
||||
// })
|
||||
// console.log(updatedRows, deletedRows, addedRows);
|
||||
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
export const SCRIBE_PROMPT_MAP: {
|
||||
[key in ScribeFieldTypes | "default"]?: { prompt: string; example: string };
|
||||
} = {
|
||||
default: {
|
||||
prompt: "A normal string value",
|
||||
example: "A value",
|
||||
},
|
||||
date: {
|
||||
prompt: "A date value",
|
||||
example: "2003-12-21",
|
||||
},
|
||||
"datetime-local": {
|
||||
prompt: `A date time value in ISO format. Current timestamp is ${dayjs(new Date()).format("YYYY-MM-DDTHH:mm")}`,
|
||||
example: "2003-12-21T23:10",
|
||||
},
|
||||
"cui-date": {
|
||||
prompt: `A date time value in ISO format. Current timestamp is ${dayjs(new Date()).format("YYYY-MM-DDTHH:mm")}`,
|
||||
example: "2003-12-21T23:10",
|
||||
},
|
||||
"cui-multi-select": {
|
||||
prompt: `An array of normal string values`,
|
||||
example: `["an","example"]`,
|
||||
},
|
||||
number: {
|
||||
prompt: "An integer value",
|
||||
example: "42",
|
||||
},
|
||||
};
|
112
src/utils/api.ts
Normal file
112
src/utils/api.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import {
|
||||
CreateFileRequest,
|
||||
CreateFileResponse,
|
||||
FacilityModel,
|
||||
FileUploadModel,
|
||||
ScribeModel,
|
||||
UserModel,
|
||||
} from "../types";
|
||||
|
||||
type methods = "POST" | "GET" | "PATCH" | "DELETE" | "PUT";
|
||||
|
||||
type options = {
|
||||
formdata?: boolean;
|
||||
external?: boolean;
|
||||
headers?: any;
|
||||
auth?: boolean;
|
||||
};
|
||||
|
||||
const CARE_BASE_URL = import.meta.env.VITE_CARE_API_URL || "";
|
||||
const CARE_ACCESS_TOKEN_LOCAL_STORAGE_KEY = "care_access_token";
|
||||
|
||||
const request = async <T extends unknown>(
|
||||
endpoint: string,
|
||||
method: methods = "GET",
|
||||
data: any = {},
|
||||
options: options = {},
|
||||
): Promise<T> => {
|
||||
const { formdata, external, headers, auth: isAuth } = options;
|
||||
|
||||
let url = external ? endpoint : CARE_BASE_URL + endpoint;
|
||||
let payload: null | string = formdata ? data : JSON.stringify(data);
|
||||
|
||||
if (method === "GET") {
|
||||
const requestParams = data
|
||||
? `?${Object.keys(data)
|
||||
.filter((key) => data[key] !== null && data[key] !== undefined)
|
||||
.map((key) => `${key}=${data[key]}`)
|
||||
.join("&")}`
|
||||
: "";
|
||||
url += requestParams;
|
||||
payload = null;
|
||||
}
|
||||
|
||||
const localToken = localStorage.getItem(CARE_ACCESS_TOKEN_LOCAL_STORAGE_KEY);
|
||||
|
||||
const auth =
|
||||
isAuth === false || typeof localToken === "undefined" || localToken === null
|
||||
? ""
|
||||
: "Bearer " + localToken;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: external
|
||||
? { ...headers }
|
||||
: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: auth,
|
||||
...headers,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
try {
|
||||
const txt = await response.clone().text();
|
||||
if (txt === "") {
|
||||
return {} as any;
|
||||
}
|
||||
const json = await response.clone().json();
|
||||
if (json && response.ok) {
|
||||
return json;
|
||||
} else {
|
||||
throw json;
|
||||
}
|
||||
} catch (error) {
|
||||
throw { error };
|
||||
}
|
||||
};
|
||||
|
||||
export const API = {
|
||||
scribe: {
|
||||
create: (req: Partial<ScribeModel>) =>
|
||||
request<ScribeModel>("/api/care_scribe/scribe/", "POST", req),
|
||||
get: (scribeId: string) =>
|
||||
request<ScribeModel>(`/api/care_scribe/scribe/${scribeId}/`),
|
||||
update: (scribeId: string, data: Partial<ScribeModel>) =>
|
||||
request<ScribeModel>(`/api/care_scribe/scribe/${scribeId}/`, "PUT", data),
|
||||
createFileUpload: (data: CreateFileRequest) =>
|
||||
request<CreateFileResponse>(
|
||||
"/api/care_scribe/scribe_file/",
|
||||
"POST",
|
||||
data,
|
||||
),
|
||||
editFileUpload: (
|
||||
id: string,
|
||||
fileType: string,
|
||||
associatingId: string,
|
||||
data: Partial<FileUploadModel>,
|
||||
) =>
|
||||
request<FileUploadModel>(
|
||||
`/api/care_scribe/scribe_file/${id}/?file_type=${fileType}&associating_id=${associatingId}`,
|
||||
"PATCH",
|
||||
data,
|
||||
),
|
||||
},
|
||||
facilities: {
|
||||
getPermitted: (facilityId: string) =>
|
||||
request<FacilityModel>(`/api/v1/facility/${facilityId}/`),
|
||||
},
|
||||
users: {
|
||||
current: () => request<UserModel>(`/api/v1/users/getcurrentuser/`)
|
||||
}
|
||||
};
|
17
src/utils/icons.tsx
Normal file
17
src/utils/icons.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { SVGProps } from "react";
|
||||
|
||||
export function MicrophoneSlashIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" {...props}>
|
||||
<path d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L472.1 344.7c15.2-26 23.9-56.3 23.9-88.7V216c0-13.3-10.7-24-24-24s-24 10.7-24 24v40c0 21.2-5.1 41.1-14.2 58.7L416 300.8V96c0-53-43-96-96-96s-96 43-96 96v54.3L38.8 5.1zm362.5 407l-43.1-33.9C346.1 382 333.3 384 320 384c-70.7 0-128-57.3-128-128v-8.7L144.7 210c-.5 1.9-.7 3.9-.7 6v40c0 89.1 66.2 162.7 152 174.4V464H248c-13.3 0-24 10.7-24 24s10.7 24 24 24h72 72c13.3 0 24-10.7 24-24s-10.7-24-24-24H344V430.4c20.4-2.8 39.7-9.1 57.3-18.2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MicrophoneIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" {...props}>
|
||||
<path d="M192 0C139 0 96 43 96 96V256c0 53 43 96 96 96s96-43 96-96V96c0-53-43-96-96-96zM64 216c0-13.3-10.7-24-24-24s-24 10.7-24 24v40c0 89.1 66.2 162.7 152 174.4V464H120c-13.3 0-24 10.7-24 24s10.7 24 24 24h72 72c13.3 0 24-10.7 24-24s-10.7-24-24-24H216V430.4c85.8-11.7 152-85.3 152-174.4V216c0-13.3-10.7-24-24-24s-24 10.7-24 24v40c0 70.7-57.3 128-128 128s-128-57.3-128-128V216z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
348
src/utils/prompts.ts
Normal file
348
src/utils/prompts.ts
Normal file
@ -0,0 +1,348 @@
|
||||
import { ScribePromptMap } from "@/types";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const MEDICATION_STATEMENT_STATUS = [
|
||||
"active",
|
||||
"on_hold",
|
||||
"completed",
|
||||
"stopped",
|
||||
"unknown",
|
||||
"entered_in_error",
|
||||
"not_taken",
|
||||
"intended",
|
||||
] as const;
|
||||
|
||||
const ARBITRARY_INPUT_PROMPTS: ScribePromptMap = {
|
||||
default: {
|
||||
prompt: "A normal string value JSON encoded",
|
||||
example: "A value"
|
||||
},
|
||||
integer: {
|
||||
prompt: "An integer value JSON encoded",
|
||||
example: 42,
|
||||
},
|
||||
date: {
|
||||
prompt: `A date value JSON encoded. Current date : ${dayjs(new Date()).format("YYYY-MM-DD")}`,
|
||||
example: "2003-12-21",
|
||||
},
|
||||
boolean: {
|
||||
prompt: "A true or false value JSON encoded",
|
||||
example: true,
|
||||
},
|
||||
dateTime: {
|
||||
prompt: `A date time value in ISO format. Current timestamp is ${dayjs(new Date()).format("YYYY-MM-DDTHH:mm")}`,
|
||||
example: "2003-12-21T23:10",
|
||||
},
|
||||
}
|
||||
|
||||
export const STRUCTURED_INPUT_PROMPTS = {
|
||||
"encounter": {
|
||||
prompt: `An array of only one object of the following schema. Everything in brackets is for your information and is not part of the schema. : {
|
||||
status?: "planned" | "in_progress" | "on_hold" | "discharged" | "completed" | "cancelled" | "discontinued" | "entered_in_error" | "unknown",
|
||||
encounter_class? : "imp" (Inpatient (IP)) | "amb" (Ambulatory (OP)) | "obsenc" (Observation Room) | "emer" (Emergency) | "vr" (Virtual) | "hh" (Home Health),'
|
||||
priority?: "ASAP" | "callback_results" | "callback_for_scheduling" | "elective" | "emergency" | "preop" | "as_needed" | "routine" | "rush_reporting" | "stat" | "timing_critical" | "use_as_directed" | "urgent";
|
||||
external_identifier (ip/op/obs/emr number)?: string;
|
||||
|
||||
(This will only be applicable if encounter_class is "imp", "absenc", or "emer")
|
||||
hospitalization?: {
|
||||
re_admission?: boolean;
|
||||
admit_source?: "hosp_trans" (Hospital Transfer) | "emd" (Emergency Department) | "outp" (Outpatient Department) | "born" (Born) | "gp" (General Practitioner) | "mp" (Medical Practitioner) | "nursing" (Nursing Home) | "psych" (Psychiatric Hospital) | "rehab" (Rehabilitation Facility) | "other" (Other);
|
||||
diet_preference?: "vegetarian" (Vegetarian) | "diary_free" (Dairy Free) | "nut_free" (Nut Free) | "gluten_free" (Gluten Free) | "vegan" (Vegan) | "halal" (Halal) | "kosher" (Kosher) | "none" (None);
|
||||
|
||||
(only applicable if status is "completed")
|
||||
discharge_disposition?: "home" (Home) | "alt_home" (Alternate Home) | "other_hcf" (Other Healthcare Facility) | "hosp" (Hospice) | "long" (Long Term Care) | "aadvice" (Left Against Advice) | "exp" (Expired) | "psy" (Psychiatric Hospital) | "rehab" (Rehabilitation) | "snf" (Skilled Nursing Facility) | "oth" (Other);
|
||||
};
|
||||
|
||||
...other data that is READ ONLY
|
||||
}. Make sure to only update the existing data of the user and not remove or update any data that was not explicitly told to be updated. Return ONLY the original data with requested updates.`,
|
||||
example: [{
|
||||
status: "in_progress",
|
||||
encounter_class: "imp",
|
||||
priority: "callback_for_scheduling",
|
||||
external_identifier: "1212",
|
||||
hospitalization: {
|
||||
re_admission: true,
|
||||
admit_source: "outp",
|
||||
discharge_disposition: "home",
|
||||
diet_preference: "nut_free"
|
||||
},
|
||||
"...other_data": "keep other data as is"
|
||||
}],
|
||||
},
|
||||
"medication_request": {
|
||||
prompt: `An array of objects of the following type based on the SNOMED CT Code for the applicable diagnoses: {
|
||||
status?: "active" | "on-hold" | "ended" | "stopped" | "completed" | "cancelled" | "entered-in-error" | "draft" | "unknown",
|
||||
intent?: "proposal" | "plan" | "order" | "original_order" | "reflex_order" | "filler_order" | "instance_order",
|
||||
category?: "inpatient" | "outpatient" | "community" | "discharge",
|
||||
priority?: "stat" | "urgent" | "asap" | "routine"
|
||||
do_not_perform?: boolean;
|
||||
medication? : CodeType;
|
||||
authored_on?: ISO time string,
|
||||
dosage_instruction: {
|
||||
sequence?: number;
|
||||
text?: string;
|
||||
additional_instruction?: {
|
||||
system: string;
|
||||
code: string;
|
||||
display?: string;
|
||||
}[];
|
||||
patient_instruction?: string;
|
||||
timing?: {
|
||||
repeat?: {
|
||||
frequency?: number;
|
||||
period: number; // number of units (ex. 12 days would mean 12 with unit "d")
|
||||
period_unit: "s" | "min" | "h" | "d" | "wk" | "mo" | "a";
|
||||
};
|
||||
};
|
||||
/**
|
||||
* True if it is a PRN medication
|
||||
*/
|
||||
as_needed_boolean?: boolean;
|
||||
/**
|
||||
* If it is a PRN medication (as_needed_boolean is true), the indicator.
|
||||
*/
|
||||
as_needed_for?: CodeType;
|
||||
site?: CodeType;
|
||||
route?: CodeType;
|
||||
method?: CodeType;
|
||||
/**
|
||||
* One of \`dose_quantity\` or \`dose_range\` must be present.
|
||||
* \`type\` is optional and defaults to \`ordered\`.
|
||||
*
|
||||
* - If \`type\` is \`ordered\`, \`dose_quantity\` must be present.
|
||||
* - If \`type\` is \`calculated\`, \`dose_range\` must be present. This is used for titrated medications.
|
||||
*/
|
||||
dose_and_rate?: (
|
||||
| {
|
||||
type?: "ordered";
|
||||
dose_quantity?: DosageQuantity;
|
||||
dose_range?: undefined;
|
||||
}
|
||||
| {
|
||||
type: "calculated";
|
||||
dose_range?: {
|
||||
low: DosageQuantity;
|
||||
high: DosageQuantity;
|
||||
};
|
||||
dose_quantity?: undefined;
|
||||
}
|
||||
)[];
|
||||
max_dose_per_period?: {
|
||||
low: DosageQuantity;
|
||||
high: DosageQuantity;
|
||||
};
|
||||
}[];
|
||||
note?: string
|
||||
}.
|
||||
|
||||
DosageQuantity {
|
||||
value?: number;
|
||||
unit?: "mg" | "g" | "ml" | "drop(s)" | "ampule(s)" | "tsp" | "mcg" | "unit(s)"
|
||||
}
|
||||
|
||||
CodeType {
|
||||
code: string,
|
||||
display: string,
|
||||
system: "http://snomed.info/sct"
|
||||
}
|
||||
|
||||
Update existing data, delete existing data or append to the existing list as per the will of the user. Current date is ${new Date().toLocaleDateString()}`,
|
||||
example: [
|
||||
{
|
||||
status: "active",
|
||||
intent: "original_order",
|
||||
category: "inpatient",
|
||||
priority: "urgent",
|
||||
do_not_perform: false,
|
||||
medication: {
|
||||
code: "1214771000202109",
|
||||
display:
|
||||
"Ciprofloxacin and fluocinolone only product in otic dose form",
|
||||
system: "http://snomed.info/sct",
|
||||
},
|
||||
authored_on: "2024-12-29T22:16:45.404Z",
|
||||
dosage_instruction: [
|
||||
{
|
||||
dose_and_rate: [
|
||||
{
|
||||
type: "ordered",
|
||||
dose_quantity: {
|
||||
unit: "g",
|
||||
value: 11,
|
||||
},
|
||||
},
|
||||
],
|
||||
route: {
|
||||
code: "58831000052108",
|
||||
display: "Subretinal route",
|
||||
system: "http://snomed.info/sct",
|
||||
},
|
||||
method: {
|
||||
code: "1231460007",
|
||||
display: "Dialysis system",
|
||||
system: "http://snomed.info/sct",
|
||||
},
|
||||
site: {
|
||||
code: "16217661000119109",
|
||||
display: "Structure of right deltoid muscle",
|
||||
system: "http://snomed.info/sct",
|
||||
},
|
||||
as_needed_boolean: true,
|
||||
timing: {
|
||||
repeat: {
|
||||
frequency: 1,
|
||||
period: 1,
|
||||
period_unit: "d",
|
||||
},
|
||||
},
|
||||
additional_instruction: [
|
||||
{
|
||||
code: "421984009",
|
||||
display: "Until finished",
|
||||
system: "http://snomed.info/sct",
|
||||
},
|
||||
],
|
||||
as_needed_for: {
|
||||
code: "972604701000119104",
|
||||
display:
|
||||
"Acquired arteriovenous malformation of vascular structure of gastrointestinal tract",
|
||||
system: "http://snomed.info/sct",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
"medication_statement": {
|
||||
prompt: `An array of objects of the following type, based on the SNOMED CT Code for the applicable diagnoses {
|
||||
status?: ${MEDICATION_STATEMENT_STATUS.join(" | ")},
|
||||
dosage_text?: string,
|
||||
information_source?: "patient" | "user" | "related_person"
|
||||
medication?: {
|
||||
code: string,
|
||||
display: string,
|
||||
system: "http://snomed.info/sct"
|
||||
},
|
||||
note?: string,
|
||||
reason?: string,
|
||||
effective_period?: {
|
||||
start: ISO date string,
|
||||
end: ISO date string
|
||||
}
|
||||
}. Update existing data, delete existing data or append to the existing list as per the will of the user. Current date is ${new Date().toLocaleDateString()}`,
|
||||
example: [
|
||||
{
|
||||
status: "completed",
|
||||
dosage_text: "10 ml",
|
||||
information_source: "patient",
|
||||
medication: {
|
||||
code: "1213681000202103",
|
||||
display: "Cabotegravir only product in oral dose form",
|
||||
system: "http://snomed.info/sct",
|
||||
},
|
||||
note: "a note",
|
||||
reason: "patient was feeling dizzy",
|
||||
effective_period: {
|
||||
start: "2024-12-12T18:30:00.000Z",
|
||||
end: "2025-01-07T18:30:00.000Z",
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
"symptom": {
|
||||
prompt: `An array of objects of the following type, based on the SNOMED CT Code for the applicable symptoms: {
|
||||
code: {"code" : string, "display" : string, "system" : "http://snomed.info/sct"},
|
||||
clinical_status: "active" | "recurrence" | "relapse" | "inactive" | "remission" | "resolved",
|
||||
verification_status: "unconfirmed" | "provisional" | "differential" | "confirmed" | "refuted" | "entered-in-error",
|
||||
severity?: "severe" | "moderate" | "mild",
|
||||
onset?: {
|
||||
onset_datetime: YYYY-MM-DD string
|
||||
},
|
||||
note?: string
|
||||
}. Update existing data, delete existing data or append to the existing list as per the will of the user. Current date is ${new Date().toLocaleDateString()} Default onset_datetime to today unless otherwise specified`,
|
||||
example: [
|
||||
{
|
||||
code: {
|
||||
code: "972900701000119109",
|
||||
display: "Venous ulcer of toe of left foot",
|
||||
system: "http://snomed.info/sct",
|
||||
},
|
||||
clinical_status: "recurrence",
|
||||
verification_status: "provisional",
|
||||
severity: "severe",
|
||||
onset: {
|
||||
onset_datetime: "2024-12-03",
|
||||
},
|
||||
note: "Note here",
|
||||
},
|
||||
]
|
||||
},
|
||||
"diagnosis": {
|
||||
prompt: `An array of objects of the following type, based on the SNOMED CT Code for the applicable diagnoses: {
|
||||
code: {"code" : string, "display" : string, "system" : "http://snomed.info/sct"},
|
||||
clinical_status: "active" | "recurrence" | "relapse" | "inactive" | "remission" | "resolved",
|
||||
verification_status: "unconfirmed" | "provisional" | "differential" | "confirmed" | "refuted" | "entered-in-error",
|
||||
onset: {
|
||||
onset_datetime: YYYY-MM-DD string
|
||||
},
|
||||
note?: string
|
||||
}. Update existing data, delete existing data or append to the existing list as per the will of the user. Current date is ${new Date().toLocaleDateString()} Default onset_datetime to today unless otherwise specified`,
|
||||
example: [
|
||||
{
|
||||
code: {
|
||||
code: "972900701000119109",
|
||||
display: "Venous ulcer of toe of left foot",
|
||||
system: "http://snomed.info/sct",
|
||||
},
|
||||
clinical_status: "recurrence",
|
||||
verification_status: "provisional",
|
||||
onset: {
|
||||
onset_datetime: "2024-12-03",
|
||||
},
|
||||
note: "Note here",
|
||||
},
|
||||
]
|
||||
},
|
||||
"allergy_intolerance": {
|
||||
prompt: `An array of objects of the following type based on the SNOMED CT Code for the applicable diagnoses: {
|
||||
code: {
|
||||
code: string,
|
||||
display: string,
|
||||
system: "http://snomed.info/sct"
|
||||
},
|
||||
clinical_status?: "active" | "inactive" | "resolved",
|
||||
category?: "food" | "medication" | "environment" | "biologic",
|
||||
criticality?: "low" | "high" | "unable-to-assess",
|
||||
verification?: "unconfirmed" | "presumed" | "confirmed" | "refuted" | "entered-in-error"
|
||||
last_occurrence?: YYYY-MM-DD string,
|
||||
note?: string
|
||||
}. Update existing data, delete existing data or append to the existing list as per the will of the user. Current date is ${new Date().toLocaleDateString()}`,
|
||||
example: [
|
||||
{
|
||||
code: {
|
||||
code: "842825221000119100",
|
||||
display: "Anifrolumab",
|
||||
system: "http://snomed.info/sct",
|
||||
},
|
||||
clinical_status: "inactive",
|
||||
category: "environment",
|
||||
criticality: "high",
|
||||
last_occurrence: "2024-12-11",
|
||||
note: "212",
|
||||
},
|
||||
]
|
||||
},
|
||||
"follow_up_appointment": {
|
||||
prompt: `An object of the following type : {
|
||||
reason_for_visit: string
|
||||
}. Update the existing data on the will of the user.`,
|
||||
example: {
|
||||
reason_for_visit: "No change in condition"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const SCRIBE_PROMPT_MAP: ScribePromptMap = {
|
||||
...ARBITRARY_INPUT_PROMPTS,
|
||||
};
|
57
src/utils/uploadFile.ts
Normal file
57
src/utils/uploadFile.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
|
||||
export function handleUploadPercentage(
|
||||
event: ProgressEvent,
|
||||
setUploadPercent: Dispatch<SetStateAction<number>>,
|
||||
) {
|
||||
if (event.lengthComputable) {
|
||||
const percentComplete = Math.round((event.loaded / event.total) * 100);
|
||||
setUploadPercent(percentComplete);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const uploadFile = (
|
||||
url: string,
|
||||
file: File | FormData,
|
||||
reqMethod: string,
|
||||
headers: object,
|
||||
onLoad: (xhr: XMLHttpRequest) => void,
|
||||
setUploadPercent: Dispatch<SetStateAction<number>> | null,
|
||||
onError: () => void,
|
||||
) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(reqMethod, url);
|
||||
|
||||
Object.entries(headers).forEach(([key, value]) => {
|
||||
xhr.setRequestHeader(key, value);
|
||||
});
|
||||
|
||||
xhr.onload = () => {
|
||||
onLoad(xhr);
|
||||
if (400 <= xhr.status && xhr.status <= 499) {
|
||||
const error = JSON.parse(xhr.responseText);
|
||||
if (typeof error === "object" && !Array.isArray(error)) {
|
||||
Object.values(error).forEach((msg) => {
|
||||
window.alert(msg || "Something went wrong!");
|
||||
});
|
||||
} else {
|
||||
window.alert(error || "Something went wrong!");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (setUploadPercent != null) {
|
||||
xhr.upload.onprogress = (event: ProgressEvent) => {
|
||||
handleUploadPercentage(event, setUploadPercent);
|
||||
};
|
||||
}
|
||||
|
||||
xhr.onerror = () => {
|
||||
window.alert("Network Failure. Please check your internet connectivity.");
|
||||
onError();
|
||||
};
|
||||
xhr.send(file);
|
||||
};
|
||||
|
||||
export default uploadFile;
|
217
src/utils/utils.tsx
Normal file
217
src/utils/utils.tsx
Normal file
@ -0,0 +1,217 @@
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
FormQuestion,
|
||||
ScribeAIResponse,
|
||||
ScribeField,
|
||||
ScribeFieldSuggestion,
|
||||
} from "../types";
|
||||
import clsx, { ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export const getQuestionInputs: (formState: any) => ScribeField[] = (
|
||||
formState: any,
|
||||
) => {
|
||||
const formElement = document;
|
||||
const questions = [
|
||||
...formElement.querySelectorAll("[data-question-id]"),
|
||||
] as HTMLInputElement[];
|
||||
|
||||
return questions
|
||||
.map((ele) => {
|
||||
const questionId = ele.getAttribute("data-question-id");
|
||||
const question = findQuestion(formState, questionId || "");
|
||||
|
||||
if (!question) throw Error("No Question Found");
|
||||
|
||||
const currentValue = formState
|
||||
.find((qn: any) =>
|
||||
qn.responses.some(
|
||||
(response: any) => response.question_id === questionId,
|
||||
),
|
||||
)
|
||||
?.responses.find((response: any) => response.question_id === questionId)
|
||||
?.values?.[0]?.value;
|
||||
|
||||
return {
|
||||
question,
|
||||
fieldElement: ele,
|
||||
value: JSON.stringify(currentValue || null),
|
||||
} as ScribeField;
|
||||
})
|
||||
.filter((i) => !!i);
|
||||
};
|
||||
|
||||
export const getFieldsToReview = (
|
||||
aiResponse: ScribeAIResponse,
|
||||
scrapedFields: ScribeField[],
|
||||
) => {
|
||||
return scrapedFields
|
||||
.map((f, i) => ({ ...f, newValue: aiResponse[i] }))
|
||||
.filter((f) => f.newValue);
|
||||
};
|
||||
|
||||
export const renderFieldValue = (
|
||||
field: ScribeFieldSuggestion,
|
||||
useNewValue?: boolean,
|
||||
) => {
|
||||
const val = useNewValue ? field.newValue : field.value;
|
||||
const parsedValue = JSON.parse(val as string);
|
||||
if (Array.isArray(parsedValue)) {
|
||||
return (
|
||||
<ul className="list-disc pl-5">
|
||||
{parsedValue.map((item, index) => (
|
||||
<li key={index}>
|
||||
{typeof item === "object" && item !== null ? (
|
||||
<ul className="list-disc pl-5">
|
||||
{Object.entries(item).map(([key, value]) => (
|
||||
<li key={key}>
|
||||
<span className="font-semibold">{key}:</span>{" "}
|
||||
{typeof value === "string" || typeof value === "number"
|
||||
? String(value)
|
||||
: "..."}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
String(item)
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
} else if (typeof parsedValue === "object" && parsedValue !== null) {
|
||||
return (
|
||||
<ul className="list-disc pl-5">
|
||||
{Object.entries(parsedValue).map(([key, value]) => (
|
||||
<li key={key}>
|
||||
<span className="font-semibold">{key}:</span>{" "}
|
||||
{typeof value === "string" || typeof value === "number"
|
||||
? String(value)
|
||||
: "..."}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span>
|
||||
{typeof parsedValue === "string" &&
|
||||
dayjs(parsedValue.replace(/^"(.*)"$/, "$1")).isValid()
|
||||
? dayjs(parsedValue.replace(/^"(.*)"$/, "$1")).format(
|
||||
"MMMM D, YYYY h:mm A",
|
||||
)
|
||||
: String(parsedValue)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const sleep = async (seconds: number) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, seconds);
|
||||
});
|
||||
};
|
||||
|
||||
export const updateFieldValue = (
|
||||
field: ScribeFieldSuggestion,
|
||||
useNewValue?: boolean,
|
||||
formState?: any,
|
||||
setFormState?: any,
|
||||
) => {
|
||||
let val = (useNewValue ? field.newValue : field.value) as any;
|
||||
try {
|
||||
val = JSON.parse(val);
|
||||
} catch (error) {}
|
||||
const element = field.fieldElement as HTMLElement;
|
||||
|
||||
const qId = element.getAttribute("data-question-id");
|
||||
|
||||
// just incase scribe does not include previous data
|
||||
if (qId === "encounter") {
|
||||
val = [
|
||||
{
|
||||
...JSON.parse(field.value as any)[0],
|
||||
...val[0],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const formQuestionnaire = formState.map((qn: any) => ({
|
||||
...qn,
|
||||
responses: qn.responses.map((response: any) =>
|
||||
response.question_id === qId
|
||||
? {
|
||||
...response,
|
||||
values: response.values.length
|
||||
? response.values.map((v: any, i: number) =>
|
||||
i === 0
|
||||
? {
|
||||
...v,
|
||||
value: val,
|
||||
}
|
||||
: v,
|
||||
)
|
||||
: [
|
||||
{
|
||||
type: field.question.structured_type || typeof val,
|
||||
value: val,
|
||||
},
|
||||
],
|
||||
}
|
||||
: response,
|
||||
),
|
||||
}));
|
||||
setFormState(formQuestionnaire);
|
||||
};
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
function isFormQuestion(value: unknown): value is FormQuestion {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"id" in value &&
|
||||
"text" in value &&
|
||||
typeof (value as { id: unknown }).id === "string" &&
|
||||
typeof (value as { text: unknown }).text === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export function findQuestion(
|
||||
form: unknown,
|
||||
questionId: string,
|
||||
): FormQuestion | undefined {
|
||||
// If array, search each element
|
||||
if (Array.isArray(form)) {
|
||||
for (const element of form) {
|
||||
const result = findQuestion(element, questionId);
|
||||
if (result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (form !== null && typeof form === "object") {
|
||||
if (isFormQuestion(form) && form.id === questionId) {
|
||||
return form;
|
||||
}
|
||||
|
||||
// Otherwise, search all properties of this object
|
||||
for (const key in form) {
|
||||
// Must ensure it's an own property
|
||||
if (Object.prototype.hasOwnProperty.call(form, key)) {
|
||||
const value = (form as Record<string, unknown>)[key];
|
||||
const result = findQuestion(value, questionId);
|
||||
if (result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
17
tailwind.config.js
Normal file
17
tailwind.config.js
Normal file
@ -0,0 +1,17 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)'
|
||||
},
|
||||
colors: {}
|
||||
}
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
}
|
||||
|
32
tsconfig.app.json
Normal file
32
tsconfig.app.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
13
tsconfig.json
Normal file
13
tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
24
tsconfig.node.json
Normal file
24
tsconfig.node.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
42
vite.config.ts
Normal file
42
vite.config.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import path from "path"
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import federation from "@originjs/vite-plugin-federation";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [federation({
|
||||
name: "care_scribe",
|
||||
filename: "remoteEntry.js",
|
||||
exposes: {
|
||||
"./manifest": "./src/manifest.ts",
|
||||
},
|
||||
shared: ["react", "react-dom"],
|
||||
}),
|
||||
react(),],
|
||||
build: {
|
||||
target: "esnext",
|
||||
minify: false,
|
||||
cssCodeSplit: false,
|
||||
modulePreload: {
|
||||
polyfill: false,
|
||||
},
|
||||
rollupOptions: {
|
||||
external: [],
|
||||
input: {
|
||||
main: "./src/main.tsx",
|
||||
},
|
||||
output: {
|
||||
format: "esm",
|
||||
entryFileNames: "assets/[name].js",
|
||||
chunkFileNames: "assets/[name].js",
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
base: "./"
|
||||
})
|
Loading…
x
Reference in New Issue
Block a user