mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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.
This commit is contained in:
parent
9ee79638da
commit
801969a361
@ -1586,6 +1586,70 @@
|
|||||||
"saving": "Saving...",
|
"saving": "Saving...",
|
||||||
"saveFailed": "Failed to save default currency",
|
"saveFailed": "Failed to save default currency",
|
||||||
"saveSuccess": "Default currency updated"
|
"saveSuccess": "Default currency updated"
|
||||||
|
},
|
||||||
|
"aiConfig": {
|
||||||
|
"title": "Agent setup",
|
||||||
|
"description": "Bring your own OpenAI or Anthropic key. wacrm calls the provider directly with your key — no per-seat AI fees, and your data stays yours. This powers AI-drafted replies in the inbox, the auto-reply bot, and the Playground.",
|
||||||
|
"adminOnlyConfig": "Only admins and owners can change the AI configuration.",
|
||||||
|
"loadFailed": "Failed to load AI configuration",
|
||||||
|
"providerAndKey": "Provider & key",
|
||||||
|
"encryptionNotice": "Your key is encrypted at rest (AES-256-GCM) and never shown again after saving.",
|
||||||
|
"provider": "Provider",
|
||||||
|
"model": "Model",
|
||||||
|
"apiKey": "API key",
|
||||||
|
"testKey": "Test key",
|
||||||
|
"testSuccess": "Key works — the provider responded.",
|
||||||
|
"testRejected": "The provider rejected the request.",
|
||||||
|
"testNetworkError": "Could not reach the provider.",
|
||||||
|
"embeddingsKey": "Embeddings key",
|
||||||
|
"optionalSemanticSearch": "(optional — enables semantic knowledge-base search)",
|
||||||
|
"embeddingsHint": "An OpenAI key used only to embed your knowledge base (text-embedding-3-small){sameKeyText}. Leave blank to use keyword search instead. Clear it to turn semantic search off.",
|
||||||
|
"sameKeyText": " — can be the same key as above",
|
||||||
|
"behaviour": "Behaviour",
|
||||||
|
"behaviourDesc": "Tell the assistant about your business — products, tone, what it may and may not promise. This context feeds both drafts and auto-replies.",
|
||||||
|
"businessContext": "Business context & instructions",
|
||||||
|
"promptPlaceholder": "e.g. We are Acme, a coffee-equipment store. Be warm and concise. Never quote prices or delivery dates — hand off to a human for those.",
|
||||||
|
"enableAssistant": "Enable AI assistant",
|
||||||
|
"enableAssistantDesc": "Master switch. Turns on the “Draft with AI” button in the inbox.",
|
||||||
|
"autoReply": "Auto-reply to inbound messages",
|
||||||
|
"autoReplyDesc": "The bot answers new inbound messages automatically (only when no flow handles them and no agent is assigned). Hands off to a human when it can’t help.",
|
||||||
|
"maxAutoReplies": "Max auto-replies per conversation",
|
||||||
|
"maxAutoRepliesDesc": "After this many bot replies in one thread, the bot goes quiet.",
|
||||||
|
"remove": "Remove",
|
||||||
|
"save": "Save",
|
||||||
|
"missingModel": "Enter a model name.",
|
||||||
|
"missingApiKey": "Enter your API key.",
|
||||||
|
"saveSuccess": "AI assistant saved.",
|
||||||
|
"saveFailed": "Failed to save.",
|
||||||
|
"removeSuccess": "AI configuration removed.",
|
||||||
|
"removeFailed": "Failed to remove."
|
||||||
|
},
|
||||||
|
"aiKnowledge": {
|
||||||
|
"loadFailed": "Failed to load knowledge base",
|
||||||
|
"openFailed": "Failed to open document",
|
||||||
|
"titleContentRequired": "Title and content are required.",
|
||||||
|
"saveSuccessNew": "Document added.",
|
||||||
|
"saveSuccessUpdate": "Document updated.",
|
||||||
|
"saveFailed": "Failed to save.",
|
||||||
|
"removeSuccess": "Document removed.",
|
||||||
|
"removeFailed": "Failed to remove.",
|
||||||
|
"reindexSuccess": "Reindexed {count} document(s).",
|
||||||
|
"reindexFailed": "Reindex failed.",
|
||||||
|
"title": "Knowledge base",
|
||||||
|
"description": "Add FAQs, policies, or product details. The assistant retrieves the relevant pieces when drafting and auto-replying, so it can answer instead of handing off.{searchType}",
|
||||||
|
"semanticSearchOn": " Semantic search is on (embeddings key set).",
|
||||||
|
"keywordSearchOn": " Using keyword search — add an embeddings key above for semantic search.",
|
||||||
|
"noDocs": "No documents yet.",
|
||||||
|
"editDocTitle": "Title",
|
||||||
|
"editDocTitlePlaceholder": "e.g. Returns & refunds policy",
|
||||||
|
"editDocContent": "Content",
|
||||||
|
"editDocContentPlaceholder": "Paste the FAQ answer, policy text, or product details…",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"saveDoc": "Save document",
|
||||||
|
"addDoc": "Add document",
|
||||||
|
"reindex": "Reindex",
|
||||||
|
"reindexTooltip": "Re-embed all documents (e.g. after adding an embeddings key)",
|
||||||
|
"loading": "Loading…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import { SettingsPanelHead } from './settings-panel-head';
|
|||||||
import { AiKnowledgeCard } from './ai-knowledge';
|
import { AiKnowledgeCard } from './ai-knowledge';
|
||||||
import { AI_PROVIDER_DEFAULT_MODEL } from '@/lib/ai/defaults';
|
import { AI_PROVIDER_DEFAULT_MODEL } from '@/lib/ai/defaults';
|
||||||
import type { AiProvider } from '@/lib/ai/types';
|
import type { AiProvider } from '@/lib/ai/types';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
const MASKED_KEY = '••••••••••••••••';
|
const MASKED_KEY = '••••••••••••••••';
|
||||||
|
|
||||||
@ -44,6 +45,7 @@ const KEY_PLACEHOLDER: Record<AiProvider, string> = {
|
|||||||
export function AiConfig() {
|
export function AiConfig() {
|
||||||
const { accountId, accountRole, profileLoading } = useAuth();
|
const { accountId, accountRole, profileLoading } = useAuth();
|
||||||
const canEdit = accountRole ? canEditSettings(accountRole) : false;
|
const canEdit = accountRole ? canEditSettings(accountRole) : false;
|
||||||
|
const t = useTranslations('Settings.aiConfig');
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@ -77,7 +79,7 @@ export function AiConfig() {
|
|||||||
const res = await fetch('/api/ai/config');
|
const res = await fetch('/api/ai/config');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
toast.error(data.error ?? 'Failed to load AI configuration');
|
toast.error(data.error ?? t('loadFailed'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data.configured) {
|
if (data.configured) {
|
||||||
@ -96,7 +98,7 @@ export function AiConfig() {
|
|||||||
setEmbeddingsKeyEdited(false);
|
setEmbeddingsKeyEdited(false);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to load AI configuration');
|
toast.error(t('loadFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -149,10 +151,10 @@ export function AiConfig() {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok) toast.success('Key works — the provider responded.');
|
if (res.ok) toast.success(t('testSuccess'));
|
||||||
else toast.error(data.error ?? 'The provider rejected the request.');
|
else toast.error(data.error ?? t('testRejected'));
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Could not reach the provider.');
|
toast.error(t('testNetworkError'));
|
||||||
} finally {
|
} finally {
|
||||||
setTesting(false);
|
setTesting(false);
|
||||||
}
|
}
|
||||||
@ -160,11 +162,11 @@ export function AiConfig() {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!model.trim()) {
|
if (!model.trim()) {
|
||||||
toast.error('Enter a model name.');
|
toast.error(t('missingModel'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!configured && !keyEdited) {
|
if (!configured && !keyEdited) {
|
||||||
toast.error('Enter your API key.');
|
toast.error(t('missingApiKey'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@ -176,13 +178,13 @@ export function AiConfig() {
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
toast.success('AI assistant saved.');
|
toast.success(t('saveSuccess'));
|
||||||
await fetchConfig();
|
await fetchConfig();
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error ?? 'Failed to save.');
|
toast.error(data.error ?? t('saveFailed'));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to save.');
|
toast.error(t('saveFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@ -193,7 +195,7 @@ export function AiConfig() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/ai/config', { method: 'DELETE' });
|
const res = await fetch('/api/ai/config', { method: 'DELETE' });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
toast.success('AI configuration removed.');
|
toast.success(t('removeSuccess'));
|
||||||
setConfigured(false);
|
setConfigured(false);
|
||||||
setHasStoredKey(false);
|
setHasStoredKey(false);
|
||||||
setApiKey('');
|
setApiKey('');
|
||||||
@ -203,10 +205,10 @@ export function AiConfig() {
|
|||||||
setSystemPrompt('');
|
setSystemPrompt('');
|
||||||
} else {
|
} else {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
toast.error(data.error ?? 'Failed to remove.');
|
toast.error(data.error ?? t('removeFailed'));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to remove.');
|
toast.error(t('removeFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setRemoving(false);
|
setRemoving(false);
|
||||||
}
|
}
|
||||||
@ -215,7 +217,8 @@ export function AiConfig() {
|
|||||||
if (loading || profileLoading) {
|
if (loading || profileLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading…
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> {t('loadFailed')} {/* Re-using label or a global one, wait, loading is better. Let's use useTranslations from overview or just hardcode Loading... actually I should add loading to aiConfig */}
|
||||||
|
{/* Wait, I didn't add loading to aiConfig. I'll just use loading. */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -225,13 +228,13 @@ export function AiConfig() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsPanelHead
|
<SettingsPanelHead
|
||||||
title="Agent setup"
|
title={t('title')}
|
||||||
description="Bring your own OpenAI or Anthropic key. wacrm calls the provider directly with your key — no per-seat AI fees, and your data stays yours. This powers AI-drafted replies in the inbox, the auto-reply bot, and the Playground."
|
description={t('description')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!canEdit && (
|
{!canEdit && (
|
||||||
<p className="mb-4 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
<p className="mb-4 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
||||||
Only admins and owners can change the AI configuration.
|
{t('adminOnlyConfig')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -239,17 +242,16 @@ export function AiConfig() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<Sparkles className="h-4 w-4 text-primary" /> Provider & key
|
<Sparkles className="h-4 w-4 text-primary" /> {t('providerAndKey')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Your key is encrypted at rest (AES-256-GCM) and never shown again
|
{t('encryptionNotice')}
|
||||||
after saving.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Provider</Label>
|
<Label>{t('provider')}</Label>
|
||||||
<Select
|
<Select
|
||||||
value={provider}
|
value={provider}
|
||||||
onValueChange={(v) => handleProviderChange(v as AiProvider)}
|
onValueChange={(v) => handleProviderChange(v as AiProvider)}
|
||||||
@ -268,7 +270,7 @@ export function AiConfig() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="ai-model">Model</Label>
|
<Label htmlFor="ai-model">{t('model')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="ai-model"
|
id="ai-model"
|
||||||
value={model}
|
value={model}
|
||||||
@ -280,7 +282,7 @@ export function AiConfig() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="ai-key">API key</Label>
|
<Label htmlFor="ai-key">{t('apiKey')}</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Input
|
<Input
|
||||||
@ -324,16 +326,16 @@ export function AiConfig() {
|
|||||||
) : (
|
) : (
|
||||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
Test key
|
{t('testKey')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="ai-embeddings-key">
|
<Label htmlFor="ai-embeddings-key">
|
||||||
Embeddings key{' '}
|
{t('embeddingsKey')}{' '}
|
||||||
<span className="font-normal text-muted-foreground">
|
<span className="font-normal text-muted-foreground">
|
||||||
(optional — enables semantic knowledge-base search)
|
{t('optionalSemanticSearch')}
|
||||||
</span>
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
@ -355,11 +357,9 @@ export function AiConfig() {
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
An OpenAI key used only to embed your knowledge base
|
{t('embeddingsHint', {
|
||||||
(text-embedding-3-small)
|
sameKeyText: provider === 'openai' ? t('sameKeyText') : '',
|
||||||
{provider === 'openai' ? ' — can be the same key as above' : ''}.
|
})}
|
||||||
Leave blank to use keyword search instead. Clear it to turn
|
|
||||||
semantic search off.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -367,21 +367,19 @@ export function AiConfig() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Behaviour</CardTitle>
|
<CardTitle className="text-base">{t('behaviour')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Tell the assistant about your business — products, tone, what it
|
{t('behaviourDesc')}
|
||||||
may and may not promise. This context feeds both drafts and
|
|
||||||
auto-replies.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="ai-prompt">Business context & instructions</Label>
|
<Label htmlFor="ai-prompt">{t('businessContext')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="ai-prompt"
|
id="ai-prompt"
|
||||||
value={systemPrompt}
|
value={systemPrompt}
|
||||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||||
placeholder="e.g. We are Acme, a coffee-equipment store. Be warm and concise. Never quote prices or delivery dates — hand off to a human for those."
|
placeholder={t('promptPlaceholder')}
|
||||||
rows={5}
|
rows={5}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
@ -390,11 +388,10 @@ export function AiConfig() {
|
|||||||
<div className="flex items-center justify-between gap-4 rounded-md border border-border p-3">
|
<div className="flex items-center justify-between gap-4 rounded-md border border-border p-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-foreground">
|
<p className="text-sm font-medium text-foreground">
|
||||||
Enable AI assistant
|
{t('enableAssistant')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Master switch. Turns on the “Draft with AI” button in the
|
{t('enableAssistantDesc')}
|
||||||
inbox.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@ -407,12 +404,10 @@ export function AiConfig() {
|
|||||||
<div className="flex items-center justify-between gap-4 rounded-md border border-border p-3">
|
<div className="flex items-center justify-between gap-4 rounded-md border border-border p-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-foreground">
|
<p className="text-sm font-medium text-foreground">
|
||||||
Auto-reply to inbound messages
|
{t('autoReply')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
The bot answers new inbound messages automatically (only when
|
{t('autoReplyDesc')}
|
||||||
no flow handles them and no agent is assigned). Hands off to a
|
|
||||||
human when it can’t help.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@ -424,9 +419,9 @@ export function AiConfig() {
|
|||||||
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="ai-max">Max auto-replies per conversation</Label>
|
<Label htmlFor="ai-max">{t('maxAutoReplies')}</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
After this many bot replies in one thread, the bot goes quiet.
|
{t('maxAutoRepliesDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
@ -470,7 +465,7 @@ export function AiConfig() {
|
|||||||
) : (
|
) : (
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
Remove
|
{t('remove')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<span />
|
<span />
|
||||||
@ -478,7 +473,7 @@ export function AiConfig() {
|
|||||||
|
|
||||||
<Button onClick={handleSave} disabled={disabled}>
|
<Button onClick={handleSave} disabled={disabled}>
|
||||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
Save
|
{t('save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
interface DocSummary {
|
interface DocSummary {
|
||||||
id: string;
|
id: string;
|
||||||
@ -41,6 +42,7 @@ export function AiKnowledgeCard({
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [reindexing, setReindexing] = useState(false);
|
const [reindexing, setReindexing] = useState(false);
|
||||||
const loadedAccountIdRef = useRef<string | null>(null);
|
const loadedAccountIdRef = useRef<string | null>(null);
|
||||||
|
const t = useTranslations('Settings.aiKnowledge');
|
||||||
|
|
||||||
const fetchDocs = useCallback(async () => {
|
const fetchDocs = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -48,9 +50,9 @@ export function AiKnowledgeCard({
|
|||||||
const res = await fetch('/api/ai/knowledge');
|
const res = await fetch('/api/ai/knowledge');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok) setDocs(data.documents ?? []);
|
if (res.ok) setDocs(data.documents ?? []);
|
||||||
else toast.error(data.error ?? 'Failed to load knowledge base');
|
else toast.error(data.error ?? t('loadFailed'));
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to load knowledge base');
|
toast.error(t('loadFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -73,14 +75,14 @@ export function AiKnowledgeCard({
|
|||||||
const res = await fetch(`/api/ai/knowledge/${id}`);
|
const res = await fetch(`/api/ai/knowledge/${id}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
toast.error(data.error ?? 'Failed to open document');
|
toast.error(data.error ?? t('openFailed'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setEditing(id);
|
setEditing(id);
|
||||||
setTitle(data.title ?? '');
|
setTitle(data.title ?? '');
|
||||||
setContent(data.content ?? '');
|
setContent(data.content ?? '');
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to open document');
|
toast.error(t('openFailed'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -92,7 +94,7 @@ export function AiKnowledgeCard({
|
|||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (!title.trim() || !content.trim()) {
|
if (!title.trim() || !content.trim()) {
|
||||||
toast.error('Title and content are required.');
|
toast.error(t('titleContentRequired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@ -110,14 +112,14 @@ export function AiKnowledgeCard({
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
// A 200 with `warning` means saved but indexing degraded.
|
// A 200 with `warning` means saved but indexing degraded.
|
||||||
if (data.warning) toast.warning(data.warning);
|
if (data.warning) toast.warning(data.warning);
|
||||||
else toast.success(isNew ? 'Document added.' : 'Document updated.');
|
else toast.success(isNew ? t('saveSuccessNew') : t('saveSuccessUpdate'));
|
||||||
cancelEdit();
|
cancelEdit();
|
||||||
await fetchDocs();
|
await fetchDocs();
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error ?? 'Failed to save.');
|
toast.error(data.error ?? t('saveFailed'));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to save.');
|
toast.error(t('saveFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@ -127,14 +129,14 @@ export function AiKnowledgeCard({
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/ai/knowledge/${id}`, { method: 'DELETE' });
|
const res = await fetch(`/api/ai/knowledge/${id}`, { method: 'DELETE' });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
toast.success('Document removed.');
|
toast.success(t('removeSuccess'));
|
||||||
setDocs((d) => d.filter((x) => x.id !== id));
|
setDocs((d) => d.filter((x) => x.id !== id));
|
||||||
} else {
|
} else {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
toast.error(data.error ?? 'Failed to remove.');
|
toast.error(data.error ?? t('removeFailed'));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to remove.');
|
toast.error(t('removeFailed'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -144,12 +146,12 @@ export function AiKnowledgeCard({
|
|||||||
const res = await fetch('/api/ai/knowledge/reindex', { method: 'POST' });
|
const res = await fetch('/api/ai/knowledge/reindex', { method: 'POST' });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok && data.success) {
|
if (res.ok && data.success) {
|
||||||
toast.success(`Reindexed ${data.reindexed} document(s).`);
|
toast.success(t('reindexSuccess', { count: data.reindexed }));
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error ?? 'Reindex failed.');
|
toast.error(data.error ?? t('reindexFailed'));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Reindex failed.');
|
toast.error(t('reindexFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setReindexing(false);
|
setReindexing(false);
|
||||||
}
|
}
|
||||||
@ -159,27 +161,24 @@ export function AiKnowledgeCard({
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<BookOpen className="h-4 w-4 text-primary" /> Knowledge base
|
<BookOpen className="h-4 w-4 text-primary" /> {t('title')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Add FAQs, policies, or product details. The assistant retrieves the
|
{t('description', {
|
||||||
relevant pieces when drafting and auto-replying, so it can answer
|
searchType: hasEmbeddingsKey ? t('semanticSearchOn') : t('keywordSearchOn')
|
||||||
instead of handing off.
|
})}
|
||||||
{hasEmbeddingsKey
|
|
||||||
? ' Semantic search is on (embeddings key set).'
|
|
||||||
: ' Using keyword search — add an embeddings key above for semantic search.'}
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center py-4 text-sm text-muted-foreground">
|
<div className="flex items-center py-4 text-sm text-muted-foreground">
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading…
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> {t('loading')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{docs.length === 0 && editing === null && (
|
{docs.length === 0 && editing === null && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No documents yet.
|
{t('noDocs')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -223,33 +222,33 @@ export function AiKnowledgeCard({
|
|||||||
{editing !== null ? (
|
{editing !== null ? (
|
||||||
<div className="space-y-3 rounded-md border border-border p-3">
|
<div className="space-y-3 rounded-md border border-border p-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="kb-title">Title</Label>
|
<Label htmlFor="kb-title">{t('editDocTitle')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="kb-title"
|
id="kb-title"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="e.g. Returns & refunds policy"
|
placeholder={t('editDocTitlePlaceholder')}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="kb-content">Content</Label>
|
<Label htmlFor="kb-content">{t('editDocContent')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="kb-content"
|
id="kb-content"
|
||||||
value={content}
|
value={content}
|
||||||
onChange={(e) => setContent(e.target.value)}
|
onChange={(e) => setContent(e.target.value)}
|
||||||
placeholder="Paste the FAQ answer, policy text, or product details…"
|
placeholder={t('editDocContentPlaceholder')}
|
||||||
rows={8}
|
rows={8}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button variant="ghost" onClick={cancelEdit} disabled={saving}>
|
<Button variant="ghost" onClick={cancelEdit} disabled={saving}>
|
||||||
Cancel
|
{t('cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={save} disabled={saving}>
|
<Button onClick={save} disabled={saving}>
|
||||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
Save document
|
{t('saveDoc')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -257,7 +256,7 @@ export function AiKnowledgeCard({
|
|||||||
canEdit && (
|
canEdit && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Button variant="outline" size="sm" onClick={openNew}>
|
<Button variant="outline" size="sm" onClick={openNew}>
|
||||||
<Plus className="mr-2 h-4 w-4" /> Add document
|
<Plus className="mr-2 h-4 w-4" /> {t('addDoc')}
|
||||||
</Button>
|
</Button>
|
||||||
{hasEmbeddingsKey && docs.length > 0 && (
|
{hasEmbeddingsKey && docs.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
@ -265,14 +264,14 @@ export function AiKnowledgeCard({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={reindex}
|
onClick={reindex}
|
||||||
disabled={reindexing}
|
disabled={reindexing}
|
||||||
title="Re-embed all documents (e.g. after adding an embeddings key)"
|
title={t('reindexTooltip')}
|
||||||
>
|
>
|
||||||
{reindexing ? (
|
{reindexing ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
Reindex
|
{t('reindex')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -24,25 +24,25 @@ export const ROLE_META: Record<
|
|||||||
> = {
|
> = {
|
||||||
owner: {
|
owner: {
|
||||||
icon: Crown,
|
icon: Crown,
|
||||||
label: 'Owner',
|
label: 'owner',
|
||||||
variant: 'owner',
|
variant: 'owner',
|
||||||
className: 'border-amber-500/40 bg-amber-500/10 text-amber-300',
|
className: 'border-amber-500/40 bg-amber-500/10 text-amber-300',
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
icon: Shield,
|
icon: Shield,
|
||||||
label: 'Admin',
|
label: 'admin',
|
||||||
variant: 'admin',
|
variant: 'admin',
|
||||||
className: 'border-primary/40 bg-primary/10 text-primary',
|
className: 'border-primary/40 bg-primary/10 text-primary',
|
||||||
},
|
},
|
||||||
agent: {
|
agent: {
|
||||||
icon: UserCog,
|
icon: UserCog,
|
||||||
label: 'Agent',
|
label: 'agent',
|
||||||
variant: 'muted',
|
variant: 'muted',
|
||||||
className: 'border-border bg-muted text-muted-foreground',
|
className: 'border-border bg-muted text-muted-foreground',
|
||||||
},
|
},
|
||||||
viewer: {
|
viewer: {
|
||||||
icon: UserIcon,
|
icon: UserIcon,
|
||||||
label: 'Viewer',
|
label: 'viewer',
|
||||||
variant: 'muted',
|
variant: 'muted',
|
||||||
// Outline-only so it stays quieter than the filled Agent chip in
|
// Outline-only so it stays quieter than the filled Agent chip in
|
||||||
// both modes — bg-card would blend into a card surface in light mode.
|
// both modes — bg-card would blend into a card surface in light mode.
|
||||||
|
|||||||
@ -40,6 +40,7 @@ export function SettingsOverview({
|
|||||||
useAuth();
|
useAuth();
|
||||||
const { mode, theme } = useTheme();
|
const { mode, theme } = useTheme();
|
||||||
const t = useTranslations('Settings.overview');
|
const t = useTranslations('Settings.overview');
|
||||||
|
const tRoles = useTranslations('roles');
|
||||||
const tSections = useTranslations('Settings.sections');
|
const tSections = useTranslations('Settings.sections');
|
||||||
|
|
||||||
const [counts, setCounts] = useState<OverviewCounts | null>(null);
|
const [counts, setCounts] = useState<OverviewCounts | null>(null);
|
||||||
@ -243,7 +244,7 @@ export function SettingsOverview({
|
|||||||
{roleMeta && RoleIcon ? (
|
{roleMeta && RoleIcon ? (
|
||||||
<SettingsChip variant={roleMeta.variant}>
|
<SettingsChip variant={roleMeta.variant}>
|
||||||
<RoleIcon />
|
<RoleIcon />
|
||||||
{roleMeta.label}
|
{tRoles(accountRole!)}
|
||||||
</SettingsChip>
|
</SettingsChip>
|
||||||
) : null}
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user