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...",
|
||||
"saveFailed": "Failed to save default currency",
|
||||
"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 { AI_PROVIDER_DEFAULT_MODEL } from '@/lib/ai/defaults';
|
||||
import type { AiProvider } from '@/lib/ai/types';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
const MASKED_KEY = '••••••••••••••••';
|
||||
|
||||
@ -44,6 +45,7 @@ const KEY_PLACEHOLDER: Record<AiProvider, string> = {
|
||||
export function AiConfig() {
|
||||
const { accountId, accountRole, profileLoading } = useAuth();
|
||||
const canEdit = accountRole ? canEditSettings(accountRole) : false;
|
||||
const t = useTranslations('Settings.aiConfig');
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@ -77,7 +79,7 @@ export function AiConfig() {
|
||||
const res = await fetch('/api/ai/config');
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
toast.error(data.error ?? 'Failed to load AI configuration');
|
||||
toast.error(data.error ?? t('loadFailed'));
|
||||
return;
|
||||
}
|
||||
if (data.configured) {
|
||||
@ -96,7 +98,7 @@ export function AiConfig() {
|
||||
setEmbeddingsKeyEdited(false);
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to load AI configuration');
|
||||
toast.error(t('loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -149,10 +151,10 @@ export function AiConfig() {
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) toast.success('Key works — the provider responded.');
|
||||
else toast.error(data.error ?? 'The provider rejected the request.');
|
||||
if (res.ok) toast.success(t('testSuccess'));
|
||||
else toast.error(data.error ?? t('testRejected'));
|
||||
} catch {
|
||||
toast.error('Could not reach the provider.');
|
||||
toast.error(t('testNetworkError'));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
@ -160,11 +162,11 @@ export function AiConfig() {
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!model.trim()) {
|
||||
toast.error('Enter a model name.');
|
||||
toast.error(t('missingModel'));
|
||||
return;
|
||||
}
|
||||
if (!configured && !keyEdited) {
|
||||
toast.error('Enter your API key.');
|
||||
toast.error(t('missingApiKey'));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
@ -176,13 +178,13 @@ export function AiConfig() {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
toast.success('AI assistant saved.');
|
||||
toast.success(t('saveSuccess'));
|
||||
await fetchConfig();
|
||||
} else {
|
||||
toast.error(data.error ?? 'Failed to save.');
|
||||
toast.error(data.error ?? t('saveFailed'));
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to save.');
|
||||
toast.error(t('saveFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@ -193,7 +195,7 @@ export function AiConfig() {
|
||||
try {
|
||||
const res = await fetch('/api/ai/config', { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
toast.success('AI configuration removed.');
|
||||
toast.success(t('removeSuccess'));
|
||||
setConfigured(false);
|
||||
setHasStoredKey(false);
|
||||
setApiKey('');
|
||||
@ -203,10 +205,10 @@ export function AiConfig() {
|
||||
setSystemPrompt('');
|
||||
} else {
|
||||
const data = await res.json();
|
||||
toast.error(data.error ?? 'Failed to remove.');
|
||||
toast.error(data.error ?? t('removeFailed'));
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to remove.');
|
||||
toast.error(t('removeFailed'));
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
@ -215,7 +217,8 @@ export function AiConfig() {
|
||||
if (loading || profileLoading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -225,13 +228,13 @@ export function AiConfig() {
|
||||
return (
|
||||
<div>
|
||||
<SettingsPanelHead
|
||||
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."
|
||||
title={t('title')}
|
||||
description={t('description')}
|
||||
/>
|
||||
|
||||
{!canEdit && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
@ -239,17 +242,16 @@ export function AiConfig() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<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>
|
||||
<CardDescription>
|
||||
Your key is encrypted at rest (AES-256-GCM) and never shown again
|
||||
after saving.
|
||||
{t('encryptionNotice')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Provider</Label>
|
||||
<Label>{t('provider')}</Label>
|
||||
<Select
|
||||
value={provider}
|
||||
onValueChange={(v) => handleProviderChange(v as AiProvider)}
|
||||
@ -268,7 +270,7 @@ export function AiConfig() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ai-model">Model</Label>
|
||||
<Label htmlFor="ai-model">{t('model')}</Label>
|
||||
<Input
|
||||
id="ai-model"
|
||||
value={model}
|
||||
@ -280,7 +282,7 @@ export function AiConfig() {
|
||||
</div>
|
||||
|
||||
<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="relative flex-1">
|
||||
<Input
|
||||
@ -324,16 +326,16 @@ export function AiConfig() {
|
||||
) : (
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Test key
|
||||
{t('testKey')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ai-embeddings-key">
|
||||
Embeddings key{' '}
|
||||
{t('embeddingsKey')}{' '}
|
||||
<span className="font-normal text-muted-foreground">
|
||||
(optional — enables semantic knowledge-base search)
|
||||
{t('optionalSemanticSearch')}
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
@ -355,11 +357,9 @@ export function AiConfig() {
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
An OpenAI key used only to embed your knowledge base
|
||||
(text-embedding-3-small)
|
||||
{provider === 'openai' ? ' — can be the same key as above' : ''}.
|
||||
Leave blank to use keyword search instead. Clear it to turn
|
||||
semantic search off.
|
||||
{t('embeddingsHint', {
|
||||
sameKeyText: provider === 'openai' ? t('sameKeyText') : '',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
@ -367,21 +367,19 @@ export function AiConfig() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Behaviour</CardTitle>
|
||||
<CardTitle className="text-base">{t('behaviour')}</CardTitle>
|
||||
<CardDescription>
|
||||
Tell the assistant about your business — products, tone, what it
|
||||
may and may not promise. This context feeds both drafts and
|
||||
auto-replies.
|
||||
{t('behaviourDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ai-prompt">Business context & instructions</Label>
|
||||
<Label htmlFor="ai-prompt">{t('businessContext')}</Label>
|
||||
<Textarea
|
||||
id="ai-prompt"
|
||||
value={systemPrompt}
|
||||
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}
|
||||
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>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Enable AI assistant
|
||||
{t('enableAssistant')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Master switch. Turns on the “Draft with AI” button in the
|
||||
inbox.
|
||||
{t('enableAssistantDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Auto-reply to inbound messages
|
||||
{t('autoReply')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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.
|
||||
{t('autoReplyDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@ -424,9 +419,9 @@ export function AiConfig() {
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<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">
|
||||
After this many bot replies in one thread, the bot goes quiet.
|
||||
{t('maxAutoRepliesDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
@ -470,7 +465,7 @@ export function AiConfig() {
|
||||
) : (
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Remove
|
||||
{t('remove')}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
@ -478,7 +473,7 @@ export function AiConfig() {
|
||||
|
||||
<Button onClick={handleSave} disabled={disabled}>
|
||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Save
|
||||
{t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface DocSummary {
|
||||
id: string;
|
||||
@ -41,6 +42,7 @@ export function AiKnowledgeCard({
|
||||
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);
|
||||
@ -48,9 +50,9 @@ export function AiKnowledgeCard({
|
||||
const res = await fetch('/api/ai/knowledge');
|
||||
const data = await res.json();
|
||||
if (res.ok) setDocs(data.documents ?? []);
|
||||
else toast.error(data.error ?? 'Failed to load knowledge base');
|
||||
else toast.error(data.error ?? t('loadFailed'));
|
||||
} catch {
|
||||
toast.error('Failed to load knowledge base');
|
||||
toast.error(t('loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -73,14 +75,14 @@ export function AiKnowledgeCard({
|
||||
const res = await fetch(`/api/ai/knowledge/${id}`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
toast.error(data.error ?? 'Failed to open document');
|
||||
toast.error(data.error ?? t('openFailed'));
|
||||
return;
|
||||
}
|
||||
setEditing(id);
|
||||
setTitle(data.title ?? '');
|
||||
setContent(data.content ?? '');
|
||||
} catch {
|
||||
toast.error('Failed to open document');
|
||||
toast.error(t('openFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@ -92,7 +94,7 @@ export function AiKnowledgeCard({
|
||||
|
||||
const save = async () => {
|
||||
if (!title.trim() || !content.trim()) {
|
||||
toast.error('Title and content are required.');
|
||||
toast.error(t('titleContentRequired'));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
@ -110,14 +112,14 @@ export function AiKnowledgeCard({
|
||||
if (res.ok) {
|
||||
// A 200 with `warning` means saved but indexing degraded.
|
||||
if (data.warning) toast.warning(data.warning);
|
||||
else toast.success(isNew ? 'Document added.' : 'Document updated.');
|
||||
else toast.success(isNew ? t('saveSuccessNew') : t('saveSuccessUpdate'));
|
||||
cancelEdit();
|
||||
await fetchDocs();
|
||||
} else {
|
||||
toast.error(data.error ?? 'Failed to save.');
|
||||
toast.error(data.error ?? t('saveFailed'));
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to save.');
|
||||
toast.error(t('saveFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@ -127,14 +129,14 @@ export function AiKnowledgeCard({
|
||||
try {
|
||||
const res = await fetch(`/api/ai/knowledge/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
toast.success('Document removed.');
|
||||
toast.success(t('removeSuccess'));
|
||||
setDocs((d) => d.filter((x) => x.id !== id));
|
||||
} else {
|
||||
const data = await res.json();
|
||||
toast.error(data.error ?? 'Failed to remove.');
|
||||
toast.error(data.error ?? t('removeFailed'));
|
||||
}
|
||||
} 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 data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
toast.success(`Reindexed ${data.reindexed} document(s).`);
|
||||
toast.success(t('reindexSuccess', { count: data.reindexed }));
|
||||
} else {
|
||||
toast.error(data.error ?? 'Reindex failed.');
|
||||
toast.error(data.error ?? t('reindexFailed'));
|
||||
}
|
||||
} catch {
|
||||
toast.error('Reindex failed.');
|
||||
toast.error(t('reindexFailed'));
|
||||
} finally {
|
||||
setReindexing(false);
|
||||
}
|
||||
@ -159,27 +161,24 @@ export function AiKnowledgeCard({
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<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>
|
||||
<CardDescription>
|
||||
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.
|
||||
{hasEmbeddingsKey
|
||||
? ' Semantic search is on (embeddings key set).'
|
||||
: ' Using keyword search — add an embeddings key above for semantic search.'}
|
||||
{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" /> Loading…
|
||||
<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">
|
||||
No documents yet.
|
||||
{t('noDocs')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -223,33 +222,33 @@ export function AiKnowledgeCard({
|
||||
{editing !== null ? (
|
||||
<div className="space-y-3 rounded-md border border-border p-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="kb-title">Title</Label>
|
||||
<Label htmlFor="kb-title">{t('editDocTitle')}</Label>
|
||||
<Input
|
||||
id="kb-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g. Returns & refunds policy"
|
||||
placeholder={t('editDocTitlePlaceholder')}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="kb-content">Content</Label>
|
||||
<Label htmlFor="kb-content">{t('editDocContent')}</Label>
|
||||
<Textarea
|
||||
id="kb-content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Paste the FAQ answer, policy text, or product details…"
|
||||
placeholder={t('editDocContentPlaceholder')}
|
||||
rows={8}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={cancelEdit} disabled={saving}>
|
||||
Cancel
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button onClick={save} disabled={saving}>
|
||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Save document
|
||||
{t('saveDoc')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -257,7 +256,7 @@ export function AiKnowledgeCard({
|
||||
canEdit && (
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
{hasEmbeddingsKey && docs.length > 0 && (
|
||||
<Button
|
||||
@ -265,14 +264,14 @@ export function AiKnowledgeCard({
|
||||
size="sm"
|
||||
onClick={reindex}
|
||||
disabled={reindexing}
|
||||
title="Re-embed all documents (e.g. after adding an embeddings key)"
|
||||
title={t('reindexTooltip')}
|
||||
>
|
||||
{reindexing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Reindex
|
||||
{t('reindex')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -24,25 +24,25 @@ export const ROLE_META: Record<
|
||||
> = {
|
||||
owner: {
|
||||
icon: Crown,
|
||||
label: 'Owner',
|
||||
label: 'owner',
|
||||
variant: 'owner',
|
||||
className: 'border-amber-500/40 bg-amber-500/10 text-amber-300',
|
||||
},
|
||||
admin: {
|
||||
icon: Shield,
|
||||
label: 'Admin',
|
||||
label: 'admin',
|
||||
variant: 'admin',
|
||||
className: 'border-primary/40 bg-primary/10 text-primary',
|
||||
},
|
||||
agent: {
|
||||
icon: UserCog,
|
||||
label: 'Agent',
|
||||
label: 'agent',
|
||||
variant: 'muted',
|
||||
className: 'border-border bg-muted text-muted-foreground',
|
||||
},
|
||||
viewer: {
|
||||
icon: UserIcon,
|
||||
label: 'Viewer',
|
||||
label: 'viewer',
|
||||
variant: 'muted',
|
||||
// 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.
|
||||
|
||||
@ -40,6 +40,7 @@ export function SettingsOverview({
|
||||
useAuth();
|
||||
const { mode, theme } = useTheme();
|
||||
const t = useTranslations('Settings.overview');
|
||||
const tRoles = useTranslations('roles');
|
||||
const tSections = useTranslations('Settings.sections');
|
||||
|
||||
const [counts, setCounts] = useState<OverviewCounts | null>(null);
|
||||
@ -243,7 +244,7 @@ export function SettingsOverview({
|
||||
{roleMeta && RoleIcon ? (
|
||||
<SettingsChip variant={roleMeta.variant}>
|
||||
<RoleIcon />
|
||||
{roleMeta.label}
|
||||
{tRoles(accountRole!)}
|
||||
</SettingsChip>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user