mirror of
https://github.com/icereed/paperless-gpt.git
synced 2025-03-12 21:08:00 -05:00
commit
31f0e81465
12 changed files with 755 additions and 497 deletions
166
main.go
166
main.go
|
@ -51,13 +51,28 @@ type GetDocumentsApiResponse struct {
|
||||||
} `json:"results"`
|
} `json:"results"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Document is a stripped down version of the document object from paperless-ngx.
|
||||||
|
// Response payload for /documents endpoint and part of request payload for /generate-suggestions endpoint
|
||||||
type Document struct {
|
type Document struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
SuggestedTitle string `json:"suggested_title,omitempty"`
|
}
|
||||||
SuggestedTags []string `json:"suggested_tags,omitempty"`
|
|
||||||
|
// GenerateSuggestionsRequest is the request payload for generating suggestions for /generate-suggestions endpoint
|
||||||
|
type GenerateSuggestionsRequest struct {
|
||||||
|
Documents []Document `json:"documents"`
|
||||||
|
GenerateTitles bool `json:"generate_titles,omitempty"`
|
||||||
|
GenerateTags bool `json:"generate_tags,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocumentSuggestion is the response payload for /generate-suggestions endpoint and the request payload for /update-documents endpoint (as an array)
|
||||||
|
type DocumentSuggestion struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
OriginalDocument Document `json:"original_document"`
|
||||||
|
SuggestedTitle string `json:"suggested_title,omitempty"`
|
||||||
|
SuggestedTags []string `json:"suggested_tags,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
@ -207,14 +222,14 @@ func documentsHandler(c *gin.Context) {
|
||||||
func generateSuggestionsHandler(c *gin.Context) {
|
func generateSuggestionsHandler(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
var documents []Document
|
var suggestionRequest GenerateSuggestionsRequest
|
||||||
if err := c.ShouldBindJSON(&documents); err != nil {
|
if err := c.ShouldBindJSON(&suggestionRequest); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid request payload: %v", err)})
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid request payload: %v", err)})
|
||||||
log.Printf("Invalid request payload: %v", err)
|
log.Printf("Invalid request payload: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
results, err := processDocuments(ctx, documents)
|
results, err := generateDocumentSuggestions(ctx, suggestionRequest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Error processing documents: %v", err)})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Error processing documents: %v", err)})
|
||||||
log.Printf("Error processing documents: %v", err)
|
log.Printf("Error processing documents: %v", err)
|
||||||
|
@ -227,7 +242,7 @@ func generateSuggestionsHandler(c *gin.Context) {
|
||||||
// updateDocumentsHandler updates documents with new titles
|
// updateDocumentsHandler updates documents with new titles
|
||||||
func updateDocumentsHandler(c *gin.Context) {
|
func updateDocumentsHandler(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
var documents []Document
|
var documents []DocumentSuggestion
|
||||||
if err := c.ShouldBindJSON(&documents); err != nil {
|
if err := c.ShouldBindJSON(&documents); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid request payload: %v", err)})
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid request payload: %v", err)})
|
||||||
log.Printf("Invalid request payload: %v", err)
|
log.Printf("Invalid request payload: %v", err)
|
||||||
|
@ -244,50 +259,6 @@ func updateDocumentsHandler(c *gin.Context) {
|
||||||
c.Status(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getIDMappingForTags(ctx context.Context, baseURL, apiToken string, tagsToFilter []string) (map[string]int, error) {
|
|
||||||
url := fmt.Sprintf("%s/api/tags/", baseURL)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", fmt.Sprintf("Token %s", apiToken))
|
|
||||||
|
|
||||||
client := &http.Client{}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
||||||
return nil, fmt.Errorf("Error fetching tags: %d, %s", resp.StatusCode, string(bodyBytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
var tagsResponse struct {
|
|
||||||
Results []struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
} `json:"results"`
|
|
||||||
}
|
|
||||||
|
|
||||||
err = json.NewDecoder(resp.Body).Decode(&tagsResponse)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
tagIDMapping := make(map[string]int)
|
|
||||||
for _, tag := range tagsResponse.Results {
|
|
||||||
for _, filterTag := range tagsToFilter {
|
|
||||||
if tag.Name == filterTag {
|
|
||||||
tagIDMapping[tag.Name] = tag.ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tagIDMapping, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getDocumentsByTags(ctx context.Context, baseURL, apiToken string, tags []string) ([]Document, error) {
|
func getDocumentsByTags(ctx context.Context, baseURL, apiToken string, tags []string) ([]Document, error) {
|
||||||
tagQueries := make([]string, len(tags))
|
tagQueries := make([]string, len(tags))
|
||||||
for i, tag := range tags {
|
for i, tag := range tags {
|
||||||
|
@ -348,7 +319,7 @@ func getDocumentsByTags(ctx context.Context, baseURL, apiToken string, tags []st
|
||||||
return documents, nil
|
return documents, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func processDocuments(ctx context.Context, documents []Document) ([]Document, error) {
|
func generateDocumentSuggestions(ctx context.Context, suggestionRequest GenerateSuggestionsRequest) ([]DocumentSuggestion, error) {
|
||||||
llm, err := createLLM()
|
llm, err := createLLM()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create LLM client: %v", err)
|
return nil, fmt.Errorf("failed to create LLM client: %v", err)
|
||||||
|
@ -369,6 +340,9 @@ func processDocuments(ctx context.Context, documents []Document) ([]Document, er
|
||||||
availableTagNames = append(availableTagNames, tagName)
|
availableTagNames = append(availableTagNames, tagName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
documents := suggestionRequest.Documents
|
||||||
|
documentSuggestions := []DocumentSuggestion{}
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
errors := make([]error, 0)
|
errors := make([]error, 0)
|
||||||
|
@ -385,27 +359,50 @@ func processDocuments(ctx context.Context, documents []Document) ([]Document, er
|
||||||
content = content[:5000]
|
content = content[:5000]
|
||||||
}
|
}
|
||||||
|
|
||||||
suggestedTitle, err := getSuggestedTitle(ctx, llm, content)
|
var suggestedTitle string
|
||||||
if err != nil {
|
var suggestedTags []string
|
||||||
mu.Lock()
|
|
||||||
errors = append(errors, fmt.Errorf("Document %d: %v", documentID, err))
|
if suggestionRequest.GenerateTitles {
|
||||||
mu.Unlock()
|
suggestedTitle, err = getSuggestedTitle(ctx, llm, content)
|
||||||
log.Printf("Error processing document %d: %v", documentID, err)
|
if err != nil {
|
||||||
return
|
mu.Lock()
|
||||||
|
errors = append(errors, fmt.Errorf("Document %d: %v", documentID, err))
|
||||||
|
mu.Unlock()
|
||||||
|
log.Printf("Error processing document %d: %v", documentID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suggestedTags, err := getSuggestedTags(ctx, llm, content, suggestedTitle, availableTagNames)
|
if suggestionRequest.GenerateTags {
|
||||||
if err != nil {
|
suggestedTags, err = getSuggestedTags(ctx, llm, content, suggestedTitle, availableTagNames)
|
||||||
mu.Lock()
|
if err != nil {
|
||||||
errors = append(errors, fmt.Errorf("Document %d: %v", documentID, err))
|
mu.Lock()
|
||||||
mu.Unlock()
|
errors = append(errors, fmt.Errorf("Document %d: %v", documentID, err))
|
||||||
log.Printf("Error generating tags for document %d: %v", documentID, err)
|
mu.Unlock()
|
||||||
return
|
log.Printf("Error generating tags for document %d: %v", documentID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
doc.SuggestedTitle = suggestedTitle
|
suggestion := DocumentSuggestion{
|
||||||
doc.SuggestedTags = suggestedTags
|
ID: documentID,
|
||||||
|
OriginalDocument: *doc,
|
||||||
|
}
|
||||||
|
// Titles
|
||||||
|
if suggestionRequest.GenerateTitles {
|
||||||
|
suggestion.SuggestedTitle = suggestedTitle
|
||||||
|
} else {
|
||||||
|
suggestion.SuggestedTitle = doc.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
if suggestionRequest.GenerateTags {
|
||||||
|
suggestion.SuggestedTags = suggestedTags
|
||||||
|
} else {
|
||||||
|
suggestion.SuggestedTags = removeTagFromList(doc.Tags, tagToFilter)
|
||||||
|
}
|
||||||
|
documentSuggestions = append(documentSuggestions, suggestion)
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
log.Printf("Document %d processed successfully.", documentID)
|
log.Printf("Document %d processed successfully.", documentID)
|
||||||
}(&documents[i])
|
}(&documents[i])
|
||||||
|
@ -417,7 +414,17 @@ func processDocuments(ctx context.Context, documents []Document) ([]Document, er
|
||||||
return nil, errors[0]
|
return nil, errors[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
return documents, nil
|
return documentSuggestions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeTagFromList(tags []string, tagToRemove string) []string {
|
||||||
|
filteredTags := []string{}
|
||||||
|
for _, tag := range tags {
|
||||||
|
if tag != tagToRemove {
|
||||||
|
filteredTags = append(filteredTags, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filteredTags
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSuggestedTags(ctx context.Context, llm llms.Model, content string, suggestedTitle string, availableTags []string) ([]string, error) {
|
func getSuggestedTags(ctx context.Context, llm llms.Model, content string, suggestedTitle string, availableTags []string) ([]string, error) {
|
||||||
|
@ -507,7 +514,7 @@ Content:
|
||||||
return strings.TrimSpace(strings.Trim(completion.Choices[0].Content, "\"")), nil
|
return strings.TrimSpace(strings.Trim(completion.Choices[0].Content, "\"")), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateDocuments(ctx context.Context, baseURL, apiToken string, documents []Document) error {
|
func updateDocuments(ctx context.Context, baseURL, apiToken string, documents []DocumentSuggestion) error {
|
||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
|
|
||||||
// Fetch all available tags
|
// Fetch all available tags
|
||||||
|
@ -524,8 +531,13 @@ func updateDocuments(ctx context.Context, baseURL, apiToken string, documents []
|
||||||
|
|
||||||
newTags := []int{}
|
newTags := []int{}
|
||||||
|
|
||||||
|
tags := document.SuggestedTags
|
||||||
|
if len(tags) == 0 {
|
||||||
|
tags = document.OriginalDocument.Tags
|
||||||
|
}
|
||||||
|
|
||||||
// Map suggested tag names to IDs
|
// Map suggested tag names to IDs
|
||||||
for _, tagName := range document.SuggestedTags {
|
for _, tagName := range tags {
|
||||||
if tagID, exists := availableTags[tagName]; exists {
|
if tagID, exists := availableTags[tagName]; exists {
|
||||||
// Skip the tag that we are filtering
|
// Skip the tag that we are filtering
|
||||||
if tagName == tagToFilter {
|
if tagName == tagToFilter {
|
||||||
|
@ -543,7 +555,11 @@ func updateDocuments(ctx context.Context, baseURL, apiToken string, documents []
|
||||||
if len(suggestedTitle) > 128 {
|
if len(suggestedTitle) > 128 {
|
||||||
suggestedTitle = suggestedTitle[:128]
|
suggestedTitle = suggestedTitle[:128]
|
||||||
}
|
}
|
||||||
updatedFields["title"] = suggestedTitle
|
if suggestedTitle != "" {
|
||||||
|
updatedFields["title"] = suggestedTitle
|
||||||
|
} else {
|
||||||
|
log.Printf("No valid title found for document %d, skipping.", documentID)
|
||||||
|
}
|
||||||
|
|
||||||
// Send the update request
|
// Send the update request
|
||||||
url := fmt.Sprintf("%s/api/documents/%d/", baseURL, documentID)
|
url := fmt.Sprintf("%s/api/documents/%d/", baseURL, documentID)
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" class="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Paperless GPT</title>
|
<title>Paperless GPT</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
|
@ -1,5 +1,5 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import DocumentProcessor from './components/DocumentProcessor';
|
import DocumentProcessor from './DocumentProcessor';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
|
|
247
web-app/src/DocumentProcessor.tsx
Normal file
247
web-app/src/DocumentProcessor.tsx
Normal file
|
@ -0,0 +1,247 @@
|
||||||
|
import axios from "axios";
|
||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import "react-tag-autocomplete/example/src/styles.css"; // Ensure styles are loaded
|
||||||
|
import DocumentsToProcess from "./components/DocumentsToProcess";
|
||||||
|
import NoDocuments from "./components/NoDocuments";
|
||||||
|
import SuccessModal from "./components/SuccessModal";
|
||||||
|
import SuggestionsReview from "./components/SuggestionsReview";
|
||||||
|
|
||||||
|
export interface Document {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateSuggestionsRequest {
|
||||||
|
documents: Document[];
|
||||||
|
generate_titles?: boolean;
|
||||||
|
generate_tags?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentSuggestion {
|
||||||
|
id: number;
|
||||||
|
original_document: Document;
|
||||||
|
suggested_title?: string;
|
||||||
|
suggested_tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TagOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DocumentProcessor: React.FC = () => {
|
||||||
|
const [documents, setDocuments] = useState<Document[]>([]);
|
||||||
|
const [suggestions, setSuggestions] = useState<DocumentSuggestion[]>([]);
|
||||||
|
const [availableTags, setAvailableTags] = useState<TagOption[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [processing, setProcessing] = useState(false);
|
||||||
|
const [updating, setUpdating] = useState(false);
|
||||||
|
const [isSuccessModalOpen, setIsSuccessModalOpen] = useState(false);
|
||||||
|
const [filterTag, setFilterTag] = useState<string | null>(null);
|
||||||
|
const [generateTitles, setGenerateTitles] = useState(true);
|
||||||
|
const [generateTags, setGenerateTags] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Custom hook to fetch initial data
|
||||||
|
const fetchInitialData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [filterTagRes, documentsRes, tagsRes] = await Promise.all([
|
||||||
|
axios.get<{ tag: string }>("/api/filter-tag"),
|
||||||
|
axios.get<Document[]>("/api/documents"),
|
||||||
|
axios.get<Record<string, number>>("/api/tags"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setFilterTag(filterTagRes.data.tag);
|
||||||
|
setDocuments(documentsRes.data);
|
||||||
|
const tags = Object.keys(tagsRes.data).map((tag) => ({
|
||||||
|
id: tag,
|
||||||
|
name: tag,
|
||||||
|
}));
|
||||||
|
setAvailableTags(tags);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching initial data:", err);
|
||||||
|
setError("Failed to fetch initial data.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchInitialData();
|
||||||
|
}, [fetchInitialData]);
|
||||||
|
|
||||||
|
const handleProcessDocuments = async () => {
|
||||||
|
setProcessing(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const requestPayload: GenerateSuggestionsRequest = {
|
||||||
|
documents,
|
||||||
|
generate_titles: generateTitles,
|
||||||
|
generate_tags: generateTags,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = await axios.post<DocumentSuggestion[]>(
|
||||||
|
"/api/generate-suggestions",
|
||||||
|
requestPayload
|
||||||
|
);
|
||||||
|
setSuggestions(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error generating suggestions:", err);
|
||||||
|
setError("Failed to generate suggestions.");
|
||||||
|
} finally {
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateDocuments = async () => {
|
||||||
|
setUpdating(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await axios.patch("/api/update-documents", suggestions);
|
||||||
|
setIsSuccessModalOpen(true);
|
||||||
|
setSuggestions([]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error updating documents:", err);
|
||||||
|
setError("Failed to update documents.");
|
||||||
|
} finally {
|
||||||
|
setUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTagAddition = (docId: number, tag: TagOption) => {
|
||||||
|
setSuggestions((prevSuggestions) =>
|
||||||
|
prevSuggestions.map((doc) =>
|
||||||
|
doc.id === docId
|
||||||
|
? {
|
||||||
|
...doc,
|
||||||
|
suggested_tags: [...(doc.suggested_tags || []), tag.name],
|
||||||
|
}
|
||||||
|
: doc
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTagDeletion = (docId: number, index: number) => {
|
||||||
|
setSuggestions((prevSuggestions) =>
|
||||||
|
prevSuggestions.map((doc) =>
|
||||||
|
doc.id === docId
|
||||||
|
? {
|
||||||
|
...doc,
|
||||||
|
suggested_tags: doc.suggested_tags?.filter(
|
||||||
|
(_, i) => i !== index
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: doc
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTitleChange = (docId: number, title: string) => {
|
||||||
|
setSuggestions((prevSuggestions) =>
|
||||||
|
prevSuggestions.map((doc) =>
|
||||||
|
doc.id === docId
|
||||||
|
? { ...doc, suggested_title: title }
|
||||||
|
: doc
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetSuggestions = () => {
|
||||||
|
setSuggestions([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reloadDocuments = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get<Document[]>("/api/documents");
|
||||||
|
setDocuments(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error reloading documents:", err);
|
||||||
|
setError("Failed to reload documents.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (documents.length === 0) {
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get<Document[]>("/api/documents");
|
||||||
|
setDocuments(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error reloading documents:", err);
|
||||||
|
setError("Failed to reload documents.");
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [documents]);
|
||||||
|
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-white dark:bg-gray-900">
|
||||||
|
<div className="text-xl font-semibold text-gray-800 dark:text-gray-200">Loading documents...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto p-6 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200">
|
||||||
|
<header className="text-center">
|
||||||
|
<h1 className="text-4xl font-bold mb-8">Paperless GPT</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-4 bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 rounded">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{documents.length === 0 ? (
|
||||||
|
<NoDocuments
|
||||||
|
filterTag={filterTag}
|
||||||
|
onReload={reloadDocuments}
|
||||||
|
processing={processing}
|
||||||
|
/>
|
||||||
|
) : suggestions.length === 0 ? (
|
||||||
|
<DocumentsToProcess
|
||||||
|
documents={documents}
|
||||||
|
generateTitles={generateTitles}
|
||||||
|
setGenerateTitles={setGenerateTitles}
|
||||||
|
generateTags={generateTags}
|
||||||
|
setGenerateTags={setGenerateTags}
|
||||||
|
onProcess={handleProcessDocuments}
|
||||||
|
processing={processing}
|
||||||
|
onReload={reloadDocuments}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SuggestionsReview
|
||||||
|
suggestions={suggestions}
|
||||||
|
availableTags={availableTags}
|
||||||
|
onTitleChange={handleTitleChange}
|
||||||
|
onTagAddition={handleTagAddition}
|
||||||
|
onTagDeletion={handleTagDeletion}
|
||||||
|
onBack={resetSuggestions}
|
||||||
|
onUpdate={handleUpdateDocuments}
|
||||||
|
updating={updating}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SuccessModal
|
||||||
|
isOpen={isSuccessModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsSuccessModalOpen(false);
|
||||||
|
reloadDocuments();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocumentProcessor;
|
45
web-app/src/components/DocumentCard.tsx
Normal file
45
web-app/src/components/DocumentCard.tsx
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Document } from "../DocumentProcessor";
|
||||||
|
|
||||||
|
interface DocumentCardProps {
|
||||||
|
document: Document;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DocumentCard: React.FC<DocumentCardProps> = ({ document }) => (
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow-lg shadow-blue-500/50 rounded-md p-4 relative group overflow-hidden">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200">{document.title}</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2 truncate">
|
||||||
|
{document.content.length > 100
|
||||||
|
? `${document.content.substring(0, 100)}...`
|
||||||
|
: document.content}
|
||||||
|
</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
{document.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center p-4 rounded-md">
|
||||||
|
<div className="text-sm text-white p-2 bg-gray-800 dark:bg-gray-900 rounded-md w-full max-h-full overflow-y-auto">
|
||||||
|
<h3 className="text-lg font-semibold text-white">{document.title}</h3>
|
||||||
|
<p className="mt-2 whitespace-pre-wrap">{document.content}</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
{document.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default DocumentCard;
|
|
@ -1,417 +0,0 @@
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
Transition,
|
|
||||||
TransitionChild,
|
|
||||||
} from "@headlessui/react";
|
|
||||||
import { ArrowPathIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
|
|
||||||
import axios from "axios";
|
|
||||||
import React, { Fragment, useEffect, useState } from "react";
|
|
||||||
import { ReactTags } from "react-tag-autocomplete";
|
|
||||||
import "react-tag-autocomplete/example/src/styles.css"; // Ensure styles are loaded
|
|
||||||
|
|
||||||
interface Document {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
tags: string[];
|
|
||||||
suggested_title?: string;
|
|
||||||
suggested_tags?: { value: string; label: string }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
type ApiDocument = Omit<Document, "suggested_tags"> & {
|
|
||||||
suggested_tags?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const DocumentProcessor: React.FC = () => {
|
|
||||||
const [documents, setDocuments] = useState<Document[]>([]);
|
|
||||||
const [availableTags, setAvailableTags] = useState<{ value: string; label: string }[]>([]);
|
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
|
||||||
const [processing, setProcessing] = useState<boolean>(false);
|
|
||||||
const [updating, setUpdating] = useState<boolean>(false);
|
|
||||||
const [successModalOpen, setSuccessModalOpen] = useState<boolean>(false);
|
|
||||||
const [filterTag, setFilterTag] = useState<string | undefined>(undefined);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchData = async () => {
|
|
||||||
try {
|
|
||||||
const [filterTagResponse, documentsResponse, tagsResponse] =
|
|
||||||
await Promise.all([
|
|
||||||
axios.get("/api/filter-tag"),
|
|
||||||
axios.get("/api/documents"),
|
|
||||||
axios.get("/api/tags"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
setFilterTag(filterTagResponse.data?.tag);
|
|
||||||
const rawDocuments = documentsResponse.data as ApiDocument[];
|
|
||||||
const documents = rawDocuments.map((doc) => ({
|
|
||||||
...doc,
|
|
||||||
suggested_tags: doc.tags.map((tag) => ({ value: tag, label: tag })),
|
|
||||||
}));
|
|
||||||
console.log(documents);
|
|
||||||
setDocuments(documents);
|
|
||||||
|
|
||||||
// Store available tags as objects with value and label
|
|
||||||
// tagsResponse.data is a map of name to id
|
|
||||||
const tags = Object.entries(tagsResponse.data).map(([name]) => ({
|
|
||||||
value: name,
|
|
||||||
label: name,
|
|
||||||
}));
|
|
||||||
setAvailableTags(tags);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching data:", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleProcessDocuments = async () => {
|
|
||||||
setProcessing(true);
|
|
||||||
try {
|
|
||||||
const apiDocuments: ApiDocument[] = documents.map((doc) => ({
|
|
||||||
...doc,
|
|
||||||
suggested_tags: doc.suggested_tags?.map((tag) => tag.value) || [],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const response = await axios.post<ApiDocument[]>("/api/generate-suggestions", apiDocuments);
|
|
||||||
setDocuments(response.data.map((doc) => ({
|
|
||||||
...doc,
|
|
||||||
suggested_tags: doc.suggested_tags?.map((tag) => ({ value: tag, label: tag })) || [],
|
|
||||||
})));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error generating suggestions:", error);
|
|
||||||
} finally {
|
|
||||||
setProcessing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateDocuments = async () => {
|
|
||||||
setUpdating(true);
|
|
||||||
try {
|
|
||||||
const apiDocuments: ApiDocument[] = documents.map((doc) => ({
|
|
||||||
...doc,
|
|
||||||
tags: [], // Remove tags from the API document
|
|
||||||
suggested_tags: doc.suggested_tags?.map((tag) => tag.value) || [],
|
|
||||||
}));
|
|
||||||
await axios.patch("/api/update-documents", apiDocuments);
|
|
||||||
setSuccessModalOpen(true);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating documents:", error);
|
|
||||||
} finally {
|
|
||||||
setUpdating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetSuggestions = () => {
|
|
||||||
const resetDocs = documents.map((doc) => ({
|
|
||||||
...doc,
|
|
||||||
suggested_title: undefined,
|
|
||||||
suggested_tags: [],
|
|
||||||
}));
|
|
||||||
setDocuments(resetDocs);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchDocuments = async () => {
|
|
||||||
try {
|
|
||||||
const response = await axios.get("/api/documents"); // API endpoint to fetch documents
|
|
||||||
setDocuments(response.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching documents:", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (documents.length === 0) {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
fetchDocuments();
|
|
||||||
}, 500);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}
|
|
||||||
}, [documents]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-screen">
|
|
||||||
<div className="text-xl font-semibold">Loading documents...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-5xl mx-auto p-6">
|
|
||||||
<h1 className="text-4xl font-bold mb-8 text-center text-gray-800">
|
|
||||||
Paperless GPT
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{documents.length === 0 && (
|
|
||||||
<div className="flex items-center justify-center h-screen">
|
|
||||||
<div className="text-xl font-semibold">
|
|
||||||
No documents found with filter tag{" "}
|
|
||||||
<span className="bg-blue-100 text-blue-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded dark:bg-blue-900 dark:text-blue-300bg-blue-100 text-blue-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-blue-900 dark:text-blue-300">
|
|
||||||
{filterTag}
|
|
||||||
</span>{" "}
|
|
||||||
found. Try{" "}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setDocuments([]);
|
|
||||||
setLoading(true);
|
|
||||||
fetchDocuments();
|
|
||||||
}}
|
|
||||||
className="text-blue-600 hover:underline focus:outline-none"
|
|
||||||
>
|
|
||||||
reloading <ArrowPathIcon className="h-5 w-5 inline" />
|
|
||||||
</button>
|
|
||||||
.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!documents.some((doc) => doc.suggested_title) && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-700">
|
|
||||||
Documents to Process
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setDocuments([]);
|
|
||||||
setLoading(true);
|
|
||||||
fetchDocuments();
|
|
||||||
}}
|
|
||||||
disabled={processing}
|
|
||||||
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 focus:outline-none"
|
|
||||||
>
|
|
||||||
<ArrowPathIcon className="h-5 w-5 inline" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleProcessDocuments}
|
|
||||||
disabled={processing}
|
|
||||||
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 focus:outline-none"
|
|
||||||
>
|
|
||||||
{processing ? "Processing..." : "Generate Suggestions"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white shadow rounded-md overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-3 text-left text-sm font-medium text-gray-500">
|
|
||||||
ID
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-sm font-medium text-gray-500">
|
|
||||||
Title
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{documents.map((doc) => (
|
|
||||||
<tr key={doc.id}>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
||||||
{doc.id}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
||||||
{doc.title}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{documents.some((doc) => doc.suggested_title) && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-700">
|
|
||||||
Review and Edit Suggested Titles
|
|
||||||
</h2>
|
|
||||||
<div className="bg-white shadow rounded-md overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-500">
|
|
||||||
ID
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-500">
|
|
||||||
Original Title
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-500">
|
|
||||||
Suggested Title
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-500">
|
|
||||||
Suggested Tags
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{documents.map(
|
|
||||||
(doc) =>
|
|
||||||
doc.suggested_title && (
|
|
||||||
<tr key={doc.id}>
|
|
||||||
<td className="px-4 py-3 text-sm text-gray-500">
|
|
||||||
{doc.id}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-gray-900">
|
|
||||||
{doc.title}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-gray-900">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={doc.suggested_title}
|
|
||||||
onChange={(e) => {
|
|
||||||
const updatedDocuments = documents.map((d) =>
|
|
||||||
d.id === doc.id
|
|
||||||
? { ...d, suggested_title: e.target.value }
|
|
||||||
: d
|
|
||||||
);
|
|
||||||
setDocuments(updatedDocuments);
|
|
||||||
}}
|
|
||||||
className="w-full border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-gray-900">
|
|
||||||
<ReactTags
|
|
||||||
selected={doc.suggested_tags || []}
|
|
||||||
suggestions={availableTags}
|
|
||||||
|
|
||||||
onAdd={(tag) => {
|
|
||||||
const updatedTags = [...(doc.suggested_tags || []), { value: tag.value as string, label: tag.label }];
|
|
||||||
const updatedDocuments = documents.map((d) =>
|
|
||||||
d.id === doc.id
|
|
||||||
? { ...d, suggested_tags: updatedTags }
|
|
||||||
: d
|
|
||||||
);
|
|
||||||
setDocuments(updatedDocuments);
|
|
||||||
}}
|
|
||||||
onDelete={(i) => {
|
|
||||||
const updatedTags = doc.suggested_tags?.filter(
|
|
||||||
(_, index) => index !== i
|
|
||||||
);
|
|
||||||
const updatedDocuments = documents.map((d) =>
|
|
||||||
d.id === doc.id
|
|
||||||
? { ...d, suggested_tags: updatedTags }
|
|
||||||
: d
|
|
||||||
);
|
|
||||||
setDocuments(updatedDocuments);
|
|
||||||
}}
|
|
||||||
allowNew={false}
|
|
||||||
placeholderText="Add a tag"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end space-x-4">
|
|
||||||
<button
|
|
||||||
onClick={resetSuggestions}
|
|
||||||
className="bg-gray-200 text-gray-700 px-4 py-2 rounded hover:bg-gray-300 focus:outline-none"
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleUpdateDocuments}
|
|
||||||
disabled={updating}
|
|
||||||
className={`${
|
|
||||||
updating ? "bg-green-400" : "bg-green-600 hover:bg-green-700"
|
|
||||||
} text-white px-4 py-2 rounded focus:outline-none`}
|
|
||||||
>
|
|
||||||
{updating ? "Updating..." : "Apply Suggestions"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Transition show={successModalOpen} as={Fragment}>
|
|
||||||
<Dialog
|
|
||||||
as="div"
|
|
||||||
static
|
|
||||||
className="fixed z-10 inset-0 overflow-y-auto"
|
|
||||||
open={successModalOpen}
|
|
||||||
onClose={setSuccessModalOpen}
|
|
||||||
>
|
|
||||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
|
||||||
<TransitionChild
|
|
||||||
as="div"
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
|
||||||
</TransitionChild>
|
|
||||||
|
|
||||||
<span
|
|
||||||
className="hidden sm:inline-block sm:align-middle sm:h-screen"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
​
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<TransitionChild
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
|
||||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
|
||||||
>
|
|
||||||
<div className="inline-block align-bottom bg-white rounded-lg px-6 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
|
||||||
<div className="sm:flex sm:items-start">
|
|
||||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-green-100 sm:mx-0 sm:h-12 sm:w-12">
|
|
||||||
<CheckCircleIcon
|
|
||||||
className="h-6 w-6 text-green-600"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
|
||||||
<DialogTitle
|
|
||||||
as="h3"
|
|
||||||
className="text-lg leading-6 font-medium text-gray-900"
|
|
||||||
>
|
|
||||||
Documents Updated
|
|
||||||
</DialogTitle>
|
|
||||||
<div className="mt-2">
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
The documents have been successfully updated with the
|
|
||||||
new titles.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setSuccessModalOpen(false);
|
|
||||||
setDocuments([]);
|
|
||||||
setLoading(true);
|
|
||||||
axios.get("/api/documents").then((response) => {
|
|
||||||
setDocuments(response.data);
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-green-600 text-base font-medium text-white hover:bg-green-700 focus:outline-none sm:ml-3 sm:w-auto sm:text-sm"
|
|
||||||
>
|
|
||||||
OK
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TransitionChild>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DocumentProcessor;
|
|
77
web-app/src/components/DocumentsToProcess.tsx
Normal file
77
web-app/src/components/DocumentsToProcess.tsx
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
import ArrowPathIcon from "@heroicons/react/24/outline/ArrowPathIcon";
|
||||||
|
import React from "react";
|
||||||
|
import { Document } from "../DocumentProcessor";
|
||||||
|
import DocumentCard from "./DocumentCard";
|
||||||
|
|
||||||
|
interface DocumentsToProcessProps {
|
||||||
|
documents: Document[];
|
||||||
|
generateTitles: boolean;
|
||||||
|
setGenerateTitles: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
generateTags: boolean;
|
||||||
|
setGenerateTags: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
onProcess: () => void;
|
||||||
|
processing: boolean;
|
||||||
|
onReload: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DocumentsToProcess: React.FC<DocumentsToProcessProps> = ({
|
||||||
|
documents,
|
||||||
|
generateTitles,
|
||||||
|
setGenerateTitles,
|
||||||
|
generateTags,
|
||||||
|
setGenerateTags,
|
||||||
|
onProcess,
|
||||||
|
processing,
|
||||||
|
onReload,
|
||||||
|
}) => (
|
||||||
|
<section>
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-700 dark:text-gray-200">Documents to Process</h2>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={onReload}
|
||||||
|
disabled={processing}
|
||||||
|
className="bg-blue-600 text-white dark:bg-blue-800 dark:text-gray-200 px-4 py-2 rounded hover:bg-blue-700 dark:hover:bg-blue-900 focus:outline-none"
|
||||||
|
>
|
||||||
|
<ArrowPathIcon className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onProcess}
|
||||||
|
disabled={processing}
|
||||||
|
className="bg-blue-600 text-white dark:bg-blue-800 dark:text-gray-200 px-4 py-2 rounded hover:bg-blue-700 dark:hover:bg-blue-900 focus:outline-none"
|
||||||
|
>
|
||||||
|
{processing ? "Processing..." : "Generate Suggestions"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-4 mb-6">
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={generateTitles}
|
||||||
|
onChange={(e) => setGenerateTitles(e.target.checked)}
|
||||||
|
className="dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
<span className="text-gray-700 dark:text-gray-200">Generate Titles</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={generateTags}
|
||||||
|
onChange={(e) => setGenerateTags(e.target.checked)}
|
||||||
|
className="dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
<span className="text-gray-700 dark:text-gray-200">Generate Tags</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{documents.map((doc) => (
|
||||||
|
<DocumentCard key={doc.id} document={doc} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default DocumentsToProcess;
|
36
web-app/src/components/NoDocuments.tsx
Normal file
36
web-app/src/components/NoDocuments.tsx
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import { ArrowPathIcon } from "@heroicons/react/24/outline";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface NoDocumentsProps {
|
||||||
|
filterTag: string | null;
|
||||||
|
onReload: () => void;
|
||||||
|
processing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NoDocuments: React.FC<NoDocumentsProps> = ({
|
||||||
|
filterTag,
|
||||||
|
onReload,
|
||||||
|
processing,
|
||||||
|
}) => (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200">
|
||||||
|
<p className="text-xl font-semibold mb-4">
|
||||||
|
No documents found with filter tag{" "}
|
||||||
|
{filterTag && (
|
||||||
|
<span className="bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 text-sm font-medium px-2.5 py-0.5 rounded-full">
|
||||||
|
{filterTag}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onReload}
|
||||||
|
disabled={processing}
|
||||||
|
className="flex items-center bg-blue-600 dark:bg-blue-800 text-white dark:text-gray-200 px-4 py-2 rounded hover:bg-blue-700 dark:hover:bg-blue-900 focus:outline-none"
|
||||||
|
>
|
||||||
|
Reload
|
||||||
|
<ArrowPathIcon className="h-5 w-5 ml-2" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default NoDocuments;
|
84
web-app/src/components/SuccessModal.tsx
Normal file
84
web-app/src/components/SuccessModal.tsx
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
import { Dialog, DialogTitle, Transition } from "@headlessui/react";
|
||||||
|
import { CheckCircleIcon } from "@heroicons/react/24/outline";
|
||||||
|
import React, { Fragment } from "react";
|
||||||
|
|
||||||
|
interface SuccessModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SuccessModal: React.FC<SuccessModalProps> = ({ isOpen, onClose }) => (
|
||||||
|
<Transition show={isOpen} as={Fragment}>
|
||||||
|
<Dialog
|
||||||
|
as="div"
|
||||||
|
className="fixed z-10 inset-0 overflow-y-auto"
|
||||||
|
open={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-gray-500 dark:bg-gray-800 bg-opacity-75 transition-opacity" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className="hidden sm:inline-block sm:align-middle sm:h-screen"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
​
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
>
|
||||||
|
<div className="inline-block align-bottom bg-white dark:bg-gray-900 rounded-lg px-6 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||||
|
<div className="sm:flex sm:items-start">
|
||||||
|
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 dark:bg-green-900 sm:mx-0 sm:h-12 sm:w-12">
|
||||||
|
<CheckCircleIcon
|
||||||
|
className="h-6 w-6 text-green-600 dark:text-green-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||||
|
<DialogTitle
|
||||||
|
as="h3"
|
||||||
|
className="text-lg leading-6 font-medium text-gray-900 dark:text-gray-200"
|
||||||
|
>
|
||||||
|
Documents Updated
|
||||||
|
</DialogTitle>
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
The documents have been successfully updated with the new titles and tags.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-green-600 dark:bg-green-700 text-base font-medium text-white hover:bg-green-700 dark:hover:bg-green-800 focus:outline-none sm:ml-3 sm:w-auto sm:text-sm"
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default SuccessModal;
|
106
web-app/src/components/SuggestionCard.tsx
Normal file
106
web-app/src/components/SuggestionCard.tsx
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
import React from "react";
|
||||||
|
import { ReactTags } from "react-tag-autocomplete";
|
||||||
|
import { DocumentSuggestion, TagOption } from "../DocumentProcessor";
|
||||||
|
|
||||||
|
interface SuggestionCardProps {
|
||||||
|
suggestion: DocumentSuggestion;
|
||||||
|
availableTags: TagOption[];
|
||||||
|
onTitleChange: (docId: number, title: string) => void;
|
||||||
|
onTagAddition: (docId: number, tag: TagOption) => void;
|
||||||
|
onTagDeletion: (docId: number, index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SuggestionCard: React.FC<SuggestionCardProps> = ({
|
||||||
|
suggestion,
|
||||||
|
availableTags,
|
||||||
|
onTitleChange,
|
||||||
|
onTagAddition,
|
||||||
|
onTagDeletion,
|
||||||
|
}) => {
|
||||||
|
const document = suggestion.original_document;
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow-lg shadow-blue-500/50 rounded-md p-4 relative flex flex-col justify-between h-full">
|
||||||
|
<div className="flex items-center group relative">
|
||||||
|
<div className="relative">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
||||||
|
{document.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2 truncate">
|
||||||
|
{document.content.length > 40
|
||||||
|
? `${document.content.substring(0, 40)}...`
|
||||||
|
: document.content}
|
||||||
|
</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
{document.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center p-4 rounded-md">
|
||||||
|
<div className="text-sm text-white p-2 bg-gray-800 dark:bg-gray-900 rounded-md w-full max-h-full overflow-y-auto">
|
||||||
|
<p className="mt-2 whitespace-pre-wrap">{document.content}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={suggestion.suggested_title || ""}
|
||||||
|
onChange={(e) => onTitleChange(suggestion.id, e.target.value)}
|
||||||
|
className="w-full border border-gray-300 dark:border-gray-600 rounded px-2 py-1 mt-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-gray-200"
|
||||||
|
/>
|
||||||
|
<div className="mt-4">
|
||||||
|
<ReactTags
|
||||||
|
selected={
|
||||||
|
suggestion.suggested_tags?.map((tag, index) => ({
|
||||||
|
id: index.toString(),
|
||||||
|
name: tag,
|
||||||
|
label: tag,
|
||||||
|
value: index.toString(),
|
||||||
|
})) || []
|
||||||
|
}
|
||||||
|
suggestions={availableTags.map((tag) => ({
|
||||||
|
id: tag.id,
|
||||||
|
name: tag.name,
|
||||||
|
label: tag.name,
|
||||||
|
value: tag.id,
|
||||||
|
}))}
|
||||||
|
onAdd={(tag) =>
|
||||||
|
onTagAddition(suggestion.id, {
|
||||||
|
id: String(tag.label),
|
||||||
|
name: String(tag.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onDelete={(index) => onTagDeletion(suggestion.id, index)}
|
||||||
|
allowNew={true}
|
||||||
|
placeholderText="Add a tag"
|
||||||
|
classNames={{
|
||||||
|
root: "react-tags dark:bg-gray-800",
|
||||||
|
rootIsActive: "is-active",
|
||||||
|
rootIsDisabled: "is-disabled",
|
||||||
|
rootIsInvalid: "is-invalid",
|
||||||
|
label: "react-tags__label",
|
||||||
|
tagList: "react-tags__list",
|
||||||
|
tagListItem: "react-tags__list-item",
|
||||||
|
tag: "react-tags__tag dark:bg-blue-900 dark:text-blue-200",
|
||||||
|
tagName: "react-tags__tag-name",
|
||||||
|
comboBox: "react-tags__combobox dark:bg-gray-700 dark:text-gray-200",
|
||||||
|
input: "react-tags__combobox-input dark:bg-gray-700 dark:text-gray-200",
|
||||||
|
listBox: "react-tags__listbox dark:bg-gray-700 dark:text-gray-200",
|
||||||
|
option: "react-tags__listbox-option dark:bg-gray-700 dark:text-gray-200 hover:bg-blue-500 dark:hover:bg-blue-800",
|
||||||
|
optionIsActive: "is-active",
|
||||||
|
highlight: "react-tags__highlight dark:bg-gray-800",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SuggestionCard;
|
64
web-app/src/components/SuggestionsReview.tsx
Normal file
64
web-app/src/components/SuggestionsReview.tsx
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
import React from "react";
|
||||||
|
import { DocumentSuggestion, TagOption } from "../DocumentProcessor";
|
||||||
|
import SuggestionCard from "./SuggestionCard";
|
||||||
|
|
||||||
|
interface SuggestionsReviewProps {
|
||||||
|
suggestions: DocumentSuggestion[];
|
||||||
|
availableTags: TagOption[];
|
||||||
|
onTitleChange: (docId: number, title: string) => void;
|
||||||
|
onTagAddition: (docId: number, tag: TagOption) => void;
|
||||||
|
onTagDeletion: (docId: number, index: number) => void;
|
||||||
|
onBack: () => void;
|
||||||
|
onUpdate: () => void;
|
||||||
|
updating: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SuggestionsReview: React.FC<SuggestionsReviewProps> = ({
|
||||||
|
suggestions,
|
||||||
|
availableTags,
|
||||||
|
onTitleChange,
|
||||||
|
onTagAddition,
|
||||||
|
onTagDeletion,
|
||||||
|
onBack,
|
||||||
|
onUpdate,
|
||||||
|
updating,
|
||||||
|
}) => (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-6">
|
||||||
|
Review and Edit Suggested Titles
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{suggestions.map((doc) => (
|
||||||
|
<SuggestionCard
|
||||||
|
key={doc.id}
|
||||||
|
suggestion={doc}
|
||||||
|
availableTags={availableTags}
|
||||||
|
onTitleChange={onTitleChange}
|
||||||
|
onTagAddition={onTagAddition}
|
||||||
|
onTagDeletion={onTagDeletion}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end space-x-4 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 px-4 py-2 rounded hover:bg-gray-300 dark:hover:bg-gray-600 focus:outline-none"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onUpdate}
|
||||||
|
disabled={updating}
|
||||||
|
className={`${
|
||||||
|
updating
|
||||||
|
? "bg-green-400 dark:bg-green-600 cursor-not-allowed"
|
||||||
|
: "bg-green-600 dark:bg-green-700 hover:bg-green-700 dark:hover:bg-green-800"
|
||||||
|
} text-white px-4 py-2 rounded focus:outline-none`}
|
||||||
|
>
|
||||||
|
{updating ? "Updating..." : "Apply Suggestions"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default SuggestionsReview;
|
|
@ -1 +1 @@
|
||||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/documentprocessor.tsx"],"version":"5.6.2"}
|
{"root":["./src/app.tsx","./src/documentprocessor.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/documentcard.tsx","./src/components/documentstoprocess.tsx","./src/components/nodocuments.tsx","./src/components/successmodal.tsx","./src/components/suggestioncard.tsx","./src/components/suggestionsreview.tsx"],"version":"5.6.2"}
|
Loading…
Reference in a new issue