Phase 4: Documents & Memory — upload, FTS, AI tools, context injection
Backend:
- Document + MemoryEntry models with Alembic migration (GIN FTS index)
- File upload endpoint with path traversal protection (sanitized filenames)
- Background document text extraction (PyMuPDF)
- Full-text search on extracted_text via PostgreSQL tsvector/tsquery
- Memory CRUD with enum-validated categories/importance, field allow-list
- AI tools: save_memory, search_documents, get_memory (Claude function calling)
- Tool execution loop in stream_ai_response (multi-turn tool use)
- Context assembly: injects critical memory + relevant doc excerpts
- File storage abstraction (local filesystem, S3-swappable)
- Secure file deletion (DB flush before disk delete)
Frontend:
- Document upload dialog (drag-and-drop + file picker)
- Document list with status badges, search, download (via authenticated blob)
- Document viewer with extracted text preview
- Memory list grouped by category with importance color coding
- Memory editor with category/importance dropdowns
- Documents + Memory pages with full CRUD
- Enabled sidebar navigation for both sections
Review fixes applied:
- Sanitized upload filenames (path traversal prevention)
- Download via axios blob (not bare <a href>, preserves auth)
- Route ordering: /search before /{id}/reindex
- Memory update allows is_active=False + field allow-list
- MemoryEditor form resets on mode switch
- Literal enum validation on category/importance schemas
- DB flush before file deletion for data integrity
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -90,6 +90,55 @@
|
||||
"subtitle": "This context is added to all your AI conversations",
|
||||
"placeholder": "Add personal information that the AI should know about you..."
|
||||
},
|
||||
"documents": {
|
||||
"upload": "Upload",
|
||||
"drop_or_click": "Drop a file here or click to browse",
|
||||
"doc_type": "Document Type",
|
||||
"no_documents": "No documents uploaded yet.",
|
||||
"download": "Download",
|
||||
"reindex": "Re-extract text",
|
||||
"extracted_text": "Extracted Text",
|
||||
"search_placeholder": "Search documents...",
|
||||
"clear_search": "Clear",
|
||||
"types": {
|
||||
"other": "Other",
|
||||
"lab_result": "Lab Result",
|
||||
"consultation": "Consultation",
|
||||
"prescription": "Prescription",
|
||||
"imaging": "Imaging"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
"processing": "Processing",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed"
|
||||
}
|
||||
},
|
||||
"memory": {
|
||||
"create": "Add Memory Entry",
|
||||
"edit": "Edit Memory Entry",
|
||||
"no_entries": "No memory entries yet. The AI will save important health information here.",
|
||||
"category": "Category",
|
||||
"importance": "Importance",
|
||||
"title_field": "Title",
|
||||
"title_placeholder": "e.g. Diabetes Type 2",
|
||||
"content_field": "Content",
|
||||
"content_placeholder": "Detailed information...",
|
||||
"categories": {
|
||||
"condition": "Condition",
|
||||
"medication": "Medication",
|
||||
"allergy": "Allergy",
|
||||
"vital": "Vital Sign",
|
||||
"document_summary": "Document Summary",
|
||||
"other": "Other"
|
||||
},
|
||||
"importance_levels": {
|
||||
"critical": "Critical",
|
||||
"high": "High",
|
||||
"medium": "Medium",
|
||||
"low": "Low"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"error": "An error occurred",
|
||||
|
||||
@@ -90,6 +90,55 @@
|
||||
"subtitle": "Этот контекст добавляется ко всем вашим разговорам с ИИ",
|
||||
"placeholder": "Добавьте личную информацию, которую ИИ должен знать о вас..."
|
||||
},
|
||||
"documents": {
|
||||
"upload": "Загрузить",
|
||||
"drop_or_click": "Перетащите файл или нажмите для выбора",
|
||||
"doc_type": "Тип документа",
|
||||
"no_documents": "Документы ещё не загружены.",
|
||||
"download": "Скачать",
|
||||
"reindex": "Извлечь текст заново",
|
||||
"extracted_text": "Извлечённый текст",
|
||||
"search_placeholder": "Поиск по документам...",
|
||||
"clear_search": "Очистить",
|
||||
"types": {
|
||||
"other": "Другое",
|
||||
"lab_result": "Анализы",
|
||||
"consultation": "Консультация",
|
||||
"prescription": "Рецепт",
|
||||
"imaging": "Снимки"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Ожидание",
|
||||
"processing": "Обработка",
|
||||
"completed": "Готово",
|
||||
"failed": "Ошибка"
|
||||
}
|
||||
},
|
||||
"memory": {
|
||||
"create": "Добавить запись",
|
||||
"edit": "Редактировать запись",
|
||||
"no_entries": "Записей пока нет. ИИ будет сохранять важную информацию о здоровье здесь.",
|
||||
"category": "Категория",
|
||||
"importance": "Важность",
|
||||
"title_field": "Заголовок",
|
||||
"title_placeholder": "напр. Диабет 2 типа",
|
||||
"content_field": "Содержание",
|
||||
"content_placeholder": "Подробная информация...",
|
||||
"categories": {
|
||||
"condition": "Заболевание",
|
||||
"medication": "Лекарство",
|
||||
"allergy": "Аллергия",
|
||||
"vital": "Показатели",
|
||||
"document_summary": "Сводка документа",
|
||||
"other": "Другое"
|
||||
},
|
||||
"importance_levels": {
|
||||
"critical": "Критическая",
|
||||
"high": "Высокая",
|
||||
"medium": "Средняя",
|
||||
"low": "Низкая"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "Загрузка...",
|
||||
"error": "Произошла ошибка",
|
||||
|
||||
70
frontend/src/api/documents.ts
Normal file
70
frontend/src/api/documents.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import api from "./client";
|
||||
|
||||
export interface Document {
|
||||
id: string;
|
||||
user_id: string;
|
||||
filename: string;
|
||||
original_filename: string;
|
||||
mime_type: string;
|
||||
file_size: number;
|
||||
doc_type: string;
|
||||
processing_status: string;
|
||||
extracted_text: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DocumentListResponse {
|
||||
documents: Document[];
|
||||
}
|
||||
|
||||
export async function uploadDocument(file: File, docType = "other"): Promise<Document> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const { data } = await api.post<Document>(`/documents/?doc_type=${docType}`, form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getDocuments(
|
||||
docType?: string,
|
||||
processingStatus?: string
|
||||
): Promise<Document[]> {
|
||||
const params: Record<string, string> = {};
|
||||
if (docType) params.doc_type = docType;
|
||||
if (processingStatus) params.processing_status = processingStatus;
|
||||
const { data } = await api.get<DocumentListResponse>("/documents/", { params });
|
||||
return data.documents;
|
||||
}
|
||||
|
||||
export async function getDocument(docId: string): Promise<Document> {
|
||||
const { data } = await api.get<Document>(`/documents/${docId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteDocument(docId: string): Promise<void> {
|
||||
await api.delete(`/documents/${docId}`);
|
||||
}
|
||||
|
||||
export async function reindexDocument(docId: string): Promise<Document> {
|
||||
const { data } = await api.post<Document>(`/documents/${docId}/reindex`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function searchDocuments(query: string): Promise<Document[]> {
|
||||
const { data } = await api.post<DocumentListResponse>("/documents/search", { query });
|
||||
return data.documents;
|
||||
}
|
||||
|
||||
export async function downloadDocument(docId: string, filename: string): Promise<void> {
|
||||
const { data } = await api.get(`/documents/${docId}/download`, { responseType: "blob" });
|
||||
const url = URL.createObjectURL(data);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
59
frontend/src/api/memory.ts
Normal file
59
frontend/src/api/memory.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import api from "./client";
|
||||
|
||||
export interface MemoryEntry {
|
||||
id: string;
|
||||
user_id: string;
|
||||
category: string;
|
||||
title: string;
|
||||
content: string;
|
||||
source_document_id: string | null;
|
||||
importance: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MemoryEntryListResponse {
|
||||
entries: MemoryEntry[];
|
||||
}
|
||||
|
||||
export async function createMemory(data: {
|
||||
category: string;
|
||||
title: string;
|
||||
content: string;
|
||||
importance?: string;
|
||||
}): Promise<MemoryEntry> {
|
||||
const { data: entry } = await api.post<MemoryEntry>("/memory/", data);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export async function getMemories(filters?: {
|
||||
category?: string;
|
||||
importance?: string;
|
||||
is_active?: boolean;
|
||||
}): Promise<MemoryEntry[]> {
|
||||
const { data } = await api.get<MemoryEntryListResponse>("/memory/", { params: filters });
|
||||
return data.entries;
|
||||
}
|
||||
|
||||
export async function getMemory(entryId: string): Promise<MemoryEntry> {
|
||||
const { data } = await api.get<MemoryEntry>(`/memory/${entryId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateMemory(
|
||||
entryId: string,
|
||||
updates: Partial<{
|
||||
category: string;
|
||||
title: string;
|
||||
content: string;
|
||||
importance: string;
|
||||
is_active: boolean;
|
||||
}>
|
||||
): Promise<MemoryEntry> {
|
||||
const { data } = await api.patch<MemoryEntry>(`/memory/${entryId}`, updates);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteMemory(entryId: string): Promise<void> {
|
||||
await api.delete(`/memory/${entryId}`);
|
||||
}
|
||||
68
frontend/src/components/documents/document-list.tsx
Normal file
68
frontend/src/components/documents/document-list.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileText, Download, Trash2, RefreshCw } from "lucide-react";
|
||||
import type { Document } from "@/api/documents";
|
||||
import { downloadDocument } from "@/api/documents";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DocumentListProps {
|
||||
documents: Document[];
|
||||
onDelete: (docId: string) => void;
|
||||
onReindex: (docId: string) => void;
|
||||
onSelect: (doc: Document) => void;
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400",
|
||||
processing: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
|
||||
completed: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
|
||||
failed: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
|
||||
};
|
||||
|
||||
export function DocumentList({ documents, onDelete, onReindex, onSelect }: DocumentListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (documents.length === 0) {
|
||||
return (
|
||||
<p className="text-center text-muted-foreground py-8">{t("documents.no_documents")}</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{documents.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
onClick={() => onSelect(doc)}
|
||||
className="flex items-center gap-3 rounded-lg border bg-card p-4 cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<FileText className="h-5 w-5 text-primary shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{doc.original_filename}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{t(`documents.types.${doc.doc_type}`)}</span>
|
||||
<span>{(doc.file_size / 1024 / 1024).toFixed(2)} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn("rounded-full px-2 py-0.5 text-xs font-medium", statusColors[doc.processing_status])}>
|
||||
{t(`documents.status.${doc.processing_status}`)}
|
||||
</span>
|
||||
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => downloadDocument(doc.id, doc.original_filename)}
|
||||
className="rounded p-2 hover:bg-accent transition-colors"
|
||||
title={t("documents.download")}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
<button onClick={() => onReindex(doc.id)} className="rounded p-2 hover:bg-accent transition-colors" title={t("documents.reindex")}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
<button onClick={() => onDelete(doc.id)} className="rounded p-2 hover:bg-destructive/10 hover:text-destructive transition-colors">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
frontend/src/components/documents/document-viewer.tsx
Normal file
44
frontend/src/components/documents/document-viewer.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Document } from "@/api/documents";
|
||||
import { downloadDocument } from "@/api/documents";
|
||||
import { Download, ArrowLeft } from "lucide-react";
|
||||
|
||||
interface DocumentViewerProps {
|
||||
document: Document;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function DocumentViewer({ document: doc, onBack }: DocumentViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={onBack} className="rounded p-2 hover:bg-accent transition-colors">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-semibold">{doc.original_filename}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(`documents.types.${doc.doc_type}`)} · {(doc.file_size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => downloadDocument(doc.id, doc.original_filename)}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Download className="h-4 w-4" /> {t("documents.download")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{doc.extracted_text && (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="text-sm font-medium mb-2">{t("documents.extracted_text")}</h3>
|
||||
<pre className="whitespace-pre-wrap text-sm text-muted-foreground max-h-96 overflow-y-auto">
|
||||
{doc.extracted_text}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
frontend/src/components/documents/upload-dialog.tsx
Normal file
100
frontend/src/components/documents/upload-dialog.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { uploadDocument } from "@/api/documents";
|
||||
import { Upload, X } from "lucide-react";
|
||||
|
||||
interface UploadDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const DOC_TYPES = ["other", "lab_result", "consultation", "prescription", "imaging"];
|
||||
|
||||
export function UploadDialog({ open, onClose }: UploadDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [docType, setDocType] = useState("other");
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => uploadDocument(file!, docType),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["documents"] });
|
||||
setFile(null);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const dropped = e.dataTransfer.files[0];
|
||||
if (dropped) setFile(dropped);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md rounded-xl border bg-card p-6 shadow-lg space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{t("documents.upload")}</h2>
|
||||
<button onClick={onClose} className="rounded p-1 hover:bg-accent">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className={`flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 cursor-pointer transition-colors ${
|
||||
dragOver ? "border-primary bg-primary/5" : "border-input hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<Upload className="h-8 w-8 text-muted-foreground mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{file ? file.name : t("documents.drop_or_click")}
|
||||
</p>
|
||||
{file && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{(file.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
)}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.tiff,.webp"
|
||||
className="hidden"
|
||||
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t("documents.doc_type")}</label>
|
||||
<select
|
||||
value={docType}
|
||||
onChange={(e) => setDocType(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
|
||||
>
|
||||
{DOC_TYPES.map((dt) => (
|
||||
<option key={dt} value={dt}>{t(`documents.types.${dt}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={!file || mutation.isPending}
|
||||
className="inline-flex h-10 w-full items-center justify-center rounded-md bg-primary text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{mutation.isPending ? t("common.loading") : t("documents.upload")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,8 +19,8 @@ const navItems = [
|
||||
{ key: "chats", to: "/chat", icon: MessageSquare, enabled: true, end: false },
|
||||
{ key: "skills", to: "/skills", icon: Sparkles, enabled: true, end: true },
|
||||
{ key: "personal_context", to: "/profile/context", icon: BookOpen, enabled: true, end: true },
|
||||
{ key: "documents", to: "/documents", icon: FileText, enabled: false, end: true },
|
||||
{ key: "memory", to: "/memory", icon: Brain, enabled: false, end: true },
|
||||
{ key: "documents", to: "/documents", icon: FileText, enabled: true, end: true },
|
||||
{ key: "memory", to: "/memory", icon: Brain, enabled: true, end: true },
|
||||
{ key: "notifications", to: "/notifications", icon: Bell, enabled: false, end: true },
|
||||
];
|
||||
|
||||
|
||||
90
frontend/src/components/memory/memory-editor.tsx
Normal file
90
frontend/src/components/memory/memory-editor.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MemoryEntry } from "@/api/memory";
|
||||
|
||||
const CATEGORIES = ["condition", "medication", "allergy", "vital", "document_summary", "other"];
|
||||
const IMPORTANCE_LEVELS = ["critical", "high", "medium", "low"];
|
||||
|
||||
interface MemoryEditorProps {
|
||||
entry?: MemoryEntry | null;
|
||||
onSave: (data: { category: string; title: string; content: string; importance: string }) => void;
|
||||
onCancel: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function MemoryEditor({ entry, onSave, onCancel, loading }: MemoryEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [category, setCategory] = useState("other");
|
||||
const [title, setTitle] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [importance, setImportance] = useState("medium");
|
||||
|
||||
useEffect(() => {
|
||||
if (entry) {
|
||||
setCategory(entry.category);
|
||||
setTitle(entry.title);
|
||||
setContent(entry.content);
|
||||
setImportance(entry.importance);
|
||||
} else {
|
||||
setCategory("other");
|
||||
setTitle("");
|
||||
setContent("");
|
||||
setImportance("medium");
|
||||
}
|
||||
}, [entry]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave({ category, title, content, importance });
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t("memory.category")}</label>
|
||||
<select value={category} onChange={(e) => setCategory(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm">
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>{t(`memory.categories.${c}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t("memory.importance")}</label>
|
||||
<select value={importance} onChange={(e) => setImportance(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm">
|
||||
{IMPORTANCE_LEVELS.map((l) => (
|
||||
<option key={l} value={l}>{t(`memory.importance_levels.${l}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t("memory.title_field")}</label>
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} required maxLength={255}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
placeholder={t("memory.title_placeholder")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t("memory.content_field")}</label>
|
||||
<textarea value={content} onChange={(e) => setContent(e.target.value)} required rows={4}
|
||||
className="w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
placeholder={t("memory.content_placeholder")} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={onCancel}
|
||||
className="inline-flex h-9 items-center rounded-md border px-4 text-sm font-medium hover:bg-accent transition-colors">
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button type="submit" disabled={loading || !title.trim() || !content.trim()}
|
||||
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors">
|
||||
{loading ? t("common.loading") : t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
66
frontend/src/components/memory/memory-list.tsx
Normal file
66
frontend/src/components/memory/memory-list.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import type { MemoryEntry } from "@/api/memory";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MemoryListProps {
|
||||
entries: MemoryEntry[];
|
||||
onEdit: (entry: MemoryEntry) => void;
|
||||
onDelete: (entryId: string) => void;
|
||||
}
|
||||
|
||||
const importanceColors: Record<string, string> = {
|
||||
critical: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
|
||||
high: "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400",
|
||||
medium: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
|
||||
low: "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400",
|
||||
};
|
||||
|
||||
export function MemoryList({ entries, onEdit, onDelete }: MemoryListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (entries.length === 0) {
|
||||
return <p className="text-center text-muted-foreground py-8">{t("memory.no_entries")}</p>;
|
||||
}
|
||||
|
||||
// Group by category
|
||||
const grouped = entries.reduce<Record<string, MemoryEntry[]>>((acc, entry) => {
|
||||
(acc[entry.category] ??= []).push(entry);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{Object.entries(grouped).map(([category, items]) => (
|
||||
<div key={category}>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
{t(`memory.categories.${category}`)}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{items.map((entry) => (
|
||||
<div key={entry.id} className={cn("flex items-start gap-3 rounded-lg border bg-card p-4", !entry.is_active && "opacity-50")}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className="font-medium">{entry.title}</p>
|
||||
<span className={cn("rounded-full px-2 py-0.5 text-xs font-medium", importanceColors[entry.importance])}>
|
||||
{t(`memory.importance_levels.${entry.importance}`)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{entry.content}</p>
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<button onClick={() => onEdit(entry)} className="rounded p-2 hover:bg-accent transition-colors">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button onClick={() => onDelete(entry.id)} className="rounded p-2 hover:bg-destructive/10 hover:text-destructive transition-colors">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
frontend/src/pages/documents.tsx
Normal file
104
frontend/src/pages/documents.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getDocuments, deleteDocument, reindexDocument, searchDocuments, type Document } from "@/api/documents";
|
||||
import { DocumentList } from "@/components/documents/document-list";
|
||||
import { DocumentViewer } from "@/components/documents/document-viewer";
|
||||
import { UploadDialog } from "@/components/documents/upload-dialog";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
|
||||
export function DocumentsPage() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<Document | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<Document[] | null>(null);
|
||||
|
||||
const { data: documents = [] } = useQuery({
|
||||
queryKey: ["documents"],
|
||||
queryFn: () => getDocuments(),
|
||||
});
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: deleteDocument,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["documents"] });
|
||||
setSelected(null);
|
||||
},
|
||||
});
|
||||
|
||||
const reindexMut = useMutation({
|
||||
mutationFn: reindexDocument,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["documents"] }),
|
||||
});
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults(null);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
try {
|
||||
const results = await searchDocuments(searchQuery);
|
||||
setSearchResults(results);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (selected) {
|
||||
return <DocumentViewer document={selected} onBack={() => setSelected(null)} />;
|
||||
}
|
||||
|
||||
const displayDocs = searchResults ?? documents;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">{t("layout.documents")}</h1>
|
||||
<button
|
||||
onClick={() => setUploadOpen(true)}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> {t("documents.upload")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
placeholder={t("documents.search_placeholder")}
|
||||
className="flex-1 h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={searching}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md border px-3 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</button>
|
||||
{searchResults && (
|
||||
<button
|
||||
onClick={() => { setSearchResults(null); setSearchQuery(""); }}
|
||||
className="inline-flex h-9 items-center px-3 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("documents.clear_search")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DocumentList
|
||||
documents={displayDocs}
|
||||
onDelete={(id) => deleteMut.mutate(id)}
|
||||
onReindex={(id) => reindexMut.mutate(id)}
|
||||
onSelect={setSelected}
|
||||
/>
|
||||
|
||||
<UploadDialog open={uploadOpen} onClose={() => setUploadOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
frontend/src/pages/memory.tsx
Normal file
67
frontend/src/pages/memory.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getMemories, createMemory, updateMemory, deleteMemory, type MemoryEntry } from "@/api/memory";
|
||||
import { MemoryList } from "@/components/memory/memory-list";
|
||||
import { MemoryEditor } from "@/components/memory/memory-editor";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
export function MemoryPage() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [editing, setEditing] = useState<MemoryEntry | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const { data: entries = [] } = useQuery({
|
||||
queryKey: ["memory"],
|
||||
queryFn: () => getMemories(),
|
||||
});
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: createMemory,
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["memory"] }); setCreating(false); },
|
||||
});
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, ...data }: { id: string } & Record<string, string>) => updateMemory(id, data),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["memory"] }); setEditing(null); },
|
||||
});
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: deleteMemory,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["memory"] }),
|
||||
});
|
||||
|
||||
if (creating || editing) {
|
||||
return (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{creating ? t("memory.create") : t("memory.edit")}
|
||||
</h1>
|
||||
<MemoryEditor
|
||||
entry={editing}
|
||||
onSave={(data) =>
|
||||
editing ? updateMut.mutate({ id: editing.id, ...data }) : createMut.mutate(data)
|
||||
}
|
||||
onCancel={() => { setCreating(false); setEditing(null); }}
|
||||
loading={createMut.isPending || updateMut.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">{t("layout.memory")}</h1>
|
||||
<button
|
||||
onClick={() => setCreating(true)}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> {t("common.create")}
|
||||
</button>
|
||||
</div>
|
||||
<MemoryList entries={entries} onEdit={setEditing} onDelete={(id) => deleteMut.mutate(id)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { SkillsPage } from "@/pages/skills";
|
||||
import { PersonalContextPage } from "@/pages/profile/context";
|
||||
import { AdminContextPage } from "@/pages/admin/context";
|
||||
import { AdminSkillsPage } from "@/pages/admin/skills";
|
||||
import { DocumentsPage } from "@/pages/documents";
|
||||
import { MemoryPage } from "@/pages/memory";
|
||||
import { NotFoundPage } from "@/pages/not-found";
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
@@ -29,6 +31,8 @@ export const router = createBrowserRouter([
|
||||
{ index: true, element: <DashboardPage /> },
|
||||
{ path: "chat", element: <ChatPage /> },
|
||||
{ path: "chat/:chatId", element: <ChatPage /> },
|
||||
{ path: "documents", element: <DocumentsPage /> },
|
||||
{ path: "memory", element: <MemoryPage /> },
|
||||
{ path: "skills", element: <SkillsPage /> },
|
||||
{ path: "profile/context", element: <PersonalContextPage /> },
|
||||
{ path: "admin/context", element: <AdminContextPage /> },
|
||||
|
||||
Reference in New Issue
Block a user