AI settings
Settings of an AI assistant. Memory you can see and edit, custom instructions with a live preview, data controls with export and delete, connected apps with access per scope and rules for a whole workspace.
One of 3 components in AI setup
Variants
Details
What AI settings brings along, read from the registry and the source.
- Category
- AI setup
- Area
- App
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/ai-settings.tsx
- npm packages
- None
- Languages
- English and German, 61 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The AiSettingsProps interface from components/ui/ai-settings.tsx, with the comments from the code.
variantoptionalAiSettingsVariantmemoryinstructionsdata-controlsconnected-appsworkspace-policy
titleoptionalstringdescriptionoptionalstringmemoriesoptionalAiMemory[]memory
memoryOnoptionalbooleanaboutoptionalstringinstructions
styleoptionalstringpresetsoptionalAiTonePreset[]sampleQuestionoptionalstringinstructions: the question the sample answer replies to
maxLengthoptionalnumberretentionoptionalstring[]data-controls: how long chats are kept, the first is the default
connectionsoptionalAiConnection[]connected-apps
rulesoptionalAiPolicyRule[]workspace-policy
modelsoptional{ id: string; name: string; allowed: boolean }[]
Built-in texts
AI settings writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 61 texts
| Key | English | German |
|---|---|---|
| memoryOn | English Remember things across chats | German Über Chats hinweg merken |
| memoryNote | English The assistant keeps short facts you share, so you do not have to repeat them. You see and control every one of them here. | German Der Assistent merkt sich kurze Fakten, die du teilst, damit du sie nicht wiederholen musst. Jeden davon siehst und steuerst du hier. |
| search | English Search memory | German Gedächtnis durchsuchen |
| count | English {n} entries | German {n} Einträge |
| learned | English From {source} | German Aus {source} |
| edit | English Edit {name} | German {name} bearbeiten |
| save | English Save | German Speichern |
| cancel | English Cancel | German Abbrechen |
| forget | English Forget {name} | German {name} vergessen |
| forgotten | English Forgotten. The assistant no longer uses it. | German Vergessen. Der Assistent nutzt es nicht mehr. |
| undo | English Undo | German Rückgängig |
| forgetAll | English Forget everything | German Alles vergessen |
| forgetAllAsk | English Forget all {n} memories? This cannot be undone. | German Alle {n} Einträge vergessen? Das lässt sich nicht rückgängig machen. |
| confirmForget | English Forget all | German Alle vergessen |
| empty | English Nothing remembered yet. Facts appear here as you chat. | German Noch nichts gemerkt. Fakten erscheinen hier, während du chattest. |
| noMatch | English No memory matches your search. | German Kein Eintrag passt zur Suche. |
| paused | English Memory is off. Existing memories stay until you delete them. | German Gedächtnis ist aus. Bestehende Einträge bleiben, bis du sie löschst. |
| about | English What should the assistant know about you? | German Was soll der Assistent über dich wissen? |
| aboutHint | English Role, company, what you work on | German Rolle, Firma, woran du arbeitest |
| style | English How should it answer? | German Wie soll er antworten? |
| styleHint | English Length, tone, format, language | German Länge, Ton, Format, Sprache |
| presets | English Start from a preset | German Mit einer Vorlage starten |
| preview | English Preview | German Vorschau |
| previewNote | English A sample answer with these settings | German Eine Beispielantwort mit diesen Einstellungen |
| saved | English Saved. New chats use these instructions. | German Gespeichert. Neue Chats nutzen diese Anweisungen. |
| chars | English {n} of {max} | German {n} von {max} |
| unsaved | English Unsaved changes | German Nicht gespeichert |
| dataTitle | English Your data | German Deine Daten |
| training | English Improve the models with my chats | German Modelle mit meinen Chats verbessern |
| trainingNote | English Off by default. Chats are never used for training unless you turn this on. | German Standardmäßig aus. Chats werden nie zum Training genutzt, außer du schaltest es ein. |
| history | English Keep chat history | German Chatverlauf behalten |
| historyNote | English Older chats are deleted automatically. | German Ältere Chats werden automatisch gelöscht. |
| temporary | English Start temporary chats by default | German Neue Chats vorübergehend starten |
| temporaryNote | English Temporary chats are not saved and not remembered. | German Vorübergehende Chats werden nicht gespeichert und nicht gemerkt. |
| export | English Export my data | German Meine Daten exportieren |
| exportNote | English A file with all chats and memories, ready within a few minutes. | German Eine Datei mit allen Chats und Einträgen, in wenigen Minuten fertig. |
| exportStart | English Request export | German Export anfordern |
| exportReady | English Export requested. We email you the link. | German Export angefordert. Wir mailen dir den Link. |
| deleteAll | English Delete all chats | German Alle Chats löschen |
| deleteNote | English Removes every chat for good. Memories stay unless you forget them. | German Entfernt jeden Chat endgültig. Das Gedächtnis bleibt, bis du es leerst. |
| deleteConfirm | English Type DELETE to confirm | German Zum Bestätigen LÖSCHEN eingeben |
| deleteWord | English DELETE | German LÖSCHEN |
| deleteDone | English All chats deleted. | German Alle Chats gelöscht. |
| delete | English Delete | German Löschen |
| connections | English Connected apps | German Verbundene Apps |
| connectionsNote | English The assistant only reads what you allow here. Every access is listed with its last use. | German Der Assistent liest nur, was du hier erlaubst. Jeder Zugriff steht mit seiner letzten Nutzung da. |
| connect | English Connect | German Verbinden |
| disconnect | English Disconnect | German Trennen |
| lastUsed | English Last used {when} | German Zuletzt {when} |
| notConnected | English Not connected | German Nicht verbunden |
| access | English Access | German Zugriff |
| allowed | English {on} of {n} allowed | German {on} von {n} erlaubt |
| policy | English Rules for the workspace | German Regeln für den Arbeitsbereich |
| policyNote | English Apply to everyone in the workspace. Members see which rule blocked a request. | German Gelten für alle im Arbeitsbereich. Mitglieder sehen, welche Regel eine Anfrage gestoppt hat. |
| allowedModels | English Allowed models | German Erlaubte Modelle |
| rulesTitle | English Protection | German Schutz |
| changedBy | English Changes are logged | German Änderungen werden protokolliert |
| publish | English Apply rules | German Regeln anwenden |
| applied | English Rules applied to all members. | German Regeln für alle Mitglieder angewendet. |
| on | English On | German An |
| off | English Off | German Aus |
Accessibility
Read from the source of the component.
- AI settings handles the keys Escape, Arrow up, Arrow down, Arrow left, Arrow right, Delete and Space itself.
- Sets aria-checked, aria-expanded, aria-label and aria-live, so screen readers announce purpose and state.
- Uses the roles radio, radiogroup and status.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 814 lines
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';
import { Switch } from '@/components/ui/switch';
export type AiSettingsVariant = 'memory' | 'instructions' | 'data-controls' | 'connected-apps' | 'workspace-policy';
export interface AiMemory {
id: string;
text: string;
/** Where it was learned, e.g. "Chat on Oct 12" */
source: string;
group: string;
}
export interface AiTonePreset {
id: string;
label: string;
/** How the sample answer reads with this preset */
sample: string;
}
export interface AiConnection {
id: string;
name: string;
icon?: IconName;
/** What it gives the assistant, e.g. "Read your calendar" */
access: { id: string; label: string; on: boolean }[];
lastUsed?: string;
connected: boolean;
}
export interface AiPolicyRule {
id: string;
label: string;
description: string;
icon?: IconName;
on: boolean;
/** Shown as a badge, e.g. "Blocks 12 requests a week" */
impact?: string;
}
export interface AiSettingsProps {
variant?: AiSettingsVariant;
title?: string;
description?: string;
/** memory */
memories?: AiMemory[];
memoryOn?: boolean;
/** instructions */
about?: string;
style?: string;
presets?: AiTonePreset[];
/** instructions: the question the sample answer replies to */
sampleQuestion?: string;
maxLength?: number;
/** data-controls: how long chats are kept, the first is the default */
retention?: string[];
/** connected-apps */
connections?: AiConnection[];
/** workspace-policy */
rules?: AiPolicyRule[];
models?: { id: string; name: string; allowed: boolean }[];
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
memoryOn: 'Remember things across chats',
memoryNote: 'The assistant keeps short facts you share, so you do not have to repeat them. You see and control every one of them here.',
search: 'Search memory',
count: '{n} entries',
learned: 'From {source}',
edit: 'Edit {name}',
save: 'Save',
cancel: 'Cancel',
forget: 'Forget {name}',
forgotten: 'Forgotten. The assistant no longer uses it.',
undo: 'Undo',
forgetAll: 'Forget everything',
forgetAllAsk: 'Forget all {n} memories? This cannot be undone.',
confirmForget: 'Forget all',
empty: 'Nothing remembered yet. Facts appear here as you chat.',
noMatch: 'No memory matches your search.',
paused: 'Memory is off. Existing memories stay until you delete them.',
about: 'What should the assistant know about you?',
aboutHint: 'Role, company, what you work on',
style: 'How should it answer?',
styleHint: 'Length, tone, format, language',
presets: 'Start from a preset',
preview: 'Preview',
previewNote: 'A sample answer with these settings',
saved: 'Saved. New chats use these instructions.',
chars: '{n} of {max}',
unsaved: 'Unsaved changes',
dataTitle: 'Your data',
training: 'Improve the models with my chats',
trainingNote: 'Off by default. Chats are never used for training unless you turn this on.',
history: 'Keep chat history',
historyNote: 'Older chats are deleted automatically.',
temporary: 'Start temporary chats by default',
temporaryNote: 'Temporary chats are not saved and not remembered.',
export: 'Export my data',
exportNote: 'A file with all chats and memories, ready within a few minutes.',
exportStart: 'Request export',
exportReady: 'Export requested. We email you the link.',
deleteAll: 'Delete all chats',
deleteNote: 'Removes every chat for good. Memories stay unless you forget them.',
deleteConfirm: 'Type DELETE to confirm',
deleteWord: 'DELETE',
deleteDone: 'All chats deleted.',
delete: 'Delete',
connections: 'Connected apps',
connectionsNote: 'The assistant only reads what you allow here. Every access is listed with its last use.',
connect: 'Connect',
disconnect: 'Disconnect',
lastUsed: 'Last used {when}',
notConnected: 'Not connected',
access: 'Access',
allowed: '{on} of {n} allowed',
policy: 'Rules for the workspace',
policyNote: 'Apply to everyone in the workspace. Members see which rule blocked a request.',
allowedModels: 'Allowed models',
rulesTitle: 'Protection',
changedBy: 'Changes are logged',
publish: 'Apply rules',
applied: 'Rules applied to all members.',
on: 'On',
off: 'Off',
},
de: {
memoryOn: 'Über Chats hinweg merken',
memoryNote: 'Der Assistent merkt sich kurze Fakten, die du teilst, damit du sie nicht wiederholen musst. Jeden davon siehst und steuerst du hier.',
search: 'Gedächtnis durchsuchen',
count: '{n} Einträge',
learned: 'Aus {source}',
edit: '{name} bearbeiten',
save: 'Speichern',
cancel: 'Abbrechen',
forget: '{name} vergessen',
forgotten: 'Vergessen. Der Assistent nutzt es nicht mehr.',
undo: 'Rückgängig',
forgetAll: 'Alles vergessen',
forgetAllAsk: 'Alle {n} Einträge vergessen? Das lässt sich nicht rückgängig machen.',
confirmForget: 'Alle vergessen',
empty: 'Noch nichts gemerkt. Fakten erscheinen hier, während du chattest.',
noMatch: 'Kein Eintrag passt zur Suche.',
paused: 'Gedächtnis ist aus. Bestehende Einträge bleiben, bis du sie löschst.',
about: 'Was soll der Assistent über dich wissen?',
aboutHint: 'Rolle, Firma, woran du arbeitest',
style: 'Wie soll er antworten?',
styleHint: 'Länge, Ton, Format, Sprache',
presets: 'Mit einer Vorlage starten',
preview: 'Vorschau',
previewNote: 'Eine Beispielantwort mit diesen Einstellungen',
saved: 'Gespeichert. Neue Chats nutzen diese Anweisungen.',
chars: '{n} von {max}',
unsaved: 'Nicht gespeichert',
dataTitle: 'Deine Daten',
training: 'Modelle mit meinen Chats verbessern',
trainingNote: 'Standardmäßig aus. Chats werden nie zum Training genutzt, außer du schaltest es ein.',
history: 'Chatverlauf behalten',
historyNote: 'Ältere Chats werden automatisch gelöscht.',
temporary: 'Neue Chats vorübergehend starten',
temporaryNote: 'Vorübergehende Chats werden nicht gespeichert und nicht gemerkt.',
export: 'Meine Daten exportieren',
exportNote: 'Eine Datei mit allen Chats und Einträgen, in wenigen Minuten fertig.',
exportStart: 'Export anfordern',
exportReady: 'Export angefordert. Wir mailen dir den Link.',
deleteAll: 'Alle Chats löschen',
deleteNote: 'Entfernt jeden Chat endgültig. Das Gedächtnis bleibt, bis du es leerst.',
deleteConfirm: 'Zum Bestätigen LÖSCHEN eingeben',
deleteWord: 'LÖSCHEN',
deleteDone: 'Alle Chats gelöscht.',
delete: 'Löschen',
connections: 'Verbundene Apps',
connectionsNote: 'Der Assistent liest nur, was du hier erlaubst. Jeder Zugriff steht mit seiner letzten Nutzung da.',
connect: 'Verbinden',
disconnect: 'Trennen',
lastUsed: 'Zuletzt {when}',
notConnected: 'Nicht verbunden',
access: 'Zugriff',
allowed: '{on} von {n} erlaubt',
policy: 'Regeln für den Arbeitsbereich',
policyNote: 'Gelten für alle im Arbeitsbereich. Mitglieder sehen, welche Regel eine Anfrage gestoppt hat.',
allowedModels: 'Erlaubte Modelle',
rulesTitle: 'Schutz',
changedBy: 'Änderungen werden protokolliert',
publish: 'Regeln anwenden',
applied: 'Regeln für alle Mitglieder angewendet.',
on: 'An',
off: 'Aus',
},
};
type Texts = (typeof TEXTS)['en'];
type P = AiSettingsProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
/** State seeded from a prop that starts over when the prop changes (the Studio edits props live) */
function useSynced<T>(value: T) {
const [state, setState] = useState(value);
const key = JSON.stringify(value);
const seen = useRef(key);
if (seen.current !== key) {
seen.current = key;
setState(value);
}
return [state, setState] as const;
}
const CSS =
'@keyframes ais-up{from{opacity:0;transform:translateY(8px)}}@keyframes ais-out{to{opacity:0;transform:translateX(20px)}}@keyframes ais-swap{from{opacity:0;filter:blur(3px)}}' +
'@media (prefers-reduced-motion:no-preference){.ais-up{animation:ais-up .45s var(--ease-kit) var(--ais-d,0ms) both}.ais-out{animation:ais-out .3s var(--ease-kit) both}.ais-swap{animation:ais-swap .5s var(--ease-kit) both}}';
function Style() {
return (
<style href="kit-ai-settings" precedence="default">
{CSS}
</style>
);
}
/* ------------------------------------------------------------------ */
/* Shared parts */
/* ------------------------------------------------------------------ */
function Stage({ title, description, children, className, wide }: { title?: string; description?: string; children: React.ReactNode; className?: string; wide?: boolean }) {
return (
<section className={cn('kit-glow relative overflow-clip bg-surface py-12 sm:py-16 lg:py-20', className)}>
<div className="kit-container">
<div className={cn('mx-auto w-full', wide ? 'max-w-[64rem]' : 'max-w-[46rem]')}>
{(title || description) && (
<Reveal className="mb-6 sm:mb-8">
{title && <h2 className="text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[26px]">{title}</h2>}
{description && <p className="mt-2 max-w-[38rem] text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{description}</p>}
</Reveal>
)}
{children}
</div>
</div>
</section>
);
}
function Card({ children, className }: { children: React.ReactNode; className?: string }) {
return <div className={cn('rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]', className)}>{children}</div>;
}
const primaryBtn = 'inline-flex h-11 items-center justify-center gap-1.5 rounded-lg bg-primary px-4 text-[13.5px] font-medium text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] transition-colors hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] disabled:bg-[color-mix(in_oklab,var(--foreground)_8%,var(--card))] disabled:text-subtle-foreground disabled:shadow-none sm:h-9';
const quietBtn = 'inline-flex h-11 items-center justify-center gap-1.5 rounded-lg border border-border bg-card px-4 text-[13.5px] font-medium text-foreground transition-colors hover:bg-accent disabled:opacity-50 sm:h-9';
const dangerBtn = 'inline-flex h-11 items-center justify-center gap-1.5 rounded-lg border border-[color-mix(in_oklab,var(--destructive)_40%,var(--border))] px-4 text-[13.5px] font-medium text-destructive transition-colors hover:bg-[color-mix(in_oklab,var(--destructive)_10%,transparent)] sm:h-9';
const iconBtn = 'grid size-11 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground sm:size-8';
const field = 'w-full rounded-xl border border-border bg-background px-3.5 py-3 text-[14.5px] leading-relaxed text-foreground outline-none transition-[border-color,box-shadow] placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] focus:shadow-[0_0_0_4px_color-mix(in_oklab,var(--primary)_14%,transparent)] focus-visible:outline-none';
function Status({ children, tone = 'success' }: { children: React.ReactNode; tone?: 'success' | 'muted' }) {
return (
<span role="status" className={cn('ais-up inline-flex items-center gap-1.5 text-[13px]', tone === 'success' ? 'text-success-foreground' : 'text-muted-foreground')}>
<Icon name={tone === 'success' ? 'check-circle' : 'info'} size={15} />
{children}
</span>
);
}
/* ------------------------------------------------------------------ */
/* memory */
/* ------------------------------------------------------------------ */
function Memory({ t, title, description, memories = [], memoryOn = true, className }: P) {
const [items, setItems] = useSynced(memories);
const [on, setOn] = useState(memoryOn);
const [q, setQ] = useState('');
const [editing, setEditing] = useState<string | null>(null);
const [draft, setDraft] = useState('');
const [leaving, setLeaving] = useState<string | null>(null);
const [last, setLast] = useState<{ m: AiMemory; i: number } | null>(null);
const [ask, setAsk] = useState(false);
const uid = useId();
const shown = items.filter((m) => !q || m.text.toLowerCase().includes(q.toLowerCase()));
const groups = Array.from(new Set(shown.map((m) => m.group)));
const forget = (m: AiMemory) => {
setLeaving(m.id);
window.setTimeout(
() => {
setItems((s) => s.filter((x) => x.id !== m.id));
setLast({ m, i: items.findIndex((x) => x.id === m.id) });
setLeaving(null);
},
prefersReducedMotion() ? 0 : 300,
);
};
return (
<Stage title={title} description={description} className={className}>
<Reveal delay={60}>
<Card>
<div className="border-b border-border p-4 sm:p-5">
<Switch label={<span className="text-[14.5px] font-medium">{t.memoryOn}</span>} description={<span className="text-[13px] leading-relaxed">{t.memoryNote}</span>} checked={on} onCheckedChange={setOn} />
</div>
{!on && (
<p className="ais-up flex items-center gap-2 border-b border-border bg-[color-mix(in_oklab,var(--warning)_10%,var(--card))] px-4 py-2.5 text-[13px] text-warning-foreground sm:px-5">
<Icon name="pause" size={14} />
{t.paused}
</p>
)}
<div className={cn('transition-opacity duration-300', !on && 'opacity-60')}>
<div className="border-b border-border p-3 sm:px-5">
<label className="flex h-11 min-w-0 items-center gap-2 rounded-lg border border-border bg-background px-3 focus-within:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] focus-within:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:h-9">
<Icon name="search" size={15} className="shrink-0 text-muted-foreground" />
<span className="sr-only">{t.search}</span>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder={t.search} className="w-0 min-w-0 flex-1 bg-transparent text-[14px] text-foreground outline-none placeholder:text-subtle-foreground focus-visible:outline-none" />
<span className="shrink-0 rounded-md bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)] px-1.5 py-0.5 text-[12px] tabular-nums text-muted-foreground" aria-label={fill(t.count, { n: shown.length })}>
{shown.length}
</span>
</label>
</div>
{last && (
<p role="status" className="ais-up flex items-center justify-between gap-3 border-b border-border bg-surface/60 px-4 py-2 text-[13px] text-muted-foreground sm:px-5">
{t.forgotten}
<button type="button" onClick={() => (setItems((s) => [...s.slice(0, last.i), last.m, ...s.slice(last.i)]), setLast(null))} className="-my-1 inline-flex h-11 items-center rounded-md px-2 font-medium text-foreground hover:bg-accent sm:h-8">
{t.undo}
</button>
</p>
)}
{items.length === 0 ? (
<p className="px-5 py-10 text-center text-[13.5px] text-muted-foreground">{t.empty}</p>
) : shown.length === 0 ? (
<p className="px-5 py-10 text-center text-[13.5px] text-muted-foreground">{t.noMatch}</p>
) : (
<div className="divide-y divide-border">
{groups.map((g) => (
<div key={g} className="py-2">
<p className="px-4 pb-1 pt-2 text-[12px] font-medium text-subtle-foreground sm:px-5">{g}</p>
<Stagger as="ul" step={50}>
{shown
.filter((m) => m.group === g)
.map((m) => (
<li key={m.id} className={cn('group flex items-start gap-3 px-4 py-2.5 sm:px-5', leaving === m.id && 'ais-out')}>
<span aria-hidden className="mt-2 size-1.5 shrink-0 rounded-full bg-primary" />
{editing === m.id ? (
<form
className="min-w-0 flex-1"
onSubmit={(e) => {
e.preventDefault();
setItems((s) => s.map((x) => (x.id === m.id ? { ...x, text: draft.trim() || x.text } : x)));
setEditing(null);
}}
>
<label className="sr-only" htmlFor={`${uid}-mem-${m.id}`}>
{fill(t.edit, { name: m.text })}
</label>
<input id={`${uid}-mem-${m.id}`} autoFocus value={draft} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => e.key === 'Escape' && setEditing(null)} className={cn(field, 'h-11 py-0 sm:h-9')} />
<div className="mt-2 flex gap-2">
<button type="submit" className={primaryBtn}>
{t.save}
</button>
<button type="button" onClick={() => setEditing(null)} className={quietBtn}>
{t.cancel}
</button>
</div>
</form>
) : (
<>
<span className="min-w-0 flex-1">
<span className="block text-pretty text-[14.5px] leading-snug text-foreground">{m.text}</span>
<span className="mt-0.5 block text-[12px] text-muted-foreground">{fill(t.learned, { source: m.source })}</span>
</span>
<span className="-my-1 flex shrink-0 opacity-100 transition-opacity sm:opacity-0 sm:group-focus-within:opacity-100 sm:group-hover:opacity-100">
<button type="button" aria-label={fill(t.edit, { name: m.text })} onClick={() => (setEditing(m.id), setDraft(m.text))} className={iconBtn}>
<Icon name="edit" size={15} />
</button>
<button type="button" aria-label={fill(t.forget, { name: m.text })} onClick={() => forget(m)} className={cn(iconBtn, 'hover:text-destructive')}>
<Icon name="trash" size={15} />
</button>
</span>
</>
)}
</li>
))}
</Stagger>
</div>
))}
</div>
)}
</div>
<div className="border-t border-border bg-surface/60 px-4 py-3 sm:px-5">
{ask ? (
<div className="ais-up flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-[13.5px] text-foreground">{fill(t.forgetAllAsk, { n: items.length })}</p>
<div className="flex gap-2">
<button type="button" onClick={() => setAsk(false)} className={quietBtn}>
{t.cancel}
</button>
<button type="button" onClick={() => (setItems([]), setAsk(false), setLast(null))} className="inline-flex h-11 items-center rounded-lg bg-destructive px-4 text-[13.5px] font-medium text-destructive-foreground sm:h-9">
{t.confirmForget}
</button>
</div>
</div>
) : (
<button type="button" disabled={!items.length} onClick={() => setAsk(true)} className={cn(dangerBtn, 'disabled:opacity-40')}>
<Icon name="trash" size={15} />
{t.forgetAll}
</button>
)}
</div>
</Card>
</Reveal>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* instructions */
/* ------------------------------------------------------------------ */
function Instructions({ t, title, description, about = '', style = '', presets = [], sampleQuestion, maxLength = 1500, className }: P) {
const [a, setA] = useSynced(about);
const [s, setS] = useSynced(style);
const [preset, setPreset] = useState(presets[0]?.id);
const [saved, setSaved] = useState({ a: about, s: style, preset: presets[0]?.id });
const [flash, setFlash] = useState(false);
const dirty = a !== saved.a || s !== saved.s || preset !== saved.preset;
const cur = presets.find((p) => p.id === preset);
const id = useId();
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const count = (v: string) => (
<span className={cn('tabular-nums', v.length > maxLength * 0.9 ? 'text-warning-foreground' : 'text-subtle-foreground')}>{fill(t.chars, { n: v.length, max: maxLength })}</span>
);
return (
<Stage title={title} description={description} className={className} wide>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)] lg:items-start lg:gap-6">
<Reveal delay={60}>
<Card>
<form
className="space-y-5 p-4 sm:p-5"
onSubmit={(e) => {
e.preventDefault();
setSaved({ a, s, preset });
setFlash(true);
}}
>
<div>
<label htmlFor={`${id}-a`} className="block text-[14px] font-medium text-foreground">
{t.about}
</label>
<p className="mb-2 mt-0.5 text-[12.5px] text-muted-foreground">{t.aboutHint}</p>
<textarea id={`${id}-a`} rows={4} maxLength={maxLength} value={a} onChange={(e) => (setA(e.target.value), setFlash(false))} className={cn(field, 'resize-none')} />
<p className="mt-1 text-right text-[12px]">{count(a)}</p>
</div>
<div>
<label htmlFor={`${id}-s`} className="block text-[14px] font-medium text-foreground">
{t.style}
</label>
<p className="mb-2 mt-0.5 text-[12.5px] text-muted-foreground">{t.styleHint}</p>
<textarea id={`${id}-s`} rows={3} maxLength={maxLength} value={s} onChange={(e) => (setS(e.target.value), setFlash(false))} className={cn(field, 'resize-none')} />
<p className="mt-1 text-right text-[12px]">{count(s)}</p>
</div>
{presets.length > 0 && (
<div role="radiogroup" aria-label={t.presets}>
<p className="mb-2 text-[12.5px] font-medium text-muted-foreground">{t.presets}</p>
<div className="flex flex-wrap gap-2">
{presets.map((p, i) => {
const on = p.id === preset;
return (
<button
key={p.id}
ref={(el) => {
refs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={on}
tabIndex={on ? 0 : -1}
onClick={() => (setPreset(p.id), setFlash(false))}
onKeyDown={(e) => {
const d = ['ArrowRight', 'ArrowDown'].includes(e.key) ? 1 : ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 0;
if (!d) return;
e.preventDefault();
const n = (i + d + presets.length) % presets.length;
setPreset(presets[n].id);
refs.current[n]?.focus();
}}
className={cn('inline-flex min-h-11 items-center rounded-lg border px-3 text-[13px] font-medium transition-colors sm:min-h-9', on ? 'border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-foreground' : 'border-border text-muted-foreground hover:text-foreground')}
>
{p.label}
</button>
);
})}
</div>
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border pt-4">
<span aria-live="polite">{flash ? <Status>{t.saved}</Status> : dirty ? <Status tone="muted">{t.unsaved}</Status> : null}</span>
<div className="flex gap-2">
<button type="button" disabled={!dirty} onClick={() => (setA(saved.a), setS(saved.s), setPreset(saved.preset))} className={quietBtn}>
{t.cancel}
</button>
<button type="submit" disabled={!dirty} className={primaryBtn}>
{t.save}
</button>
</div>
</div>
</form>
</Card>
</Reveal>
<Reveal delay={160} className="lg:sticky lg:top-6">
<Card className="overflow-hidden">
<div className="border-b border-border px-4 py-3 sm:px-5">
<p className="text-[13px] font-medium text-foreground">{t.preview}</p>
<p className="text-[12px] text-muted-foreground">{t.previewNote}</p>
</div>
<div className="flex flex-wrap gap-1.5 border-b border-border px-4 py-2.5 sm:px-5">
{[a, s]
.filter(Boolean)
.map((v, i) => (
<span key={i} className="max-w-full truncate rounded-md bg-[color-mix(in_oklab,var(--primary)_10%,transparent)] px-2 py-1 text-[11.5px] text-[color-mix(in_oklab,var(--primary)_70%,var(--foreground))]">
{v.length > 46 ? `${v.slice(0, 44)}…` : v}
</span>
))}
</div>
<div className="space-y-3 p-4 sm:p-5">
{sampleQuestion && <p className="ml-auto w-fit max-w-[90%] rounded-2xl rounded-br-md bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] px-3.5 py-2.5 text-[14px] text-foreground">{sampleQuestion}</p>}
<div className="flex gap-2.5">
<span aria-hidden className="grid size-7 shrink-0 place-items-center rounded-full bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-primary">
<Icon name="sparkle" size={14} />
</span>
<p key={preset} className="ais-swap whitespace-pre-line text-pretty text-[14px] leading-relaxed text-foreground">
{cur?.sample}
</p>
</div>
</div>
</Card>
</Reveal>
</div>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* data-controls */
/* ------------------------------------------------------------------ */
function DataControls({ t, title, description, retention = [], className }: P) {
const [keep, setKeep] = useState(retention[0] ?? '');
const [exported, setExported] = useState(false);
const [del, setDel] = useState<'idle' | 'ask' | 'done'>('idle');
const [word, setWord] = useState('');
const id = useId();
const row = 'flex flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6 sm:px-5';
return (
<Stage title={title} description={description} className={className}>
<Stagger step={90} className="space-y-4">
<Card>
<p className="border-b border-border px-4 py-3 text-[13px] font-medium text-foreground sm:px-5">{t.dataTitle}</p>
<div className="divide-y divide-border">
<div className="px-4 py-4 sm:px-5">
<Switch label={<span className="text-[14.5px] font-medium">{t.training}</span>} description={<span className="text-[13px] leading-relaxed">{t.trainingNote}</span>} defaultChecked={false} />
</div>
<div className={row}>
<div className="min-w-0">
<label htmlFor={`${id}-keep`} className="block text-[14.5px] font-medium text-foreground">
{t.history}
</label>
<p className="mt-0.5 text-[13px] leading-relaxed text-muted-foreground">{t.historyNote}</p>
</div>
<span className="relative shrink-0 sm:w-44">
<select id={`${id}-keep`} value={keep} onChange={(e) => setKeep(e.target.value)} className="h-11 w-full appearance-none rounded-lg border border-border bg-background pl-3 pr-9 text-[14px] text-foreground hover:border-border-strong sm:h-9">
{retention.map((r) => (
<option key={r}>{r}</option>
))}
</select>
<Icon name="chevrons-up-down" size={15} className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
</span>
</div>
<div className="px-4 py-4 sm:px-5">
<Switch label={<span className="text-[14.5px] font-medium">{t.temporary}</span>} description={<span className="text-[13px] leading-relaxed">{t.temporaryNote}</span>} />
</div>
</div>
</Card>
<Card>
<div className={row}>
<div className="min-w-0">
<p className="text-[14.5px] font-medium text-foreground">{t.export}</p>
<p className="mt-0.5 text-[13px] leading-relaxed text-muted-foreground">{t.exportNote}</p>
</div>
{exported ? (
<Status>{t.exportReady}</Status>
) : (
<button type="button" onClick={() => setExported(true)} className={cn(quietBtn, 'shrink-0 self-start sm:self-auto')}>
<Icon name="download" size={15} />
{t.exportStart}
</button>
)}
</div>
</Card>
<Card className="border-[color-mix(in_oklab,var(--destructive)_30%,var(--border))]">
<div className={row}>
<div className="min-w-0">
<p className="text-[14.5px] font-medium text-foreground">{t.deleteAll}</p>
<p className="mt-0.5 text-[13px] leading-relaxed text-muted-foreground">{t.deleteNote}</p>
</div>
{del === 'idle' && (
<button type="button" onClick={() => setDel('ask')} className={cn(dangerBtn, 'shrink-0 self-start sm:self-auto')}>
<Icon name="trash" size={15} />
{t.deleteAll}
</button>
)}
{del === 'done' && <Status>{t.deleteDone}</Status>}
</div>
{del === 'ask' && (
<form
className="ais-up flex flex-col gap-2 border-t border-border px-4 py-4 sm:flex-row sm:items-end sm:px-5"
onSubmit={(e) => {
e.preventDefault();
if (word === t.deleteWord) setDel('done');
}}
>
<label className="min-w-0 flex-1">
<span className="mb-1.5 block text-[13px] text-muted-foreground">{t.deleteConfirm}</span>
<input autoFocus value={word} onChange={(e) => setWord(e.target.value)} className={cn(field, 'h-11 py-0 sm:h-9')} />
</label>
<div className="flex gap-2">
<button type="button" onClick={() => (setDel('idle'), setWord(''))} className={quietBtn}>
{t.cancel}
</button>
<button type="submit" disabled={word !== t.deleteWord} className="inline-flex h-11 items-center rounded-lg bg-destructive px-4 text-[13.5px] font-medium text-destructive-foreground disabled:opacity-40 sm:h-9">
{t.delete}
</button>
</div>
</form>
)}
</Card>
</Stagger>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* connected-apps */
/* ------------------------------------------------------------------ */
function ConnectedApps({ t, title, description, connections = [], className }: P) {
const [items, setItems] = useSynced(connections);
const [open, setOpen] = useState<string | null>(connections.find((c) => c.connected)?.id ?? null);
const toggle = (cid: string, aid: string) => setItems((s) => s.map((c) => (c.id === cid ? { ...c, access: c.access.map((a) => (a.id === aid ? { ...a, on: !a.on } : a)) } : c)));
return (
<Stage title={title ?? t.connections} description={description ?? t.connectionsNote} className={className}>
<Stagger as="ul" step={70} className="space-y-3">
{items.map((c) => {
const opened = open === c.id && c.connected;
const on = c.access.filter((a) => a.on).length;
return (
<li key={c.id}>
<Card className={cn('overflow-hidden transition-[border-color] duration-300', opened && 'border-border-strong')}>
<div className="flex items-center gap-3 p-4 sm:px-5">
<span aria-hidden className={cn('grid size-11 shrink-0 place-items-center rounded-xl border border-border', c.connected ? 'bg-background text-foreground' : 'bg-surface text-muted-foreground')}>
<Icon name={c.icon ?? 'plug'} size={20} />
</span>
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 text-[15px] font-medium text-foreground">
{c.name}
{c.connected && <span aria-hidden className="size-2 rounded-full bg-success" />}
</p>
<p className="truncate text-[12.5px] text-muted-foreground">{c.connected ? `${fill(t.allowed, { on, n: c.access.length })}${c.lastUsed ? ` · ${fill(t.lastUsed, { when: c.lastUsed })}` : ''}` : t.notConnected}</p>
</div>
{c.connected ? (
<button type="button" aria-expanded={opened} aria-label={`${t.access}, ${c.name}`} onClick={() => setOpen(opened ? null : c.id)} className={iconBtn}>
<Icon name="chevron-down" size={17} className={cn('transition-transform duration-300', opened && 'rotate-180')} />
</button>
) : (
<button type="button" onClick={() => (setItems((s) => s.map((x) => (x.id === c.id ? { ...x, connected: true } : x))), setOpen(c.id))} className={cn(quietBtn, 'shrink-0')}>
<Icon name="plug" size={15} />
{t.connect}
</button>
)}
</div>
<div className={cn('grid transition-[grid-template-rows] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', opened ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')}>
<div className="overflow-hidden" inert={!opened || undefined}>
<div className="border-t border-border px-4 py-2 sm:px-5">
<ul className="divide-y divide-border">
{c.access.map((a) => (
<li key={a.id} className="py-1">
<Switch size="sm" label={<span className="text-[13.5px]">{a.label}</span>} checked={a.on} onCheckedChange={() => toggle(c.id, a.id)} className="min-h-11" />
</li>
))}
</ul>
<div className="flex justify-end py-3">
<button type="button" onClick={() => (setItems((s) => s.map((x) => (x.id === c.id ? { ...x, connected: false } : x))), setOpen(null))} className={dangerBtn}>
{t.disconnect}
</button>
</div>
</div>
</div>
</div>
</Card>
</li>
);
})}
</Stagger>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* workspace-policy */
/* ------------------------------------------------------------------ */
function WorkspacePolicy({ t, title, description, rules = [], models = [], className }: P) {
const [r, setR] = useSynced(rules);
const [m, setM] = useSynced(models);
const [applied, setApplied] = useState(false);
const first = useRef(true);
useEffect(() => {
if (first.current) return void (first.current = false);
setApplied(false);
}, [r, m]);
return (
<Stage title={title ?? t.policy} description={description ?? t.policyNote} className={className} wide>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)] lg:items-start lg:gap-6">
<Reveal delay={60}>
<Card>
<p className="border-b border-border px-4 py-3 text-[13px] font-medium text-foreground sm:px-5">{t.rulesTitle}</p>
<Stagger as="ul" step={60} className="divide-y divide-border">
{r.map((x) => (
<li key={x.id} className="flex gap-3 px-4 py-4 sm:px-5">
<span aria-hidden className={cn('grid size-10 shrink-0 place-items-center rounded-xl transition-colors duration-300', x.on ? 'bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-primary' : 'bg-surface text-muted-foreground')}>
<Icon name={x.icon ?? 'shield'} size={18} />
</span>
<div className="min-w-0 flex-1">
<Switch label={<span className="text-[14.5px] font-medium">{x.label}</span>} description={<span className="text-[13px] leading-relaxed">{x.description}</span>} checked={x.on} onCheckedChange={(v) => setR((s) => s.map((y) => (y.id === x.id ? { ...y, on: v } : y)))} />
{x.impact && x.on && <span className="ais-up mt-2 inline-flex rounded-md bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] px-2 py-1 text-[12px] text-muted-foreground">{x.impact}</span>}
</div>
</li>
))}
</Stagger>
</Card>
</Reveal>
<Reveal delay={160} className="space-y-4 lg:sticky lg:top-6">
<Card>
<p className="border-b border-border px-4 py-3 text-[13px] font-medium text-foreground sm:px-5">{t.allowedModels}</p>
<ul className="p-2">
{m.map((x) => (
<li key={x.id}>
<label className="flex min-h-11 cursor-pointer items-center gap-3 rounded-lg px-2.5 hover:bg-accent/60">
<input type="checkbox" checked={x.allowed} onChange={() => setM((s) => s.map((y) => (y.id === x.id ? { ...y, allowed: !y.allowed } : y)))} className="size-[18px] accent-[var(--primary)]" />
<span className={cn('text-[14px]', x.allowed ? 'text-foreground' : 'text-muted-foreground')}>{x.name}</span>
<span className="ml-auto text-[12px] text-subtle-foreground">{x.allowed ? t.on : t.off}</span>
</label>
</li>
))}
</ul>
</Card>
<Card className="p-4 sm:p-5">
<p className="flex items-center gap-1.5 text-[12.5px] text-muted-foreground">
<Icon name="history" size={14} />
{t.changedBy}
</p>
<div className="mt-3" aria-live="polite">
{applied ? (
<Status>{t.applied}</Status>
) : (
<button type="button" onClick={() => setApplied(true)} className={cn(primaryBtn, 'w-full')}>
{t.publish}
</button>
)}
</div>
</Card>
</Reveal>
</div>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/**
* Settings of an AI assistant: memory you can see and edit, custom
* instructions with a live preview, data controls with export and delete,
* connected apps with access per scope and rules for a whole workspace.
*/
export function AiSettings({ variant = 'memory', labels, ...props }: AiSettingsProps) {
const t = useTexts(TEXTS, labels);
const p: P = { ...props, t };
const body = (() => {
switch (variant) {
case 'instructions':
return <Instructions {...p} />;
case 'data-controls':
return <DataControls {...p} />;
case 'connected-apps':
return <ConnectedApps {...p} />;
case 'workspace-policy':
return <WorkspacePolicy {...p} />;
default:
return <Memory {...p} />;
}
})();
return (
<>
<Style />
{body}
</>
);
}Frequent questions
How do I install AI settings?
With aiw add @ai-website/ai-settings. The command puts components/ui/ai-settings.tsx into your project, the code is yours from then on. It brings along icon, reveal, motion and switch. It needs no further npm packages.
Which variants does AI settings have?
5 variants, Memory, Custom instructions, Data controls, Connected apps and Workspace rules. You pick one with the variant prop, for example variant="instructions".
Can I translate the texts of AI settings?
Yes. AI settings carries 61 texts of its own in English and German, such as “Remember things across chats”, “The assistant keeps short facts you share, so you do not have to repeat them. You see and control every one of them here.” and “Search memory”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with AI settings?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find AI settings with aiw_components and install it with aiw_add @ai-website/ai-settings. Name the variant in your prompt, for example variant="memory", and the assistant writes your content straight into the props.
Can I adjust AI settings without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching AiSettings code to copy.