Skip to content
UI Kit
Accent color

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

5 variants

Variants

Memory

Every remembered fact with where it came from, grouped, searchable, editable in place and forgettable with undo. One switch pauses memory.

Open in Studio

Custom instructions

Two fields with counters, tone presets as a choice and a preview whose sample answer changes with the preset. Save only lights up after a change.

Open in Studio

Data controls

Training off by default, how long chats are kept, temporary chats, an export and deleting every chat behind a typed confirmation.

Open in Studio

Connected apps

Every connected app with its scopes as switches and the last use. Rows open with a soft height change, disconnect is one click.

Open in Studio

Workspace rules

Rules for everyone in a workspace on sensitive data, training, logs and allowed models, applied together and logged.

Open in Studio

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
Brings along
utilskiticonrevealmotionswitch
npm packages
None
Languages
English and German, 61 texts of its own

Props

The AiSettingsProps interface from components/ui/ai-settings.tsx, with the comments from the code.

  • variantoptional
    AiSettingsVariant

    memoryinstructionsdata-controlsconnected-appsworkspace-policy

  • titleoptional
    string
  • descriptionoptional
    string
  • memoriesoptional
    AiMemory[]

    memory

  • memoryOnoptional
    boolean
  • aboutoptional
    string

    instructions

  • styleoptional
    string
  • presetsoptional
    AiTonePreset[]
  • sampleQuestionoptional
    string

    instructions: the question the sample answer replies to

  • maxLengthoptional
    number
  • retentionoptional
    string[]

    data-controls: how long chats are kept, the first is the default

  • connectionsoptional
    AiConnection[]

    connected-apps

  • rulesoptional
    AiPolicyRule[]

    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
memoryOnEnglish Remember things across chatsGerman Über Chats hinweg merken
memoryNoteEnglish 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.
searchEnglish Search memoryGerman Gedächtnis durchsuchen
countEnglish {n} entriesGerman {n} Einträge
learnedEnglish From {source}German Aus {source}
editEnglish Edit {name}German {name} bearbeiten
saveEnglish SaveGerman Speichern
cancelEnglish CancelGerman Abbrechen
forgetEnglish Forget {name}German {name} vergessen
forgottenEnglish Forgotten. The assistant no longer uses it.German Vergessen. Der Assistent nutzt es nicht mehr.
undoEnglish UndoGerman Rückgängig
forgetAllEnglish Forget everythingGerman Alles vergessen
forgetAllAskEnglish Forget all {n} memories? This cannot be undone.German Alle {n} Einträge vergessen? Das lässt sich nicht rückgängig machen.
confirmForgetEnglish Forget allGerman Alle vergessen
emptyEnglish Nothing remembered yet. Facts appear here as you chat.German Noch nichts gemerkt. Fakten erscheinen hier, während du chattest.
noMatchEnglish No memory matches your search.German Kein Eintrag passt zur Suche.
pausedEnglish Memory is off. Existing memories stay until you delete them.German Gedächtnis ist aus. Bestehende Einträge bleiben, bis du sie löschst.
aboutEnglish What should the assistant know about you?German Was soll der Assistent über dich wissen?
aboutHintEnglish Role, company, what you work onGerman Rolle, Firma, woran du arbeitest
styleEnglish How should it answer?German Wie soll er antworten?
styleHintEnglish Length, tone, format, languageGerman Länge, Ton, Format, Sprache
presetsEnglish Start from a presetGerman Mit einer Vorlage starten
previewEnglish PreviewGerman Vorschau
previewNoteEnglish A sample answer with these settingsGerman Eine Beispielantwort mit diesen Einstellungen
savedEnglish Saved. New chats use these instructions.German Gespeichert. Neue Chats nutzen diese Anweisungen.
charsEnglish {n} of {max}German {n} von {max}
unsavedEnglish Unsaved changesGerman Nicht gespeichert
dataTitleEnglish Your dataGerman Deine Daten
trainingEnglish Improve the models with my chatsGerman Modelle mit meinen Chats verbessern
trainingNoteEnglish 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.
historyEnglish Keep chat historyGerman Chatverlauf behalten
historyNoteEnglish Older chats are deleted automatically.German Ältere Chats werden automatisch gelöscht.
temporaryEnglish Start temporary chats by defaultGerman Neue Chats vorübergehend starten
temporaryNoteEnglish Temporary chats are not saved and not remembered.German Vorübergehende Chats werden nicht gespeichert und nicht gemerkt.
exportEnglish Export my dataGerman Meine Daten exportieren
exportNoteEnglish 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.
exportStartEnglish Request exportGerman Export anfordern
exportReadyEnglish Export requested. We email you the link.German Export angefordert. Wir mailen dir den Link.
deleteAllEnglish Delete all chatsGerman Alle Chats löschen
deleteNoteEnglish 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.
deleteConfirmEnglish Type DELETE to confirmGerman Zum Bestätigen LÖSCHEN eingeben
deleteWordEnglish DELETEGerman LÖSCHEN
deleteDoneEnglish All chats deleted.German Alle Chats gelöscht.
deleteEnglish DeleteGerman Löschen
connectionsEnglish Connected appsGerman Verbundene Apps
connectionsNoteEnglish 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.
connectEnglish ConnectGerman Verbinden
disconnectEnglish DisconnectGerman Trennen
lastUsedEnglish Last used {when}German Zuletzt {when}
notConnectedEnglish Not connectedGerman Nicht verbunden
accessEnglish AccessGerman Zugriff
allowedEnglish {on} of {n} allowedGerman {on} von {n} erlaubt
policyEnglish Rules for the workspaceGerman Regeln für den Arbeitsbereich
policyNoteEnglish 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.
allowedModelsEnglish Allowed modelsGerman Erlaubte Modelle
rulesTitleEnglish ProtectionGerman Schutz
changedByEnglish Changes are loggedGerman Änderungen werden protokolliert
publishEnglish Apply rulesGerman Regeln anwenden
appliedEnglish Rules applied to all members.German Regeln für alle Mitglieder angewendet.
onEnglish OnGerman An
offEnglish OffGerman 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
components/ui/ai-settings.tsx
'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.

Often searched

Jump to

Copied to the clipboard