mirror of
https://github.com/arc53/DocsGPT.git
synced 2025-11-29 08:33:20 +00:00
Merge pull request #1624 from siiddhantt/feat/edit-chunks
feat: view chunks for docs and add/delete them
This commit is contained in:
@@ -1506,7 +1506,7 @@ class GetFeedbackAnalytics(Resource):
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error getting API key: {err}")
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
|
||||
|
||||
end_date = datetime.datetime.now(datetime.timezone.utc)
|
||||
|
||||
if filter_option == "last_hour":
|
||||
@@ -1553,11 +1553,8 @@ class GetFeedbackAnalytics(Resource):
|
||||
{"$match": {"queries.feedback": {"$exists": True}}},
|
||||
{
|
||||
"$group": {
|
||||
"_id": {
|
||||
"time": date_field,
|
||||
"feedback": "$queries.feedback"
|
||||
},
|
||||
"count": {"$sum": 1}
|
||||
"_id": {"time": date_field, "feedback": "$queries.feedback"},
|
||||
"count": {"$sum": 1},
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1568,7 +1565,7 @@ class GetFeedbackAnalytics(Resource):
|
||||
"$cond": [
|
||||
{"$eq": ["$_id.feedback", "LIKE"]},
|
||||
"$count",
|
||||
0
|
||||
0,
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -1577,13 +1574,13 @@ class GetFeedbackAnalytics(Resource):
|
||||
"$cond": [
|
||||
{"$eq": ["$_id.feedback", "DISLIKE"]},
|
||||
"$count",
|
||||
0
|
||||
0,
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
{"$sort": {"_id": 1}}
|
||||
{"$sort": {"_id": 1}},
|
||||
]
|
||||
|
||||
feedback_data = conversations_collection.aggregate(pipeline)
|
||||
@@ -1602,7 +1599,7 @@ class GetFeedbackAnalytics(Resource):
|
||||
for entry in feedback_data:
|
||||
daily_feedback[entry["_id"]] = {
|
||||
"positive": entry["positive"],
|
||||
"negative": entry["negative"]
|
||||
"negative": entry["negative"],
|
||||
}
|
||||
|
||||
except Exception as err:
|
||||
@@ -2071,3 +2068,199 @@ class DeleteTool(Resource):
|
||||
return {"success": False}, 400
|
||||
|
||||
return {"success": True}, 200
|
||||
|
||||
|
||||
def get_vector_store(source_id):
|
||||
"""
|
||||
Get the Vector Store
|
||||
Args:
|
||||
source_id (str): source id of the document
|
||||
"""
|
||||
|
||||
store = VectorCreator.create_vectorstore(
|
||||
settings.VECTOR_STORE,
|
||||
source_id=source_id,
|
||||
embeddings_key=os.getenv("EMBEDDINGS_KEY"),
|
||||
)
|
||||
return store
|
||||
|
||||
|
||||
@user_ns.route("/api/get_chunks")
|
||||
class GetChunks(Resource):
|
||||
@api.doc(
|
||||
description="Retrieves all chunks associated with a document",
|
||||
params={"id": "The document ID"},
|
||||
)
|
||||
def get(self):
|
||||
doc_id = request.args.get("id")
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = int(request.args.get("per_page", 10))
|
||||
|
||||
if not ObjectId.is_valid(doc_id):
|
||||
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
|
||||
|
||||
try:
|
||||
store = get_vector_store(doc_id)
|
||||
chunks = store.get_chunks()
|
||||
total_chunks = len(chunks)
|
||||
start = (page - 1) * per_page
|
||||
end = start + per_page
|
||||
paginated_chunks = chunks[start:end]
|
||||
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total": total_chunks,
|
||||
"chunks": paginated_chunks,
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return make_response(jsonify({"error": str(e)}), 500)
|
||||
|
||||
|
||||
@user_ns.route("/api/add_chunk")
|
||||
class AddChunk(Resource):
|
||||
@api.expect(
|
||||
api.model(
|
||||
"AddChunkModel",
|
||||
{
|
||||
"id": fields.String(required=True, description="Document ID"),
|
||||
"text": fields.String(required=True, description="Text of the chunk"),
|
||||
"metadata": fields.Raw(
|
||||
required=False,
|
||||
description="Metadata associated with the chunk",
|
||||
),
|
||||
},
|
||||
)
|
||||
)
|
||||
@api.doc(
|
||||
description="Adds a new chunk to the document",
|
||||
)
|
||||
def post(self):
|
||||
data = request.get_json()
|
||||
required_fields = ["id", "text"]
|
||||
missing_fields = check_required_fields(data, required_fields)
|
||||
if missing_fields:
|
||||
return missing_fields
|
||||
|
||||
doc_id = data.get("id")
|
||||
text = data.get("text")
|
||||
metadata = data.get("metadata", {})
|
||||
|
||||
if not ObjectId.is_valid(doc_id):
|
||||
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
|
||||
|
||||
try:
|
||||
store = get_vector_store(doc_id)
|
||||
chunk_id = store.add_chunk(text, metadata)
|
||||
return make_response(
|
||||
jsonify({"message": "Chunk added successfully", "chunk_id": chunk_id}),
|
||||
201,
|
||||
)
|
||||
except Exception as e:
|
||||
return make_response(jsonify({"error": str(e)}), 500)
|
||||
|
||||
|
||||
@user_ns.route("/api/delete_chunk")
|
||||
class DeleteChunk(Resource):
|
||||
@api.doc(
|
||||
description="Deletes a specific chunk from the document.",
|
||||
params={"id": "The document ID", "chunk_id": "The ID of the chunk to delete"},
|
||||
)
|
||||
def delete(self):
|
||||
doc_id = request.args.get("id")
|
||||
chunk_id = request.args.get("chunk_id")
|
||||
|
||||
if not ObjectId.is_valid(doc_id):
|
||||
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
|
||||
|
||||
try:
|
||||
store = get_vector_store(doc_id)
|
||||
deleted = store.delete_chunk(chunk_id)
|
||||
if deleted:
|
||||
return make_response(
|
||||
jsonify({"message": "Chunk deleted successfully"}), 200
|
||||
)
|
||||
else:
|
||||
return make_response(
|
||||
jsonify({"message": "Chunk not found or could not be deleted"}),
|
||||
404,
|
||||
)
|
||||
except Exception as e:
|
||||
return make_response(jsonify({"error": str(e)}), 500)
|
||||
|
||||
|
||||
@user_ns.route("/api/update_chunk")
|
||||
class UpdateChunk(Resource):
|
||||
@api.expect(
|
||||
api.model(
|
||||
"UpdateChunkModel",
|
||||
{
|
||||
"id": fields.String(required=True, description="Document ID"),
|
||||
"chunk_id": fields.String(
|
||||
required=True, description="Chunk ID to update"
|
||||
),
|
||||
"text": fields.String(
|
||||
required=False, description="New text of the chunk"
|
||||
),
|
||||
"metadata": fields.Raw(
|
||||
required=False,
|
||||
description="Updated metadata associated with the chunk",
|
||||
),
|
||||
},
|
||||
)
|
||||
)
|
||||
@api.doc(
|
||||
description="Updates an existing chunk in the document.",
|
||||
)
|
||||
def put(self):
|
||||
data = request.get_json()
|
||||
required_fields = ["id", "chunk_id"]
|
||||
missing_fields = check_required_fields(data, required_fields)
|
||||
if missing_fields:
|
||||
return missing_fields
|
||||
|
||||
doc_id = data.get("id")
|
||||
chunk_id = data.get("chunk_id")
|
||||
text = data.get("text")
|
||||
metadata = data.get("metadata")
|
||||
|
||||
if not ObjectId.is_valid(doc_id):
|
||||
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
|
||||
|
||||
try:
|
||||
store = get_vector_store(doc_id)
|
||||
chunks = store.get_chunks()
|
||||
existing_chunk = next((c for c in chunks if c["doc_id"] == chunk_id), None)
|
||||
if not existing_chunk:
|
||||
return make_response(jsonify({"error": "Chunk not found"}), 404)
|
||||
|
||||
deleted = store.delete_chunk(chunk_id)
|
||||
if not deleted:
|
||||
return make_response(
|
||||
jsonify({"error": "Failed to delete existing chunk"}), 500
|
||||
)
|
||||
|
||||
new_text = text if text is not None else existing_chunk["text"]
|
||||
new_metadata = (
|
||||
metadata if metadata is not None else existing_chunk["metadata"]
|
||||
)
|
||||
|
||||
new_chunk_id = store.add_chunk(new_text, new_metadata)
|
||||
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"message": "Chunk updated successfully",
|
||||
"new_chunk_id": new_chunk_id,
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
except Exception as e:
|
||||
return make_response(jsonify({"error": str(e)}), 500)
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
from langchain_community.vectorstores import FAISS
|
||||
from application.vectorstore.base import BaseVectorStore
|
||||
from application.core.settings import settings
|
||||
import os
|
||||
|
||||
from langchain_community.vectorstores import FAISS
|
||||
|
||||
from application.core.settings import settings
|
||||
from application.parser.schema.base import Document
|
||||
from application.vectorstore.base import BaseVectorStore
|
||||
|
||||
|
||||
def get_vectorstore(path: str) -> str:
|
||||
if path:
|
||||
vectorstore = os.path.join("application", "indexes", path)
|
||||
@@ -10,21 +14,25 @@ def get_vectorstore(path: str) -> str:
|
||||
vectorstore = os.path.join("application")
|
||||
return vectorstore
|
||||
|
||||
|
||||
class FaissStore(BaseVectorStore):
|
||||
def __init__(self, source_id: str, embeddings_key: str, docs_init=None):
|
||||
super().__init__()
|
||||
self.source_id = source_id
|
||||
self.path = get_vectorstore(source_id)
|
||||
embeddings = self._get_embeddings(settings.EMBEDDINGS_NAME, embeddings_key)
|
||||
self.embeddings = self._get_embeddings(settings.EMBEDDINGS_NAME, embeddings_key)
|
||||
|
||||
try:
|
||||
if docs_init:
|
||||
self.docsearch = FAISS.from_documents(docs_init, embeddings)
|
||||
self.docsearch = FAISS.from_documents(docs_init, self.embeddings)
|
||||
else:
|
||||
self.docsearch = FAISS.load_local(self.path, embeddings, allow_dangerous_deserialization=True)
|
||||
self.docsearch = FAISS.load_local(
|
||||
self.path, self.embeddings, allow_dangerous_deserialization=True
|
||||
)
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
self.assert_embedding_dimensions(embeddings)
|
||||
self.assert_embedding_dimensions(self.embeddings)
|
||||
|
||||
def search(self, *args, **kwargs):
|
||||
return self.docsearch.similarity_search(*args, **kwargs)
|
||||
@@ -40,11 +48,42 @@ class FaissStore(BaseVectorStore):
|
||||
|
||||
def assert_embedding_dimensions(self, embeddings):
|
||||
"""Check that the word embedding dimension of the docsearch index matches the dimension of the word embeddings used."""
|
||||
if settings.EMBEDDINGS_NAME == "huggingface_sentence-transformers/all-mpnet-base-v2":
|
||||
word_embedding_dimension = getattr(embeddings, 'dimension', None)
|
||||
if (
|
||||
settings.EMBEDDINGS_NAME
|
||||
== "huggingface_sentence-transformers/all-mpnet-base-v2"
|
||||
):
|
||||
word_embedding_dimension = getattr(embeddings, "dimension", None)
|
||||
if word_embedding_dimension is None:
|
||||
raise AttributeError("'dimension' attribute not found in embeddings instance.")
|
||||
|
||||
raise AttributeError(
|
||||
"'dimension' attribute not found in embeddings instance."
|
||||
)
|
||||
|
||||
docsearch_index_dimension = self.docsearch.index.d
|
||||
if word_embedding_dimension != docsearch_index_dimension:
|
||||
raise ValueError(f"Embedding dimension mismatch: embeddings.dimension ({word_embedding_dimension}) != docsearch index dimension ({docsearch_index_dimension})")
|
||||
raise ValueError(
|
||||
f"Embedding dimension mismatch: embeddings.dimension ({word_embedding_dimension}) != docsearch index dimension ({docsearch_index_dimension})"
|
||||
)
|
||||
|
||||
def get_chunks(self):
|
||||
chunks = []
|
||||
if self.docsearch:
|
||||
for doc_id, doc in self.docsearch.docstore._dict.items():
|
||||
chunk_data = {
|
||||
"doc_id": doc_id,
|
||||
"text": doc.page_content,
|
||||
"metadata": doc.metadata,
|
||||
}
|
||||
chunks.append(chunk_data)
|
||||
return chunks
|
||||
|
||||
def add_chunk(self, text, metadata=None):
|
||||
metadata = metadata or {}
|
||||
doc = Document(text=text, extra_info=metadata).to_langchain_format()
|
||||
doc_id = self.docsearch.add_documents([doc])
|
||||
self.save_local(self.path)
|
||||
return doc_id
|
||||
|
||||
def delete_chunk(self, chunk_id):
|
||||
self.delete_index([chunk_id])
|
||||
self.save_local(self.path)
|
||||
return True
|
||||
|
||||
@@ -124,3 +124,53 @@ class MongoDBVectorStore(BaseVectorStore):
|
||||
|
||||
def delete_index(self, *args, **kwargs):
|
||||
self._collection.delete_many({"source_id": self._source_id})
|
||||
|
||||
def get_chunks(self):
|
||||
try:
|
||||
chunks = []
|
||||
cursor = self._collection.find({"source_id": self._source_id})
|
||||
for doc in cursor:
|
||||
doc_id = str(doc.get("_id"))
|
||||
text = doc.get(self._text_key)
|
||||
metadata = {
|
||||
k: v
|
||||
for k, v in doc.items()
|
||||
if k
|
||||
not in ["_id", self._text_key, self._embedding_key, "source_id"]
|
||||
}
|
||||
|
||||
if text:
|
||||
chunks.append(
|
||||
{"doc_id": doc_id, "text": text, "metadata": metadata}
|
||||
)
|
||||
|
||||
return chunks
|
||||
except Exception as e:
|
||||
print(f"Error getting chunks: {e}")
|
||||
return []
|
||||
|
||||
def add_chunk(self, text, metadata=None):
|
||||
metadata = metadata or {}
|
||||
embeddings = self._embedding.embed_documents([text])
|
||||
if not embeddings:
|
||||
raise ValueError("Could not generate embedding for chunk")
|
||||
|
||||
chunk_data = {
|
||||
self._text_key: text,
|
||||
self._embedding_key: embeddings[0],
|
||||
"source_id": self._source_id,
|
||||
**metadata,
|
||||
}
|
||||
result = self._collection.insert_one(chunk_data)
|
||||
return str(result.inserted_id)
|
||||
|
||||
def delete_chunk(self, chunk_id):
|
||||
try:
|
||||
from bson.objectid import ObjectId
|
||||
|
||||
object_id = ObjectId(chunk_id)
|
||||
result = self._collection.delete_one({"_id": object_id})
|
||||
return result.deleted_count > 0
|
||||
except Exception as e:
|
||||
print(f"Error deleting chunk: {e}")
|
||||
return False
|
||||
|
||||
@@ -24,6 +24,12 @@ const endpoints = {
|
||||
UPDATE_TOOL_STATUS: '/api/update_tool_status',
|
||||
UPDATE_TOOL: '/api/update_tool',
|
||||
DELETE_TOOL: '/api/delete_tool',
|
||||
GET_CHUNKS: (docId: string, page: number, per_page: number) =>
|
||||
`/api/get_chunks?id=${docId}&page=${page}&per_page=${per_page}`,
|
||||
ADD_CHUNK: '/api/add_chunk',
|
||||
DELETE_CHUNK: (docId: string, chunkId: string) =>
|
||||
`/api/delete_chunk?id=${docId}&chunk_id=${chunkId}`,
|
||||
UPDATE_CHUNK: '/api/update_chunk',
|
||||
},
|
||||
CONVERSATION: {
|
||||
ANSWER: '/api/answer',
|
||||
|
||||
@@ -47,6 +47,18 @@ const userService = {
|
||||
apiClient.post(endpoints.USER.UPDATE_TOOL, data),
|
||||
deleteTool: (data: any): Promise<any> =>
|
||||
apiClient.post(endpoints.USER.DELETE_TOOL, data),
|
||||
getDocumentChunks: (
|
||||
docId: string,
|
||||
page: number,
|
||||
perPage: number,
|
||||
): Promise<any> =>
|
||||
apiClient.get(endpoints.USER.GET_CHUNKS(docId, page, perPage)),
|
||||
addChunk: (data: any): Promise<any> =>
|
||||
apiClient.post(endpoints.USER.ADD_CHUNK, data),
|
||||
deleteChunk: (docId: string, chunkId: string): Promise<any> =>
|
||||
apiClient.delete(endpoints.USER.DELETE_CHUNK(docId, chunkId)),
|
||||
updateChunk: (data: any): Promise<any> =>
|
||||
apiClient.put(endpoints.USER.UPDATE_CHUNK, data),
|
||||
};
|
||||
|
||||
export default userService;
|
||||
|
||||
43
frontend/src/components/Spinner.tsx
Normal file
43
frontend/src/components/Spinner.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
|
||||
type SpinnerProps = {
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export default function Spinner({
|
||||
size = 'medium',
|
||||
color = 'grey',
|
||||
}: SpinnerProps) {
|
||||
const sizeMap = {
|
||||
small: '20px',
|
||||
medium: '30px',
|
||||
large: '40px',
|
||||
};
|
||||
const spinnerSize = sizeMap[size];
|
||||
|
||||
const spinnerStyle = {
|
||||
width: spinnerSize,
|
||||
height: spinnerSize,
|
||||
aspectRatio: '1',
|
||||
borderRadius: '50%',
|
||||
background: `
|
||||
radial-gradient(farthest-side, ${color} 94%, #0000) top/8px 8px no-repeat,
|
||||
conic-gradient(#0000 30%, ${color})
|
||||
`,
|
||||
WebkitMask:
|
||||
'radial-gradient(farthest-side, #0000 calc(100% - 8px), #000 0)',
|
||||
animation: 'l13 1s infinite linear',
|
||||
} as React.CSSProperties;
|
||||
|
||||
const keyframesStyle = `@keyframes l13 {
|
||||
100% { transform: rotate(1turn) }
|
||||
}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{keyframesStyle}</style>
|
||||
<div className="loader" style={spinnerStyle} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export default function AddActionModal({
|
||||
New Action
|
||||
</h2>
|
||||
<div className="mt-6 relative px-3">
|
||||
<span className="absolute left-5 -top-2 bg-white px-2 text-xs text-gray-4000 dark:bg-[#26272E] dark:text-silver">
|
||||
<span className="z-10 absolute left-5 -top-2 bg-white px-2 text-xs text-gray-4000 dark:bg-[#26272E] dark:text-silver">
|
||||
Action Name
|
||||
</span>
|
||||
<Input
|
||||
|
||||
193
frontend/src/modals/ChunkModal.tsx
Normal file
193
frontend/src/modals/ChunkModal.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import React from 'react';
|
||||
|
||||
import Exit from '../assets/exit.svg';
|
||||
import Input from '../components/Input';
|
||||
import { ActiveState } from '../models/misc';
|
||||
import ConfirmationModal from './ConfirmationModal';
|
||||
|
||||
export default function ChunkModal({
|
||||
type,
|
||||
modalState,
|
||||
setModalState,
|
||||
handleSubmit,
|
||||
originalTitle,
|
||||
originalText,
|
||||
handleDelete,
|
||||
}: {
|
||||
type: 'ADD' | 'EDIT';
|
||||
modalState: ActiveState;
|
||||
setModalState: (state: ActiveState) => void;
|
||||
handleSubmit: (title: string, text: string) => void;
|
||||
originalTitle?: string;
|
||||
originalText?: string;
|
||||
handleDelete?: () => void;
|
||||
}) {
|
||||
const [title, setTitle] = React.useState('');
|
||||
const [chunkText, setChunkText] = React.useState('');
|
||||
const [deleteModal, setDeleteModal] = React.useState<ActiveState>('INACTIVE');
|
||||
|
||||
React.useEffect(() => {
|
||||
setTitle(originalTitle || '');
|
||||
setChunkText(originalText || '');
|
||||
}, [originalTitle, originalText]);
|
||||
if (type === 'ADD') {
|
||||
return (
|
||||
<div
|
||||
className={`${
|
||||
modalState === 'ACTIVE' ? 'visible' : 'hidden'
|
||||
} fixed top-0 left-0 z-30 h-screen w-screen bg-gray-alpha flex items-center justify-center`}
|
||||
>
|
||||
<article className="flex w-11/12 sm:w-[620px] flex-col gap-4 rounded-2xl bg-white shadow-lg dark:bg-[#26272E]">
|
||||
<div className="relative">
|
||||
<button
|
||||
className="absolute top-3 right-4 m-2 w-3"
|
||||
onClick={() => {
|
||||
setModalState('INACTIVE');
|
||||
}}
|
||||
>
|
||||
<img className="filter dark:invert" src={Exit} />
|
||||
</button>
|
||||
<div className="p-6">
|
||||
<h2 className="font-semibold text-xl text-jet dark:text-bright-gray px-3">
|
||||
Add Chunk
|
||||
</h2>
|
||||
<div className="mt-6 relative px-3">
|
||||
<span className="z-10 absolute left-5 -top-2 bg-white px-2 text-xs text-gray-4000 dark:bg-[#26272E] dark:text-silver">
|
||||
Title
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
borderVariant="thin"
|
||||
placeholder={'Enter title'}
|
||||
></Input>
|
||||
</div>
|
||||
<div className="mt-6 relative px-3">
|
||||
<div className="pt-3 pb-1 border border-silver dark:border-silver/40 rounded-lg">
|
||||
<span className="absolute left-5 -top-2 bg-white px-2 text-xs text-gray-4000 dark:bg-[#26272E] dark:text-silver rounded-lg">
|
||||
Body text
|
||||
</span>
|
||||
<textarea
|
||||
id="chunk-body-text"
|
||||
className="h-60 w-full px-3 outline-none dark:bg-transparent dark:text-white"
|
||||
value={chunkText}
|
||||
onChange={(e) => setChunkText(e.target.value)}
|
||||
aria-label="Prompt Text"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 flex flex-row-reverse gap-1 px-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
handleSubmit(title, chunkText);
|
||||
setModalState('INACTIVE');
|
||||
}}
|
||||
className="rounded-3xl bg-purple-30 px-5 py-2 text-sm text-white transition-all hover:bg-[#6F3FD1]"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setModalState('INACTIVE');
|
||||
}}
|
||||
className="cursor-pointer rounded-3xl px-5 py-2 text-sm font-medium hover:bg-gray-100 dark:bg-transparent dark:text-light-gray dark:hover:bg-[#767183]/50"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div
|
||||
className={`${
|
||||
modalState === 'ACTIVE' ? 'visible' : 'hidden'
|
||||
} fixed top-0 left-0 z-30 h-screen w-screen bg-gray-alpha flex items-center justify-center`}
|
||||
>
|
||||
<article className="flex w-11/12 sm:w-[620px] flex-col gap-4 rounded-2xl bg-white shadow-lg dark:bg-[#26272E]">
|
||||
<div className="relative">
|
||||
<button
|
||||
className="absolute top-3 right-4 m-2 w-3"
|
||||
onClick={() => {
|
||||
setModalState('INACTIVE');
|
||||
}}
|
||||
>
|
||||
<img className="filter dark:invert" src={Exit} />
|
||||
</button>
|
||||
<div className="p-6">
|
||||
<h2 className="font-semibold text-xl text-jet dark:text-bright-gray px-3">
|
||||
Edit Chunk
|
||||
</h2>
|
||||
<div className="mt-6 relative px-3">
|
||||
<span className="z-10 absolute left-5 -top-2 bg-white px-2 text-xs text-gray-4000 dark:bg-[#26272E] dark:text-silver">
|
||||
Title
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
borderVariant="thin"
|
||||
placeholder={'Enter title'}
|
||||
></Input>
|
||||
</div>
|
||||
<div className="mt-6 relative px-3">
|
||||
<div className="pt-3 pb-1 border border-silver dark:border-silver/40 rounded-lg">
|
||||
<span className="absolute left-5 -top-2 bg-white px-2 text-xs text-gray-4000 dark:bg-[#26272E] dark:text-silver rounded-lg">
|
||||
Body text
|
||||
</span>
|
||||
<textarea
|
||||
id="chunk-body-text"
|
||||
className="h-60 w-full px-3 outline-none dark:bg-transparent dark:text-white"
|
||||
value={chunkText}
|
||||
onChange={(e) => setChunkText(e.target.value)}
|
||||
aria-label="Prompt Text"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 w-full px-3 flex items-center justify-between">
|
||||
<button
|
||||
className="rounded-full px-5 py-2 border border-solid border-red-500 text-red-500 hover:bg-red-500 hover:text-white text-nowrap text-sm"
|
||||
onClick={() => {
|
||||
setDeleteModal('ACTIVE');
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<div className="flex flex-row-reverse gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
handleSubmit(title, chunkText);
|
||||
setModalState('INACTIVE');
|
||||
}}
|
||||
className="rounded-3xl bg-purple-30 px-5 py-2 text-sm text-white transition-all hover:bg-[#6F3FD1]"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setModalState('INACTIVE');
|
||||
}}
|
||||
className="cursor-pointer rounded-3xl px-5 py-2 text-sm font-medium hover:bg-gray-100 dark:bg-transparent dark:text-light-gray dark:hover:bg-[#767183]/50"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<ConfirmationModal
|
||||
message="Are you sure you want to delete this chunk?"
|
||||
modalState={deleteModal}
|
||||
setModalState={setDeleteModal}
|
||||
handleSubmit={handleDelete ? handleDelete : () => {}}
|
||||
submitLabel="Delete"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,33 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import userService from '../api/services/userService';
|
||||
import SyncIcon from '../assets/sync.svg';
|
||||
import Trash from '../assets/trash.svg';
|
||||
import caretSort from '../assets/caret-sort.svg';
|
||||
import DropdownMenu from '../components/DropdownMenu';
|
||||
import SkeletonLoader from '../components/SkeletonLoader';
|
||||
import Input from '../components/Input';
|
||||
import Upload from '../upload/Upload'; // Import the Upload component
|
||||
import Pagination from '../components/DocumentPagination';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Doc, DocumentsProps, ActiveState } from '../models/misc'; // Ensure ActiveState type is imported
|
||||
import { getDocs, getDocsWithPagination } from '../preferences/preferenceApi';
|
||||
import { setSourceDocs } from '../preferences/preferenceSlice';
|
||||
import { setPaginatedDocuments } from '../preferences/preferenceSlice';
|
||||
import { formatDate } from '../utils/dateTimeUtils';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
|
||||
// Utility function to format numbers
|
||||
import userService from '../api/services/userService';
|
||||
import ArrowLeft from '../assets/arrow-left.svg';
|
||||
import caretSort from '../assets/caret-sort.svg';
|
||||
import Edit from '../assets/edit.svg';
|
||||
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
|
||||
import NoFilesIcon from '../assets/no-files.svg';
|
||||
import SyncIcon from '../assets/sync.svg';
|
||||
import Trash from '../assets/trash.svg';
|
||||
import Pagination from '../components/DocumentPagination';
|
||||
import DropdownMenu from '../components/DropdownMenu';
|
||||
import Input from '../components/Input';
|
||||
import SkeletonLoader from '../components/SkeletonLoader';
|
||||
import Spinner from '../components/Spinner';
|
||||
import { useDarkTheme } from '../hooks';
|
||||
import ChunkModal from '../modals/ChunkModal';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import { ActiveState, Doc, DocumentsProps } from '../models/misc';
|
||||
import { getDocs, getDocsWithPagination } from '../preferences/preferenceApi';
|
||||
import {
|
||||
setPaginatedDocuments,
|
||||
setSourceDocs,
|
||||
} from '../preferences/preferenceSlice';
|
||||
import Upload from '../upload/Upload';
|
||||
import { formatDate } from '../utils/dateTimeUtils';
|
||||
import { ChunkType } from './types';
|
||||
|
||||
const formatTokens = (tokens: number): string => {
|
||||
const roundToTwoDecimals = (num: number): string => {
|
||||
return (Math.round((num + Number.EPSILON) * 100) / 100).toString();
|
||||
@@ -35,17 +44,16 @@ const formatTokens = (tokens: number): string => {
|
||||
}
|
||||
};
|
||||
|
||||
const Documents: React.FC<DocumentsProps> = ({
|
||||
export default function Documents({
|
||||
paginatedDocuments,
|
||||
handleDeleteDocument,
|
||||
}) => {
|
||||
}: DocumentsProps) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
// State for search input
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||
// State for modal: active/inactive
|
||||
const [modalState, setModalState] = useState<ActiveState>('INACTIVE'); // Initialize with inactive state
|
||||
const [isOnboarding, setIsOnboarding] = useState<boolean>(false); // State for onboarding flag
|
||||
const [modalState, setModalState] = useState<ActiveState>('INACTIVE');
|
||||
const [isOnboarding, setIsOnboarding] = useState<boolean>(false);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [sortField, setSortField] = useState<'date' | 'tokens'>('date');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
@@ -60,6 +68,7 @@ const Documents: React.FC<DocumentsProps> = ({
|
||||
{ label: t('settings.documents.syncFrequency.weekly'), value: 'weekly' },
|
||||
{ label: t('settings.documents.syncFrequency.monthly'), value: 'monthly' },
|
||||
];
|
||||
const [showDocumentChunks, setShowDocumentChunks] = useState<Doc>();
|
||||
|
||||
const refreshDocs = useCallback(
|
||||
(
|
||||
@@ -159,7 +168,14 @@ const Documents: React.FC<DocumentsProps> = ({
|
||||
refreshDocs(undefined, 1, rowsPerPage);
|
||||
}, [searchTerm]);
|
||||
|
||||
return (
|
||||
return showDocumentChunks ? (
|
||||
<DocumentChunks
|
||||
document={showDocumentChunks}
|
||||
handleGoBack={() => {
|
||||
setShowDocumentChunks(undefined);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col mt-8">
|
||||
<div className="flex flex-col relative flex-grow">
|
||||
<div className="mb-6">
|
||||
@@ -183,6 +199,7 @@ const Documents: React.FC<DocumentsProps> = ({
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
borderVariant="thin"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@@ -256,7 +273,11 @@ const Documents: React.FC<DocumentsProps> = ({
|
||||
</tr>
|
||||
) : (
|
||||
currentDocuments.map((document, index) => (
|
||||
<tr key={index} className="group transition-colors">
|
||||
<tr
|
||||
key={index}
|
||||
className="group transition-colors"
|
||||
onClick={() => setShowDocumentChunks(document)}
|
||||
>
|
||||
<td
|
||||
className="py-4 px-4 text-sm text-gray-700 dark:text-[#E0E0E0] w-[45%] truncate group-hover:bg-gray-50 dark:group-hover:bg-gray-800/50"
|
||||
title={document.name}
|
||||
@@ -359,11 +380,260 @@ const Documents: React.FC<DocumentsProps> = ({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
Documents.propTypes = {
|
||||
//documents: PropTypes.array.isRequired,
|
||||
handleDeleteDocument: PropTypes.func.isRequired,
|
||||
};
|
||||
function DocumentChunks({
|
||||
document,
|
||||
handleGoBack,
|
||||
}: {
|
||||
document: Doc;
|
||||
handleGoBack: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkTheme] = useDarkTheme();
|
||||
const [paginatedChunks, setPaginatedChunks] = useState<ChunkType[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(5);
|
||||
const [totalChunks, setTotalChunks] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||
const [addModal, setAddModal] = useState<ActiveState>('INACTIVE');
|
||||
const [editModal, setEditModal] = useState<{
|
||||
state: ActiveState;
|
||||
chunk: ChunkType | null;
|
||||
}>({ state: 'INACTIVE', chunk: null });
|
||||
|
||||
export default Documents;
|
||||
const fetchChunks = () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
userService
|
||||
.getDocumentChunks(document.id ?? '', page, perPage)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
setLoading(false);
|
||||
setPaginatedChunks([]);
|
||||
throw new Error('Failed to fetch chunks data');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
setPage(data.page);
|
||||
setPerPage(data.per_page);
|
||||
setTotalChunks(data.total);
|
||||
setPaginatedChunks(data.chunks);
|
||||
setLoading(false);
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddChunk = (title: string, text: string) => {
|
||||
try {
|
||||
userService
|
||||
.addChunk({
|
||||
id: document.id ?? '',
|
||||
text: text,
|
||||
metadata: {
|
||||
title: title,
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to add chunk');
|
||||
}
|
||||
fetchChunks();
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateChunk = (title: string, text: string, chunk: ChunkType) => {
|
||||
try {
|
||||
userService
|
||||
.updateChunk({
|
||||
id: document.id ?? '',
|
||||
chunk_id: chunk.doc_id,
|
||||
text: text,
|
||||
metadata: {
|
||||
title: title,
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update chunk');
|
||||
}
|
||||
fetchChunks();
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteChunk = (chunk: ChunkType) => {
|
||||
try {
|
||||
userService
|
||||
.deleteChunk(document.id ?? '', chunk.doc_id)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete chunk');
|
||||
}
|
||||
setEditModal({ state: 'INACTIVE', chunk: null });
|
||||
fetchChunks();
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchChunks();
|
||||
}, [page, perPage]);
|
||||
return (
|
||||
<div className="flex flex-col mt-8">
|
||||
<div className="mb-3 flex items-center gap-3 text-eerie-black dark:text-bright-gray text-sm">
|
||||
<button
|
||||
className="text-sm text-gray-400 dark:text-gray-500 border dark:border-0 dark:bg-[#28292D] dark:hover:bg-[#2E2F34] p-3 rounded-full"
|
||||
onClick={handleGoBack}
|
||||
>
|
||||
<img src={ArrowLeft} alt="left-arrow" className="w-3 h-3" />
|
||||
</button>
|
||||
<p className="mt-px">Back to all documents</p>
|
||||
</div>
|
||||
<div className="my-3 flex justify-between items-center gap-1">
|
||||
<div className="w-full sm:w-auto flex items-center gap-2 text-eerie-black dark:text-bright-gray">
|
||||
<p className="font-semibold text-2xl hidden sm:flex">{`${totalChunks} Chunks`}</p>
|
||||
<label htmlFor="chunk-search-input" className="sr-only">
|
||||
{t('settings.documents.searchPlaceholder')}
|
||||
</label>
|
||||
<Input
|
||||
maxLength={256}
|
||||
placeholder={t('settings.documents.searchPlaceholder')}
|
||||
name="chunk-search-input"
|
||||
type="text"
|
||||
id="chunk-search-input"
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
}}
|
||||
borderVariant="thin"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-full w-full sm:w-40 bg-purple-30 px-4 py-3 text-white hover:bg-[#6F3FD1]"
|
||||
title={t('settings.documents.addNew')}
|
||||
onClick={() => setAddModal('ACTIVE')}
|
||||
>
|
||||
{t('settings.documents.addNew')}
|
||||
</button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="h-32 flex items-center justify-center mt-24 col-span-2 lg:col-span-3">
|
||||
<Spinner />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{paginatedChunks.filter((chunk) =>
|
||||
chunk.metadata?.title
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase()),
|
||||
).length === 0 ? (
|
||||
<div className="mt-24 col-span-2 lg:col-span-3 text-center text-gray-500 dark:text-gray-400">
|
||||
<img
|
||||
src={isDarkTheme ? NoFilesDarkIcon : NoFilesIcon}
|
||||
alt="No tools found"
|
||||
className="h-24 w-24 mx-auto mb-2"
|
||||
/>
|
||||
No chunks found
|
||||
</div>
|
||||
) : (
|
||||
paginatedChunks
|
||||
.filter((chunk) =>
|
||||
chunk.metadata?.title
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase()),
|
||||
)
|
||||
.map((chunk, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="relative h-56 w-full p-6 border rounded-2xl border-silver dark:border-silver/40 flex flex-col justify-between"
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<button
|
||||
aria-label={'edit'}
|
||||
onClick={() => {
|
||||
setEditModal({
|
||||
state: 'ACTIVE',
|
||||
chunk: chunk,
|
||||
});
|
||||
}}
|
||||
className="absolute top-3 right-3 h-4 w-4 cursor-pointer"
|
||||
>
|
||||
<img
|
||||
alt={'edit'}
|
||||
src={Edit}
|
||||
className="opacity-60 hover:opacity-100"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-[9px]">
|
||||
<p className="h-12 text-sm font-semibold text-eerie-black dark:text-[#EEEEEE] leading-relaxed break-words ellipsis-text">
|
||||
{chunk.metadata?.title}
|
||||
</p>
|
||||
<p className="mt-1 pr-1 h-[110px] overflow-y-auto text-[13px] text-gray-600 dark:text-gray-400 leading-relaxed break-words">
|
||||
{chunk.text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!loading &&
|
||||
paginatedChunks.filter((chunk) =>
|
||||
chunk.metadata?.title
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase()),
|
||||
).length !== 0 && (
|
||||
<div className="mt-10 w-full flex items-center justify-center">
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={Math.ceil(totalChunks / perPage)}
|
||||
rowsPerPage={perPage}
|
||||
onPageChange={(page) => {
|
||||
setPage(page);
|
||||
}}
|
||||
onRowsPerPageChange={(rows) => {
|
||||
setPerPage(rows);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ChunkModal
|
||||
type="ADD"
|
||||
modalState={addModal}
|
||||
setModalState={setAddModal}
|
||||
handleSubmit={handleAddChunk}
|
||||
/>
|
||||
<ChunkModal
|
||||
type="EDIT"
|
||||
modalState={editModal.state}
|
||||
setModalState={(state) => setEditModal((prev) => ({ ...prev, state }))}
|
||||
handleSubmit={(title, text) => {
|
||||
handleUpdateChunk(title, text, editModal.chunk as ChunkType);
|
||||
}}
|
||||
originalText={editModal.chunk?.text}
|
||||
originalTitle={editModal.chunk?.metadata?.title}
|
||||
handleDelete={() => {
|
||||
handleDeleteChunk(editModal.chunk as ChunkType);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ export default function ToolConfig({
|
||||
{Object.keys(tool?.config).length !== 0 &&
|
||||
tool.name !== 'api_tool' && (
|
||||
<div className="relative w-96">
|
||||
<span className="absolute left-5 -top-2 bg-white px-2 text-xs text-gray-4000 dark:bg-[#26272E] dark:text-silver">
|
||||
<span className="z-10 absolute left-5 -top-2 bg-white px-2 text-xs text-gray-4000 dark:bg-[#26272E] dark:text-silver">
|
||||
API Key / Oauth
|
||||
</span>
|
||||
<Input
|
||||
@@ -447,7 +447,7 @@ function APIToolConfig({
|
||||
</div>
|
||||
<div className="mt-8 px-5">
|
||||
<div className="relative w-full">
|
||||
<span className="absolute left-5 -top-2 bg-white px-2 text-xs text-gray-4000 dark:bg-raisin-black dark:text-silver">
|
||||
<span className="z-10 absolute left-5 -top-2 bg-white px-2 text-xs text-gray-4000 dark:bg-raisin-black dark:text-silver">
|
||||
URL
|
||||
</span>
|
||||
<Input
|
||||
@@ -516,7 +516,7 @@ function APIToolConfig({
|
||||
</div>
|
||||
<div className="mt-4 px-5 py-2">
|
||||
<div className="relative w-full">
|
||||
<span className="absolute left-5 -top-2 bg-white px-2 text-xs text-gray-4000 dark:bg-raisin-black dark:text-silver">
|
||||
<span className="z-10 absolute left-5 -top-2 bg-white px-2 text-xs text-gray-4000 dark:bg-raisin-black dark:text-silver">
|
||||
Description
|
||||
</span>
|
||||
<Input
|
||||
|
||||
@@ -85,6 +85,7 @@ export default function Tools() {
|
||||
id="tool-search-input"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
borderVariant="thin"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
export type ChunkType = {
|
||||
doc_id: string;
|
||||
text: string;
|
||||
metadata: { [key: string]: string };
|
||||
};
|
||||
|
||||
export type APIKeyData = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user