feat: implement v2 organizrr with file merge support, overhauled ui/ux and indicator for active llm queries
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
RaviAnand Mohabir 2025-04-07 18:19:38 +02:00
parent dc75582bec
commit 9dfbfcdb35
14 changed files with 1109 additions and 323 deletions

7
.gitignore vendored
View File

@ -135,5 +135,12 @@ dist
.yarn/install-state.gz
.pnp.*
dev-dist/
src/wasm_exec.js
public/main.wasm
worker.ts
# Terraform
.infracost/

141
main.go
View File

@ -6,6 +6,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"path/filepath"
"slices"
"time"
@ -21,16 +22,27 @@ type Customer struct {
LastName string `json:"lastName"`
}
type Document struct {
ID string `json:"id"`
Name string `json:"name"`
Blob string `json:"blob"`
}
type CustomerFile struct {
Name string `json:"name"`
Blob string `json:"blob"`
ID string `json:"id"`
Documents []CustomerDocument `json:"documents"`
Suffix string `json:"suffix"`
}
type CustomerDocument struct {
ID string `json:"id"`
SelectedPages []string `json:"selectedPages"`
Suffix string `json:"suffix"`
}
type CreateArchiveInput struct {
Customer Customer `json:"customer"`
Files []CustomerFile `json:"files"`
Customer Customer `json:"customer"`
Documents []Document `json:"documents"`
Files []CustomerFile `json:"files"`
}
type CreateArchiveResult struct {
@ -66,7 +78,29 @@ func createArchive(this js.Value, args []js.Value) any {
var fileNames []string
for _, file := range input.Files {
ext := filepath.Ext(file.Name)
if len(file.Documents) == 0 {
reject.Invoke("At least one document must be provided")
return nil
}
var document *Document
for _, doc := range input.Documents {
if doc.ID == file.Documents[0].ID {
document = &doc
break
}
}
if document == nil {
reject.Invoke("Couldn't find doc by ID: " + file.Documents[0].ID)
return nil
}
ext := filepath.Ext(document.Name)
fileName := fmt.Sprintf("%s_%s%s", filePrefix, file.Suffix, ext)
i := 1
@ -86,7 +120,7 @@ func createArchive(this js.Value, args []js.Value) any {
return nil
}
b, err := base64.StdEncoding.DecodeString(file.Blob)
b, err := base64.StdEncoding.DecodeString(document.Blob)
if err != nil {
reject.Invoke(err.Error())
@ -94,17 +128,7 @@ func createArchive(this js.Value, args []js.Value) any {
return nil
}
if ext == ".pdf" && len(file.SelectedPages) > 0 {
rs := bytes.NewReader(b)
err = pdfcpu.Trim(rs, f, file.SelectedPages, nil)
if err != nil {
reject.Invoke(err.Error())
return nil
}
} else {
if ext != ".pdf" {
_, err = f.Write(b)
if err != nil {
@ -112,7 +136,88 @@ func createArchive(this js.Value, args []js.Value) any {
return nil
}
continue
}
if len(file.Documents) == 1 {
if len(file.Documents[0].SelectedPages) > 0 {
rs := bytes.NewReader(b)
err = pdfcpu.Trim(rs, f, file.Documents[0].SelectedPages, nil)
if err != nil {
reject.Invoke(err.Error())
return nil
}
} else {
_, err = f.Write(b)
if err != nil {
reject.Invoke(err.Error())
return nil
}
}
continue
}
var rsc []io.ReadSeeker
for i := range file.Documents {
var document *Document
for _, doc := range input.Documents {
if doc.ID == file.Documents[i].ID {
document = &doc
break
}
}
if document == nil {
reject.Invoke("Couldn't find doc by ID: " + file.Documents[i].ID)
return nil
}
if i != 0 {
b, err = base64.StdEncoding.DecodeString(document.Blob)
if err != nil {
reject.Invoke(err.Error())
return nil
}
}
var (
rs = bytes.NewReader(b)
)
if len(file.Documents[i].SelectedPages) > 0 {
var (
buf []byte
res = bytes.NewBuffer(buf)
)
err = pdfcpu.Trim(rs, res, file.Documents[i].SelectedPages, nil)
if err != nil {
reject.Invoke(err.Error())
return nil
}
rsc = append(rsc, bytes.NewReader(res.Bytes()))
} else {
rsc = append(rsc, rs)
}
}
pdfcpu.MergeRaw(rsc, f, false, nil)
}
if err = w.Close(); err != nil {

View File

@ -2,10 +2,12 @@ import "@mantine/core/styles.css";
import "@mantine/notifications/styles.css";
import "./wasm_exec";
import FileOrganizer from "./FileOrganizer";
import { MantineProvider } from "@mantine/core";
import { MantineProvider, createTheme } from "@mantine/core";
import { MLEngineContextProvider } from "./MLEngineContext";
import { ModalsProvider } from "@mantine/modals";
import { Notifications } from "@mantine/notifications";
import Organizrr from "./Organizrr";
import { useEffect } from "react";
declare class Go {
@ -18,6 +20,8 @@ declare class Go {
run(instance: WebAssembly.Instance): Promise<void>;
}
const theme = createTheme({ primaryColor: "violet" });
function App() {
useEffect(() => {
const go = new Go();
@ -30,10 +34,12 @@ function App() {
}, []);
return (
<MantineProvider>
<MantineProvider theme={theme}>
<Notifications />
<ModalsProvider>
<FileOrganizer />
<MLEngineContextProvider>
<Organizrr />
</MLEngineContextProvider>
</ModalsProvider>
</MantineProvider>
);

42
src/Dropzone.tsx Normal file
View File

@ -0,0 +1,42 @@
import { Box, Group, Text } from "@mantine/core";
import { DropzoneProps, Dropzone as MantineDropzone } from "@mantine/dropzone";
import { IconFiles, IconUpload, IconX } from "@tabler/icons-react";
function Dropzone(props: DropzoneProps) {
return (
<MantineDropzone {...props}>
<Group
justify="center"
gap="xl"
mih={130}
style={{ pointerEvents: "none" }}
>
<MantineDropzone.Accept>
<IconUpload
size={52}
color="var(--mantine-primary-color-6)"
stroke={1.5}
/>
</MantineDropzone.Accept>
<MantineDropzone.Reject>
<IconX size={52} color="var(--mantine-color-red-6)" stroke={1.5} />
</MantineDropzone.Reject>
<MantineDropzone.Idle>
<IconFiles
size={52}
color="var(--mantine-color-dimmed)"
stroke={1.5}
/>
</MantineDropzone.Idle>
<Box>
<Text size="md" inline>
Drag files here or click to select
</Text>
</Box>
</Group>
</MantineDropzone>
);
}
export default Dropzone;

View File

@ -1,6 +1,5 @@
import {
Autocomplete,
Box,
Button,
Flex,
Group,
@ -14,98 +13,29 @@ import {
Title,
Tooltip,
} from "@mantine/core";
import {
ChatCompletionMessageParam,
CreateMLCEngine,
InitProgressCallback,
MLCEngine,
prebuiltAppConfig,
} from "@mlc-ai/web-llm";
import { Document, Page } from "react-pdf";
import { Dropzone, FileWithPath } from "@mantine/dropzone";
import { Form, isNotEmpty, useForm } from "@mantine/form";
import {
IconCheck,
IconExclamationMark,
IconFile,
IconRobotFace,
IconUpload,
IconX,
} from "@tabler/icons-react";
import { useCallback, useEffect, useRef, useState } from "react";
createArchiveAndDownload,
fileCategories,
getFileDataUrl,
systemMessage,
} from "./utils";
import { useCallback, useState } from "react";
import { notifications } from "@mantine/notifications";
import { useLocalStorage } from "@mantine/hooks";
declare global {
function createArchive(payload: string): Promise<string>;
}
import { ChatCompletionMessageParam } from "@mlc-ai/web-llm";
import { CreateArchiveInput } from "./types";
import Dropzone from "./Dropzone";
import { FileWithPath } from "@mantine/dropzone";
import { IconRobotFace } from "@tabler/icons-react";
import LLMPicker from "./LLMPicker";
import { useMLEngine } from "./MLEngineContext";
type FormValues = {
files: { file: FileWithPath; suffix: string; selectedPages: string[] }[];
customer: { firstName: string; lastName: string };
};
const fileCategories = [
"Lohnausweis",
"Öffentlicher Verkehr",
"Steuererklärung",
"Weiterbildung",
"3A Bescheinigung",
"Steuerzugangsdaten",
"Wertschriften",
"Kontoauszug",
"Zinsauweis",
"Krankenkassenprämie",
"Krankenkassenrechnung",
];
const systemMessage = {
role: "system",
content: `
You are an AI assistant for a financial advisor. You will help them label files based on the file name using the following categories: ${fileCategories.join(
", "
)}
The user will provide the file name. Make the best assumption based on common financial document names. Respond ONLY with the label or "Unable to label file" - nothing else.
`,
} satisfies ChatCompletionMessageParam;
const models = [
"TinyLlama-1.1B-Chat-v0.4-q4f32_1-MLC",
"phi-1_5-q4f16_1-MLC",
"gemma-2-2b-it-q4f16_1-MLC-1k",
"gemma-2-2b-it-q4f32_1-MLC-1k",
"phi-2-q4f16_1-MLC-1k",
"Qwen2.5-3B-Instruct-q4f16_1-MLC",
"Phi-3-mini-4k-instruct-q4f16_1-MLC-1k",
"Phi-3.5-mini-instruct-q4f32_1-MLC-1k",
"Llama-3.1-8B-Instruct-q4f16_1-MLC-1k",
];
const modelList = [
{
group: "Primär",
items: prebuiltAppConfig.model_list
.filter((m) => models.includes(m.model_id))
.map((m) => ({
...m,
value: m.model_id,
label: m.model_id,
})),
},
{
group: "Andere",
items: prebuiltAppConfig.model_list
.filter((m) => !models.includes(m.model_id))
.map((m) => ({
...m,
value: m.model_id,
label: m.model_id,
})),
},
];
function FileOrganizer() {
const form = useForm<FormValues>({
mode: "uncontrolled",
@ -123,49 +53,7 @@ function FileOrganizer() {
validateInputOnBlur: true,
});
const engine = useRef<MLCEngine>(null);
const [loadingModel, setLoadingModel] = useState<string | null>(null);
const [loadingProgress, setLoadingProgress] = useState<number | null>(null);
const [runningModel, setRunningModel] = useState<string | null>(null);
const [selectedModel, setSelectedModel] = useLocalStorage<string | null>({
key: "modelId",
defaultValue: null,
});
useEffect(() => {
if (selectedModel && runningModel !== selectedModel) {
(async () => {
setLoadingModel(selectedModel);
const initProgressCallback: InitProgressCallback = async (
initProgress
) => {
setLoadingProgress(initProgress.progress);
if (
initProgress.progress === 1 &&
initProgress.text.startsWith("Finish loading")
) {
setRunningModel(selectedModel);
setLoadingModel(null);
setLoadingProgress(null);
}
};
engine.current = await CreateMLCEngine(
selectedModel,
{ initProgressCallback: initProgressCallback } // engineConfig
);
})();
}
}, [
engine,
selectedModel,
runningModel,
setRunningModel,
setLoadingModel,
setLoadingProgress,
]);
const { engine } = useMLEngine();
const [selectedFile, setSelectedFile] = useState<number | null>(null);
const [numPages, setNumPages] = useState<number>();
@ -178,109 +66,40 @@ function FileOrganizer() {
[setNumPages]
);
const handleSubmit = async (values: FormValues) => {
const id = Math.random().toString(36).replace("0.", "notification_");
const handleSubmit = async ({ customer, files }: FormValues) => {
await createArchiveAndDownload(async () => {
const f = await Promise.all(
files.map(async (f) => {
const dataURL = await getFileDataUrl(f.file);
notifications.show({
id,
title: "Dateien werden verarbeitet",
message: "Dateien werden nun benennt und als ZIP gespeichert",
autoClose: false,
withCloseButton: false,
loading: true,
});
const f = await Promise.all(
values.files.map(async (f) => {
const getDataURL = () => {
const reader = new FileReader();
const promise = new Promise<string>((res, rej) => {
reader.onload = () => {
res((reader.result as string)?.split(",")[1]);
};
reader.onerror = () => {
rej();
};
});
reader.readAsDataURL(f.file);
return promise;
};
const dataURL = await getDataURL();
return {
...f,
name: f.file.name,
blob: dataURL,
};
})
);
try {
const resultArchive = await createArchive(
JSON.stringify({
customer: {
firstName: values.customer.firstName,
lastName: values.customer.lastName,
},
files: f,
return {
...f,
name: f.file.name,
blob: dataURL,
};
})
);
notifications.update({
id,
color: "teal",
title: "Dateien wurden verarbeitet",
message: `Dateien können nun heruntergeladen werden!`,
icon: <IconCheck size={18} />,
loading: false,
autoClose: 2000,
const payload: CreateArchiveInput = {
customer,
documents: [],
files: [],
};
f.forEach(({ blob, name, suffix, selectedPages }) => {
const id = Math.random().toString(36).replace("0.", "doc_");
const fileId = Math.random().toString(36).replace("0.", "file_");
payload.documents.push({ id, blob, name });
payload.files.push({
id: fileId,
suffix,
documents: [{ id, selectedPages }],
});
});
const uri = `data:application/zip;base64,${resultArchive}`;
const date = `${new Date().getFullYear()}-${new Date()
.getMonth()
.toString()
.padStart(2, "0")}-${new Date()
.getDate()
.toString()
.padStart(2, "0")}_${new Date()
.getHours()
.toString()
.padStart(2, "0")}-${new Date()
.getMinutes()
.toString()
.padStart(2, "0")}-${new Date()
.getSeconds()
.toString()
.padStart(2, "0")}`;
const link = document.createElement("a");
link.download = `${date}_${values.customer.firstName}_${values.customer.lastName}`;
link.href = uri;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error: unknown) {
notifications.update({
id,
color: "red",
title: "Ein Fehler ist passiert",
message: error as string,
icon: <IconExclamationMark size={18} />,
loading: false,
autoClose: 2000,
});
console.error(error);
return;
}
return payload;
});
};
const handleFilesDrop = async (files: FileWithPath[]) => {
@ -349,44 +168,7 @@ function FileOrganizer() {
key={form.key("customer.lastName")}
/>
<Group ml="auto">
<Stack>
<Group>
<Tooltip
label={
runningModel
? loadingModel
? `${runningModel} (KI Modell wird geladen)`
: runningModel
: "KI Modell wird geladen"
}
>
<Indicator
color={
runningModel
? loadingModel
? "blue"
: "green"
: "orange"
}
processing={loadingModel !== null}
>
<IconRobotFace />
</Indicator>
</Tooltip>
{modelList && (
<Select
data={modelList}
value={selectedModel}
onChange={(val) => val && setSelectedModel(val)}
searchable
clearable
/>
)}
</Group>
{loadingProgress !== null && (
<Progress value={loadingProgress} striped animated />
)}
</Stack>
<LLMPicker />
</Group>
</Group>
<Flex direction="row-reverse" p="md">
@ -435,42 +217,7 @@ function FileOrganizer() {
</Stack>
))}
</Stack>
<Dropzone onDrop={handleFilesDrop} accept={["application/pdf"]}>
<Group
justify="center"
gap="xl"
mih={120}
style={{ pointerEvents: "none" }}
>
<Dropzone.Accept>
<IconUpload
size={52}
color="var(--mantine-color-blue-6)"
stroke={1.5}
/>
</Dropzone.Accept>
<Dropzone.Reject>
<IconX
size={52}
color="var(--mantine-color-red-6)"
stroke={1.5}
/>
</Dropzone.Reject>
<Dropzone.Idle>
<IconFile
size={52}
color="var(--mantine-color-dimmed)"
stroke={1.5}
/>
</Dropzone.Idle>
<Box>
<Text size="xl" inline>
Drag files here or click to select
</Text>
</Box>
</Group>
</Dropzone>
<Dropzone onDrop={handleFilesDrop} accept={["application/pdf"]} />
<Button type="submit">Submit</Button>
</Stack>
<Flex flex={2} align="center" direction="column">

52
src/LLMPicker.tsx Normal file
View File

@ -0,0 +1,52 @@
import {
Group,
Indicator,
Progress,
Select,
Stack,
Tooltip,
} from "@mantine/core";
import { IconRobotFace } from "@tabler/icons-react";
import { useMLEngine } from "./MLEngineContext";
function LLMPicker() {
const { loadingModel, activeModel, selectModel, modelList } = useMLEngine();
return (
<Stack>
<Group>
<Tooltip
label={
activeModel
? loadingModel
? `${activeModel} (KI Modell wird geladen)`
: activeModel
: "KI Modell wird geladen"
}
>
<Indicator
color={activeModel ? (loadingModel ? "" : "green") : "orange"}
processing={loadingModel !== null}
>
<IconRobotFace />
</Indicator>
</Tooltip>
{modelList && (
<Select
data={modelList}
value={activeModel}
onChange={(val) => val && selectModel(val)}
searchable
clearable
/>
)}
</Group>
{loadingModel && (
<Progress value={loadingModel.progress} striped animated />
)}
</Stack>
);
}
export default LLMPicker;

140
src/MLEngineContext.tsx Normal file
View File

@ -0,0 +1,140 @@
import {
CreateMLCEngine,
InitProgressCallback,
MLCEngine,
prebuiltAppConfig,
} from "@mlc-ai/web-llm";
import {
ReactNode,
RefObject,
createContext,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { useLocalStorage } from "@mantine/hooks";
const models = [
"TinyLlama-1.1B-Chat-v0.4-q4f32_1-MLC",
"phi-1_5-q4f16_1-MLC",
"gemma-2-2b-it-q4f16_1-MLC-1k",
"gemma-2-2b-it-q4f32_1-MLC-1k",
"phi-2-q4f16_1-MLC-1k",
"Qwen2.5-3B-Instruct-q4f16_1-MLC",
"Phi-3-mini-4k-instruct-q4f16_1-MLC-1k",
"Phi-3.5-mini-instruct-q4f32_1-MLC-1k",
"Llama-3.1-8B-Instruct-q4f16_1-MLC-1k",
];
const modelList = [
{
group: "Primär",
items: prebuiltAppConfig.model_list
.filter((m) => models.includes(m.model_id))
.map((m) => ({
...m,
value: m.model_id,
label: m.model_id,
})),
},
{
group: "Andere",
items: prebuiltAppConfig.model_list
.filter((m) => !models.includes(m.model_id))
.map((m) => ({
...m,
value: m.model_id,
label: m.model_id,
})),
},
];
type MLEngineContext = {
activeModel: string | null;
loadingModel: {
name: string;
progress: number;
} | null;
engine: RefObject<MLCEngine | null>;
selectModel: (name: string) => void;
modelList: {
group: string;
items: any;
}[];
};
const MLEngineContext = createContext<MLEngineContext>({
activeModel: null,
loadingModel: null,
engine: { current: null },
selectModel: () => {},
modelList,
});
export function MLEngineContextProvider({ children }: { children: ReactNode }) {
const engine = useRef<MLCEngine>(null);
const [loadingModel, setLoadingModel] = useState<string | null>(null);
const [loadingProgress, setLoadingProgress] = useState<number | null>(null);
const [runningModel, setRunningModel] = useState<string | null>(null);
const [selectedModel, setSelectedModel] = useLocalStorage<string | null>({
key: "modelId",
defaultValue: null,
});
useEffect(() => {
if (selectedModel && runningModel !== selectedModel) {
(async () => {
setLoadingModel(selectedModel);
const initProgressCallback: InitProgressCallback = async (
initProgress
) => {
setLoadingProgress(initProgress.progress);
if (
initProgress.progress === 1 &&
initProgress.text.startsWith("Finish loading")
) {
setRunningModel(selectedModel);
setLoadingModel(null);
setLoadingProgress(null);
}
};
engine.current = await CreateMLCEngine(
selectedModel,
{ initProgressCallback: initProgressCallback } // engineConfig
);
})();
}
}, [
engine,
selectedModel,
runningModel,
setRunningModel,
setLoadingModel,
setLoadingProgress,
]);
return (
<MLEngineContext.Provider
value={{
engine,
loadingModel:
loadingModel && loadingProgress !== null
? { name: loadingModel, progress: loadingProgress }
: null,
activeModel: runningModel,
selectModel: setSelectedModel,
modelList,
}}
>
{children}
</MLEngineContext.Provider>
);
}
export const useMLEngine = () => useContext(MLEngineContext);

4
src/Organizrr.module.css Normal file
View File

@ -0,0 +1,4 @@
.pdfPreview {
width: 100%;
height: 100%;
}

531
src/Organizrr.tsx Normal file
View File

@ -0,0 +1,531 @@
import {
ActionIcon,
AppShell,
Autocomplete,
Box,
Burger,
Button,
Group,
Indicator,
Loader,
Pagination,
Paper,
Progress,
ScrollArea,
Select,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import { Document, Page } from "react-pdf";
import { Form, isNotEmpty, useForm } from "@mantine/form";
import {
IconDownload,
IconEye,
IconEyeOff,
IconFiles,
IconLayoutSidebarLeftCollapse,
IconLayoutSidebarLeftExpand,
IconRestore,
IconRobotFace,
IconTrash,
} from "@tabler/icons-react";
import {
createArchiveAndDownload,
fileCategories,
getFileDataUrl,
systemMessage,
} from "./utils";
import { useCallback, useMemo, useState } from "react";
import { ChatCompletionMessageParam } from "@mlc-ai/web-llm";
import Dropzone from "./Dropzone";
import { FileWithPath } from "@mantine/dropzone";
import LLMPicker from "./LLMPicker";
import classNames from "./Organizrr.module.css";
import { useDisclosure } from "@mantine/hooks";
import { useMLEngine } from "./MLEngineContext";
type FormValues = {
customer: { firstName: string; lastName: string };
documents: { file: FileWithPath; id: string }[];
files: {
id: string;
documents: { id: string; selectedPages?: string }[];
suffix: string;
}[];
};
function Organizrr() {
const [mobileOpened, { toggle: toggleMobile, close: closeMobile }] =
useDisclosure(true);
const [desktopOpened, { close: closeDesktop, open: openDesktop }] =
useDisclosure(true);
const [previewMobileOpened, { toggle: togglePreviewMobile }] =
useDisclosure();
const [previewOpened, { toggle: togglePreview, open: openPreview }] =
useDisclosure(true);
const { engine } = useMLEngine();
const [activeDocumentId, setActiveDocumentId] = useState<string | null>(null);
const [activeFile, setActiveFile] = useState<number | null>(null);
const [generatingFilenames, setGeneratingFilenames] = useState<string[]>([]);
const form = useForm<FormValues>({
mode: "controlled",
initialValues: {
customer: { firstName: "", lastName: "" },
documents: [],
files: [],
},
validate: {
customer: {
firstName: isNotEmpty("First name must be given"),
lastName: isNotEmpty("Last name must be given"),
},
files: {
suffix: isNotEmpty("Suffix must be given"),
documents: {
id: isNotEmpty("Document must be selected"),
},
},
},
validateInputOnBlur: true,
});
const activeDocument = useMemo(
() =>
activeDocumentId &&
form.values.documents.find((d) => d.id === activeDocumentId),
[activeDocumentId, form]
);
const [numPages, setNumPages] = useState<number>();
const [pageNumber, setPageNumber] = useState<number>(1);
const onDocumentLoadSuccess = useCallback(
({ numPages }: { numPages: number }) => {
setNumPages(numPages);
},
[setNumPages]
);
const handleFileDrop = (files: FileWithPath[]) => {
if (files.length < 1) return;
files.forEach((f) => {
const id = Math.random().toString(36).replace("0.", "doc_");
const fileId = Math.random().toString(36).replace("0.", "file_");
form.insertListItem("documents", { id, file: f });
form.insertListItem("files", {
id: fileId,
documents: [{ id }],
suffix: "",
});
const messages: ChatCompletionMessageParam[] = [
systemMessage,
{ role: "user", content: "The file name is: " + f.name },
];
setGeneratingFilenames((fns) => [...fns, fileId]);
engine.current?.chat.completions
.create({
messages,
})
.then((reply) => {
if (
reply &&
reply.choices[0].message.content &&
!reply?.choices[0].message.content?.includes(
"Unable to label file"
) &&
!reply?.choices[0].message.content?.includes("I'm sorry")
) {
console.log(reply?.choices[0].message.content);
form.getValues().files.forEach((f, idx) => {
if (f.id === fileId) {
form.setFieldValue(
`files.${idx}.suffix`,
reply?.choices[0].message.content?.split("\n")[0]
);
}
});
} else {
console.warn(reply?.choices[0].message.content);
}
setGeneratingFilenames((fns) => fns.filter((fn) => fn !== fileId));
})
.catch((e) => {
console.error(e);
setGeneratingFilenames((fns) => fns.filter((fn) => fn !== fileId));
});
});
setActiveFile(form.getValues().files.length - 1);
setActiveDocumentId(
form.getValues().documents[form.getValues().documents.length - 1].id
);
};
const handleDocumentDrop = (files: FileWithPath[]) => {
if (activeFile === null) return;
files.forEach((f) => {
const id = Math.random().toString(36).replace("0.", "doc_");
form.insertListItem("documents", { id, file: f });
form.insertListItem(`files.${activeFile}.documents`, { id });
});
setActiveDocumentId(
form.getValues().documents[form.getValues().documents.length - 1].id
);
};
const handleDocumentSelect = (id: string | null) => {
if (id === null) return;
if (activeFile === null) return;
form.insertListItem(`files.${activeFile}.documents`, { id });
setActiveDocumentId(id);
};
const handleSubmit = async ({ customer, files, documents }: FormValues) => {
await createArchiveAndDownload(async () => {
return {
customer,
files: files.map((f) => ({
...f,
documents: f.documents.map((d) => {
const selectedPages = d.selectedPages
?.split(",")
.map((sp) => sp.replace(" ", ""));
return {
...d,
selectedPages: selectedPages?.length ? selectedPages : [],
};
}),
})),
documents: await Promise.all(
documents.map(async (d) => ({
...d,
name: d.file.name,
blob: await getFileDataUrl(d.file),
}))
),
};
});
};
const handleErrors = (errors: typeof form.errors) => {
for (const [key] of Object.entries(errors)) {
if (key.startsWith("files.")) {
if (!Number.isNaN(key.split(".")[1])) {
const idx = parseInt(key.split(".")[1]);
setActiveFile(idx);
break;
}
}
}
};
return (
<AppShell
header={{ height: 100 }}
footer={{ height: desktopOpened ? 0 : 60 }}
navbar={{
width: 300,
breakpoint: "sm",
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
}}
aside={{
width: 600,
breakpoint: "sm",
collapsed: { mobile: !previewMobileOpened, desktop: !previewOpened },
}}
padding="md"
>
<form onSubmit={form.onSubmit(handleSubmit, handleErrors)}>
<AppShell.Header bg="var(--mantine-primary-color-3)">
<Group px="md">
<Burger
opened={mobileOpened}
onClick={toggleMobile}
hiddenFrom="sm"
size="sm"
/>
<Stack>
<Title c="white" order={1}>
Organizrr
</Title>
<Text c="var(--mantine-primary-color-8)">By InnoPeak</Text>
</Stack>
<Group ml="auto">
<LLMPicker />
<ActionIcon
variant="subtle"
onClick={togglePreview}
visibleFrom="sm"
>
{previewOpened ? <IconEyeOff /> : <IconEye />}
</ActionIcon>
<ActionIcon
variant="subtle"
onClick={togglePreviewMobile}
hiddenFrom="sm"
>
{previewMobileOpened ? <IconEyeOff /> : <IconEye />}
</ActionIcon>
</Group>
</Group>
</AppShell.Header>
<AppShell.Navbar p="md" bg="var(--mantine-primary-color-1)">
<AppShell.Section>
<Group>
<IconFiles />
<Title order={3}>Files</Title>
</Group>
</AppShell.Section>
<AppShell.Section grow my="md" component={ScrollArea}>
<Stack>
{form.values.files.map((f, idx) => (
<Stack key={f.id} gap="xs">
<Button
variant={idx === activeFile ? "" : "subtle"}
onClick={() => {
setActiveFile(idx);
closeMobile();
}}
rightSection={
generatingFilenames.includes(f.id) ? (
<Loader size="xs" type="bars" color="white" />
) : null
}
>
<Text>{f.suffix || f.id}</Text>
</Button>
{activeFile != idx &&
form.errors &&
Object.entries(form.errors)
.filter(([key]) => key.startsWith(`files.${idx}`))
.map(([_, error]) => <Text c="red">{error}</Text>)}
</Stack>
))}
</Stack>
</AppShell.Section>
<AppShell.Section>
<Stack>
<Dropzone onDrop={handleFileDrop} />
<Group justify="flex-end">
<ActionIcon variant="transparent" onClick={closeDesktop}>
<IconLayoutSidebarLeftCollapse />
</ActionIcon>
</Group>
</Stack>
</AppShell.Section>
</AppShell.Navbar>
<AppShell.Main pb="md">
<Stack pb={30}>
<Group>
<Title order={2} size="h1">
Customer
</Title>
<Group ml="auto">
<Tooltip label="Formular zurücksetzen">
<ActionIcon
variant="subtle"
c="red"
onClick={() => form.reset()}
>
<IconRestore />
</ActionIcon>
</Tooltip>
</Group>
</Group>
<Group>
<TextInput
label="First Name"
placeholder="First Name"
withAsterisk
required
{...form.getInputProps("customer.firstName")}
key={form.key("customer.firstName")}
/>
<TextInput
label="Last Name"
placeholder="Last Name"
withAsterisk
required
{...form.getInputProps("customer.lastName")}
key={form.key("customer.lastName")}
/>
</Group>
{activeFile !== null && (
<Paper p="md" bg="var(--mantine-primary-color-1)">
<Stack>
<Autocomplete
label="Category"
data={fileCategories}
withAsterisk
required
{...form.getInputProps(`files.${activeFile}.suffix`)}
key={form.key(`files.${activeFile}.suffix`)}
/>
<Group>
<IconFiles />
<Title order={3}>Documents</Title>
</Group>
<ScrollArea offsetScrollbars>
<Group wrap="nowrap" align="start">
{form.values.files[activeFile].documents.map((d, idx) => (
<Stack
key={`${d.id}-${idx}`}
h={500}
mah={500}
w={300}
style={{ cursor: "pointer" }}
onClick={() => {
setActiveDocumentId(d.id);
openPreview();
}}
>
<Group>
<Text>
{
form.values.documents.find(
(_d) => _d.id === d.id
)?.file.name
}
</Text>
<ActionIcon
ml="auto"
variant="subtle"
color="red"
onClick={(e) => {
e.stopPropagation();
form.removeListItem(
`files.${activeFile}.documents`,
idx
);
}}
>
<IconTrash />
</ActionIcon>
</Group>
{form.values.documents
.find((_d) => _d.id === d.id)
?.file.name.endsWith(".pdf") && (
<Box style={{ flexGrow: 1, minHeight: 0 }}>
<ScrollArea h="100%">
<Document
file={
form.values.documents.find(
(_d) => _d.id === d.id
)?.file
}
className={classNames.pdfPreview}
>
<Page pageNumber={1} scale={0.5} />
</Document>
</ScrollArea>
</Box>
)}
<TextInput
label="Selected pages"
placeholder="1, 3-4, even, odd"
onClick={(e) => e.stopPropagation()}
{...form.getInputProps(
`files.${activeFile}.documents.${idx}.selectedPages`
)}
key={form.key(
`files.${activeFile}.documents.${idx}.selectedPages`
)}
/>
</Stack>
))}
{form.values.documents
.find(
(doc) =>
doc.id ===
form.values.files[activeFile].documents[0].id
)
?.file.name.endsWith(".pdf") && (
<Stack
w={300}
key={form.values.files[activeFile].documents.length}
>
<Select
label="Add file"
data={form.values.documents.map(({ id, file }) => ({
value: id,
label: file.name,
}))}
onChange={handleDocumentSelect}
/>
<Dropzone onDrop={handleDocumentDrop} />
</Stack>
)}
</Group>
</ScrollArea>
</Stack>
</Paper>
)}
<Group justify="end">
<Button leftSection={<IconDownload />} type="submit">
Download
</Button>
</Group>
</Stack>
</AppShell.Main>
<AppShell.Aside p="md" bg="var(--mantine-primary-color-1)">
{activeDocument && activeDocument.file.name.endsWith(".pdf") && (
<Stack align="center">
<Text>{activeDocument.file.name}</Text>
<ScrollArea w="100%">
<Document
file={activeDocument.file}
onLoadSuccess={onDocumentLoadSuccess}
>
<Page pageNumber={pageNumber} scale={0.8} />
</Document>
</ScrollArea>
<Pagination
value={pageNumber}
onChange={setPageNumber}
total={numPages ?? 0}
/>
</Stack>
)}
</AppShell.Aside>
<AppShell.Footer p="md">
<ActionIcon
variant="transparent"
onClick={openDesktop}
visibleFrom="sm"
>
<IconLayoutSidebarLeftExpand />
</ActionIcon>
</AppShell.Footer>
</form>
</AppShell>
);
}
export default Organizrr;

1
src/global.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare function createArchive(payload: string): Promise<string>;

32
src/types.ts Normal file
View File

@ -0,0 +1,32 @@
export interface Customer {
firstName: string;
lastName: string;
}
export interface Document {
id: string;
name: string;
blob: string;
}
export interface CustomerFile {
id: string;
documents: CustomerDocument[];
suffix: string;
}
export interface CustomerDocument {
id: string;
selectedPages: string[];
}
export interface CreateArchiveInput {
customer: Customer;
documents: Document[];
files: CustomerFile[];
}
export interface CreateArchiveResult {
resultArchive?: string;
error?: string;
}

119
src/utils.tsx Normal file
View File

@ -0,0 +1,119 @@
import { IconCheck, IconExclamationMark } from "@tabler/icons-react";
import { CreateArchiveInput } from "./types";
import { notifications } from "@mantine/notifications";
import { ChatCompletionMessageParam } from "@mlc-ai/web-llm";
export const createArchiveAndDownload = async (
getPayload: () => Promise<CreateArchiveInput>
) => {
const id = Math.random().toString(36).replace("0.", "notification_");
notifications.show({
id,
title: "Dateien werden verarbeitet",
message: "Dateien werden nun benennt und als ZIP gespeichert",
autoClose: false,
withCloseButton: false,
loading: true,
});
const payload = await getPayload();
try {
const resultArchive = await createArchive(JSON.stringify(payload));
notifications.update({
id,
color: "teal",
title: "Dateien wurden verarbeitet",
message: `Dateien können nun heruntergeladen werden!`,
icon: <IconCheck size={18} />,
loading: false,
autoClose: 2000,
});
const uri = `data:application/zip;base64,${resultArchive}`;
const date = `${new Date().getFullYear()}-${new Date()
.getMonth()
.toString()
.padStart(2, "0")}-${new Date()
.getDate()
.toString()
.padStart(2, "0")}_${new Date()
.getHours()
.toString()
.padStart(2, "0")}-${new Date()
.getMinutes()
.toString()
.padStart(2, "0")}-${new Date()
.getSeconds()
.toString()
.padStart(2, "0")}`;
const link = document.createElement("a");
link.download = `${date}_${payload.customer.firstName}_${payload.customer.lastName}`;
link.href = uri;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error: unknown) {
notifications.update({
id,
color: "red",
title: "Ein Fehler ist passiert",
message: String(error),
icon: <IconExclamationMark size={18} />,
loading: false,
autoClose: 2000,
});
console.error(error);
return;
}
};
export const getFileDataUrl = (file: File) => {
const reader = new FileReader();
const promise = new Promise<string>((res, rej) => {
reader.onload = () => {
res((reader.result as string)?.split(",")[1]);
};
reader.onerror = () => {
rej();
};
});
reader.readAsDataURL(file);
return promise;
};
export const fileCategories = [
"Lohnausweis",
"Öffentlicher Verkehr",
"Steuererklärung",
"Weiterbildung",
"3A Bescheinigung",
"Steuerzugangsdaten",
"Wertschriften",
"Kontoauszug",
"Zinsauweis",
"Krankenkassenprämie",
"Krankenkassenrechnung",
];
export const systemMessage = {
role: "system",
content: `
You are an AI assistant for a financial advisor. You will help them label files based on the file name using the following categories: ${fileCategories.join(
", "
)}
The user will provide the file name. Make the best assumption based on common financial document names. Respond ONLY with the label or "Unable to label file" - nothing else.
`,
} satisfies ChatCompletionMessageParam;

View File

@ -18,7 +18,7 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
"jsx": "react-jsx",
},
"include": [
"src"

View File

@ -9,6 +9,9 @@ export default defineConfig({
VitePWA({
registerType: "autoUpdate",
includeAssets: ["favicon.ico", "apple-touch-icon.png"],
workbox: {
maximumFileSizeToCacheInBytes: 17000000,
},
manifest: {
name: "Organizrr",
short_name: "Organizrr",
@ -30,9 +33,6 @@ export default defineConfig({
background_color: "#8A1E59",
display: "standalone",
},
devOptions: {
enabled: true,
},
}),
],
});