wacrm/src/components/settings/ai-knowledge.tsx
Ivan Mercedes 801969a361 feat(i18n): localize settings ai and meta sections
- Integrated next-intl into ai-config.tsx and ai-knowledge.tsx panels.
- Defined translation schemas for Settings.aiConfig and Settings.aiKnowledge in en.json.
- Migrated hardcoded role labels from role-meta.ts to use translation keys dynamically in settings-overview.tsx using the 'roles' namespace.
- All Typescript and Linter checks successfully passed without regressions.
2026-07-07 21:17:41 -04:00

286 lines
9.0 KiB
TypeScript

'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import { Loader2, Plus, Trash2, Pencil, RefreshCw, BookOpen } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
import { useTranslations } from 'next-intl';
interface DocSummary {
id: string;
title: string;
updated_at: string;
}
/** Editor target: 'new' when creating, a doc id when editing, null when closed. */
type EditTarget = 'new' | string | null;
export function AiKnowledgeCard({
accountId,
canEdit,
hasEmbeddingsKey,
}: {
accountId: string | null;
canEdit: boolean;
hasEmbeddingsKey: boolean;
}) {
const [docs, setDocs] = useState<DocSummary[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<EditTarget>(null);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [saving, setSaving] = useState(false);
const [reindexing, setReindexing] = useState(false);
const loadedAccountIdRef = useRef<string | null>(null);
const t = useTranslations('Settings.aiKnowledge');
const fetchDocs = useCallback(async () => {
setLoading(true);
try {
const res = await fetch('/api/ai/knowledge');
const data = await res.json();
if (res.ok) setDocs(data.documents ?? []);
else toast.error(data.error ?? t('loadFailed'));
} catch {
toast.error(t('loadFailed'));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!accountId || loadedAccountIdRef.current === accountId) return;
loadedAccountIdRef.current = accountId;
void fetchDocs();
}, [accountId, fetchDocs]);
const openNew = () => {
setEditing('new');
setTitle('');
setContent('');
};
const openEdit = async (id: string) => {
try {
const res = await fetch(`/api/ai/knowledge/${id}`);
const data = await res.json();
if (!res.ok) {
toast.error(data.error ?? t('openFailed'));
return;
}
setEditing(id);
setTitle(data.title ?? '');
setContent(data.content ?? '');
} catch {
toast.error(t('openFailed'));
}
};
const cancelEdit = () => {
setEditing(null);
setTitle('');
setContent('');
};
const save = async () => {
if (!title.trim() || !content.trim()) {
toast.error(t('titleContentRequired'));
return;
}
setSaving(true);
try {
const isNew = editing === 'new';
const res = await fetch(
isNew ? '/api/ai/knowledge' : `/api/ai/knowledge/${editing}`,
{
method: isNew ? 'POST' : 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title.trim(), content: content.trim() }),
},
);
const data = await res.json();
if (res.ok) {
// A 200 with `warning` means saved but indexing degraded.
if (data.warning) toast.warning(data.warning);
else toast.success(isNew ? t('saveSuccessNew') : t('saveSuccessUpdate'));
cancelEdit();
await fetchDocs();
} else {
toast.error(data.error ?? t('saveFailed'));
}
} catch {
toast.error(t('saveFailed'));
} finally {
setSaving(false);
}
};
const remove = async (id: string) => {
try {
const res = await fetch(`/api/ai/knowledge/${id}`, { method: 'DELETE' });
if (res.ok) {
toast.success(t('removeSuccess'));
setDocs((d) => d.filter((x) => x.id !== id));
} else {
const data = await res.json();
toast.error(data.error ?? t('removeFailed'));
}
} catch {
toast.error(t('removeFailed'));
}
};
const reindex = async () => {
setReindexing(true);
try {
const res = await fetch('/api/ai/knowledge/reindex', { method: 'POST' });
const data = await res.json();
if (res.ok && data.success) {
toast.success(t('reindexSuccess', { count: data.reindexed }));
} else {
toast.error(data.error ?? t('reindexFailed'));
}
} catch {
toast.error(t('reindexFailed'));
} finally {
setReindexing(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<BookOpen className="h-4 w-4 text-primary" /> {t('title')}
</CardTitle>
<CardDescription>
{t('description', {
searchType: hasEmbeddingsKey ? t('semanticSearchOn') : t('keywordSearchOn')
})}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<div className="flex items-center py-4 text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> {t('loading')}
</div>
) : (
<>
{docs.length === 0 && editing === null && (
<p className="text-sm text-muted-foreground">
{t('noDocs')}
</p>
)}
{docs.length > 0 && (
<ul className="divide-y divide-border rounded-md border border-border">
{docs.map((doc) => (
<li
key={doc.id}
className="flex items-center justify-between gap-2 px-3 py-2"
>
<span className="min-w-0 truncate text-sm text-foreground">
{doc.title}
</span>
{canEdit && (
<span className="flex shrink-0 gap-1">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => void openEdit(doc.id)}
title="Edit"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
onClick={() => void remove(doc.id)}
title="Delete"
>
<Trash2 className="h-4 w-4" />
</Button>
</span>
)}
</li>
))}
</ul>
)}
{editing !== null ? (
<div className="space-y-3 rounded-md border border-border p-3">
<div className="space-y-2">
<Label htmlFor="kb-title">{t('editDocTitle')}</Label>
<Input
id="kb-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t('editDocTitlePlaceholder')}
disabled={saving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="kb-content">{t('editDocContent')}</Label>
<Textarea
id="kb-content"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={t('editDocContentPlaceholder')}
rows={8}
disabled={saving}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={cancelEdit} disabled={saving}>
{t('cancel')}
</Button>
<Button onClick={save} disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('saveDoc')}
</Button>
</div>
</div>
) : (
canEdit && (
<div className="flex items-center justify-between">
<Button variant="outline" size="sm" onClick={openNew}>
<Plus className="mr-2 h-4 w-4" /> {t('addDoc')}
</Button>
{hasEmbeddingsKey && docs.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={reindex}
disabled={reindexing}
title={t('reindexTooltip')}
>
{reindexing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
{t('reindex')}
</Button>
)}
</div>
)
)}
</>
)}
</CardContent>
</Card>
);
}