mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
- 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.
286 lines
9.0 KiB
TypeScript
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>
|
|
);
|
|
}
|