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:
2026-03-19 13:46:59 +03:00
parent 03afb7a075
commit 8b8fe916f0
37 changed files with 1921 additions and 26 deletions

View File

@@ -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",

View File

@@ -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": "Произошла ошибка",

View 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);
}

View 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}`);
}

View 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>
);
}

View 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}`)} &middot; {(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>
);
}

View 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>
);
}

View File

@@ -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 },
];

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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 /> },