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
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
parent
dc75582bec
commit
9dfbfcdb35
7
.gitignore
vendored
7
.gitignore
vendored
@ -135,5 +135,12 @@ dist
|
|||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
|
dev-dist/
|
||||||
|
|
||||||
src/wasm_exec.js
|
src/wasm_exec.js
|
||||||
public/main.wasm
|
public/main.wasm
|
||||||
|
|
||||||
|
worker.ts
|
||||||
|
|
||||||
|
# Terraform
|
||||||
|
.infracost/
|
||||||
|
141
main.go
141
main.go
@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
@ -21,16 +22,27 @@ type Customer struct {
|
|||||||
LastName string `json:"lastName"`
|
LastName string `json:"lastName"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Document struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Blob string `json:"blob"`
|
||||||
|
}
|
||||||
|
|
||||||
type CustomerFile struct {
|
type CustomerFile struct {
|
||||||
Name string `json:"name"`
|
ID string `json:"id"`
|
||||||
Blob string `json:"blob"`
|
Documents []CustomerDocument `json:"documents"`
|
||||||
|
Suffix string `json:"suffix"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomerDocument struct {
|
||||||
|
ID string `json:"id"`
|
||||||
SelectedPages []string `json:"selectedPages"`
|
SelectedPages []string `json:"selectedPages"`
|
||||||
Suffix string `json:"suffix"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateArchiveInput struct {
|
type CreateArchiveInput struct {
|
||||||
Customer Customer `json:"customer"`
|
Customer Customer `json:"customer"`
|
||||||
Files []CustomerFile `json:"files"`
|
Documents []Document `json:"documents"`
|
||||||
|
Files []CustomerFile `json:"files"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateArchiveResult struct {
|
type CreateArchiveResult struct {
|
||||||
@ -66,7 +78,29 @@ func createArchive(this js.Value, args []js.Value) any {
|
|||||||
var fileNames []string
|
var fileNames []string
|
||||||
|
|
||||||
for _, file := range input.Files {
|
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)
|
fileName := fmt.Sprintf("%s_%s%s", filePrefix, file.Suffix, ext)
|
||||||
i := 1
|
i := 1
|
||||||
@ -86,7 +120,7 @@ func createArchive(this js.Value, args []js.Value) any {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
b, err := base64.StdEncoding.DecodeString(file.Blob)
|
b, err := base64.StdEncoding.DecodeString(document.Blob)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
reject.Invoke(err.Error())
|
reject.Invoke(err.Error())
|
||||||
@ -94,17 +128,7 @@ func createArchive(this js.Value, args []js.Value) any {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if ext == ".pdf" && len(file.SelectedPages) > 0 {
|
if ext != ".pdf" {
|
||||||
rs := bytes.NewReader(b)
|
|
||||||
|
|
||||||
err = pdfcpu.Trim(rs, f, file.SelectedPages, nil)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
reject.Invoke(err.Error())
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_, err = f.Write(b)
|
_, err = f.Write(b)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -112,7 +136,88 @@ func createArchive(this js.Value, args []js.Value) any {
|
|||||||
|
|
||||||
return nil
|
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 {
|
if err = w.Close(); err != nil {
|
||||||
|
14
src/App.tsx
14
src/App.tsx
@ -2,10 +2,12 @@ import "@mantine/core/styles.css";
|
|||||||
import "@mantine/notifications/styles.css";
|
import "@mantine/notifications/styles.css";
|
||||||
import "./wasm_exec";
|
import "./wasm_exec";
|
||||||
|
|
||||||
import FileOrganizer from "./FileOrganizer";
|
import { MantineProvider, createTheme } from "@mantine/core";
|
||||||
import { MantineProvider } from "@mantine/core";
|
|
||||||
|
import { MLEngineContextProvider } from "./MLEngineContext";
|
||||||
import { ModalsProvider } from "@mantine/modals";
|
import { ModalsProvider } from "@mantine/modals";
|
||||||
import { Notifications } from "@mantine/notifications";
|
import { Notifications } from "@mantine/notifications";
|
||||||
|
import Organizrr from "./Organizrr";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
declare class Go {
|
declare class Go {
|
||||||
@ -18,6 +20,8 @@ declare class Go {
|
|||||||
run(instance: WebAssembly.Instance): Promise<void>;
|
run(instance: WebAssembly.Instance): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const theme = createTheme({ primaryColor: "violet" });
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const go = new Go();
|
const go = new Go();
|
||||||
@ -30,10 +34,12 @@ function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MantineProvider>
|
<MantineProvider theme={theme}>
|
||||||
<Notifications />
|
<Notifications />
|
||||||
<ModalsProvider>
|
<ModalsProvider>
|
||||||
<FileOrganizer />
|
<MLEngineContextProvider>
|
||||||
|
<Organizrr />
|
||||||
|
</MLEngineContextProvider>
|
||||||
</ModalsProvider>
|
</ModalsProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
);
|
);
|
||||||
|
42
src/Dropzone.tsx
Normal file
42
src/Dropzone.tsx
Normal 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;
|
@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
Box,
|
|
||||||
Button,
|
Button,
|
||||||
Flex,
|
Flex,
|
||||||
Group,
|
Group,
|
||||||
@ -14,98 +13,29 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
|
||||||
ChatCompletionMessageParam,
|
|
||||||
CreateMLCEngine,
|
|
||||||
InitProgressCallback,
|
|
||||||
MLCEngine,
|
|
||||||
prebuiltAppConfig,
|
|
||||||
} from "@mlc-ai/web-llm";
|
|
||||||
import { Document, Page } from "react-pdf";
|
import { Document, Page } from "react-pdf";
|
||||||
import { Dropzone, FileWithPath } from "@mantine/dropzone";
|
|
||||||
import { Form, isNotEmpty, useForm } from "@mantine/form";
|
import { Form, isNotEmpty, useForm } from "@mantine/form";
|
||||||
import {
|
import {
|
||||||
IconCheck,
|
createArchiveAndDownload,
|
||||||
IconExclamationMark,
|
fileCategories,
|
||||||
IconFile,
|
getFileDataUrl,
|
||||||
IconRobotFace,
|
systemMessage,
|
||||||
IconUpload,
|
} from "./utils";
|
||||||
IconX,
|
import { useCallback, useState } from "react";
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
|
|
||||||
import { notifications } from "@mantine/notifications";
|
import { ChatCompletionMessageParam } from "@mlc-ai/web-llm";
|
||||||
import { useLocalStorage } from "@mantine/hooks";
|
import { CreateArchiveInput } from "./types";
|
||||||
|
import Dropzone from "./Dropzone";
|
||||||
declare global {
|
import { FileWithPath } from "@mantine/dropzone";
|
||||||
function createArchive(payload: string): Promise<string>;
|
import { IconRobotFace } from "@tabler/icons-react";
|
||||||
}
|
import LLMPicker from "./LLMPicker";
|
||||||
|
import { useMLEngine } from "./MLEngineContext";
|
||||||
|
|
||||||
type FormValues = {
|
type FormValues = {
|
||||||
files: { file: FileWithPath; suffix: string; selectedPages: string[] }[];
|
files: { file: FileWithPath; suffix: string; selectedPages: string[] }[];
|
||||||
customer: { firstName: string; lastName: 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() {
|
function FileOrganizer() {
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
mode: "uncontrolled",
|
mode: "uncontrolled",
|
||||||
@ -123,49 +53,7 @@ function FileOrganizer() {
|
|||||||
validateInputOnBlur: true,
|
validateInputOnBlur: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const engine = useRef<MLCEngine>(null);
|
const { engine } = useMLEngine();
|
||||||
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 [selectedFile, setSelectedFile] = useState<number | null>(null);
|
const [selectedFile, setSelectedFile] = useState<number | null>(null);
|
||||||
const [numPages, setNumPages] = useState<number>();
|
const [numPages, setNumPages] = useState<number>();
|
||||||
@ -178,109 +66,40 @@ function FileOrganizer() {
|
|||||||
[setNumPages]
|
[setNumPages]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmit = async (values: FormValues) => {
|
const handleSubmit = async ({ customer, files }: FormValues) => {
|
||||||
const id = Math.random().toString(36).replace("0.", "notification_");
|
await createArchiveAndDownload(async () => {
|
||||||
|
const f = await Promise.all(
|
||||||
|
files.map(async (f) => {
|
||||||
|
const dataURL = await getFileDataUrl(f.file);
|
||||||
|
|
||||||
notifications.show({
|
return {
|
||||||
id,
|
...f,
|
||||||
title: "Dateien werden verarbeitet",
|
name: f.file.name,
|
||||||
message: "Dateien werden nun benennt und als ZIP gespeichert",
|
blob: dataURL,
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
notifications.update({
|
const payload: CreateArchiveInput = {
|
||||||
id,
|
customer,
|
||||||
color: "teal",
|
documents: [],
|
||||||
title: "Dateien wurden verarbeitet",
|
files: [],
|
||||||
message: `Dateien können nun heruntergeladen werden!`,
|
};
|
||||||
icon: <IconCheck size={18} />,
|
|
||||||
loading: false,
|
f.forEach(({ blob, name, suffix, selectedPages }) => {
|
||||||
autoClose: 2000,
|
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}`;
|
return payload;
|
||||||
|
});
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFilesDrop = async (files: FileWithPath[]) => {
|
const handleFilesDrop = async (files: FileWithPath[]) => {
|
||||||
@ -349,44 +168,7 @@ function FileOrganizer() {
|
|||||||
key={form.key("customer.lastName")}
|
key={form.key("customer.lastName")}
|
||||||
/>
|
/>
|
||||||
<Group ml="auto">
|
<Group ml="auto">
|
||||||
<Stack>
|
<LLMPicker />
|
||||||
<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>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<Flex direction="row-reverse" p="md">
|
<Flex direction="row-reverse" p="md">
|
||||||
@ -435,42 +217,7 @@ function FileOrganizer() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
<Dropzone onDrop={handleFilesDrop} accept={["application/pdf"]}>
|
<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>
|
|
||||||
<Button type="submit">Submit</Button>
|
<Button type="submit">Submit</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Flex flex={2} align="center" direction="column">
|
<Flex flex={2} align="center" direction="column">
|
||||||
|
52
src/LLMPicker.tsx
Normal file
52
src/LLMPicker.tsx
Normal 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
140
src/MLEngineContext.tsx
Normal 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
4
src/Organizrr.module.css
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
.pdfPreview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
531
src/Organizrr.tsx
Normal file
531
src/Organizrr.tsx
Normal 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
1
src/global.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
declare function createArchive(payload: string): Promise<string>;
|
32
src/types.ts
Normal file
32
src/types.ts
Normal 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
119
src/utils.tsx
Normal 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;
|
@ -18,7 +18,7 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx"
|
"jsx": "react-jsx",
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src"
|
"src"
|
||||||
|
@ -9,6 +9,9 @@ export default defineConfig({
|
|||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: "autoUpdate",
|
registerType: "autoUpdate",
|
||||||
includeAssets: ["favicon.ico", "apple-touch-icon.png"],
|
includeAssets: ["favicon.ico", "apple-touch-icon.png"],
|
||||||
|
workbox: {
|
||||||
|
maximumFileSizeToCacheInBytes: 17000000,
|
||||||
|
},
|
||||||
manifest: {
|
manifest: {
|
||||||
name: "Organizrr",
|
name: "Organizrr",
|
||||||
short_name: "Organizrr",
|
short_name: "Organizrr",
|
||||||
@ -30,9 +33,6 @@ export default defineConfig({
|
|||||||
background_color: "#8A1E59",
|
background_color: "#8A1E59",
|
||||||
display: "standalone",
|
display: "standalone",
|
||||||
},
|
},
|
||||||
devOptions: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user