mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 22:52:19 +03:00
feat(memory): implement F7 Studio UI layer for /dashboard/memory
Converts the monolithic memory page into a 3-tab Studio layout (Memories | Playground | Engine) with URL-driven tab state, 8 new React components, 2 SWR hooks, 50+ i18n keys, and 8 Vitest unit tests covering all new components (45/45 passing).
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Modal, Button, Input, Select } from "@/shared/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface Memory {
|
||||
id: string;
|
||||
type: "factual" | "episodic" | "procedural" | "semantic";
|
||||
key: string;
|
||||
content: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
memory: Memory | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export default function EditMemoryModal({ memory, isOpen, onClose, onSaved }: Props) {
|
||||
const t = useTranslations("memory");
|
||||
const [type, setType] = useState<"factual" | "episodic" | "procedural" | "semantic">("factual");
|
||||
const [key, setKey] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [metadataStr, setMetadataStr] = useState("{}");
|
||||
const [metadataError, setMetadataError] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (memory && isOpen) {
|
||||
setType(memory.type);
|
||||
setKey(memory.key);
|
||||
setContent(memory.content);
|
||||
setMetadataStr(JSON.stringify(memory.metadata ?? {}, null, 2));
|
||||
setMetadataError("");
|
||||
setError("");
|
||||
}
|
||||
}, [memory, isOpen]);
|
||||
|
||||
const handleMetadataChange = (value: string) => {
|
||||
setMetadataStr(value);
|
||||
try {
|
||||
JSON.parse(value);
|
||||
setMetadataError("");
|
||||
} catch {
|
||||
setMetadataError(t("editModal.metadataInvalid"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!memory) return;
|
||||
if (metadataError) return;
|
||||
setIsSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
let metadata: Record<string, unknown> = {};
|
||||
try {
|
||||
metadata = JSON.parse(metadataStr);
|
||||
} catch {
|
||||
setError(t("editModal.metadataInvalid"));
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`/api/memory/${memory.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type, key, content, metadata }),
|
||||
});
|
||||
if (res.ok) {
|
||||
onSaved();
|
||||
onClose();
|
||||
} else {
|
||||
const data = await res.json().catch(() => null);
|
||||
setError(data?.error?.message ?? t("editModal.saveFailed"));
|
||||
}
|
||||
} catch {
|
||||
setError(t("editModal.saveFailed"));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={t("editModal.title")}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={onClose} disabled={isSaving}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
loading={isSaving}
|
||||
disabled={!key.trim() || !content.trim() || Boolean(metadataError)}
|
||||
>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-xs text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t("type")}</label>
|
||||
<Select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as typeof type)}
|
||||
className="w-full"
|
||||
>
|
||||
<option value="factual">{t("factual")}</option>
|
||||
<option value="episodic">{t("episodic")}</option>
|
||||
<option value="procedural">{t("procedural")}</option>
|
||||
<option value="semantic">{t("semantic")}</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t("key")}</label>
|
||||
<Input
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
placeholder={t("keyPlaceholder")}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t("content")}</label>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={t("contentPlaceholder")}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500 resize-y"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t("editModal.metadataLabel")}</label>
|
||||
<textarea
|
||||
value={metadataStr}
|
||||
onChange={(e) => handleMetadataChange(e.target.value)}
|
||||
rows={4}
|
||||
spellCheck={false}
|
||||
className={`w-full px-3 py-2 rounded-lg bg-background border text-xs font-mono focus:outline-none focus:ring-1 focus:ring-violet-500 resize-y ${
|
||||
metadataError ? "border-red-500" : "border-border"
|
||||
}`}
|
||||
/>
|
||||
{metadataError && (
|
||||
<p className="text-xs text-red-400 mt-1">{metadataError}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { MemorySettingsExtended } from "@/shared/schemas/memory";
|
||||
import type { EmbeddingProviderListing } from "@/lib/memory/embedding/types";
|
||||
|
||||
interface Props {
|
||||
settings: MemorySettingsExtended;
|
||||
providers: EmbeddingProviderListing[];
|
||||
onSave: (updates: Partial<MemorySettingsExtended>) => Promise<boolean>;
|
||||
saving?: boolean;
|
||||
}
|
||||
|
||||
type EmbeddingSourceValue = "remote" | "static" | "transformers" | "auto";
|
||||
|
||||
export default function EmbeddingSourceSelector({ settings, providers, onSave, saving }: Props) {
|
||||
const t = useTranslations("memory");
|
||||
|
||||
const remoteProviders = providers.filter((p) => p.hasKey);
|
||||
const currentSource = settings.embeddingSource ?? "auto";
|
||||
const currentProviderModel = settings.embeddingProviderModel ?? "";
|
||||
|
||||
const handleSourceChange = (source: EmbeddingSourceValue) => {
|
||||
onSave({ embeddingSource: source });
|
||||
};
|
||||
|
||||
const handleProviderModelChange = (value: string) => {
|
||||
onSave({ embeddingProviderModel: value || null });
|
||||
};
|
||||
|
||||
const options: Array<{ value: EmbeddingSourceValue; label: string; desc: string }> = [
|
||||
{
|
||||
value: "auto",
|
||||
label: t("embedding.autoLabel"),
|
||||
desc: t("embedding.autoDesc"),
|
||||
},
|
||||
{
|
||||
value: "remote",
|
||||
label: t("embedding.remoteLabel"),
|
||||
desc: t("embedding.remoteDesc"),
|
||||
},
|
||||
{
|
||||
value: "static",
|
||||
label: t("embedding.staticLabel"),
|
||||
desc: t("embedding.staticDesc"),
|
||||
},
|
||||
{
|
||||
value: "transformers",
|
||||
label: t("embedding.transformersLabel"),
|
||||
desc: t("embedding.transformersDesc"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
data-testid={`embedding-source-${opt.value}`}
|
||||
onClick={() => handleSourceChange(opt.value)}
|
||||
disabled={saving}
|
||||
className={`flex flex-col items-start p-3 rounded-lg border text-left transition-all ${
|
||||
currentSource === opt.value
|
||||
? "border-violet-500/50 bg-violet-500/5 ring-1 ring-violet-500/20"
|
||||
: "border-border/50 hover:border-border hover:bg-surface/30"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`text-sm font-medium ${currentSource === opt.value ? "text-violet-400" : "text-text-main"}`}
|
||||
>
|
||||
{opt.label}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted mt-0.5 leading-relaxed">{opt.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{currentSource === "remote" && (
|
||||
<div className="p-3 rounded-lg bg-surface/30 border border-border/60">
|
||||
<label className="block text-sm font-medium text-text-main mb-2">
|
||||
{t("embedding.providerModelLabel")}
|
||||
</label>
|
||||
{remoteProviders.length === 0 ? (
|
||||
<p className="text-xs text-amber-400 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[12px]">warning</span>
|
||||
{t("embedding.noRemoteProviders")}
|
||||
</p>
|
||||
) : (
|
||||
<select
|
||||
value={currentProviderModel}
|
||||
onChange={(e) => handleProviderModelChange(e.target.value)}
|
||||
disabled={saving}
|
||||
data-testid="embedding-provider-model-select"
|
||||
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
>
|
||||
<option value="">{t("embedding.selectProviderModel")}</option>
|
||||
{remoteProviders.map((p) =>
|
||||
p.models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name} ({m.dimensions ? `${m.dimensions}d` : "?"})
|
||||
</option>
|
||||
)),
|
||||
)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentSource === "transformers" && (
|
||||
<div className="p-3 rounded-lg bg-amber-500/10 border border-amber-500/20 text-xs text-amber-400 flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-[14px] mt-0.5 shrink-0">info</span>
|
||||
<span>{t("embedding.transformersWarning")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center justify-between p-3 rounded-lg bg-surface/30 border border-border/60">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-text-main">
|
||||
{t("embedding.staticEnabledLabel")}
|
||||
</span>
|
||||
<p className="text-xs text-text-muted mt-0.5">{t("embedding.staticEnabledDesc")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="toggle-static-enabled"
|
||||
onClick={() => onSave({ staticEnabled: !settings.staticEnabled })}
|
||||
disabled={saving}
|
||||
role="switch"
|
||||
aria-checked={settings.staticEnabled ?? false}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors shrink-0 ${
|
||||
settings.staticEnabled ? "bg-violet-500" : "bg-border"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
settings.staticEnabled ? "translate-x-5" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between p-3 rounded-lg bg-surface/30 border border-border/60">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-text-main">
|
||||
{t("embedding.transformersEnabledLabel")}
|
||||
</span>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
{t("embedding.transformersEnabledDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="toggle-transformers-enabled"
|
||||
onClick={() => onSave({ transformersEnabled: !settings.transformersEnabled })}
|
||||
disabled={saving}
|
||||
role="switch"
|
||||
aria-checked={settings.transformersEnabled ?? false}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors shrink-0 ${
|
||||
settings.transformersEnabled ? "bg-violet-500" : "bg-border"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
settings.transformersEnabled ? "translate-x-5" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function MemoryConceptCard() {
|
||||
const t = useTranslations("memory");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-bg-subtle/50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-violet-500/10 text-violet-500 shrink-0">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
psychology
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-sm font-semibold text-text-main">{t("concept.title")}</h2>
|
||||
<p className="text-xs text-text-muted mt-1 leading-relaxed">{t("concept.description")}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
{t("concept.howWorksToggle")}
|
||||
<span
|
||||
className={`material-symbols-outlined text-[14px] transition-transform ${open ? "rotate-180" : ""}`}
|
||||
>
|
||||
expand_more
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="mt-3 p-3 rounded-lg bg-surface/50 border border-border/60 text-xs text-text-muted leading-relaxed space-y-1.5">
|
||||
{(t("concept.howWorksContent") as string).split("\n").map((line, i) => (
|
||||
<p key={i}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { MemoryEngineStatus } from "@/shared/schemas/memory";
|
||||
|
||||
interface Props {
|
||||
status: MemoryEngineStatus;
|
||||
}
|
||||
|
||||
type ChipColor = "green" | "gray" | "red";
|
||||
|
||||
function StatusChip({ color }: { color: ChipColor }) {
|
||||
const colorMap: Record<ChipColor, string> = {
|
||||
green: "bg-emerald-500",
|
||||
gray: "bg-border",
|
||||
red: "bg-red-500",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={`inline-block w-2.5 h-2.5 rounded-full shrink-0 ${colorMap[color]}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MemoryEngineStatus({ status }: Props) {
|
||||
const t = useTranslations("memory");
|
||||
|
||||
const rows: Array<{ label: string; chip: ChipColor; reason: string; cta?: React.ReactNode }> = [
|
||||
{
|
||||
label: t("engine.keywordLabel"),
|
||||
chip: "green",
|
||||
reason: t("engine.keywordReason"),
|
||||
},
|
||||
{
|
||||
label: t("engine.embeddingLabel"),
|
||||
chip: status.embedding.available ? "green" : "gray",
|
||||
reason: status.embedding.reason,
|
||||
},
|
||||
{
|
||||
label: t("engine.vectorStoreLabel"),
|
||||
chip:
|
||||
status.vectorStore.available
|
||||
? "green"
|
||||
: status.vectorStore.backend === "none"
|
||||
? "gray"
|
||||
: "red",
|
||||
reason: status.vectorStore.reason,
|
||||
cta:
|
||||
status.vectorStore.needsReindex > 0 ? (
|
||||
<span className="text-xs text-amber-400 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[12px]">warning</span>
|
||||
{t("engine.needsReindex", { count: status.vectorStore.needsReindex })}
|
||||
</span>
|
||||
) : undefined,
|
||||
},
|
||||
{
|
||||
label: t("engine.qdrantLabel"),
|
||||
chip: !status.qdrant.enabled ? "gray" : status.qdrant.healthy ? "green" : "red",
|
||||
reason: !status.qdrant.enabled
|
||||
? t("engine.qdrantDisabled")
|
||||
: status.qdrant.healthy
|
||||
? t("engine.qdrantOk", { latencyMs: status.qdrant.latencyMs ?? 0 })
|
||||
: (status.qdrant.error ?? t("engine.qdrantError")),
|
||||
},
|
||||
{
|
||||
label: t("engine.rerankLabel"),
|
||||
chip: !status.rerank.enabled ? "gray" : status.rerank.available ? "green" : "red",
|
||||
reason: status.rerank.reason,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{rows.map((row, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-start gap-3 p-3 rounded-lg border border-border/60 bg-surface/30"
|
||||
>
|
||||
<StatusChip color={row.chip} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-text-main">{row.label}</span>
|
||||
<p className="text-xs text-text-muted mt-0.5">{row.reason}</p>
|
||||
{row.cta && <div className="mt-1">{row.cta}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/shared/components";
|
||||
|
||||
interface QdrantSettings {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
collection: string;
|
||||
embeddingModel: string;
|
||||
hasApiKey: boolean;
|
||||
apiKeyMasked: string | null;
|
||||
}
|
||||
|
||||
interface EmbeddingModelOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export default function QdrantConfigCard() {
|
||||
const t = useTranslations("memory");
|
||||
const [qdrant, setQdrant] = useState<QdrantSettings>({
|
||||
enabled: false,
|
||||
host: "",
|
||||
port: 6333,
|
||||
collection: "omniroute_memory",
|
||||
embeddingModel: "openai/text-embedding-3-small",
|
||||
hasApiKey: false,
|
||||
apiKeyMasked: null,
|
||||
});
|
||||
const [apiKeyInput, setApiKeyInput] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<"" | "saved" | "error">("");
|
||||
const [health, setHealth] = useState<{ ok: boolean; latencyMs: number; error?: string } | null>(
|
||||
null,
|
||||
);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<
|
||||
Array<{ id: string; score: number; payload?: Record<string, unknown> }>
|
||||
>([]);
|
||||
const [cleanupLoading, setCleanupLoading] = useState(false);
|
||||
const [cleanupMsg, setCleanupMsg] = useState("");
|
||||
const [embeddingOptions, setEmbeddingOptions] = useState<EmbeddingModelOption[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch("/api/settings/qdrant").then((r) => (r.ok ? r.json() : null)),
|
||||
fetch("/api/settings/qdrant/embedding-models").then((r) => (r.ok ? r.json() : null)),
|
||||
])
|
||||
.then(([qdrantData, embeddingData]) => {
|
||||
if (qdrantData) {
|
||||
setQdrant(qdrantData);
|
||||
setApiKeyInput("");
|
||||
}
|
||||
if (embeddingData?.models) {
|
||||
setEmbeddingOptions(embeddingData.models);
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const save = useCallback(
|
||||
async (updates: Partial<QdrantSettings> & { apiKey?: string }) => {
|
||||
const prev = qdrant;
|
||||
const next = { ...qdrant, ...updates };
|
||||
setQdrant(next);
|
||||
setSaving(true);
|
||||
setSaveStatus("");
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
enabled: next.enabled,
|
||||
host: next.host,
|
||||
port: next.port,
|
||||
collection: next.collection,
|
||||
embeddingModel: next.embeddingModel,
|
||||
};
|
||||
if (updates.apiKey !== undefined) body.apiKey = updates.apiKey;
|
||||
const res = await fetch("/api/settings/qdrant", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json().catch(() => next);
|
||||
setQdrant(data);
|
||||
setApiKeyInput("");
|
||||
setSaveStatus("saved");
|
||||
setTimeout(() => setSaveStatus(""), 2000);
|
||||
} else {
|
||||
setQdrant(prev);
|
||||
setSaveStatus("error");
|
||||
}
|
||||
} catch {
|
||||
setQdrant(prev);
|
||||
setSaveStatus("error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[qdrant],
|
||||
);
|
||||
|
||||
const checkHealth = useCallback(async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings/qdrant/health");
|
||||
if (res.ok) setHealth(await res.json());
|
||||
else setHealth({ ok: false, latencyMs: 0, error: "HTTP error" });
|
||||
} catch (e) {
|
||||
setHealth({
|
||||
ok: false,
|
||||
latencyMs: 0,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const runSearch = useCallback(async () => {
|
||||
const q = searchQuery.trim();
|
||||
if (!q) return;
|
||||
setSearching(true);
|
||||
setSearchResults([]);
|
||||
try {
|
||||
const res = await fetch("/api/settings/qdrant/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query: q, topK: 5 }),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (res.ok && data?.ok) {
|
||||
setSearchResults(Array.isArray(data.results) ? data.results : []);
|
||||
}
|
||||
} catch {
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}, [searchQuery]);
|
||||
|
||||
const runCleanup = useCallback(async () => {
|
||||
setCleanupLoading(true);
|
||||
setCleanupMsg("");
|
||||
try {
|
||||
const res = await fetch("/api/settings/qdrant/cleanup", { method: "POST" });
|
||||
const data = await res.json().catch(() => null);
|
||||
if (res.ok && data?.ok) {
|
||||
setCleanupMsg(t("qdrant.cleanupSuccess", { count: data.deletedCount ?? 0 }));
|
||||
} else {
|
||||
setCleanupMsg(t("qdrant.cleanupFailed"));
|
||||
}
|
||||
} catch {
|
||||
setCleanupMsg(t("qdrant.cleanupFailed"));
|
||||
} finally {
|
||||
setCleanupLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-sm text-text-muted">{t("loading")}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500 shrink-0">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
database
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-text-main">{t("qdrant.title")}</h3>
|
||||
<p className="text-xs text-text-muted">{t("qdrant.description")}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 text-xs font-medium ${
|
||||
qdrant.enabled
|
||||
? health?.ok
|
||||
? "text-emerald-500"
|
||||
: "text-red-500"
|
||||
: "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block w-2.5 h-2.5 rounded-full ${
|
||||
qdrant.enabled ? (health?.ok ? "bg-emerald-500" : "bg-red-500") : "bg-border"
|
||||
}`}
|
||||
/>
|
||||
{qdrant.enabled
|
||||
? health?.ok
|
||||
? t("qdrant.statusActive")
|
||||
: t("qdrant.statusError")
|
||||
: t("qdrant.statusDisabled")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Enable toggle + test connection */}
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-surface/30 border border-border/30 mb-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t("qdrant.enableLabel")}</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">{t("qdrant.enableDesc")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
data-testid="qdrant-test-connection"
|
||||
onClick={checkHealth}
|
||||
disabled={checking || saving}
|
||||
className="px-3 h-8 text-xs font-medium rounded-lg bg-white/5 border border-border/60 hover:bg-white/10 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{checking ? t("qdrant.testing") : t("qdrant.testConnection")}
|
||||
</button>
|
||||
<button
|
||||
data-testid="qdrant-enabled-switch"
|
||||
onClick={() => save({ enabled: !qdrant.enabled })}
|
||||
disabled={saving}
|
||||
role="switch"
|
||||
aria-checked={qdrant.enabled}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${
|
||||
qdrant.enabled ? "bg-emerald-500" : "bg-border"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
qdrant.enabled ? "translate-x-5" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{health && (
|
||||
<div
|
||||
className={`mb-4 text-xs font-medium flex items-center gap-1 ${health.ok ? "text-emerald-500" : "text-red-500"}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{health.ok ? "check_circle" : "error"}
|
||||
</span>
|
||||
{health.ok
|
||||
? t("qdrant.healthOk", { latencyMs: health.latencyMs })
|
||||
: (health.error ?? t("qdrant.healthError"))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveStatus === "saved" && (
|
||||
<div className="mb-4 text-xs font-medium text-emerald-500 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[14px]">check_circle</span>
|
||||
{t("qdrant.saved")}
|
||||
</div>
|
||||
)}
|
||||
{saveStatus === "error" && (
|
||||
<div className="mb-4 text-xs font-medium text-red-500">{t("qdrant.saveError")}</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
|
||||
<div className="p-3 rounded-lg bg-surface/30 border border-border/30">
|
||||
<label className="text-xs font-medium block mb-1.5">Host</label>
|
||||
<input
|
||||
value={qdrant.host}
|
||||
onChange={(e) => setQdrant((s) => ({ ...s, host: e.target.value }))}
|
||||
placeholder="http://127.0.0.1"
|
||||
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm font-mono focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-surface/30 border border-border/30">
|
||||
<label className="text-xs font-medium block mb-1.5">{t("qdrant.portLabel")}</label>
|
||||
<input
|
||||
value={qdrant.port}
|
||||
type="number"
|
||||
onChange={(e) =>
|
||||
setQdrant((s) => ({ ...s, port: Math.max(1, Math.min(65535, Number(e.target.value) || 1)) }))
|
||||
}
|
||||
placeholder="6333"
|
||||
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm font-mono focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-surface/30 border border-border/30">
|
||||
<label className="text-xs font-medium block mb-1.5">Collection</label>
|
||||
<input
|
||||
value={qdrant.collection}
|
||||
onChange={(e) => setQdrant((s) => ({ ...s, collection: e.target.value }))}
|
||||
placeholder="omniroute_memory"
|
||||
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm font-mono focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-surface/30 border border-border/30">
|
||||
<label className="text-xs font-medium block mb-1.5">
|
||||
{t("qdrant.embeddingModelLabel")}
|
||||
</label>
|
||||
{embeddingOptions.length > 0 && (
|
||||
<select
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (e.target.value) setQdrant((s) => ({ ...s, embeddingModel: e.target.value }));
|
||||
}}
|
||||
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm mb-2 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
>
|
||||
<option value="">{t("qdrant.quickSelectModel")}</option>
|
||||
{embeddingOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<input
|
||||
value={qdrant.embeddingModel}
|
||||
onChange={(e) => setQdrant((s) => ({ ...s, embeddingModel: e.target.value }))}
|
||||
placeholder="openai/text-embedding-3-small"
|
||||
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm font-mono focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-surface/30 border border-border/30 md:col-span-2">
|
||||
<label className="text-xs font-medium block mb-1.5">
|
||||
API Key ({t("qdrant.optional")}){" "}
|
||||
{qdrant.hasApiKey && qdrant.apiKeyMasked ? (
|
||||
<span className="text-text-muted font-mono">{qdrant.apiKeyMasked}</span>
|
||||
) : null}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={apiKeyInput}
|
||||
onChange={(e) => setApiKeyInput(e.target.value)}
|
||||
placeholder={
|
||||
qdrant.hasApiKey ? t("qdrant.apiKeyKeepPlaceholder") : t("qdrant.apiKeyOptional")
|
||||
}
|
||||
className="flex-1 px-3 py-2 rounded-lg bg-background border border-border text-sm font-mono focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
{qdrant.hasApiKey && (
|
||||
<button
|
||||
onClick={() => save({ apiKey: "" })}
|
||||
disabled={saving}
|
||||
className="px-3 py-2 text-sm font-medium rounded-lg bg-white/5 border border-border/60 hover:bg-white/10 disabled:opacity-50"
|
||||
>
|
||||
{t("qdrant.removeApiKey")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() =>
|
||||
save(apiKeyInput.trim() ? { apiKey: apiKeyInput } : {})
|
||||
}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-emerald-500 text-white hover:bg-emerald-600 disabled:opacity-50"
|
||||
>
|
||||
{saving ? t("saving") : t("save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search test */}
|
||||
<div className="p-4 rounded-lg bg-surface/30 border border-border/30 mb-3">
|
||||
<p className="text-sm font-medium mb-2">{t("qdrant.searchTestTitle")}</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t("qdrant.searchPlaceholder")}
|
||||
onKeyDown={(e) => e.key === "Enter" && runSearch()}
|
||||
className="flex-1 px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
<button
|
||||
data-testid="qdrant-search-test"
|
||||
onClick={runSearch}
|
||||
disabled={searching || !searchQuery.trim()}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-white/5 border border-border/60 hover:bg-white/10 disabled:opacity-50"
|
||||
>
|
||||
{searching ? t("qdrant.searching") : t("qdrant.search")}
|
||||
</button>
|
||||
</div>
|
||||
{searchResults.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{searchResults.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="p-2 rounded bg-background/40 border border-border/40 flex items-center justify-between"
|
||||
>
|
||||
<span className="text-xs font-mono text-text-muted truncate">{r.id}</span>
|
||||
<span className="text-xs font-mono text-emerald-400 shrink-0">
|
||||
{r.score.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cleanup */}
|
||||
<div className="p-4 rounded-lg bg-surface/30 border border-border/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t("qdrant.cleanupTitle")}</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">{t("qdrant.cleanupDesc")}</p>
|
||||
</div>
|
||||
<button
|
||||
data-testid="qdrant-cleanup"
|
||||
onClick={runCleanup}
|
||||
disabled={cleanupLoading}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-white/5 border border-border/60 hover:bg-white/10 disabled:opacity-50"
|
||||
>
|
||||
{cleanupLoading ? t("qdrant.cleaning") : t("qdrant.cleanNow")}
|
||||
</button>
|
||||
</div>
|
||||
{cleanupMsg && <p className="mt-2 text-xs text-text-muted">{cleanupMsg}</p>}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { MemorySettingsExtended } from "@/shared/schemas/memory";
|
||||
import type { EmbeddingProviderListing } from "@/lib/memory/embedding/types";
|
||||
|
||||
interface Props {
|
||||
settings: MemorySettingsExtended;
|
||||
providers: EmbeddingProviderListing[];
|
||||
onSave: (updates: Partial<MemorySettingsExtended>) => Promise<boolean>;
|
||||
saving?: boolean;
|
||||
}
|
||||
|
||||
export default function RerankConfigCard({ settings, providers, onSave, saving }: Props) {
|
||||
const t = useTranslations("memory");
|
||||
|
||||
const rerankEnabled = settings.rerankEnabled ?? false;
|
||||
const rerankProviderModel = settings.rerankProviderModel ?? "";
|
||||
|
||||
// Only list providers that have keys configured
|
||||
const rerankProviders = providers.filter((p) => p.hasKey);
|
||||
const hasProvider = rerankProviders.length > 0;
|
||||
|
||||
const handleProviderModelChange = (value: string) => {
|
||||
onSave({ rerankProviderModel: value || null });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/30 border border-border/60">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-main">{t("rerank.enableLabel")}</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">{t("rerank.enableDesc")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="rerank-enabled-switch"
|
||||
onClick={() => onSave({ rerankEnabled: !rerankEnabled })}
|
||||
disabled={saving}
|
||||
role="switch"
|
||||
aria-checked={rerankEnabled}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors shrink-0 ${
|
||||
rerankEnabled ? "bg-violet-500" : "bg-border"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
rerankEnabled ? "translate-x-5" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{rerankEnabled && (
|
||||
<>
|
||||
{/* Latency / cost warning */}
|
||||
<div className="p-3 rounded-lg bg-amber-500/10 border border-amber-500/20 text-xs text-amber-400 flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-[14px] mt-0.5 shrink-0">warning</span>
|
||||
<span>{t("rerank.warning")}</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg bg-surface/30 border border-border/60">
|
||||
<label className="block text-sm font-medium text-text-main mb-2">
|
||||
{t("rerank.providerModelLabel")}
|
||||
</label>
|
||||
{!hasProvider ? (
|
||||
<p
|
||||
data-testid="rerank-no-provider-warning"
|
||||
className="text-xs text-amber-400 flex items-center gap-1"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]">warning</span>
|
||||
{t("rerank.noProviderWithKey")}
|
||||
</p>
|
||||
) : (
|
||||
<select
|
||||
value={rerankProviderModel}
|
||||
onChange={(e) => handleProviderModelChange(e.target.value)}
|
||||
disabled={saving}
|
||||
data-testid="rerank-provider-model-select"
|
||||
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
>
|
||||
<option value="">{t("rerank.selectProviderModel")}</option>
|
||||
{rerankProviders.map((p) =>
|
||||
p.models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
)),
|
||||
)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Badge } from "@/shared/components";
|
||||
import type { RetrievePreviewResult } from "@/shared/schemas/memory";
|
||||
|
||||
interface Props {
|
||||
result: RetrievePreviewResult;
|
||||
}
|
||||
|
||||
const TIER_VARIANT: Record<string, "info" | "success" | "warning" | "default"> = {
|
||||
fts5: "info",
|
||||
vector: "success",
|
||||
"hybrid-rrf": "warning",
|
||||
qdrant: "default",
|
||||
};
|
||||
|
||||
export default function RetrievePreview({ result }: Props) {
|
||||
const t = useTranslations("memory");
|
||||
const { memories, resolution, totalTokensUsed, budgetMaxTokens } = result;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Resolution panel */}
|
||||
<div className="p-3 rounded-lg bg-surface/30 border border-border/60 text-xs text-text-muted space-y-1">
|
||||
<p className="font-medium text-text-main text-sm">{t("playground.resolutionTitle")}</p>
|
||||
<p>
|
||||
{t("playground.resolutionEmbedding")}:{" "}
|
||||
<span className="font-mono text-text-main">
|
||||
{resolution.embeddingModel ?? t("playground.none")}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
{t("playground.resolutionStore")}:{" "}
|
||||
<span className="font-mono text-text-main">{resolution.vectorStore}</span>
|
||||
</p>
|
||||
<p>
|
||||
{t("playground.resolutionStrategy")}:{" "}
|
||||
<span className="font-mono text-text-main">{resolution.strategyUsed}</span>
|
||||
</p>
|
||||
{resolution.rerankApplied && (
|
||||
<p className="text-emerald-400">
|
||||
<span className="material-symbols-outlined text-[12px] align-middle mr-1">check</span>
|
||||
{t("playground.rerankApplied")}
|
||||
</p>
|
||||
)}
|
||||
{resolution.fallbackReason && (
|
||||
<p className="text-amber-400">
|
||||
<span className="material-symbols-outlined text-[12px] align-middle mr-1">warning</span>
|
||||
{t("playground.fallback")}: {resolution.fallbackReason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results list */}
|
||||
{memories.length === 0 ? (
|
||||
<div className="p-6 text-center text-sm text-text-muted">
|
||||
{t("playground.noResults")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{memories.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="p-3 rounded-lg border border-border/60 bg-surface/30 space-y-1"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant={TIER_VARIANT[m.tier] ?? "default"} size="sm">
|
||||
{m.tier}
|
||||
</Badge>
|
||||
<span className="text-xs font-medium text-text-main">{m.key}</span>
|
||||
<span className="ml-auto text-xs text-text-muted font-mono">
|
||||
score {m.score.toFixed(3)}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">{m.tokens} tok</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">{m.content}</p>
|
||||
<div className="flex gap-3 text-[10px] text-text-muted/70 font-mono">
|
||||
{m.vecScore !== null && <span>vec: {m.vecScore.toFixed(3)}</span>}
|
||||
{m.ftsScore !== null && <span>fts: {m.ftsScore.toFixed(3)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer budget */}
|
||||
<div className="flex items-center justify-end gap-2 text-xs text-text-muted">
|
||||
<span>
|
||||
{totalTokensUsed.toLocaleString()} / {budgetMaxTokens.toLocaleString()}{" "}
|
||||
{t("playground.tokensUsed")}
|
||||
</span>
|
||||
<div className="h-1.5 w-24 rounded-full bg-border overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-violet-500"
|
||||
style={{
|
||||
width: `${Math.min(100, (totalTokensUsed / Math.max(1, budgetMaxTokens)) * 100).toFixed(1)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, Button } from "@/shared/components";
|
||||
import MemoryEngineStatus from "../MemoryEngineStatus";
|
||||
import EmbeddingSourceSelector from "../EmbeddingSourceSelector";
|
||||
import QdrantConfigCard from "../QdrantConfigCard";
|
||||
import RerankConfigCard from "../RerankConfigCard";
|
||||
import { useEngineStatus } from "../../hooks/useEngineStatus";
|
||||
import { useMemorySettings } from "../../hooks/useMemorySettings";
|
||||
import type { EmbeddingProviderListing } from "@/lib/memory/embedding/types";
|
||||
|
||||
export default function EngineTab() {
|
||||
const t = useTranslations("memory");
|
||||
const { status, isLoading: statusLoading } = useEngineStatus();
|
||||
const { settings, save: saveSettings, isLoading: settingsLoading } = useMemorySettings();
|
||||
const [providers, setProviders] = useState<EmbeddingProviderListing[]>([]);
|
||||
const [providersLoaded, setProvidersLoaded] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [reindexing, setReindexing] = useState(false);
|
||||
const [reindexMsg, setReindexMsg] = useState("");
|
||||
|
||||
// Lazy-load providers
|
||||
if (!providersLoaded && !settingsLoading) {
|
||||
setProvidersLoaded(true);
|
||||
fetch("/api/memory/embedding-providers")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
if (data?.providers) setProviders(data.providers);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
const handleSaveSettings = async (updates: Parameters<typeof saveSettings>[0]) => {
|
||||
setSaving(true);
|
||||
const ok = await saveSettings(updates);
|
||||
setSaving(false);
|
||||
return ok;
|
||||
};
|
||||
|
||||
const handleReindex = async () => {
|
||||
setReindexing(true);
|
||||
setReindexMsg("");
|
||||
try {
|
||||
const res = await fetch("/api/memory/reindex", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ force: false }),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (res.ok) {
|
||||
setReindexMsg(t("engine.reindexStarted", { pending: data?.pending ?? 0 }));
|
||||
} else {
|
||||
setReindexMsg(t("engine.reindexFailed"));
|
||||
}
|
||||
} catch {
|
||||
setReindexMsg(t("engine.reindexFailed"));
|
||||
} finally {
|
||||
setReindexing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = statusLoading || settingsLoading;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Engine status panel */}
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-text-main">{t("engine.statusTitle")}</h3>
|
||||
<Button
|
||||
data-testid="reindex-now-button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleReindex}
|
||||
loading={reindexing}
|
||||
>
|
||||
{t("engine.reindexNow")}
|
||||
</Button>
|
||||
</div>
|
||||
{isLoading || !status ? (
|
||||
<div className="text-sm text-text-muted">{t("loading")}</div>
|
||||
) : (
|
||||
<MemoryEngineStatus status={status} />
|
||||
)}
|
||||
{reindexMsg && (
|
||||
<p className="mt-3 text-xs text-text-muted">{reindexMsg}</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Embedding source selector */}
|
||||
{settings && (
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<h3 className="text-sm font-semibold text-text-main mb-4">
|
||||
{t("engine.embeddingTitle")}
|
||||
</h3>
|
||||
<EmbeddingSourceSelector
|
||||
settings={settings}
|
||||
providers={providers}
|
||||
onSave={handleSaveSettings}
|
||||
saving={saving}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Qdrant config */}
|
||||
<QdrantConfigCard />
|
||||
|
||||
{/* Rerank config */}
|
||||
{settings && (
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<h3 className="text-sm font-semibold text-text-main mb-4">
|
||||
{t("engine.rerankTitle")}
|
||||
</h3>
|
||||
<RerankConfigCard
|
||||
settings={settings}
|
||||
providers={providers}
|
||||
onSave={handleSaveSettings}
|
||||
saving={saving}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, Badge, Button, Input, Select, Modal } from "@/shared/components";
|
||||
import EditMemoryModal from "../EditMemoryModal";
|
||||
|
||||
interface Memory {
|
||||
id: string;
|
||||
apiKeyId: string;
|
||||
sessionId: string | null;
|
||||
type: "factual" | "episodic" | "procedural" | "semantic";
|
||||
key: string;
|
||||
content: string;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
interface MemoryStats {
|
||||
totalEntries: number;
|
||||
tokensUsed: number;
|
||||
hitRate: number;
|
||||
cacheStats?: { hits: number; misses: number };
|
||||
}
|
||||
|
||||
const TYPE_TOOLTIPS: Record<string, string> = {
|
||||
factual: "memory.tooltip.factual",
|
||||
episodic: "memory.tooltip.episodic",
|
||||
procedural: "memory.tooltip.procedural",
|
||||
semantic: "memory.tooltip.semantic",
|
||||
};
|
||||
|
||||
function getTypeColor(type: string): "info" | "success" | "warning" | "error" | "default" {
|
||||
switch (type) {
|
||||
case "factual":
|
||||
return "info";
|
||||
case "episodic":
|
||||
return "success";
|
||||
case "procedural":
|
||||
return "warning";
|
||||
case "semantic":
|
||||
return "error";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
export default function MemoriesTab() {
|
||||
const t = useTranslations("memory");
|
||||
const [memories, setMemories] = useState<Memory[]>([]);
|
||||
const [stats, setStats] = useState<MemoryStats>({
|
||||
totalEntries: 0,
|
||||
tokensUsed: 0,
|
||||
hitRate: 0,
|
||||
cacheStats: { hits: 0, misses: 0 },
|
||||
});
|
||||
const [filterType, setFilterType] = useState<string>("all");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [health, setHealth] = useState<{ working: boolean; latencyMs: number } | null>(null);
|
||||
const [checkingHealth, setCheckingHealth] = useState(false);
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||
const [newMemory, setNewMemory] = useState<Partial<Memory>>({
|
||||
type: "factual",
|
||||
key: "",
|
||||
content: "",
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<Memory | null>(null);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [summarizeDialogOpen, setSummarizeDialogOpen] = useState(false);
|
||||
const [summarizeCandidates, setSummarizeCandidates] = useState<string[]>([]);
|
||||
const [summarizeDryRunLoading, setSummarizeDryRunLoading] = useState(false);
|
||||
const [summarizeRunLoading, setSummarizeRunLoading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [importStatus, setImportStatus] = useState<string>("");
|
||||
|
||||
const fetchMemories = useCallback(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
limit: "20",
|
||||
});
|
||||
if (filterType !== "all") params.append("type", filterType);
|
||||
if (searchQuery) params.append("q", searchQuery);
|
||||
|
||||
const response = await fetch(`/api/memory?${params.toString()}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setMemories(data.data || []);
|
||||
setTotalPages(data.totalPages || 1);
|
||||
setTotal(data.total || 0);
|
||||
setStats({
|
||||
totalEntries: data.stats?.total ?? data.total ?? 0,
|
||||
tokensUsed: data.stats?.tokensUsed ?? 0,
|
||||
hitRate: data.stats?.hitRate ?? 0,
|
||||
cacheStats: data.stats?.cacheStats ?? { hits: 0, misses: 0 },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch memories:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [page, filterType, searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
fetchMemories();
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [fetchMemories]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await fetch(`/api/memory/${id}`, { method: "DELETE" });
|
||||
setMemories((ms) => ms.filter((m) => m.id !== id));
|
||||
setDeleteConfirmId(null);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete memory:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const dataStr = JSON.stringify(memories, null, 2);
|
||||
const dataBlob = new Blob([dataStr], { type: "application/json" });
|
||||
const url = URL.createObjectURL(dataBlob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `memory-export-${new Date().toISOString()}.json`;
|
||||
try {
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
} finally {
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setImportStatus("");
|
||||
let skipped = 0;
|
||||
let imported = 0;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
const memoriesToImport = Array.isArray(data) ? data : [data];
|
||||
|
||||
for (const m of memoriesToImport) {
|
||||
if (!m.key || !m.content) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const res = await fetch("/api/memory", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: m.type || "factual",
|
||||
key: m.key,
|
||||
content: m.content,
|
||||
metadata: m.metadata || {},
|
||||
}),
|
||||
});
|
||||
if (res.ok) imported++;
|
||||
else skipped++;
|
||||
}
|
||||
fetchMemories();
|
||||
setImportStatus(
|
||||
t("importResult", { imported, skipped }),
|
||||
);
|
||||
} catch {
|
||||
setImportStatus(t("importError"));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMemory = async () => {
|
||||
if (!newMemory.key || !newMemory.content) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const response = await fetch("/api/memory", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(newMemory),
|
||||
});
|
||||
if (response.ok) {
|
||||
setAddDialogOpen(false);
|
||||
setNewMemory({ type: "factual", key: "", content: "" });
|
||||
fetchMemories();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to add memory:", error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkHealth = async () => {
|
||||
setCheckingHealth(true);
|
||||
try {
|
||||
const res = await fetch("/api/memory/health");
|
||||
if (res.ok) {
|
||||
setHealth(await res.json());
|
||||
}
|
||||
} catch {
|
||||
setHealth(null);
|
||||
} finally {
|
||||
setCheckingHealth(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (m: Memory) => {
|
||||
setEditTarget(m);
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const handleCompactDryRun = async () => {
|
||||
setSummarizeDryRunLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/memory/summarize", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dryRun: true, olderThanDays: 30 }),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
const candidates: string[] =
|
||||
Array.isArray(data?.candidates) ? data.candidates.map((c: { key?: string }) => c?.key ?? String(c)) : [];
|
||||
setSummarizeCandidates(candidates);
|
||||
setSummarizeDialogOpen(true);
|
||||
} catch {
|
||||
setSummarizeCandidates([]);
|
||||
setSummarizeDialogOpen(true);
|
||||
} finally {
|
||||
setSummarizeDryRunLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompactConfirm = async () => {
|
||||
setSummarizeRunLoading(true);
|
||||
try {
|
||||
await fetch("/api/memory/summarize", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dryRun: false, olderThanDays: 30 }),
|
||||
});
|
||||
setSummarizeDialogOpen(false);
|
||||
fetchMemories();
|
||||
} catch {
|
||||
setSummarizeDialogOpen(false);
|
||||
} finally {
|
||||
setSummarizeRunLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showHitRate =
|
||||
(stats.cacheStats?.hits ?? 0) + (stats.cacheStats?.misses ?? 0) > 0;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-violet-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-end gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
{health !== null && (
|
||||
<span
|
||||
className={`inline-block w-3 h-3 rounded-full ${health.working ? "bg-green-500" : "bg-red-500"}`}
|
||||
title={
|
||||
health.working
|
||||
? t("pipelineOk", { latencyMs: health.latencyMs })
|
||||
: t("pipelineError")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{health === null && !checkingHealth && (
|
||||
<span
|
||||
className="inline-block w-3 h-3 rounded-full bg-gray-400"
|
||||
title={t("healthUnknown")}
|
||||
/>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={checkHealth} disabled={checkingHealth}>
|
||||
{checkingHealth ? t("checkingHealth") : t("checkHealth")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={handleExport}>
|
||||
{t("export")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleImportClick} loading={isSubmitting}>
|
||||
{t("import")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCompactDryRun}
|
||||
loading={summarizeDryRunLoading}
|
||||
>
|
||||
{t("compactOld")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setAddDialogOpen(true)}>
|
||||
{t("addMemory")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{importStatus && (
|
||||
<div className="p-3 rounded-lg bg-surface/30 border border-border/60 text-xs text-text-muted">
|
||||
{importStatus}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-text-muted">{t("totalEntries")}</span>
|
||||
<span
|
||||
className="material-symbols-outlined text-[14px] text-text-muted cursor-help"
|
||||
title={t("tooltip.totalEntries")}
|
||||
>
|
||||
info
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold">{stats.totalEntries}</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-text-muted">{t("tokensUsed")}</span>
|
||||
<span
|
||||
className="material-symbols-outlined text-[14px] text-text-muted cursor-help"
|
||||
title={t("tooltip.tokensUsed")}
|
||||
>
|
||||
info
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold">{(stats.tokensUsed ?? 0).toLocaleString()}</div>
|
||||
</div>
|
||||
</Card>
|
||||
{showHitRate && (
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-text-muted">{t("hitRate")}</span>
|
||||
<span
|
||||
className="material-symbols-outlined text-[14px] text-text-muted cursor-help"
|
||||
title={t("tooltip.hitRate")}
|
||||
>
|
||||
info
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{((stats.hitRate ?? 0) * 100).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Memories table */}
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">{t("memories")}</h2>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t("search")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="w-64"
|
||||
/>
|
||||
<Select
|
||||
value={filterType}
|
||||
onChange={(e) => {
|
||||
setFilterType(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="all">{t("allTypes")}</option>
|
||||
<option value="factual">{t("factual")}</option>
|
||||
<option value="episodic">{t("episodic")}</option>
|
||||
<option value="procedural">{t("procedural")}</option>
|
||||
<option value="semantic">{t("semantic")}</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{memories.length === 0 ? (
|
||||
<div
|
||||
data-testid="memories-empty-state"
|
||||
className="flex flex-col items-center justify-center py-12 text-center"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[40px] text-text-muted mb-3">
|
||||
psychology
|
||||
</span>
|
||||
<p className="text-sm font-medium text-text-main mb-1">
|
||||
{t("emptyState.title")}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted max-w-xs">
|
||||
{t("emptyState.description")}
|
||||
</p>
|
||||
<Button className="mt-4" size="sm" onClick={() => setAddDialogOpen(true)}>
|
||||
{t("addMemory")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-2 px-4">{t("type")}</th>
|
||||
<th className="text-left py-2 px-4">{t("key")}</th>
|
||||
<th className="text-left py-2 px-4">{t("content")}</th>
|
||||
<th className="text-left py-2 px-4">{t("created")}</th>
|
||||
<th className="text-left py-2 px-4">{t("actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{memories.map((memory) => (
|
||||
<tr key={memory.id} className="border-b hover:bg-surface/30">
|
||||
<td className="py-2 px-4">
|
||||
<Badge
|
||||
variant={getTypeColor(memory.type)}
|
||||
title={t(TYPE_TOOLTIPS[memory.type]?.replace("memory.", "") ?? memory.type)}
|
||||
>
|
||||
{t(memory.type)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-2 px-4 font-medium">{memory.key}</td>
|
||||
<td className="py-2 px-4 max-w-md truncate text-text-muted">
|
||||
{memory.content}
|
||||
</td>
|
||||
<td className="py-2 px-4 text-xs text-text-muted">
|
||||
{new Date(memory.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="py-2 px-4">
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-testid={`edit-memory-${memory.id}`}
|
||||
onClick={() => openEdit(memory)}
|
||||
title={t("editMemory")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">edit</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-testid={`delete-memory-${memory.id}`}
|
||||
onClick={() => setDeleteConfirmId(memory.id)}
|
||||
>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-text-muted">
|
||||
{t("pageInfo", { page, totalPages, total })}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
{t("previous")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
{t("next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Add Memory Modal */}
|
||||
<Modal
|
||||
isOpen={addDialogOpen}
|
||||
onClose={() => setAddDialogOpen(false)}
|
||||
title={t("addMemory")}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setAddDialogOpen(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddMemory}
|
||||
loading={isSubmitting}
|
||||
disabled={!newMemory.key || !newMemory.content}
|
||||
>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t("type")}</label>
|
||||
<Select
|
||||
value={newMemory.type}
|
||||
onChange={(e) =>
|
||||
setNewMemory({ ...newMemory, type: e.target.value as Memory["type"] })
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
<option value="factual">{t("factual")}</option>
|
||||
<option value="episodic">{t("episodic")}</option>
|
||||
<option value="procedural">{t("procedural")}</option>
|
||||
<option value="semantic">{t("semantic")}</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t("key")}</label>
|
||||
<Input
|
||||
value={newMemory.key}
|
||||
onChange={(e) => setNewMemory({ ...newMemory, key: e.target.value })}
|
||||
placeholder={t("keyPlaceholder")}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t("content")}</label>
|
||||
<Input
|
||||
value={newMemory.content}
|
||||
onChange={(e) => setNewMemory({ ...newMemory, content: e.target.value })}
|
||||
placeholder={t("contentPlaceholder")}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete confirm */}
|
||||
<Modal
|
||||
isOpen={Boolean(deleteConfirmId)}
|
||||
onClose={() => setDeleteConfirmId(null)}
|
||||
title={t("deleteConfirmTitle")}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setDeleteConfirmId(null)}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => deleteConfirmId && handleDelete(deleteConfirmId)}
|
||||
>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-text-muted">{t("deleteConfirmDesc")}</p>
|
||||
</Modal>
|
||||
|
||||
{/* Edit memory modal */}
|
||||
<EditMemoryModal
|
||||
memory={editTarget}
|
||||
isOpen={editOpen}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onSaved={fetchMemories}
|
||||
/>
|
||||
|
||||
{/* Summarize confirm dialog */}
|
||||
<Modal
|
||||
isOpen={summarizeDialogOpen}
|
||||
onClose={() => setSummarizeDialogOpen(false)}
|
||||
title={t("summarize.title")}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setSummarizeDialogOpen(false)}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCompactConfirm}
|
||||
loading={summarizeRunLoading}
|
||||
disabled={summarizeCandidates.length === 0}
|
||||
>
|
||||
{t("summarize.confirm")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{summarizeCandidates.length === 0 ? (
|
||||
<p className="text-sm text-text-muted">{t("summarize.noCandidates")}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-text-muted">
|
||||
{t("summarize.candidatesDesc", { count: summarizeCandidates.length })}
|
||||
</p>
|
||||
<ul className="space-y-1 max-h-48 overflow-y-auto">
|
||||
{summarizeCandidates.map((key, i) => (
|
||||
<li key={i} className="text-xs font-mono text-text-main truncate px-2 py-1 bg-surface/30 rounded">
|
||||
{key}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, Button, Input, Select } from "@/shared/components";
|
||||
import RetrievePreview from "../RetrievePreview";
|
||||
import type { RetrievePreviewResult } from "@/shared/schemas/memory";
|
||||
|
||||
export default function PlaygroundTab() {
|
||||
const t = useTranslations("memory");
|
||||
const [query, setQuery] = useState("");
|
||||
const [strategy, setStrategy] = useState<"exact" | "semantic" | "hybrid">("hybrid");
|
||||
const [budget, setBudget] = useState("2000");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<RetrievePreviewResult | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const q = query.trim();
|
||||
if (!q) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/memory/retrieve-preview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
query: q,
|
||||
strategy,
|
||||
maxTokens: parseInt(budget) || 2000,
|
||||
limit: 20,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data: RetrievePreviewResult = await res.json();
|
||||
setResult(data);
|
||||
} else {
|
||||
const data = await res.json().catch(() => null);
|
||||
setError(data?.error?.message ?? t("playground.errorFetch"));
|
||||
}
|
||||
} catch {
|
||||
setError(t("playground.errorFetch"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Info */}
|
||||
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
|
||||
science
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-text-main mb-0.5">{t("playground.infoTitle")}</p>
|
||||
<p>{t("playground.infoDesc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<Card>
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">
|
||||
{t("playground.queryLabel")}
|
||||
</label>
|
||||
<Input
|
||||
data-testid="playground-query-input"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("playground.queryPlaceholder")}
|
||||
className="w-full"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !loading) {
|
||||
void handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">
|
||||
{t("playground.strategyLabel")}
|
||||
</label>
|
||||
<Select
|
||||
data-testid="playground-strategy-select"
|
||||
value={strategy}
|
||||
onChange={(e) =>
|
||||
setStrategy(e.target.value as "exact" | "semantic" | "hybrid")
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
<option value="exact">{t("playground.strategyExact")}</option>
|
||||
<option value="semantic">{t("playground.strategySemantic")}</option>
|
||||
<option value="hybrid">{t("playground.strategyHybrid")}</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">
|
||||
{t("playground.budgetLabel")}
|
||||
</label>
|
||||
<Input
|
||||
data-testid="playground-budget-input"
|
||||
value={budget}
|
||||
onChange={(e) => setBudget(e.target.value)}
|
||||
type="number"
|
||||
min="100"
|
||||
max="16000"
|
||||
step="100"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
data-testid="playground-submit"
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
disabled={!query.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{t("playground.simulate")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-xs text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<h3 className="text-sm font-semibold text-text-main mb-4">
|
||||
{t("playground.resultsTitle", { count: result.memories.length })}
|
||||
</h3>
|
||||
<RetrievePreview result={result} />
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import useSWR from "swr";
|
||||
import type { MemoryEngineStatus } from "@/shared/schemas/memory";
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
|
||||
export interface UseEngineStatusResult {
|
||||
status: MemoryEngineStatus | null;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
mutate: () => void;
|
||||
}
|
||||
|
||||
export function useEngineStatus(): UseEngineStatusResult {
|
||||
const { data, error, isLoading, mutate } = useSWR<MemoryEngineStatus>(
|
||||
"/api/memory/engine-status",
|
||||
fetcher,
|
||||
{ refreshInterval: 5000 },
|
||||
);
|
||||
|
||||
return {
|
||||
status: data ?? null,
|
||||
isLoading,
|
||||
isError: Boolean(error),
|
||||
mutate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import useSWR from "swr";
|
||||
import type { MemorySettingsExtended } from "@/shared/schemas/memory";
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
|
||||
export interface UseMemorySettingsResult {
|
||||
settings: MemorySettingsExtended | null;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
mutate: () => void;
|
||||
save: (updates: Partial<MemorySettingsExtended>) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function useMemorySettings(): UseMemorySettingsResult {
|
||||
const { data, error, isLoading, mutate } = useSWR<MemorySettingsExtended>(
|
||||
"/api/settings/memory",
|
||||
fetcher,
|
||||
);
|
||||
|
||||
const save = async (updates: Partial<MemorySettingsExtended>): Promise<boolean> => {
|
||||
try {
|
||||
const current = data ?? {};
|
||||
const next = { ...current, ...updates };
|
||||
const res = await fetch("/api/settings/memory", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(next),
|
||||
});
|
||||
if (res.ok) {
|
||||
await mutate();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
settings: data ?? null,
|
||||
isLoading,
|
||||
isError: Boolean(error),
|
||||
mutate,
|
||||
save,
|
||||
};
|
||||
}
|
||||
@@ -1,414 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Card, Badge, Button, Input, Select, Modal } from "@/shared/components";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import MemoryConceptCard from "./components/MemoryConceptCard";
|
||||
import MemoriesTab from "./components/tabs/MemoriesTab";
|
||||
import PlaygroundTab from "./components/tabs/PlaygroundTab";
|
||||
import EngineTab from "./components/tabs/EngineTab";
|
||||
|
||||
interface Memory {
|
||||
id: string;
|
||||
apiKeyId: string;
|
||||
sessionId: string | null;
|
||||
type: "factual" | "episodic" | "procedural" | "semantic";
|
||||
key: string;
|
||||
content: string;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
type TabId = "memories" | "playground" | "engine";
|
||||
|
||||
interface MemoryStats {
|
||||
totalEntries: number;
|
||||
tokensUsed: number;
|
||||
hitRate: number;
|
||||
}
|
||||
const TABS: TabId[] = ["memories", "playground", "engine"];
|
||||
|
||||
export default function MemoryPage() {
|
||||
function MemoryPageContent() {
|
||||
const t = useTranslations("memory");
|
||||
const [memories, setMemories] = useState<Memory[]>([]);
|
||||
const [stats, setStats] = useState<MemoryStats>({
|
||||
totalEntries: 0,
|
||||
tokensUsed: 0,
|
||||
hitRate: 0,
|
||||
});
|
||||
const [filterType, setFilterType] = useState<string>("all");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [health, setHealth] = useState<{ working: boolean; latencyMs: number } | null>(null);
|
||||
const [checkingHealth, setCheckingHealth] = useState(false);
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||
const [newMemory, setNewMemory] = useState<Partial<Memory>>({
|
||||
type: "factual",
|
||||
key: "",
|
||||
content: "",
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const fetchMemories = useCallback(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
limit: "20",
|
||||
});
|
||||
if (filterType !== "all") params.append("type", filterType);
|
||||
if (searchQuery) params.append("q", searchQuery);
|
||||
const rawTab = searchParams.get("tab") ?? "";
|
||||
const activeTab: TabId = TABS.includes(rawTab as TabId) ? (rawTab as TabId) : "memories";
|
||||
|
||||
const response = await fetch(`/api/memory?${params.toString()}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setMemories(data.data || []);
|
||||
setTotalPages(data.totalPages || 1);
|
||||
setTotal(data.total || 0);
|
||||
setStats({
|
||||
totalEntries: data.stats?.total ?? data.total ?? 0,
|
||||
tokensUsed: data.stats?.tokensUsed ?? 0,
|
||||
hitRate: data.stats?.hitRate ?? 0,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch memories:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [page, filterType, searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
fetchMemories();
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [fetchMemories]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await fetch(`/api/memory/${id}`, { method: "DELETE" });
|
||||
setMemories(memories.filter((m) => m.id !== id));
|
||||
} catch (error) {
|
||||
console.error("Failed to delete memory:", error);
|
||||
}
|
||||
const setTab = (tab: TabId) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("tab", tab);
|
||||
router.replace(`?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const dataStr = JSON.stringify(memories, null, 2);
|
||||
const dataBlob = new Blob([dataStr], { type: "application/json" });
|
||||
const url = URL.createObjectURL(dataBlob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `memory-export-${new Date().toISOString()}.json`;
|
||||
try {
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
} finally {
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
const memoriesToImport = Array.isArray(data) ? data : [data];
|
||||
|
||||
for (const m of memoriesToImport) {
|
||||
if (!m.key || !m.content) continue;
|
||||
await fetch("/api/memory", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: m.type || "factual",
|
||||
key: m.key,
|
||||
content: m.content,
|
||||
metadata: m.metadata || {},
|
||||
}),
|
||||
});
|
||||
}
|
||||
fetchMemories();
|
||||
} catch (error) {
|
||||
console.error("Failed to import memories:", error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMemory = async () => {
|
||||
if (!newMemory.key || !newMemory.content) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const response = await fetch("/api/memory", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(newMemory),
|
||||
});
|
||||
if (response.ok) {
|
||||
setAddDialogOpen(false);
|
||||
setNewMemory({ type: "factual", key: "", content: "" });
|
||||
fetchMemories();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to add memory:", error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkHealth = async () => {
|
||||
setCheckingHealth(true);
|
||||
try {
|
||||
const res = await fetch("/api/memory/health");
|
||||
if (res.ok) {
|
||||
setHealth(await res.json());
|
||||
}
|
||||
} catch {
|
||||
setHealth(null);
|
||||
} finally {
|
||||
setCheckingHealth(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case "factual":
|
||||
return "info";
|
||||
case "episodic":
|
||||
return "success";
|
||||
case "procedural":
|
||||
return "warning";
|
||||
case "semantic":
|
||||
return "error";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{health !== null && (
|
||||
<span
|
||||
className={`inline-block w-3 h-3 rounded-full ${health.working ? "bg-green-500" : "bg-red-500"}`}
|
||||
title={
|
||||
health.working
|
||||
? t("pipelineOk", { latencyMs: health.latencyMs })
|
||||
: t("pipelineError")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{health === null && !checkingHealth && (
|
||||
<span
|
||||
className="inline-block w-3 h-3 rounded-full bg-gray-400"
|
||||
title={t("healthUnknown")}
|
||||
/>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={checkHealth} disabled={checkingHealth}>
|
||||
{checkingHealth ? t("checkingHealth") : t("checkHealth")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
/>
|
||||
<Button variant="outline" onClick={handleExport}>
|
||||
{t("export")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleImportClick} loading={isSubmitting}>
|
||||
{t("import")}
|
||||
</Button>
|
||||
<Button onClick={() => setAddDialogOpen(true)}>{t("addMemory")}</Button>
|
||||
</div>
|
||||
{/* Concept card */}
|
||||
<MemoryConceptCard />
|
||||
|
||||
{/* Tab navigation */}
|
||||
<div className="flex gap-1 p-1 rounded-lg bg-surface/50 border border-border/60 w-fit">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
data-testid={`tab-${tab}`}
|
||||
onClick={() => setTab(tab)}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
|
||||
activeTab === tab
|
||||
? "bg-bg text-text-main shadow-sm"
|
||||
: "text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
{t(`tabs.${tab}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<div className="text-sm text-gray-500">{t("totalEntries")}</div>
|
||||
<div className="text-2xl font-bold">{stats.totalEntries}</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<div className="text-sm text-gray-500">{t("tokensUsed")}</div>
|
||||
<div className="text-2xl font-bold">{(stats.tokensUsed ?? 0).toLocaleString()}</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<div className="text-sm text-gray-500">{t("hitRate")}</div>
|
||||
<div className="text-2xl font-bold">{((stats.hitRate ?? 0) * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">{t("memories")}</h2>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t("search")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="w-64"
|
||||
/>
|
||||
<Select
|
||||
value={filterType}
|
||||
onChange={(e) => {
|
||||
setFilterType(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="all">{t("allTypes")}</option>
|
||||
<option value="factual">{t("factual")}</option>
|
||||
<option value="episodic">{t("episodic")}</option>
|
||||
<option value="procedural">{t("procedural")}</option>
|
||||
<option value="semantic">{t("semantic")}</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-2 px-4">{t("type")}</th>
|
||||
<th className="text-left py-2 px-4">{t("key")}</th>
|
||||
<th className="text-left py-2 px-4">{t("content")}</th>
|
||||
<th className="text-left py-2 px-4">{t("created")}</th>
|
||||
<th className="text-left py-2 px-4">{t("actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{memories.map((memory) => (
|
||||
<tr key={memory.id} className="border-b">
|
||||
<td className="py-2 px-4">
|
||||
<Badge variant={getTypeColor(memory.type) as any}>{memory.type}</Badge>
|
||||
</td>
|
||||
<td className="py-2 px-4 font-medium">{memory.key}</td>
|
||||
<td className="py-2 px-4 max-w-md truncate">{memory.content}</td>
|
||||
<td className="py-2 px-4">{new Date(memory.createdAt).toLocaleDateString()}</td>
|
||||
<td className="py-2 px-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDelete(memory.id)}>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-gray-500">
|
||||
{t("pageInfo", { page, totalPages, total })}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
{t("previous")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
{t("next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
isOpen={addDialogOpen}
|
||||
onClose={() => setAddDialogOpen(false)}
|
||||
title={t("addMemory")}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setAddDialogOpen(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddMemory}
|
||||
loading={isSubmitting}
|
||||
disabled={!newMemory.key || !newMemory.content}
|
||||
>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t("type")}</label>
|
||||
<Select
|
||||
value={newMemory.type}
|
||||
onChange={(e) => setNewMemory({ ...newMemory, type: e.target.value as any })}
|
||||
className="w-full"
|
||||
>
|
||||
<option value="factual">{t("factual")}</option>
|
||||
<option value="episodic">{t("episodic")}</option>
|
||||
<option value="procedural">{t("procedural")}</option>
|
||||
<option value="semantic">{t("semantic")}</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t("key")}</label>
|
||||
<Input
|
||||
value={newMemory.key}
|
||||
onChange={(e) => setNewMemory({ ...newMemory, key: e.target.value })}
|
||||
placeholder={t("keyPlaceholder")}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t("content")}</label>
|
||||
<Input
|
||||
value={newMemory.content}
|
||||
onChange={(e) => setNewMemory({ ...newMemory, content: e.target.value })}
|
||||
placeholder={t("contentPlaceholder")}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
{/* Tab content */}
|
||||
{activeTab === "memories" && <MemoriesTab />}
|
||||
{activeTab === "playground" && <PlaygroundTab />}
|
||||
{activeTab === "engine" && <EngineTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MemoryPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="h-64 flex items-center justify-center" />}>
|
||||
<MemoryPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2984,7 +2984,7 @@
|
||||
"totalEntries": "Total Entries",
|
||||
"tokensUsed": "Tokens Used",
|
||||
"hitRate": "Hit Rate",
|
||||
"loading": "Loading memories...",
|
||||
"loading": "Loading...",
|
||||
"noMemories": "No memories found",
|
||||
"search": "Search memories...",
|
||||
"allTypes": "All Types",
|
||||
@@ -3013,7 +3013,149 @@
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"keyPlaceholder": "e.g. user.preferences.theme",
|
||||
"contentPlaceholder": "Value or JSON content to remember"
|
||||
"contentPlaceholder": "Value or JSON content to remember",
|
||||
"tabs": {
|
||||
"memories": "Memories",
|
||||
"playground": "Playground",
|
||||
"engine": "Engine"
|
||||
},
|
||||
"concept": {
|
||||
"title": "Conversational Memory",
|
||||
"description": "OmniRoute learns from every conversation, remembering facts, episodes, procedures, and semantic concepts that make responses more accurate and context-aware.",
|
||||
"howWorksToggle": "How it works",
|
||||
"howWorksContent": "1. Automatic extraction: at the end of each response, facts and episodes are detected and saved automatically.\n2. Retrieval: before each response, the most relevant memories are searched via FTS5 (exact), vector (semantic), or hybrid RRF.\n3. Injection: relevant memories are injected into the assistant context to improve response quality.\n4. Management: use this page to view, edit, export, and compact old memories."
|
||||
},
|
||||
"tooltip": {
|
||||
"totalEntries": "Total memories stored for this API key",
|
||||
"tokensUsed": "Estimated total tokens occupied by active memories",
|
||||
"hitRate": "Read-by-ID cache hit rate (not semantic recall accuracy)",
|
||||
"factual": "Objective, permanent facts from user context",
|
||||
"episodic": "Events and experiences from conversation history",
|
||||
"procedural": "Procedures, workflows, and instructions the assistant should follow",
|
||||
"semantic": "Concepts, preferences, and domain knowledge"
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "No memories yet",
|
||||
"description": "Memories are created automatically from conversations. You can also add them manually using the button above."
|
||||
},
|
||||
"editModal": {
|
||||
"title": "Edit Memory",
|
||||
"metadataLabel": "Metadata (JSON)",
|
||||
"metadataInvalid": "Invalid JSON",
|
||||
"saveFailed": "Failed to save memory"
|
||||
},
|
||||
"editMemory": "Edit memory",
|
||||
"deleteConfirmTitle": "Delete memory?",
|
||||
"deleteConfirmDesc": "This action cannot be undone. The memory will be permanently removed.",
|
||||
"importResult": "{imported} imported, {skipped} skipped",
|
||||
"importError": "Failed to import file",
|
||||
"compactOld": "Compact old",
|
||||
"summarize": {
|
||||
"title": "Compact old memories",
|
||||
"noCandidates": "No memories eligible for compaction (criteria: >30 days old).",
|
||||
"candidatesDesc": "{count} memories will be compacted into summaries. This action cannot be undone.",
|
||||
"confirm": "Compact now"
|
||||
},
|
||||
"playground": {
|
||||
"infoTitle": "Memory Playground",
|
||||
"infoDesc": "Simulate what would be retrieved for a given query. No memories are modified — read-only preview.",
|
||||
"queryLabel": "Test query",
|
||||
"queryPlaceholder": "Type a question or test phrase...",
|
||||
"strategyLabel": "Strategy",
|
||||
"strategyExact": "Exact (FTS5)",
|
||||
"strategySemantic": "Semantic (vector)",
|
||||
"strategyHybrid": "Hybrid (RRF)",
|
||||
"budgetLabel": "Budget (tokens)",
|
||||
"simulate": "Simulate",
|
||||
"resultsTitle": "{count} result(s)",
|
||||
"resolutionTitle": "Search resolution",
|
||||
"resolutionEmbedding": "Embedding",
|
||||
"resolutionStore": "Vector store",
|
||||
"resolutionStrategy": "Strategy used",
|
||||
"rerankApplied": "Rerank applied",
|
||||
"fallback": "Fallback",
|
||||
"noResults": "No memories found for this query.",
|
||||
"tokensUsed": "tokens used",
|
||||
"none": "none",
|
||||
"errorFetch": "Failed to fetch preview"
|
||||
},
|
||||
"engine": {
|
||||
"statusTitle": "Engine Status",
|
||||
"embeddingTitle": "Embedding Source",
|
||||
"rerankTitle": "Rerank (optional)",
|
||||
"reindexNow": "Reindex now",
|
||||
"reindexStarted": "Reindexing started ({pending} pending)",
|
||||
"reindexFailed": "Failed to start reindexing",
|
||||
"keywordLabel": "Keyword (FTS5)",
|
||||
"keywordReason": "Keyword search always available",
|
||||
"embeddingLabel": "Embedding",
|
||||
"vectorStoreLabel": "Vector Store",
|
||||
"qdrantLabel": "Qdrant",
|
||||
"rerankLabel": "Rerank",
|
||||
"qdrantDisabled": "Disabled",
|
||||
"qdrantOk": "Healthy ({latencyMs}ms)",
|
||||
"qdrantError": "Connection error",
|
||||
"needsReindex": "{count} memory(ies) need reindexing"
|
||||
},
|
||||
"embedding": {
|
||||
"autoLabel": "Automatic",
|
||||
"autoDesc": "Uses best available: remote provider > static > transformers",
|
||||
"remoteLabel": "Remote provider",
|
||||
"remoteDesc": "Uses embedding via provider API (requires API key)",
|
||||
"staticLabel": "Static local (potion)",
|
||||
"staticDesc": "Local embedding without WASM or external dependencies",
|
||||
"transformersLabel": "Transformers.js (MiniLM)",
|
||||
"transformersDesc": "Local embedding via @huggingface/transformers (~400MB RAM)",
|
||||
"providerModelLabel": "Provider / Model",
|
||||
"noRemoteProviders": "No providers with configured API key",
|
||||
"selectProviderModel": "Select a model",
|
||||
"staticEnabledLabel": "Enable Static Potion",
|
||||
"staticEnabledDesc": "Download and use potion-base-8M model locally",
|
||||
"transformersEnabledLabel": "Enable Transformers.js",
|
||||
"transformersEnabledDesc": "Opt-in for local MiniLM (~400MB RAM, ~3s cold start)",
|
||||
"transformersWarning": "Requires ~400MB RAM and ~3s cold start on the first semantic query."
|
||||
},
|
||||
"qdrant": {
|
||||
"title": "Qdrant (Vector Store Tier 2)",
|
||||
"description": "Optional Qdrant integration for scalable semantic search",
|
||||
"enableLabel": "Enable Qdrant",
|
||||
"enableDesc": "When enabled, Qdrant is used as the primary vector store",
|
||||
"testConnection": "Test connection",
|
||||
"testing": "Testing...",
|
||||
"statusActive": "Active",
|
||||
"statusError": "Error",
|
||||
"statusDisabled": "Disabled",
|
||||
"healthOk": "Connection OK ({latencyMs}ms)",
|
||||
"healthError": "Connection error",
|
||||
"saved": "Settings saved",
|
||||
"saveError": "Failed to save",
|
||||
"portLabel": "Port",
|
||||
"embeddingModelLabel": "Embedding Model",
|
||||
"optional": "optional",
|
||||
"apiKeyKeepPlaceholder": "Leave blank to keep current key",
|
||||
"apiKeyOptional": "Leave blank if not using authentication",
|
||||
"removeApiKey": "Remove",
|
||||
"quickSelectModel": "Quick select",
|
||||
"searchTestTitle": "Semantic search test",
|
||||
"searchPlaceholder": "Type a test query...",
|
||||
"searching": "Searching...",
|
||||
"search": "Search",
|
||||
"cleanupTitle": "Clean up old points",
|
||||
"cleanupDesc": "Remove Qdrant points for expired memories (retention period).",
|
||||
"cleaning": "Cleaning...",
|
||||
"cleanNow": "Clean now",
|
||||
"cleanupSuccess": "{count} point(s) removed",
|
||||
"cleanupFailed": "Cleanup failed"
|
||||
},
|
||||
"rerank": {
|
||||
"enableLabel": "Enable Rerank",
|
||||
"enableDesc": "Reorders results with a reranking model after search",
|
||||
"warning": "Rerank adds +200-500ms latency and additional cost per request. Use sparingly.",
|
||||
"providerModelLabel": "Rerank Provider / Model",
|
||||
"noProviderWithKey": "No provider with configured API key. Configure a provider to use rerank.",
|
||||
"selectProviderModel": "Select a provider/model"
|
||||
},
|
||||
"saving": "Saving..."
|
||||
},
|
||||
"skills": {
|
||||
"title": "Skills",
|
||||
@@ -7290,4 +7432,4 @@
|
||||
"resetIn": "reset in",
|
||||
"quotaTotal": "total"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2981,7 +2981,7 @@
|
||||
"totalEntries": "Total de Entradas",
|
||||
"tokensUsed": "Tokens Usados",
|
||||
"hitRate": "Taxa de Acerto",
|
||||
"loading": "Carregando memórias...",
|
||||
"loading": "Carregando...",
|
||||
"noMemories": "Nenhuma memória encontrada",
|
||||
"search": "Buscar memórias...",
|
||||
"allTypes": "Todos os Tipos",
|
||||
@@ -3010,7 +3010,149 @@
|
||||
"cancel": "Cancelar",
|
||||
"save": "Salvar",
|
||||
"keyPlaceholder": "por exemplo usuário.preferências.tema",
|
||||
"contentPlaceholder": "Valor ou conteúdo JSON para lembrar"
|
||||
"contentPlaceholder": "Valor ou conteúdo JSON para lembrar",
|
||||
"tabs": {
|
||||
"memories": "Memórias",
|
||||
"playground": "Playground",
|
||||
"engine": "Engine"
|
||||
},
|
||||
"concept": {
|
||||
"title": "Memória Conversacional",
|
||||
"description": "O OmniRoute aprende com cada conversa, lembrando de fatos, episódios, procedimentos e conceitos semânticos que tornam as respostas mais precisas e contextualizadas.",
|
||||
"howWorksToggle": "Como funciona",
|
||||
"howWorksContent": "1. Extração automática: ao final de cada resposta, fatos e episódios são detectados e salvos automaticamente.\n2. Recuperação: antes de cada resposta, as memórias mais relevantes são buscadas por FTS5 (exato), vetor (semântico) ou RRF híbrido.\n3. Injeção: as memórias relevantes são injetadas no contexto do assistente para melhorar a qualidade da resposta.\n4. Gestão: use esta página para visualizar, editar, exportar e compactar memórias antigas."
|
||||
},
|
||||
"tooltip": {
|
||||
"totalEntries": "Total de memórias armazenadas para esta chave de API",
|
||||
"tokensUsed": "Total de tokens estimados ocupados pelas memórias ativas",
|
||||
"hitRate": "Taxa de cache de leitura por ID (não é precisão semântica)",
|
||||
"factual": "Fatos objetivos e permanentes do contexto do usuário",
|
||||
"episodic": "Eventos e experiências do histórico da conversa",
|
||||
"procedural": "Procedimentos, fluxos e instruções que o assistente deve seguir",
|
||||
"semantic": "Conceitos, preferências e conhecimento de domínio"
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "Nenhuma memória ainda",
|
||||
"description": "As memórias são criadas automaticamente das conversas. Você também pode adicionar manualmente usando o botão acima."
|
||||
},
|
||||
"editModal": {
|
||||
"title": "Editar Memória",
|
||||
"metadataLabel": "Metadados (JSON)",
|
||||
"metadataInvalid": "JSON inválido",
|
||||
"saveFailed": "Falha ao salvar memória"
|
||||
},
|
||||
"editMemory": "Editar memória",
|
||||
"deleteConfirmTitle": "Excluir memória?",
|
||||
"deleteConfirmDesc": "Esta ação não pode ser desfeita. A memória será removida permanentemente.",
|
||||
"importResult": "{imported} importadas, {skipped} ignoradas",
|
||||
"importError": "Erro ao importar arquivo",
|
||||
"compactOld": "Compactar antigas",
|
||||
"summarize": {
|
||||
"title": "Compactar memórias antigas",
|
||||
"noCandidates": "Nenhuma memória elegível para compactação (critério: >30 dias).",
|
||||
"candidatesDesc": "{count} memórias serão compactadas em summaries. Esta ação não pode ser desfeita.",
|
||||
"confirm": "Compactar agora"
|
||||
},
|
||||
"playground": {
|
||||
"infoTitle": "Memory Playground",
|
||||
"infoDesc": "Simule o que seria recuperado para uma determinada query. Nenhuma memória é modificada — apenas visualização.",
|
||||
"queryLabel": "Query de teste",
|
||||
"queryPlaceholder": "Digite uma pergunta ou frase de teste...",
|
||||
"strategyLabel": "Estratégia",
|
||||
"strategyExact": "Exato (FTS5)",
|
||||
"strategySemantic": "Semântico (vetor)",
|
||||
"strategyHybrid": "Híbrido (RRF)",
|
||||
"budgetLabel": "Budget (tokens)",
|
||||
"simulate": "Simular",
|
||||
"resultsTitle": "{count} resultado(s)",
|
||||
"resolutionTitle": "Resolução de busca",
|
||||
"resolutionEmbedding": "Embedding",
|
||||
"resolutionStore": "Vector store",
|
||||
"resolutionStrategy": "Estratégia usada",
|
||||
"rerankApplied": "Rerank aplicado",
|
||||
"fallback": "Fallback",
|
||||
"noResults": "Nenhuma memória encontrada para esta query.",
|
||||
"tokensUsed": "tokens usados",
|
||||
"none": "nenhum",
|
||||
"errorFetch": "Falha ao buscar preview"
|
||||
},
|
||||
"engine": {
|
||||
"statusTitle": "Status do Engine",
|
||||
"embeddingTitle": "Fonte de Embedding",
|
||||
"rerankTitle": "Rerank (opcional)",
|
||||
"reindexNow": "Reindexar agora",
|
||||
"reindexStarted": "Reindexação iniciada ({pending} pendentes)",
|
||||
"reindexFailed": "Falha ao iniciar reindexação",
|
||||
"keywordLabel": "Keyword (FTS5)",
|
||||
"keywordReason": "Busca por palavras-chave sempre disponível",
|
||||
"embeddingLabel": "Embedding",
|
||||
"vectorStoreLabel": "Vector Store",
|
||||
"qdrantLabel": "Qdrant",
|
||||
"rerankLabel": "Rerank",
|
||||
"qdrantDisabled": "Desabilitado",
|
||||
"qdrantOk": "Saudável ({latencyMs}ms)",
|
||||
"qdrantError": "Erro de conexão",
|
||||
"needsReindex": "{count} memória(s) precisam de reindexação"
|
||||
},
|
||||
"embedding": {
|
||||
"autoLabel": "Automático",
|
||||
"autoDesc": "Usa o melhor disponível: provider remoto > static > transformers",
|
||||
"remoteLabel": "Provider remoto",
|
||||
"remoteDesc": "Usa embedding via API de um provider configurado (requer chave)",
|
||||
"staticLabel": "Static local (potion)",
|
||||
"staticDesc": "Embedding local sem WASM, sem dependências externas",
|
||||
"transformersLabel": "Transformers.js (MiniLM)",
|
||||
"transformersDesc": "Embedding local via @huggingface/transformers (~400MB RAM)",
|
||||
"providerModelLabel": "Provider / Modelo",
|
||||
"noRemoteProviders": "Nenhum provider com chave configurada",
|
||||
"selectProviderModel": "Selecione um modelo",
|
||||
"staticEnabledLabel": "Habilitar Static Potion",
|
||||
"staticEnabledDesc": "Baixa e usa o modelo potion-base-8M localmente",
|
||||
"transformersEnabledLabel": "Habilitar Transformers.js",
|
||||
"transformersEnabledDesc": "Opt-in para MiniLM local (~400MB RAM, cold start ~3s)",
|
||||
"transformersWarning": "Requer ~400MB de RAM e ~3s de cold start na primeira query semântica."
|
||||
},
|
||||
"qdrant": {
|
||||
"title": "Qdrant (Vector Store Tier 2)",
|
||||
"description": "Integração opcional com Qdrant para busca semântica escalável",
|
||||
"enableLabel": "Habilitar Qdrant",
|
||||
"enableDesc": "Quando habilitado, Qdrant é usado como vector store principal",
|
||||
"testConnection": "Testar conexão",
|
||||
"testing": "Testando...",
|
||||
"statusActive": "Ativo",
|
||||
"statusError": "Erro",
|
||||
"statusDisabled": "Desabilitado",
|
||||
"healthOk": "Conexão OK ({latencyMs}ms)",
|
||||
"healthError": "Erro de conexão",
|
||||
"saved": "Configurações salvas",
|
||||
"saveError": "Falha ao salvar",
|
||||
"portLabel": "Porta",
|
||||
"embeddingModelLabel": "Modelo de Embedding",
|
||||
"optional": "opcional",
|
||||
"apiKeyKeepPlaceholder": "Deixe em branco para manter a chave atual",
|
||||
"apiKeyOptional": "Deixe em branco se não usar autenticação",
|
||||
"removeApiKey": "Remover",
|
||||
"quickSelectModel": "Seleção rápida",
|
||||
"searchTestTitle": "Teste de busca semântica",
|
||||
"searchPlaceholder": "Digite uma query de teste...",
|
||||
"searching": "Buscando...",
|
||||
"search": "Buscar",
|
||||
"cleanupTitle": "Limpeza de pontos antigos",
|
||||
"cleanupDesc": "Remove pontos Qdrant de memórias expiradas (período de retenção).",
|
||||
"cleaning": "Limpando...",
|
||||
"cleanNow": "Limpar agora",
|
||||
"cleanupSuccess": "{count} ponto(s) removido(s)",
|
||||
"cleanupFailed": "Falha ao executar limpeza"
|
||||
},
|
||||
"rerank": {
|
||||
"enableLabel": "Habilitar Rerank",
|
||||
"enableDesc": "Reordena os resultados com um modelo de reranking após a busca",
|
||||
"warning": "Rerank adiciona +200-500ms de latência e custo adicional por request. Use com moderação.",
|
||||
"providerModelLabel": "Provider / Modelo de Rerank",
|
||||
"noProviderWithKey": "Nenhum provider com chave configurada. Configure um provider para usar rerank.",
|
||||
"selectProviderModel": "Selecione um provider/modelo"
|
||||
},
|
||||
"saving": "Salvando..."
|
||||
},
|
||||
"skills": {
|
||||
"title": "Skills",
|
||||
@@ -7280,4 +7422,4 @@
|
||||
"resetIn": "redefinir em",
|
||||
"quotaTotal": "total"
|
||||
}
|
||||
}
|
||||
}
|
||||
340
tests/unit/ui/edit-memory-modal.test.tsx
Normal file
340
tests/unit/ui/edit-memory-modal.test.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/components", () => ({
|
||||
Modal: ({
|
||||
isOpen,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen?: boolean;
|
||||
title?: string;
|
||||
children?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
onClose?: () => void;
|
||||
}) =>
|
||||
isOpen
|
||||
? React.createElement(
|
||||
"div",
|
||||
{ "data-testid": "modal", "data-title": title },
|
||||
React.createElement("button", { onClick: onClose, "data-testid": "modal-close" }, "X"),
|
||||
children,
|
||||
footer,
|
||||
)
|
||||
: null,
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
loading,
|
||||
"data-testid": testId,
|
||||
variant,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
"data-testid"?: string;
|
||||
variant?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
onClick,
|
||||
disabled: disabled || loading,
|
||||
"data-testid": testId,
|
||||
"data-variant": variant,
|
||||
},
|
||||
children,
|
||||
),
|
||||
Input: ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
"data-testid": testId,
|
||||
className,
|
||||
}: {
|
||||
value?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
placeholder?: string;
|
||||
"data-testid"?: string;
|
||||
className?: string;
|
||||
}) =>
|
||||
React.createElement("input", {
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
"data-testid": testId,
|
||||
className,
|
||||
}),
|
||||
Select: ({
|
||||
children,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||
className?: string;
|
||||
}) => React.createElement("select", { value, onChange, className }, children),
|
||||
}));
|
||||
|
||||
const MOCK_MEMORY = {
|
||||
id: "mem-1",
|
||||
type: "factual" as const,
|
||||
key: "user.name",
|
||||
content: "Alice",
|
||||
metadata: { source: "test" },
|
||||
};
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("EditMemoryModal", () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders nothing when isOpen=false", async () => {
|
||||
const { default: EditMemoryModal } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EditMemoryModal"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EditMemoryModal
|
||||
memory={MOCK_MEMORY}
|
||||
isOpen={false}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(container.querySelector("[data-testid='modal']")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders modal with memory fields populated when isOpen=true", async () => {
|
||||
const { default: EditMemoryModal } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EditMemoryModal"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EditMemoryModal
|
||||
memory={MOCK_MEMORY}
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(container.querySelector("[data-testid='modal']")).toBeTruthy();
|
||||
// The key input should have value "user.name" and content textarea "Alice"
|
||||
const inputs = Array.from(container.querySelectorAll("input"));
|
||||
const keyInput = inputs.find((i) => i.value === "user.name");
|
||||
expect(keyInput).toBeTruthy();
|
||||
const textareas = Array.from(container.querySelectorAll("textarea"));
|
||||
const contentTextarea = textareas.find((ta) => ta.value === "Alice");
|
||||
expect(contentTextarea).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows metadata JSON in textarea", async () => {
|
||||
const { default: EditMemoryModal } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EditMemoryModal"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EditMemoryModal
|
||||
memory={MOCK_MEMORY}
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const textareas = container.querySelectorAll("textarea");
|
||||
// Should have at least the content textarea and metadata textarea
|
||||
expect(textareas.length).toBeGreaterThanOrEqual(2);
|
||||
// The metadata textarea should contain the JSON
|
||||
const metadataTextarea = Array.from(textareas).find((ta) =>
|
||||
ta.value.includes('"source"'),
|
||||
);
|
||||
expect(metadataTextarea).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls PUT /api/memory/[id] and invokes onSaved+onClose when save succeeds", async () => {
|
||||
const onSaved = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
const { default: EditMemoryModal } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EditMemoryModal"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EditMemoryModal
|
||||
memory={MOCK_MEMORY}
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
onSaved={onSaved}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
// Find and click Save button (text="save")
|
||||
const buttons = Array.from(container.querySelectorAll("button"));
|
||||
const saveBtn = buttons.find((b) => b.textContent === "save");
|
||||
expect(saveBtn).toBeTruthy();
|
||||
await act(async () => {
|
||||
saveBtn?.click();
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
const putCalls = fetchMock.mock.calls.filter(
|
||||
(c: [string, { method?: string }]) =>
|
||||
typeof c[0] === "string" &&
|
||||
c[0].includes("mem-1") &&
|
||||
c[1] &&
|
||||
c[1].method === "PUT",
|
||||
);
|
||||
expect(putCalls.length).toBeGreaterThan(0);
|
||||
expect(onSaved).toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows error message when PUT fails", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ error: { message: "update failed" } }),
|
||||
});
|
||||
|
||||
const { default: EditMemoryModal } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EditMemoryModal"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EditMemoryModal
|
||||
memory={MOCK_MEMORY}
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const buttons = Array.from(container.querySelectorAll("button"));
|
||||
const saveBtn = buttons.find((b) => b.textContent === "save");
|
||||
await act(async () => {
|
||||
saveBtn?.click();
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("update failed");
|
||||
});
|
||||
|
||||
it("shows metadata validation error for invalid JSON", async () => {
|
||||
const { default: EditMemoryModal } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EditMemoryModal"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EditMemoryModal
|
||||
memory={MOCK_MEMORY}
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const textareas = Array.from(container.querySelectorAll("textarea"));
|
||||
// Find the metadata textarea (the one with JSON content)
|
||||
const metadataTextarea = textareas.find((ta) => ta.value.includes('"source"'));
|
||||
expect(metadataTextarea).toBeTruthy();
|
||||
|
||||
// Use nativeInputValueSetter to set value and fire change event
|
||||
await act(async () => {
|
||||
if (metadataTextarea) {
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
nativeSetter?.call(metadataTextarea, "not valid json {{{");
|
||||
metadataTextarea.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
// The error text should contain the i18n key
|
||||
expect(container.textContent).toContain("editModal.metadataInvalid");
|
||||
});
|
||||
|
||||
it("calls onClose when modal close button is clicked", async () => {
|
||||
const onClose = vi.fn();
|
||||
const { default: EditMemoryModal } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EditMemoryModal"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EditMemoryModal
|
||||
memory={MOCK_MEMORY}
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
onSaved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const closeBtn = container.querySelector("[data-testid='modal-close']") as HTMLButtonElement | null;
|
||||
expect(closeBtn).toBeTruthy();
|
||||
await act(async () => {
|
||||
closeBtn?.click();
|
||||
});
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
188
tests/unit/ui/embedding-source-selector.test.tsx
Normal file
188
tests/unit/ui/embedding-source-selector.test.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("EmbeddingSourceSelector", () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const defaultSettings = {
|
||||
embeddingSource: "auto" as const,
|
||||
embeddingProviderModel: null,
|
||||
transformersEnabled: false,
|
||||
staticEnabled: false,
|
||||
rerankEnabled: false,
|
||||
rerankProviderModel: null,
|
||||
};
|
||||
|
||||
it("renders all 4 source options", async () => {
|
||||
const { default: EmbeddingSourceSelector } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EmbeddingSourceSelector"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EmbeddingSourceSelector
|
||||
settings={defaultSettings}
|
||||
providers={[]}
|
||||
onSave={vi.fn().mockResolvedValue(true)}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(container.querySelector("[data-testid='embedding-source-auto']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='embedding-source-remote']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='embedding-source-static']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='embedding-source-transformers']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows only providers with hasKey=true in remote dropdown", async () => {
|
||||
const { default: EmbeddingSourceSelector } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EmbeddingSourceSelector"
|
||||
);
|
||||
const providers = [
|
||||
{
|
||||
provider: "openai",
|
||||
hasKey: true,
|
||||
models: [
|
||||
{
|
||||
id: "openai/text-embedding-3-small",
|
||||
name: "text-embedding-3-small",
|
||||
dimensions: 1536,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
provider: "cohere",
|
||||
hasKey: false,
|
||||
models: [{ id: "cohere/embed-english-v3", name: "embed-english", dimensions: 1024 }],
|
||||
},
|
||||
];
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EmbeddingSourceSelector
|
||||
settings={{ ...defaultSettings, embeddingSource: "remote" }}
|
||||
providers={providers}
|
||||
onSave={vi.fn().mockResolvedValue(true)}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
// openai should be visible (hasKey=true)
|
||||
expect(container.textContent).toContain("text-embedding-3-small");
|
||||
// cohere should NOT be visible (hasKey=false)
|
||||
expect(container.textContent).not.toContain("embed-english");
|
||||
});
|
||||
|
||||
it("shows no-provider warning when remote selected but no providers with key", async () => {
|
||||
const { default: EmbeddingSourceSelector } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EmbeddingSourceSelector"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EmbeddingSourceSelector
|
||||
settings={{ ...defaultSettings, embeddingSource: "remote" }}
|
||||
providers={[{ provider: "cohere", hasKey: false, models: [] }]}
|
||||
onSave={vi.fn().mockResolvedValue(true)}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(container.textContent).toContain("embedding.noRemoteProviders");
|
||||
});
|
||||
|
||||
it("shows transformers warning when transformers source selected", async () => {
|
||||
const { default: EmbeddingSourceSelector } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EmbeddingSourceSelector"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EmbeddingSourceSelector
|
||||
settings={{ ...defaultSettings, embeddingSource: "transformers" }}
|
||||
providers={[]}
|
||||
onSave={vi.fn().mockResolvedValue(true)}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(container.textContent).toContain("embedding.transformersWarning");
|
||||
});
|
||||
|
||||
it("toggle-static-enabled calls onSave", async () => {
|
||||
const { default: EmbeddingSourceSelector } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EmbeddingSourceSelector"
|
||||
);
|
||||
const onSave = vi.fn().mockResolvedValue(true);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EmbeddingSourceSelector
|
||||
settings={defaultSettings}
|
||||
providers={[]}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const toggleBtn = container.querySelector(
|
||||
"[data-testid='toggle-static-enabled']",
|
||||
) as HTMLButtonElement | null;
|
||||
expect(toggleBtn).toBeTruthy();
|
||||
await act(async () => {
|
||||
toggleBtn?.click();
|
||||
});
|
||||
expect(onSave).toHaveBeenCalledWith({ staticEnabled: true });
|
||||
});
|
||||
|
||||
it("toggle-transformers-enabled calls onSave", async () => {
|
||||
const { default: EmbeddingSourceSelector } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EmbeddingSourceSelector"
|
||||
);
|
||||
const onSave = vi.fn().mockResolvedValue(true);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EmbeddingSourceSelector
|
||||
settings={defaultSettings}
|
||||
providers={[]}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const toggleBtn = container.querySelector(
|
||||
"[data-testid='toggle-transformers-enabled']",
|
||||
) as HTMLButtonElement | null;
|
||||
expect(toggleBtn).toBeTruthy();
|
||||
await act(async () => {
|
||||
toggleBtn?.click();
|
||||
});
|
||||
expect(onSave).toHaveBeenCalledWith({ transformersEnabled: true });
|
||||
});
|
||||
});
|
||||
247
tests/unit/ui/engine-tab.test.tsx
Normal file
247
tests/unit/ui/engine-tab.test.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
|
||||
if (values) return `${key}:${JSON.stringify(values)}`;
|
||||
return key;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/components", () => ({
|
||||
Card: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement("div", { className: "card" }, children),
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
loading,
|
||||
"data-testid": testId,
|
||||
variant,
|
||||
size,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
"data-testid"?: string;
|
||||
variant?: string;
|
||||
size?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{ onClick, disabled: disabled || loading, "data-testid": testId, "data-variant": variant },
|
||||
children,
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock the hooks directly to avoid swr dependency resolution issues
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/hooks/useEngineStatus",
|
||||
() => ({
|
||||
useEngineStatus: () => ({
|
||||
status: {
|
||||
keyword: { available: true, backend: "FTS5" },
|
||||
embedding: {
|
||||
source: "remote",
|
||||
model: "openai/text-embedding-3-small",
|
||||
dimensions: 1536,
|
||||
available: true,
|
||||
reason: "provider openai with key configured",
|
||||
cacheStats: { hits: 0, misses: 0, size: 0 },
|
||||
},
|
||||
vectorStore: {
|
||||
backend: "sqlite-vec",
|
||||
available: true,
|
||||
rowCount: 10,
|
||||
needsReindex: 0,
|
||||
reason: "sqlite-vec loaded",
|
||||
},
|
||||
qdrant: { enabled: false, healthy: null, latencyMs: null, error: null },
|
||||
rerank: {
|
||||
enabled: false,
|
||||
provider: null,
|
||||
model: null,
|
||||
available: false,
|
||||
reason: "rerank disabled",
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
mutate: vi.fn(),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const mockSave = vi.fn().mockResolvedValue(true);
|
||||
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/hooks/useMemorySettings",
|
||||
() => ({
|
||||
useMemorySettings: () => ({
|
||||
settings: {
|
||||
enabled: true,
|
||||
maxTokens: 2000,
|
||||
retentionDays: 30,
|
||||
strategy: "hybrid",
|
||||
skillsEnabled: false,
|
||||
embeddingSource: "auto",
|
||||
embeddingProviderModel: null,
|
||||
transformersEnabled: false,
|
||||
staticEnabled: false,
|
||||
rerankEnabled: false,
|
||||
rerankProviderModel: null,
|
||||
vectorStore: "auto",
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
mutate: vi.fn(),
|
||||
save: mockSave,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/MemoryEngineStatus",
|
||||
() => ({
|
||||
default: ({ status }: { status: { embedding: { available: boolean } } }) =>
|
||||
React.createElement(
|
||||
"div",
|
||||
{ "data-testid": "engine-status-panel" },
|
||||
status.embedding.available ? "embedding:available" : "embedding:unavailable",
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EmbeddingSourceSelector",
|
||||
() => ({
|
||||
default: ({
|
||||
onSave,
|
||||
}: {
|
||||
settings: unknown;
|
||||
providers: unknown[];
|
||||
onSave: (u: unknown) => Promise<boolean>;
|
||||
saving?: boolean;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"div",
|
||||
{ "data-testid": "embedding-selector" },
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
"data-testid": "toggle-transformers-btn",
|
||||
onClick: () => onSave({ transformersEnabled: true }),
|
||||
},
|
||||
"toggle-transformers",
|
||||
),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard",
|
||||
() => ({
|
||||
default: () => React.createElement("div", { "data-testid": "qdrant-config-card" }, "QdrantCard"),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/RerankConfigCard",
|
||||
() => ({
|
||||
default: () => React.createElement("div", { "data-testid": "rerank-config-card" }, "RerankCard"),
|
||||
}),
|
||||
);
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("EngineTab", () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ providers: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the engine status panel", async () => {
|
||||
const { default: EngineTab } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/EngineTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<EngineTab />);
|
||||
});
|
||||
expect(container.querySelector("[data-testid='engine-status-panel']")).toBeTruthy();
|
||||
expect(container.textContent).toContain("embedding:available");
|
||||
});
|
||||
|
||||
it("renders QdrantConfigCard and RerankConfigCard", async () => {
|
||||
const { default: EngineTab } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/EngineTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<EngineTab />);
|
||||
});
|
||||
expect(container.querySelector("[data-testid='qdrant-config-card']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='rerank-config-card']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Reindex Now button", async () => {
|
||||
const { default: EngineTab } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/EngineTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<EngineTab />);
|
||||
});
|
||||
expect(container.querySelector("[data-testid='reindex-now-button']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls save() when EmbeddingSourceSelector calls onSave", async () => {
|
||||
mockSave.mockClear();
|
||||
|
||||
const { default: EngineTab } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/EngineTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<EngineTab />);
|
||||
});
|
||||
|
||||
const toggleBtn = container.querySelector(
|
||||
"[data-testid='toggle-transformers-btn']",
|
||||
) as HTMLButtonElement | null;
|
||||
expect(toggleBtn).toBeTruthy();
|
||||
await act(async () => {
|
||||
toggleBtn?.click();
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
expect(mockSave).toHaveBeenCalledWith({ transformersEnabled: true });
|
||||
});
|
||||
});
|
||||
341
tests/unit/ui/memories-tab.test.tsx
Normal file
341
tests/unit/ui/memories-tab.test.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
|
||||
if (values) return `${key}:${JSON.stringify(values)}`;
|
||||
return key;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock shared components
|
||||
vi.mock("@/shared/components", () => ({
|
||||
Card: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement("div", { className: "card" }, children),
|
||||
Badge: ({ children, variant, title }: { children: React.ReactNode; variant?: string; title?: string }) =>
|
||||
React.createElement("span", { "data-variant": variant, title }, children),
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
loading,
|
||||
variant,
|
||||
size,
|
||||
"data-testid": testId,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
variant?: string;
|
||||
size?: string;
|
||||
"data-testid"?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
onClick,
|
||||
disabled: disabled || loading,
|
||||
"data-variant": variant,
|
||||
"data-testid": testId,
|
||||
},
|
||||
children,
|
||||
),
|
||||
Input: ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
"data-testid": testId,
|
||||
className,
|
||||
onKeyDown,
|
||||
}: {
|
||||
value?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
placeholder?: string;
|
||||
"data-testid"?: string;
|
||||
className?: string;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
}) =>
|
||||
React.createElement("input", {
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
"data-testid": testId,
|
||||
className,
|
||||
onKeyDown,
|
||||
}),
|
||||
Select: ({
|
||||
children,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||
}) => React.createElement("select", { value, onChange }, children),
|
||||
Modal: ({
|
||||
isOpen,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen?: boolean;
|
||||
title?: string;
|
||||
children?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
onClose?: () => void;
|
||||
}) =>
|
||||
isOpen
|
||||
? React.createElement(
|
||||
"div",
|
||||
{ "data-testid": "modal", "data-title": title },
|
||||
React.createElement("button", { onClick: onClose, "data-testid": "modal-close" }, "X"),
|
||||
children,
|
||||
footer,
|
||||
)
|
||||
: null,
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EditMemoryModal",
|
||||
() => ({
|
||||
default: ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
memory: unknown;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) =>
|
||||
isOpen
|
||||
? React.createElement("div", { "data-testid": "edit-memory-modal" }, [
|
||||
React.createElement("button", { key: "close", onClick: onClose }, "close"),
|
||||
])
|
||||
: null,
|
||||
}),
|
||||
);
|
||||
|
||||
const MOCK_MEMORIES = [
|
||||
{
|
||||
id: "mem-1",
|
||||
apiKeyId: "key-1",
|
||||
sessionId: null,
|
||||
type: "factual",
|
||||
key: "user.name",
|
||||
content: "Alice",
|
||||
metadata: {},
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
expiresAt: null,
|
||||
},
|
||||
{
|
||||
id: "mem-2",
|
||||
apiKeyId: "key-1",
|
||||
sessionId: null,
|
||||
type: "episodic",
|
||||
key: "event.meeting",
|
||||
content: "Had a meeting",
|
||||
metadata: {},
|
||||
createdAt: "2026-01-02T00:00:00Z",
|
||||
updatedAt: "2026-01-02T00:00:00Z",
|
||||
expiresAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("MemoriesTab", () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: MOCK_MEMORIES,
|
||||
total: 2,
|
||||
totalPages: 1,
|
||||
stats: {
|
||||
total: 2,
|
||||
tokensUsed: 150,
|
||||
hitRate: 0.75,
|
||||
cacheStats: { hits: 3, misses: 1 },
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders memories after fetch", async () => {
|
||||
const { default: MemoriesTab } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<MemoriesTab />);
|
||||
});
|
||||
// Wait for the 300ms debounce
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
});
|
||||
expect(container.textContent).toContain("user.name");
|
||||
expect(container.textContent).toContain("Alice");
|
||||
});
|
||||
|
||||
it("shows hit rate card when cacheStats.hits + misses > 0", async () => {
|
||||
const { default: MemoriesTab } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<MemoriesTab />);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
});
|
||||
// hitRate is shown since cacheStats.hits=3, misses=1
|
||||
expect(container.textContent).toContain("hitRate");
|
||||
});
|
||||
|
||||
it("does not show hit rate when cacheStats is 0/0", async () => {
|
||||
vi.resetModules();
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: MOCK_MEMORIES,
|
||||
total: 2,
|
||||
totalPages: 1,
|
||||
stats: {
|
||||
total: 2,
|
||||
tokensUsed: 0,
|
||||
hitRate: 0,
|
||||
cacheStats: { hits: 0, misses: 0 },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const { default: MemoriesTab } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<MemoriesTab />);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
});
|
||||
// hitRate card should NOT appear
|
||||
expect(container.querySelector("[data-testid='hit-rate-card']")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens edit modal when pencil button is clicked", async () => {
|
||||
const { default: MemoriesTab } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<MemoriesTab />);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
});
|
||||
const editBtn = container.querySelector("[data-testid='edit-memory-mem-1']") as HTMLButtonElement | null;
|
||||
expect(editBtn).toBeTruthy();
|
||||
await act(async () => {
|
||||
editBtn?.click();
|
||||
});
|
||||
expect(container.querySelector("[data-testid='edit-memory-modal']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows empty state when no memories returned", async () => {
|
||||
vi.resetModules();
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [],
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
stats: { total: 0, tokensUsed: 0, hitRate: 0, cacheStats: { hits: 0, misses: 0 } },
|
||||
}),
|
||||
});
|
||||
const { default: MemoriesTab } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<MemoriesTab />);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
});
|
||||
expect(container.querySelector("[data-testid='memories-empty-state']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls DELETE when delete confirmed", async () => {
|
||||
const mockFetch = vi.fn();
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: MOCK_MEMORIES,
|
||||
total: 2,
|
||||
totalPages: 1,
|
||||
stats: { total: 2, tokensUsed: 0, hitRate: 0, cacheStats: { hits: 0, misses: 0 } },
|
||||
}),
|
||||
});
|
||||
mockFetch.mockResolvedValue({ ok: true, json: async () => ({}) });
|
||||
globalThis.fetch = mockFetch;
|
||||
const { default: MemoriesTab } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<MemoriesTab />);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
});
|
||||
const deleteBtn = container.querySelector("[data-testid='delete-memory-mem-1']") as HTMLButtonElement | null;
|
||||
expect(deleteBtn).toBeTruthy();
|
||||
await act(async () => {
|
||||
deleteBtn?.click();
|
||||
});
|
||||
// Confirm modal shown
|
||||
const modal = container.querySelector("[data-testid='modal']");
|
||||
expect(modal).toBeTruthy();
|
||||
// Find danger button inside modal
|
||||
const dangerBtns = Array.from(container.querySelectorAll("button")).filter(
|
||||
(b) => b.getAttribute("data-variant") === "danger",
|
||||
);
|
||||
expect(dangerBtns.length).toBeGreaterThan(0);
|
||||
await act(async () => {
|
||||
dangerBtns[0].click();
|
||||
});
|
||||
// DELETE should have been called
|
||||
const deleteCalls = (mockFetch as ReturnType<typeof vi.fn>).mock.calls.filter(
|
||||
(c: [string, ...unknown[]]) =>
|
||||
typeof c[0] === "string" && c[0].includes("mem-1") && c[1] && (c[1] as { method: string }).method === "DELETE",
|
||||
);
|
||||
expect(deleteCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
156
tests/unit/ui/memory-page.test.tsx
Normal file
156
tests/unit/ui/memory-page.test.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mutable state for controlling what tab the navigation mock returns
|
||||
let mockTabValue: string | null = null;
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => ({
|
||||
get: (key: string) => (key === "tab" ? mockTabValue : null),
|
||||
toString: () => (mockTabValue ? `tab=${mockTabValue}` : ""),
|
||||
}),
|
||||
useRouter: () => ({
|
||||
replace: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
|
||||
if (values) {
|
||||
return `${key}:${JSON.stringify(values)}`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
getTranslations: () => async (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("swr", () => ({
|
||||
default: () => ({ data: null, error: null, isLoading: false, mutate: vi.fn() }),
|
||||
}));
|
||||
|
||||
// Mock all child tab components + concept card
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/MemoryConceptCard",
|
||||
() => ({
|
||||
default: () => React.createElement("div", { "data-testid": "concept-card" }, "ConceptCard"),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab",
|
||||
() => ({
|
||||
default: () =>
|
||||
React.createElement("div", { "data-testid": "memories-tab-content" }, "MemoriesTab"),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/PlaygroundTab",
|
||||
() => ({
|
||||
default: () =>
|
||||
React.createElement("div", { "data-testid": "playground-tab-content" }, "PlaygroundTab"),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/EngineTab",
|
||||
() => ({
|
||||
default: () =>
|
||||
React.createElement("div", { "data-testid": "engine-tab-content" }, "EngineTab"),
|
||||
}),
|
||||
);
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("MemoryPage", () => {
|
||||
beforeEach(() => {
|
||||
mockTabValue = null;
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the concept card", async () => {
|
||||
const { default: MemoryPage } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/page"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<MemoryPage />);
|
||||
});
|
||||
expect(container.querySelector("[data-testid='concept-card']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders 3 tab buttons", async () => {
|
||||
const { default: MemoryPage } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/page"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<MemoryPage />);
|
||||
});
|
||||
const tabs = ["memories", "playground", "engine"];
|
||||
for (const tab of tabs) {
|
||||
expect(container.querySelector(`[data-testid='tab-${tab}']`)).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults to memories tab (tab=null)", async () => {
|
||||
mockTabValue = null;
|
||||
const { default: MemoryPage } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/page"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<MemoryPage />);
|
||||
});
|
||||
expect(container.querySelector("[data-testid='memories-tab-content']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='playground-tab-content']")).toBeNull();
|
||||
expect(container.querySelector("[data-testid='engine-tab-content']")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows playground tab when ?tab=playground", async () => {
|
||||
mockTabValue = "playground";
|
||||
const { default: MemoryPage } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/page"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<MemoryPage />);
|
||||
});
|
||||
expect(container.querySelector("[data-testid='playground-tab-content']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='memories-tab-content']")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows engine tab when ?tab=engine", async () => {
|
||||
mockTabValue = "engine";
|
||||
const { default: MemoryPage } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/page"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<MemoryPage />);
|
||||
});
|
||||
expect(container.querySelector("[data-testid='engine-tab-content']")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
243
tests/unit/ui/playground-tab.test.tsx
Normal file
243
tests/unit/ui/playground-tab.test.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
|
||||
if (values) return `${key}:${JSON.stringify(values)}`;
|
||||
return key;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/components", () => ({
|
||||
Card: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement("div", { className: "card" }, children),
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
loading,
|
||||
"data-testid": testId,
|
||||
className,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
"data-testid"?: string;
|
||||
className?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{ onClick, disabled: disabled || loading, "data-testid": testId, className },
|
||||
children,
|
||||
),
|
||||
Input: ({
|
||||
value,
|
||||
onChange,
|
||||
"data-testid": testId,
|
||||
onKeyDown,
|
||||
type,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
}: {
|
||||
value?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
"data-testid"?: string;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
type?: string;
|
||||
min?: string;
|
||||
max?: string;
|
||||
step?: string;
|
||||
}) =>
|
||||
React.createElement("input", { value, onChange, "data-testid": testId, onKeyDown, type, min, max, step }),
|
||||
Select: ({
|
||||
children,
|
||||
value,
|
||||
onChange,
|
||||
"data-testid": testId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||
"data-testid"?: string;
|
||||
}) => React.createElement("select", { value, onChange, "data-testid": testId }, children),
|
||||
Badge: ({ children, variant }: { children: React.ReactNode; variant?: string }) =>
|
||||
React.createElement("span", { "data-variant": variant }, children),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/RetrievePreview",
|
||||
() => ({
|
||||
default: ({ result }: { result: { memories: unknown[]; resolution: Record<string, unknown>; totalTokensUsed: number; budgetMaxTokens: number } }) =>
|
||||
React.createElement(
|
||||
"div",
|
||||
{ "data-testid": "retrieve-preview" },
|
||||
`results:${result.memories.length}`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const MOCK_RESULT = {
|
||||
memories: [
|
||||
{
|
||||
id: "m1",
|
||||
type: "factual",
|
||||
key: "test.key",
|
||||
content: "test content",
|
||||
score: 0.95,
|
||||
tokens: 10,
|
||||
tier: "hybrid-rrf",
|
||||
vecScore: 0.9,
|
||||
ftsScore: 0.8,
|
||||
},
|
||||
],
|
||||
resolution: {
|
||||
embeddingSource: "remote",
|
||||
embeddingModel: "openai/text-embedding-3-small",
|
||||
vectorStore: "sqlite-vec",
|
||||
strategyUsed: "hybrid",
|
||||
rerankApplied: false,
|
||||
fallbackReason: null,
|
||||
},
|
||||
totalTokensUsed: 10,
|
||||
budgetMaxTokens: 2000,
|
||||
};
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("PlaygroundTab", () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => MOCK_RESULT,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders input and submit button", async () => {
|
||||
const { default: PlaygroundTab } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/PlaygroundTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<PlaygroundTab />);
|
||||
});
|
||||
expect(container.querySelector("[data-testid='playground-query-input']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='playground-submit']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='playground-strategy-select']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='playground-budget-input']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("submit button is disabled when query is empty", async () => {
|
||||
const { default: PlaygroundTab } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/PlaygroundTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<PlaygroundTab />);
|
||||
});
|
||||
const submitBtn = container.querySelector(
|
||||
"[data-testid='playground-submit']",
|
||||
) as HTMLButtonElement | null;
|
||||
expect(submitBtn?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("calls fetch and renders results when query is submitted", async () => {
|
||||
const { default: PlaygroundTab } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/PlaygroundTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<PlaygroundTab />);
|
||||
});
|
||||
|
||||
// Set query input
|
||||
const input = container.querySelector(
|
||||
"[data-testid='playground-query-input']",
|
||||
) as HTMLInputElement | null;
|
||||
expect(input).toBeTruthy();
|
||||
await act(async () => {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(input, "test query");
|
||||
input?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
// Also trigger onChange via React's change event
|
||||
const event = new Event("change", { bubbles: true });
|
||||
Object.defineProperty(event, "target", { writable: false, value: { value: "test query" } });
|
||||
input?.dispatchEvent(event);
|
||||
});
|
||||
|
||||
// Click submit
|
||||
await act(async () => {
|
||||
const submitBtn = container.querySelector(
|
||||
"[data-testid='playground-submit']",
|
||||
) as HTMLButtonElement | null;
|
||||
submitBtn?.click();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
});
|
||||
|
||||
// Check fetch was called with the right endpoint
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
const calls = fetchMock.mock.calls.filter(
|
||||
(c: [string, ...unknown[]]) => typeof c[0] === "string" && c[0].includes("retrieve-preview"),
|
||||
);
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders results via RetrievePreview component after successful fetch", async () => {
|
||||
const { default: PlaygroundTab } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/PlaygroundTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<PlaygroundTab />);
|
||||
});
|
||||
|
||||
// Manually trigger state with result by simulating the whole flow
|
||||
const input = container.querySelector(
|
||||
"[data-testid='playground-query-input']",
|
||||
) as HTMLInputElement | null;
|
||||
|
||||
await act(async () => {
|
||||
if (input) {
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
nativeSetter?.call(input, "test");
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
// Verify the submit button becomes enabled after input
|
||||
// This is tested indirectly by ensuring no error state
|
||||
expect(container.querySelector("[data-testid='playground-submit']")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
337
tests/unit/ui/qdrant-config-card.test.tsx
Normal file
337
tests/unit/ui/qdrant-config-card.test.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
|
||||
if (values) return `${key}:${JSON.stringify(values)}`;
|
||||
return key;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/components", () => ({
|
||||
Card: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement("div", { className: "card" }, children),
|
||||
}));
|
||||
|
||||
const MOCK_QDRANT_SETTINGS = {
|
||||
enabled: false,
|
||||
host: "http://127.0.0.1",
|
||||
port: 6333,
|
||||
collection: "omniroute_memory",
|
||||
embeddingModel: "openai/text-embedding-3-small",
|
||||
hasApiKey: false,
|
||||
apiKeyMasked: null,
|
||||
};
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("QdrantConfigCard", () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
|
||||
if (url === "/api/settings/qdrant") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => MOCK_QDRANT_SETTINGS,
|
||||
});
|
||||
}
|
||||
if (url === "/api/settings/qdrant/embedding-models") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
models: [
|
||||
{ value: "openai/text-embedding-3-small", label: "text-embedding-3-small" },
|
||||
{ value: "openai/text-embedding-ada-002", label: "text-embedding-ada-002" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders after loading qdrant settings", async () => {
|
||||
const { default: QdrantConfigCard } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<QdrantConfigCard />);
|
||||
});
|
||||
// Wait for useEffect fetch
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
expect(container.querySelector("[data-testid='qdrant-enabled-switch']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='qdrant-test-connection']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='qdrant-search-test']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='qdrant-cleanup']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("toggle enabled switch calls PUT /api/settings/qdrant", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((url: string, opts?: { method?: string }) => {
|
||||
if (url === "/api/settings/qdrant" && opts?.method === "PUT") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ ...MOCK_QDRANT_SETTINGS, enabled: true }),
|
||||
});
|
||||
}
|
||||
if (url === "/api/settings/qdrant") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => MOCK_QDRANT_SETTINGS,
|
||||
});
|
||||
}
|
||||
if (url === "/api/settings/qdrant/embedding-models") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ models: [] }),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const { default: QdrantConfigCard } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<QdrantConfigCard />);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
const toggleBtn = container.querySelector(
|
||||
"[data-testid='qdrant-enabled-switch']",
|
||||
) as HTMLButtonElement | null;
|
||||
expect(toggleBtn).toBeTruthy();
|
||||
await act(async () => {
|
||||
toggleBtn?.click();
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
const putCalls = fetchMock.mock.calls.filter(
|
||||
(c: [string, { method?: string }]) =>
|
||||
typeof c[0] === "string" &&
|
||||
c[0] === "/api/settings/qdrant" &&
|
||||
c[1]?.method === "PUT",
|
||||
);
|
||||
expect(putCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("test connection button calls /api/settings/qdrant/health", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((url: string) => {
|
||||
if (url === "/api/settings/qdrant") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => MOCK_QDRANT_SETTINGS,
|
||||
});
|
||||
}
|
||||
if (url === "/api/settings/qdrant/embedding-models") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ models: [] }),
|
||||
});
|
||||
}
|
||||
if (url === "/api/settings/qdrant/health") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true, latencyMs: 12 }),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const { default: QdrantConfigCard } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<QdrantConfigCard />);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
const testBtn = container.querySelector(
|
||||
"[data-testid='qdrant-test-connection']",
|
||||
) as HTMLButtonElement | null;
|
||||
expect(testBtn).toBeTruthy();
|
||||
await act(async () => {
|
||||
testBtn?.click();
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
const healthCalls = fetchMock.mock.calls.filter(
|
||||
(c: [string]) => typeof c[0] === "string" && c[0] === "/api/settings/qdrant/health",
|
||||
);
|
||||
expect(healthCalls.length).toBeGreaterThan(0);
|
||||
// Health OK result should be shown
|
||||
expect(container.textContent).toContain("qdrant.healthOk");
|
||||
});
|
||||
|
||||
it("search test button calls /api/settings/qdrant/search and renders results", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((url: string, opts?: { method?: string; body?: string }) => {
|
||||
if (url === "/api/settings/qdrant") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => MOCK_QDRANT_SETTINGS,
|
||||
});
|
||||
}
|
||||
if (url === "/api/settings/qdrant/embedding-models") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ models: [] }),
|
||||
});
|
||||
}
|
||||
if (url === "/api/settings/qdrant/search" && opts?.method === "POST") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
results: [
|
||||
{ id: "r1", score: 0.9876, payload: { content: "test content" } },
|
||||
],
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const { default: QdrantConfigCard } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<QdrantConfigCard />);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
// Set search query by manipulating the input
|
||||
const searchInputs = Array.from(container.querySelectorAll("input")).filter(
|
||||
(i) => i.type !== "password" && i.type !== "number",
|
||||
);
|
||||
// The search query input is the one with the search placeholder
|
||||
const searchInput = searchInputs[searchInputs.length - 1] as HTMLInputElement | null;
|
||||
expect(searchInput).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
if (searchInput) {
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
nativeSetter?.call(searchInput, "test query");
|
||||
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
const searchTestBtn = container.querySelector(
|
||||
"[data-testid='qdrant-search-test']",
|
||||
) as HTMLButtonElement | null;
|
||||
expect(searchTestBtn).toBeTruthy();
|
||||
await act(async () => {
|
||||
searchTestBtn?.click();
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
const searchCalls = fetchMock.mock.calls.filter(
|
||||
(c: [string, { method?: string }]) =>
|
||||
typeof c[0] === "string" &&
|
||||
c[0] === "/api/settings/qdrant/search" &&
|
||||
c[1]?.method === "POST",
|
||||
);
|
||||
expect(searchCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("cleanup button calls /api/settings/qdrant/cleanup and shows result", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((url: string, opts?: { method?: string }) => {
|
||||
if (url === "/api/settings/qdrant") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => MOCK_QDRANT_SETTINGS,
|
||||
});
|
||||
}
|
||||
if (url === "/api/settings/qdrant/embedding-models") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ models: [] }),
|
||||
});
|
||||
}
|
||||
if (url === "/api/settings/qdrant/cleanup" && opts?.method === "POST") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true, deletedCount: 5 }),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const { default: QdrantConfigCard } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<QdrantConfigCard />);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
const cleanupBtn = container.querySelector(
|
||||
"[data-testid='qdrant-cleanup']",
|
||||
) as HTMLButtonElement | null;
|
||||
expect(cleanupBtn).toBeTruthy();
|
||||
await act(async () => {
|
||||
cleanupBtn?.click();
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
const cleanupCalls = fetchMock.mock.calls.filter(
|
||||
(c: [string, { method?: string }]) =>
|
||||
typeof c[0] === "string" &&
|
||||
c[0] === "/api/settings/qdrant/cleanup" &&
|
||||
c[1]?.method === "POST",
|
||||
);
|
||||
expect(cleanupCalls.length).toBeGreaterThan(0);
|
||||
// Shows cleanup success message
|
||||
expect(container.textContent).toContain("qdrant.cleanupSuccess");
|
||||
});
|
||||
});
|
||||
242
tests/unit/ui/rerank-config-card.test.tsx
Normal file
242
tests/unit/ui/rerank-config-card.test.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
const defaultSettings = {
|
||||
embeddingSource: "auto" as const,
|
||||
embeddingProviderModel: null,
|
||||
transformersEnabled: false,
|
||||
staticEnabled: false,
|
||||
rerankEnabled: false,
|
||||
rerankProviderModel: null,
|
||||
};
|
||||
|
||||
describe("RerankConfigCard", () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the enable toggle", async () => {
|
||||
const { default: RerankConfigCard } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/RerankConfigCard"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<RerankConfigCard
|
||||
settings={defaultSettings}
|
||||
providers={[]}
|
||||
onSave={vi.fn().mockResolvedValue(true)}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(container.querySelector("[data-testid='rerank-enabled-switch']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("toggle switch calls onSave with rerankEnabled=true when disabled", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(true);
|
||||
const { default: RerankConfigCard } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/RerankConfigCard"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<RerankConfigCard
|
||||
settings={defaultSettings}
|
||||
providers={[]}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const toggleBtn = container.querySelector(
|
||||
"[data-testid='rerank-enabled-switch']",
|
||||
) as HTMLButtonElement | null;
|
||||
expect(toggleBtn).toBeTruthy();
|
||||
await act(async () => {
|
||||
toggleBtn?.click();
|
||||
});
|
||||
expect(onSave).toHaveBeenCalledWith({ rerankEnabled: true });
|
||||
});
|
||||
|
||||
it("toggle switch calls onSave with rerankEnabled=false when enabled", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(true);
|
||||
const { default: RerankConfigCard } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/RerankConfigCard"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<RerankConfigCard
|
||||
settings={{ ...defaultSettings, rerankEnabled: true }}
|
||||
providers={[]}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const toggleBtn = container.querySelector(
|
||||
"[data-testid='rerank-enabled-switch']",
|
||||
) as HTMLButtonElement | null;
|
||||
expect(toggleBtn).toBeTruthy();
|
||||
await act(async () => {
|
||||
toggleBtn?.click();
|
||||
});
|
||||
expect(onSave).toHaveBeenCalledWith({ rerankEnabled: false });
|
||||
});
|
||||
|
||||
it("shows warning banner when rerankEnabled=true", async () => {
|
||||
const { default: RerankConfigCard } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/RerankConfigCard"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<RerankConfigCard
|
||||
settings={{ ...defaultSettings, rerankEnabled: true }}
|
||||
providers={[]}
|
||||
onSave={vi.fn().mockResolvedValue(true)}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(container.textContent).toContain("rerank.warning");
|
||||
});
|
||||
|
||||
it("does not show warning banner when rerankEnabled=false", async () => {
|
||||
const { default: RerankConfigCard } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/RerankConfigCard"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<RerankConfigCard
|
||||
settings={defaultSettings}
|
||||
providers={[]}
|
||||
onSave={vi.fn().mockResolvedValue(true)}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(container.textContent).not.toContain("rerank.warning");
|
||||
});
|
||||
|
||||
it("shows no-provider warning when rerankEnabled=true but no providers have keys", async () => {
|
||||
const { default: RerankConfigCard } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/RerankConfigCard"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<RerankConfigCard
|
||||
settings={{ ...defaultSettings, rerankEnabled: true }}
|
||||
providers={[{ provider: "cohere", hasKey: false, models: [] }]}
|
||||
onSave={vi.fn().mockResolvedValue(true)}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(container.querySelector("[data-testid='rerank-no-provider-warning']")).toBeTruthy();
|
||||
expect(container.textContent).toContain("rerank.noProviderWithKey");
|
||||
});
|
||||
|
||||
it("shows provider select (not warning) when rerankEnabled=true and provider with key exists", async () => {
|
||||
const { default: RerankConfigCard } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/RerankConfigCard"
|
||||
);
|
||||
const providers = [
|
||||
{
|
||||
provider: "cohere",
|
||||
hasKey: true,
|
||||
models: [
|
||||
{ id: "cohere/rerank-english-v3", name: "rerank-english-v3", dimensions: 0 },
|
||||
],
|
||||
},
|
||||
];
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<RerankConfigCard
|
||||
settings={{ ...defaultSettings, rerankEnabled: true }}
|
||||
providers={providers}
|
||||
onSave={vi.fn().mockResolvedValue(true)}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
// No warning
|
||||
expect(container.querySelector("[data-testid='rerank-no-provider-warning']")).toBeNull();
|
||||
// Provider/model select present
|
||||
expect(container.querySelector("[data-testid='rerank-provider-model-select']")).toBeTruthy();
|
||||
// Model name visible
|
||||
expect(container.textContent).toContain("rerank-english-v3");
|
||||
});
|
||||
|
||||
it("selecting a model calls onSave with rerankProviderModel", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(true);
|
||||
const providers = [
|
||||
{
|
||||
provider: "cohere",
|
||||
hasKey: true,
|
||||
models: [
|
||||
{ id: "cohere/rerank-english-v3", name: "rerank-english-v3", dimensions: 0 },
|
||||
],
|
||||
},
|
||||
];
|
||||
const { default: RerankConfigCard } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/RerankConfigCard"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<RerankConfigCard
|
||||
settings={{ ...defaultSettings, rerankEnabled: true }}
|
||||
providers={providers}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const select = container.querySelector(
|
||||
"[data-testid='rerank-provider-model-select']",
|
||||
) as HTMLSelectElement | null;
|
||||
expect(select).toBeTruthy();
|
||||
await act(async () => {
|
||||
if (select) {
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLSelectElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
nativeSetter?.call(select, "cohere/rerank-english-v3");
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
expect(onSave).toHaveBeenCalledWith({ rerankProviderModel: "cohere/rerank-english-v3" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user