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:
Ivan Mercedes 2026-07-07 21:17:41 -04:00
parent 9ee79638da
commit 801969a361
5 changed files with 144 additions and 85 deletions

View File

@ -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…"
} }
} }
} }

View File

@ -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>

View File

@ -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>

View File

@ -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.

View File

@ -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>