Skip to content
Star on GitHub
WorkspaceKnowledge sources
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Knowledge sources

The files, sites and folders the agent knows, and how far each is indexed. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add knowledge-sources
Knowledge sources
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add knowledge-sources

React

$npx designme add knowledge-sources --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

A list of what an agent knows: uploaded files, websites it crawls and connected folders of docs, each with how far it has been indexed. A source is queued, indexing with a progress bar and a count, ready with when it last synced, or failed with the reason. A failed source can offer a fix, like reading a scanned PDF with OCR, and every source has Re-sync and Remove. It answers the question "what does this agent know, and is it up to date?"

  • When people give an agent its own knowledge, like help docs, a product site or a shared drive, and need to see what it has read.
  • When indexing takes a while and people should see it progress instead of waiting on a blank list.
  • When a source can fail for a reason people can act on, like a scanned PDF or a site that blocks crawlers.
  • In an agent's settings, next to its tools and memory.
03

Usage

Import it and drive it from your own state.

Import

import { KnowledgeSources } from '@/components/designme/knowledge-sources/KnowledgeSources';

Example

The demo above as a React component, 71 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useEffect, useRef, useState } from 'react';
import { useStory } from '../lib/useScript';
import { KnowledgeSources, type KnowledgeSource, type KnowledgeSourcesFolder } from './KnowledgeSources';

/* ---------- Knowledge sources: a website is added and waits its turn; a scanned PDF fails, is read again with OCR, and everything ends indexed ---------- */
type Src = KnowledgeSource & { ms: number; total: number; scan?: boolean };
const START: Src[] = [
  { key: 'docs', kind: 'folder', name: 'Product docs', count: 86, state: 'ready', synced: '2m ago', chunks: 642, total: 642, ms: 2500 },
  { key: 'play', kind: 'files', name: 'Sales playbooks', count: 18, unit: 'files', state: 'ready', synced: '1h ago', chunks: 318, total: 318, ms: 2500 },
  { key: 'pdf', kind: 'files', name: 'Brand book 2024.pdf', count: 24, state: 'queued', total: 48, ms: 19000, scan: true }
];
const FOLDERS: KnowledgeSourcesFolder[] = [{ key: 'specs', name: 'Design specs', detail: 'Google Drive' }, { key: 'research', name: 'Research notes', detail: 'Notion' }];
const SIZE: Record<string, [number, number]> = { specs: [57, 230], research: [31, 124] };

export default function KnowledgeSourcesDemo() {
  const ref = useRef<HTMLDivElement>(null);
  const [sources, setSources] = useState<Src[]>(START);
  const [folders, setFolders] = useState(FOLDERS);
  const [address, setAddress] = useState('');
  const patch = (key: string, p: Partial<Src>) => setSources(ss => ss.map(s => s.key === key ? { ...s, ...p } : s));
  const add = (s: Omit<Src, 'state'>) => setSources(ss => [...ss.filter(x => x.key !== s.key), { ...s, state: 'queued' }]);

  // one source indexes at a time and the rest wait in line; a scanned PDF stops where its text runs out until it is read with OCR
  useEffect(() => {
    const id = setInterval(() => setSources(ss => {
      let i = ss.findIndex(s => s.state === 'indexing');
      if (i < 0) { i = ss.findIndex(s => s.state === 'queued'); if (i < 0) return ss; return ss.map((s, k) => k === i ? { ...s, state: 'indexing', done: 0 } : s); }
      const s = ss[i], done = Math.min(s.count, (s.done ?? 0) + s.count * 100 / s.ms);
      const next: Src = s.scan && done >= 9 ? { ...s, state: 'failed', done: 9, error: 'Scanned PDF, no text found', fix: 'Retry with OCR' }
        : done >= s.count ? { ...s, state: 'ready', done, synced: 'just now', chunks: s.total } : { ...s, done };
      return ss.map((x, k) => k === i ? next : x);
    }), 100);
    return () => clearInterval(id);
  }, []);

  // the preview's story: add the docs site, which waits its turn, then read the scanned PDF again with OCR
  useStory(ref, async ({ wait, press, taken }) => {
    await wait(1500); press('.dm-ksx-add');
    await wait(1000); press('.dm-ksx-pop .dm-mi:nth-child(2)');
    await wait(500);
    const url = 'designme.dev/docs';
    // taking over while it types takes the letters back
    let typing = true;
    taken(() => { if (typing) setAddress(''); });
    for (let k = 1; k <= url.length; k++) { setAddress(url.slice(0, k)); await wait(50); }
    typing = false;
    await wait(500); press('.dm-ksx-pop button[type="submit"]');
    const failed = () => ref.current?.querySelector('.dm-ksx-s[data-st="failed"]');
    for (let k = 0; k < 150 && !failed(); k++) await wait(100);
    await wait(1000); press('.dm-ksx-s[data-st="failed"] .dm-ksx-fx .dm-accent');
  });

  return (
    <div ref={ref} style={{ width: '100%' }}>
      <KnowledgeSources
        sources={sources}
        address={address} onAddressChange={setAddress}
        onAddWebsite={a => add({ key: 'web:' + a, kind: 'web', name: a, count: 142, total: 196, ms: 4500 })}
        onUpload={fs => add({ key: 'up:' + fs.map(f => f.name).join(), kind: 'files', name: fs.length === 1 ? fs[0].name : fs.length + ' files', count: fs.length, unit: 'files', total: fs.length * 12, ms: 1500 })}
        folders={folders}
        onConnectFolder={k => { const f = folders.find(x => x.key === k)!; setFolders(fs => fs.filter(x => x.key !== k)); add({ key: k, kind: 'folder', name: f.name, count: SIZE[k][0], total: SIZE[k][1], ms: 3000 }); }}
        onFix={k => patch(k, { state: 'queued', scan: false, ms: 3000, note: 'Queued to read with OCR', error: undefined, fix: undefined })}
        onResync={k => patch(k, { state: 'queued', note: undefined })}
        onRemove={k => setSources(ss => ss.filter(s => s.key !== k))}
      />
    </div>
  );
}
04

Props

Everything KnowledgeSources takes.

PropTypeWhat it does
sourcesrequiredKnowledgeSource[]
onUpload(files: File[]) => voidAdds Upload files to the Add source menu; it opens the file picker and hands you what was picked
onAddWebsite(address: string) => voidAdds Add a website to the Add source menu; it asks for an address and hands it over without http(s)://
addressstringThe address being typed. Leave it out to let the field keep its own
defaultAddressstring
onAddressChange(address: string) => void
foldersKnowledgeSourcesFolder[]Adds Connect a folder to the Add source menu, listing these
onConnectFolder(key: string) => void
onResync(key: string) => voidAdds Re-sync to each source's menu. It is unavailable while the source waits or indexes
onRemove(key: string) => voidAdds Remove to each source's menu, and beside the fix of a failed one
onFix(key: string) => voidCalled by the fix button of a failed source
titlestringDefaults to "Knowledge sources"
statusstringThe line under the title. Defaults to "4 sources · 1,204 chunks"
classNamestring
05

Code

The full source, as the CLI installs it.

KnowledgeSources.tsxReact · 178 lines
'use client';
import { useRef, useState, type CSSProperties, type FormEvent } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useIsoLayoutEffect } from '../lib/hooks';
import { Icon, type IconName } from '../lib/icons';
import { enter, Fold, Swap, Tip } from '../lib/motion';
import { usePopover } from '../lib/popover';
import '../designme.css';
import './knowledge-sources.css';

/** files: uploaded files, web: a website the agent crawls, folder: a connected folder of docs (Notion, Drive and the like) */
export type KnowledgeSourceKind = 'files' | 'web' | 'folder';

/** queued: waiting its turn, indexing: being read (with a progress bar), ready: indexed, failed: stopped, with a reason */
export type KnowledgeSourceState = 'queued' | 'indexing' | 'ready' | 'failed';

/** A file, site or folder the agent knows */
export interface KnowledgeSource {
  key: string;
  /** Like "Product docs" or "designme.dev/docs" */
  name: string;
  kind: KnowledgeSourceKind;
  /** How many pages or files it holds, like 142 */
  count: number;
  /** Defaults to pages */
  unit?: 'pages' | 'files';
  state: KnowledgeSourceState;
  /** While indexing: how many of `count` are done. The bar and "64 of 142 pages" follow it */
  done?: number;
  /** When ready: when it was last synced, like "2m ago", shown as "Synced 2m ago" */
  synced?: string;
  /** When failed: why, like "Scanned PDF, no text found" */
  error?: string;
  /** When failed: the label of a button that fixes it, like "Retry with OCR". It calls `onFix` */
  fix?: string;
  /** Replaces "Queued" while it waits, like "Queued to read with OCR" */
  note?: string;
  /** The chunks it adds to the index, counted in the line under the title. Leave it out until it has been indexed */
  chunks?: number;
}

/** A folder offered under Connect a folder */
export interface KnowledgeSourcesFolder {
  key: string;
  name: string;
  /** Where it lives, like "Google Drive" */
  detail?: string;
}

export interface KnowledgeSourcesProps {
  sources: KnowledgeSource[];
  /** Adds Upload files to the Add source menu; it opens the file picker and hands you what was picked */
  onUpload?: (files: File[]) => void;
  /** Adds Add a website to the Add source menu; it asks for an address and hands it over without http(s):// */
  onAddWebsite?: (address: string) => void;
  /** The address being typed. Leave it out to let the field keep its own */
  address?: string;
  defaultAddress?: string;
  onAddressChange?: (address: string) => void;
  /** Adds Connect a folder to the Add source menu, listing these */
  folders?: KnowledgeSourcesFolder[];
  onConnectFolder?: (key: string) => void;
  /** Adds Re-sync to each source's menu. It is unavailable while the source waits or indexes */
  onResync?: (key: string) => void;
  /** Adds Remove to each source's menu, and beside the fix of a failed one */
  onRemove?: (key: string) => void;
  /** Called by the fix button of a failed source */
  onFix?: (key: string) => void;
  /** Defaults to "Knowledge sources" */
  title?: string;
  /** The line under the title. Defaults to "4 sources · 1,204 chunks" */
  status?: string;
  className?: string;
}

const KIND: Record<KnowledgeSourceKind, [IconName, string]> = { files: ['file-text', 'var(--dm-pp)'], web: ['globe', 'var(--dm-pb)'], folder: ['folder', 'var(--dm-pm)'] };
const fmt = (n: number) => n.toLocaleString('en-US');
const of = (n: number, unit: string) => `${fmt(n)} ${n === 1 ? unit.slice(0, -1) : unit}`;
const line = (s: KnowledgeSource) => s.state === 'queued' ? s.note ?? 'Queued' : s.state === 'failed' ? s.error ?? 'Failed'
  : of(s.count, s.unit ?? 'pages') + (s.synced ? ' · Synced ' + s.synced : '');

/** One source: its type, name and state, and a menu with Re-sync and Remove */
function Row({ s, delay, onResync, onRemove, onFix }: { s: KnowledgeSource; delay: number } & Pick<KnowledgeSourcesProps, 'onResync' | 'onRemove' | 'onFix'>) {
  const menu = usePopover({ align: 'end' });
  const [icon, color] = KIND[s.kind];
  const motion = enter(delay), unit = s.unit ?? 'pages', done = Math.min(s.count, Math.max(0, Math.floor(s.done ?? 0)));
  const busy = s.state === 'queued' || s.state === 'indexing';
  const act = (f?: (key: string) => void) => { menu.closeSoon(); f?.(s.key); };
  return (
    <div className={cx('dm-ksx-s', motion.className)} style={motion.style} role="listitem" data-st={s.state}>
      <span className="dm-ic" style={{ color, background: `color-mix(in srgb,${color} 12%,transparent)` }} aria-hidden="true"><Icon name={icon} size="xs" /></span>
      <span className="dm-t">
        <span className="dm-nm">{s.name}</span>
        <span className="dm-d">
          {s.state === 'indexing'
            ? <><span className="dm-ksx-bar" role="progressbar" aria-label={'Indexing ' + s.name} aria-valuemin={0} aria-valuemax={s.count} aria-valuenow={done}><i style={{ width: done / (s.count || 1) * 100 + '%' }} /></span><span className="dm-num">{fmt(done)} of {of(s.count, unit)}</span></>
            : <Swap>{line(s)}</Swap>}
        </span>
      </span>
      {(onResync || onRemove)
        ? <Tip label="More" off={menu.open}><button type="button" className="dm-ib dm-ksx-more" aria-label={'Actions for ' + s.name} {...menu.trigger}><Icon name="more" /></button></Tip>
        : <span />}
      {(onResync || onRemove) && (
        <div {...menu.pop} className={cx(menu.pop.className, 'dm-ksx-mpop')} role="menu">
          {onResync && <button type="button" className="dm-mi" role="menuitem" disabled={busy} onClick={() => act(onResync)}><Icon name="refresh" size="xs" />Re-sync</button>}
          {onResync && onRemove && <div className="dm-sep" />}
          {onRemove && <button type="button" className="dm-mi dm-del" role="menuitem" onClick={() => act(onRemove)}><Icon name="trash" size="xs" />Remove</button>}
        </div>
      )}
      <Fold open={s.state === 'failed' && !!(s.fix || onRemove)} className="dm-ksx-fx">
        <div>
          {s.fix && <button type="button" className="dm-pill dm-accent dm-sm" onClick={() => onFix?.(s.key)}>{s.fix}</button>}
          {onRemove && <button type="button" className="dm-pill dm-ghost dm-sm" onClick={() => onRemove(s.key)}>Remove</button>}
        </div>
      </Fold>
    </div>
  );
}

/** What the agent knows: files, websites and connected folders, each with how far it is indexed. One that failed says why
    and can offer a fix; Add source uploads files, adds a website or connects a folder. */
export function KnowledgeSources({ sources, onUpload, onAddWebsite, address, defaultAddress = '', onAddressChange, folders = [], onConnectFolder,
  onResync, onRemove, onFix, title = 'Knowledge sources', status, className }: KnowledgeSourcesProps) {
  const root = useRef<HTMLDivElement>(null);
  const file = useRef<HTMLInputElement>(null);
  const field = useRef<HTMLInputElement>(null);
  const menu = usePopover({ side: 'up' });
  const [view, setView] = useState<'main' | 'web' | 'folder'>('main');
  const [url, setUrl] = useControllable(address, defaultAddress, onAddressChange);
  // the menu grows upwards from Add source whichever step it shows, so it is held by its bottom edge
  const [bottom, setBottom] = useState<CSSProperties>({});
  useIsoLayoutEffect(() => {
    const b = root.current?.querySelector<HTMLElement>('.dm-ksx-add');
    if (menu.open && b && root.current) setBottom({ top: 'auto', bottom: root.current.clientHeight - b.offsetTop + 6 });
  }, [menu.open, view]);
  // rows there from the start come in one after another; a row added later comes in on its own
  const [first] = useState(() => sources.map(s => s.key));
  const total = status ?? `${sources.length} source${sources.length === 1 ? '' : 's'} · ${fmt(sources.reduce((a, s) => a + (s.chunks ?? 0), 0))} chunks`;
  const canAdd = !!(onUpload || onAddWebsite || onConnectFolder);
  const submit = (e: FormEvent) => {
    e.preventDefault();
    const v = url.trim().replace(/^https?:\/\//, '').replace(/\/$/, '');
    if (!v) { field.current?.focus(); return; }
    menu.close(); setUrl(''); onAddWebsite?.(v);
  };
  return (
    <div data-dm="" ref={root} className={cx('dm-ksx dm-panel', className)}>
      <div className="dm-ksx-h"><div><b>{title}</b><Swap className="dm-muted dm-ksx-st">{total}</Swap></div></div>
      <div className="dm-ksx-list" role="list" aria-label="Sources">
        {sources.map(s => { const at = first.indexOf(s.key); return <Row key={s.key} s={s} delay={at >= 0 ? at * 50 : 0} onResync={onResync} onRemove={onRemove} onFix={onFix} />; })}
      </div>
      {canAdd && <>
        <button type="button" className="dm-ksx-add" {...menu.trigger} onClick={e => { if (!menu.open) setView('main'); menu.trigger.onClick(e); }}><Icon name="plus" size="xs" />Add source</button>
        <div {...menu.pop} style={{ ...menu.pop.style, ...bottom }} className={cx(menu.pop.className, 'dm-ksx-pop')} role="menu" aria-label="Add source">
          {view === 'main' && <>
            {onUpload && <button type="button" className="dm-mi" role="menuitem" onClick={() => { menu.closeSoon(); file.current?.click(); }}><Icon name="upload" size="xs" /><span>Upload files</span></button>}
            {onAddWebsite && <button type="button" className="dm-mi" role="menuitem" onClick={() => { setView('web'); requestAnimationFrame(() => field.current?.focus({ preventScroll: true })); }}><Icon name="globe" size="xs" /><span>Add a website</span></button>}
            {onConnectFolder && <button type="button" className="dm-mi" role="menuitem" onClick={() => setView('folder')}><Icon name="folder" size="xs" /><span>Connect a folder</span></button>}
          </>}
          {view === 'web' && (
            <form className="dm-ksx-web" onSubmit={submit}>
              <div className="dm-ph">Add a website</div>
              <div className="dm-ksx-in"><Icon name="globe" size="xs" /><input ref={field} value={url} onChange={e => setUrl(e.target.value)} placeholder="example.com/docs" aria-label="Website address" spellCheck={false} autoComplete="off" /><button type="submit" className="dm-pill dm-accent dm-sm">Add</button></div>
            </form>
          )}
          {view === 'folder' && <>
            <div className="dm-ph">Connect a folder</div>
            {folders.length
              ? folders.map(f => <button type="button" key={f.key} className="dm-mi" role="menuitem" onClick={() => { menu.closeSoon(); onConnectFolder?.(f.key); }}><Icon name="folder" size="xs" /><span>{f.name}{f.detail && <span className="dm-d">{f.detail}</span>}</span></button>)
              : <div className="dm-ph">All folders connected</div>}
          </>}
        </div>
        {onUpload && <input ref={file} type="file" multiple hidden onChange={e => { const fs = [...(e.target.files ?? [])]; e.target.value = ''; if (fs.length) onUpload(fs); }} />}
      </>}
    </div>
  );
}
knowledge-sources.cssReact · 43 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Knowledge sources ---------- */
/* what the agent knows: each source with its type, its size and how far it is indexed. The dot on its icon is its state,
   the same dots as Tools and connections; one that failed says why under its name and offers a fix */
.dm-ksx{padding:var(--dm-s-3);display:flex;flex-direction:column;gap:6px}
.dm-ksx-h{display:flex;align-items:center;gap:10px;padding:2px 10px var(--dm-s-2)}
.dm-ksx-h>div{display:flex;flex-direction:column;min-width:0}
.dm-ksx-h b{font-weight:500;font-size:13.5px}
.dm-ksx-st{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-ksx-list{display:flex;flex-direction:column}
.dm-ksx-s{display:grid;grid-template-columns:28px minmax(0,1fr) auto;align-items:center;column-gap:var(--dm-s-3);padding:var(--dm-s-2) 10px;border-radius:var(--dm-r-in);font-size:13px;transition:background .2s}
@media (hover:hover) and (pointer:fine){.dm-ksx-s:hover{background:var(--dm-row)}}
.dm-ksx-s .dm-ic{width:28px;height:28px;border-radius:9px;display:grid;place-items:center;position:relative}
.dm-ksx-s .dm-ic::after{content:"";position:absolute;right:-2px;bottom:-2px;box-sizing:border-box;width:8px;height:8px;border-radius:50%;background:var(--dm-ok);box-shadow:0 0 0 2px var(--dm-surface);transition:background .3s}
.dm-ksx-s[data-st="queued"] .dm-ic::after{background:var(--dm-faint)}
.dm-ksx-s[data-st="failed"] .dm-ic::after{background:var(--dm-bad)}
.dm-ksx-s[data-st="indexing"] .dm-ic::after{width:10px;height:10px;right:-3px;bottom:-3px;background:var(--dm-surface);border:1.5px solid var(--dm-accent-mark,var(--dm-accent));border-right-color:transparent;animation:dm-rot .8s linear infinite}
.dm-ksx-s .dm-t{min-width:0;display:flex;flex-direction:column}
.dm-ksx-s .dm-nm{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-ksx-s .dm-d{display:flex;align-items:center;gap:var(--dm-s-2);min-width:0;height:18px;font-size:12px;color:var(--dm-muted)}
.dm-ksx-s .dm-d>span:last-child{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-ksx-s[data-st="failed"] .dm-d{color:var(--dm-bad)}
.dm-ksx-bar{width:56px;height:4px;border-radius:2px;background:var(--dm-line);overflow:hidden;flex:none}
.dm-ksx-bar i{display:block;height:100%;border-radius:inherit;background:var(--dm-accent-mark,var(--dm-accent));transition:width .2s linear}
.dm-ksx-more{width:28px;height:28px}
.dm-ksx-s .dm-ksx-fx{grid-column:2/-1}
.dm-ksx-fx>div{overflow-clip-margin:0}
.dm-ksx-fx>div>div{display:flex;flex-wrap:wrap;gap:6px;padding-top:var(--dm-s-2)}
.dm-ksx-add{display:flex;align-items:center;gap:var(--dm-s-2);height:30px;padding:0 var(--dm-s-3) 0 10px;font-size:12.5px;color:var(--dm-fg2);border-radius:var(--dm-r-pill);align-self:flex-start;transition:background-color .2s,color .2s}
@media (hover:hover) and (pointer:fine){.dm-ksx-add:hover{color:var(--dm-fg);background:var(--dm-row)}}
.dm-ksx-pop{width:248px}
.dm-ksx-mpop{min-width:160px}
.dm-ksx-mpop .dm-mi.dm-del,.dm-ksx-mpop .dm-mi.dm-del .dm-i{color:var(--dm-bad)}
@media (hover:hover) and (pointer:fine){.dm-ksx-mpop .dm-mi.dm-del:hover{background:var(--dm-bad-bg)}}
.dm-ksx-mpop .dm-mi[disabled],.dm-ksx-mpop .dm-mi[disabled] .dm-i{opacity:1;cursor:not-allowed;background:none;color:var(--dm-dis)}
.dm-ksx-web{display:flex;flex-direction:column;gap:var(--dm-s-1)}
.dm-ksx-in{display:flex;align-items:center;gap:var(--dm-s-2);height:32px;margin:0 2px 2px;padding:0 2px 0 10px;border-radius:var(--dm-r-field);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent);color:var(--dm-muted);transition:box-shadow var(--dm-t-fast)}
@supports (corner-shape:squircle){.dm-ksx-in{corner-shape:squircle;border-radius:var(--dm-r-field-sq)}}
.dm-ksx-in:has(input:focus){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.dm-ksx-in input{flex:1;min-width:0;height:100%;border:0;outline:0;background:transparent;font:inherit;font-size:13px;color:var(--dm-fg)}
.dm-ksx-in input::placeholder{color:var(--dm-muted)}
.dm-ksx-in .dm-pill{height:28px}
knowledge-sources.htmlHTML · 13 lines
<div class="card" data-c="knowledge-sources">
  <div class="stage-in">
    <div class="ksx panel" data-reveal="flat">
      <div class="ksx-h"><div><b>Knowledge sources</b><span class="muted ksx-st" aria-live="polite"></span></div></div>
      <div class="ksx-list" role="list" aria-label="Sources"></div>
      <button class="ksx-add" data-pop=".ksx-pop" data-pop-pos="up" aria-haspopup="menu" aria-expanded="false"><svg class="i xs"><use href="#i-plus"/></svg>Add source</button>
      <div class="pop ksx-pop" role="menu" aria-label="Add source"></div>
      <div class="pop ksx-mpop" role="menu" aria-label="Source actions"><button class="mi ksx-rs" role="menuitem" data-close><svg class="i xs"><use href="#i-refresh"/></svg>Re-sync</button><div class="sep"></div><button class="mi del ksx-rm" role="menuitem" data-close><svg class="i xs"><use href="#i-trash"/></svg>Remove</button></div>
      <input class="ksx-file" type="file" multiple hidden>
    </div>
  </div>
</div>
knowledge-sources.cssHTML · 42 lines
/* ---------- Knowledge sources ---------- */
/* what the agent knows: each source with its type, its size and how far it is indexed. The dot on its icon is its state,
   the same dots as Tools and connections; one that failed says why under its name and offers a fix */
.ksx{padding:var(--s-3);display:flex;flex-direction:column;gap:6px}
.ksx-h{display:flex;align-items:center;gap:10px;padding:2px 10px var(--s-2)}
.ksx-h>div{display:flex;flex-direction:column;min-width:0}
.ksx-h b{font-weight:500;font-size:13.5px}
.ksx-st{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ksx-list{display:flex;flex-direction:column}
.ksx-s{display:grid;grid-template-columns:28px minmax(0,1fr) auto;align-items:center;column-gap:var(--s-3);padding:var(--s-2) 10px;border-radius:var(--r-in);font-size:13px;transition:background .2s}
@media (hover:hover) and (pointer:fine){.ksx-s:hover{background:var(--row)}}
.ksx-s .ic{width:28px;height:28px;border-radius:9px;display:grid;place-items:center;position:relative}
.ksx-s .ic::after{content:"";position:absolute;right:-2px;bottom:-2px;box-sizing:border-box;width:8px;height:8px;border-radius:50%;background:var(--ok);box-shadow:0 0 0 2px var(--surface);transition:background .3s}
.ksx-s[data-st="queued"] .ic::after{background:var(--faint)}
.ksx-s[data-st="failed"] .ic::after{background:var(--bad)}
.ksx-s[data-st="indexing"] .ic::after{width:10px;height:10px;right:-3px;bottom:-3px;background:var(--surface);border:1.5px solid var(--accent-mark,var(--accent));border-right-color:transparent;animation:rot .8s linear infinite}
.ksx-s .t{min-width:0;display:flex;flex-direction:column}
.ksx-s .nm{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ksx-s .d{display:flex;align-items:center;gap:var(--s-2);min-width:0;height:18px;font-size:12px;color:var(--muted)}
.ksx-s .d>span:last-child{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ksx-s[data-st="failed"] .d{color:var(--bad)}
.ksx-bar{width:56px;height:4px;border-radius:2px;background:var(--line);overflow:hidden;flex:none}
.ksx-bar i{display:block;height:100%;border-radius:inherit;background:var(--accent-mark,var(--accent));transition:width .2s linear}
.ksx-more{width:28px;height:28px}
.ksx-s .ksx-fx{grid-column:2/-1}
.ksx-fx>div{overflow-clip-margin:0}
.ksx-fx>div>div{display:flex;flex-wrap:wrap;gap:6px;padding-top:var(--s-2)}
.ksx-add{display:flex;align-items:center;gap:var(--s-2);height:30px;padding:0 var(--s-3) 0 10px;font-size:12.5px;color:var(--fg2);border-radius:var(--r-pill);align-self:flex-start;transition:background-color .2s,color .2s}
@media (hover:hover) and (pointer:fine){.ksx-add:hover{color:var(--fg);background:var(--row)}}
.ksx-pop{width:248px}
.ksx-mpop{min-width:160px}
.ksx-mpop .mi.del,.ksx-mpop .mi.del .i{color:var(--bad)}
@media (hover:hover) and (pointer:fine){.ksx-mpop .mi.del:hover{background:var(--bad-bg)}}
.ksx-mpop .mi[disabled],.ksx-mpop .mi[disabled] .i{opacity:1;cursor:not-allowed;background:none;color:var(--dis)}
.ksx-web{display:flex;flex-direction:column;gap:var(--s-1)}
.ksx-in{display:flex;align-items:center;gap:var(--s-2);height:32px;margin:0 2px 2px;padding:0 2px 0 10px;border-radius:var(--r-field);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line) 50%,transparent);color:var(--muted);transition:box-shadow var(--t-fast)}
@supports (corner-shape:squircle){.ksx-in{corner-shape:squircle;border-radius:var(--r-field-sq)}}
.ksx-in:has(input:focus){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
.ksx-in input{flex:1;min-width:0;height:100%;border:0;outline:0;background:transparent;font:inherit;font-size:13px;color:var(--fg)}
.ksx-in input::placeholder{color:var(--muted)}
.ksx-in .pill{height:28px}
knowledge-sources.jsHTML · 104 lines
DM.def('knowledge-sources', (r, c) => {
  r.innerHTML = r.innerHTML.replace('{plus}', I('plus','xs')).replace('{refresh}', I('refresh','xs')).replace('{trash}', I('trash','xs'));
  // a source's type gives it its icon: uploaded files, a website the agent crawls, or a connected folder of docs
  const KIND = { files:['file-text','var(--pp)'], web:['globe','var(--pb)'], folder:['folder','var(--pm)'] };
  // a connected folder and a set of uploads are ready, and a PDF is being read. One source indexes at a time and the rest
  // wait in line; a source's chunks count once it has been indexed. `ms` is how long a full pass takes in this preview
  const S = [
    { kind:'folder', name:'Product docs', n:86, unit:'pages', chunks:642, st:'ready', ago:'2m ago', had:true, ms:2500 },
    { kind:'files', name:'Sales playbooks', n:18, unit:'files', chunks:318, st:'ready', ago:'1h ago', had:true, ms:2500 },
    { kind:'files', name:'Brand book 2024.pdf', n:24, unit:'pages', chunks:48, st:'queued', ms:19000, fail:{ at:9, why:'Scanned PDF, no text found', fix:'Retry with OCR' } }
  ];
  const FOLDERS = [['Design specs', 'Google Drive', 57, 230], ['Research notes', 'Notion', 31, 124]];
  const list = $('.ksx-list', r), st = $('.ksx-st', r), add = $('.ksx-add', r), pop = $('.ksx-pop', r), mpop = $('.ksx-mpop', r), file = $('.ksx-file', r);
  const fmt = n => n.toLocaleString('en-US');
  const of = (n, unit) => fmt(n) + ' ' + (n === 1 ? unit.slice(0, -1) : unit);
  const line = x => x.st === 'queued' ? (x.ocr ? 'Queued to read with OCR' : 'Queued') : x.st === 'failed' ? x.fail.why : of(x.n, x.unit) + ' · Synced ' + x.ago;
  function row(x){
    const [ic, col] = KIND[x.kind];
    x.el = html(`<div class="ksx-s" role="listitem"><span class="ic" style="color:${col};background:color-mix(in srgb,${col} 12%,transparent)" aria-hidden="true">${I(ic,'xs')}</span><span class="t"><span class="nm"></span><span class="d"></span></span><button class="ib ksx-more" aria-haspopup="menu" aria-expanded="false" data-tip="More" data-pop=".ksx-mpop" data-pop-align="end">${I('more')}</button><div class="ksx-fx fold shut" inert><div><div><button class="pill accent sm ksx-fix"></button><button class="pill ghost sm ksx-del">Remove</button></div></div></div></div>`);
    $('.nm', x.el).textContent = x.name; $('.ksx-more', x.el).setAttribute('aria-label', 'Actions for ' + x.name);
    $('.ksx-fix', x.el).onclick = () => { x.ocr = true; x.st = 'queued'; x.ms = 3000; paint(x); next(); };
    $('.ksx-del', x.el).onclick = () => remove(x);
    paint(x, true); return x.el;
  }
  // the progress bar and its count, while a source is being indexed
  function prog(x){
    const bar = $('.ksx-bar', x.el); if (!bar) return; const k = Math.floor(x.done);
    $('i', bar).style.width = k / x.n * 100 + '%'; bar.setAttribute('aria-valuenow', k);
    $('.num', x.el).textContent = fmt(k) + ' of ' + of(x.n, x.unit);
  }
  function paint(x, now){
    const d = $('.d', x.el); x.el.dataset.st = x.st;
    // the bar goes through the same swap as the line before it, so a line still swapping in can't land on top of it
    if (x.st === 'indexing'){ const bar = `<span class="ksx-bar" role="progressbar" aria-label="Indexing ${x.name.replace(/[<&"]/g, '')}" aria-valuemin="0" aria-valuemax="${x.n}"><i style="width:0"></i></span><span class="num"></span>`; if (now){ d.innerHTML = bar; prog(x); } else swap(d, bar, true); }
    else { const t = document.createElement('span'); t.textContent = line(x); if (now) d.replaceChildren(t); else swap(d, t.outerHTML, true); }
    const fx = $('.ksx-fx', x.el), on = x.st === 'failed';
    if (on) $('.ksx-fix', x.el).textContent = x.fail.fix;
    fx.classList.toggle('shut', !on); fx.inert = !on;
  }
  const total = () => `${S.length} source${S.length === 1 ? '' : 's'} · ${fmt(S.reduce((a, x) => a + (x.had ? x.chunks : 0), 0))} chunks`;
  const counts = now => { if (now) st.textContent = total(); else swap(st, total()); };
  // the one being indexed moves on every tick; a scanned PDF stops where its text runs out, until it is read with OCR
  function next(){
    if (S.some(x => x.st === 'indexing')) return;
    const x = S.find(y => y.st === 'queued'); if (!x) return;
    x.st = 'indexing'; x.done = 0; paint(x);
  }
  c.every(100, () => {
    const x = S.find(y => y.st === 'indexing'); if (!x) return;
    x.done = Math.min(x.n, x.done + x.n * 100 / x.ms);
    if (x.fail && !x.ocr && x.done >= x.fail.at){ x.st = 'failed'; paint(x); counts(); return next(); }
    prog(x);
    if (x.done >= x.n){ x.st = 'ready'; x.ago = 'just now'; x.had = true; paint(x); counts(); next(); }
  });
  function addSource(x){ x.st = 'queued'; S.push(x); list.appendChild(row(x)); enter(x.el); counts(); next(); }
  function remove(x){
    const i = S.indexOf(x); if (i < 0) return; S.splice(i, 1); counts(); next();
    x.el.classList.add('leave'); c.later(REDUCE ? 0 : 340, () => x.el.remove());
  }
  S.forEach(x => list.appendChild(row(x))); stagger([...list.children], 50); counts(true); next();
  // Re-sync and Remove act on the row whose button opened the menu; a source still waiting or indexing can't be re-synced yet
  let cur = null;
  c.on(list, 'click', e => { const b = e.target.closest('.ksx-more'); if (!b) return; cur = S.find(x => x.el.contains(b)); $('.ksx-rs', mpop).disabled = !cur || cur.st === 'queued' || cur.st === 'indexing'; });
  $('.ksx-rs', mpop).onclick = () => { if (!cur || !S.includes(cur)) return; cur.st = 'queued'; cur.ms = cur.ocr ? 3000 : cur.fail ? 19000 : 2500; paint(cur); next(); };
  $('.ksx-rm', mpop).onclick = () => cur && remove(cur);
  // Add source: upload files (the file picker), add a website (its address), or connect a folder (from the ones offered)
  function view(v){
    if (v === 'web') pop.innerHTML = `<form class="ksx-web"><div class="ph">Add a website</div><div class="ksx-in">${I('globe','xs')}<input placeholder="example.com/docs" aria-label="Website address" spellcheck="false" autocomplete="off"><button class="pill accent sm" type="submit">Add</button></div></form>`;
    else if (v === 'folder') pop.innerHTML = '<div class="ph">Connect a folder</div>' + (FOLDERS.length ? FOLDERS.map((f, i) => `<button class="mi" role="menuitem" data-close data-i="${i}">${I('folder','xs')}<span>${f[0]}<span class="d">${f[1]}</span></span></button>`).join('') : '<div class="ph">All folders connected</div>');
    else pop.innerHTML = [['upload', 'Upload files', 'files'], ['globe', 'Add a website', 'web'], ['folder', 'Connect a folder', 'folder']].map(([ic, t, k]) => `<button class="mi" role="menuitem" data-v="${k}"${k === 'files' ? ' data-close' : ''}>${I(ic,'xs')}<span>${t}</span></button>`).join('');
  }
  view('main');
  c.on(add, 'click', () => { if (!pop.classList.contains('open')) view('main'); });
  // a step inside the menu swaps its content after the click has been handled, so the click still counts as inside it
  c.on(pop, 'click', e => {
    const m = e.target.closest('.mi'); if (!m) return;
    if (m.dataset.v === 'files') return file.click();
    if (m.dataset.v){ const v = m.dataset.v, mine = e.isTrusted; c.later(0, () => { view(v); placePop(add, pop); if (mine && v === 'web') $('input', pop).focus({ preventScroll:true }); }); return; }
    if (m.dataset.i){ const f = FOLDERS.splice(+m.dataset.i, 1)[0]; addSource({ kind:'folder', name:f[0], n:f[2], unit:'pages', chunks:f[3], ms:3000 }); }
  });
  c.on(pop, 'submit', e => {
    e.preventDefault(); const inp = $('input', pop), v = inp.value.trim().replace(/^https?:\/\//, '').replace(/\/$/, '');
    if (!v) return inp.focus();
    closePops(); addSource({ kind:'web', name:v, n:142, unit:'pages', chunks:196, ms:4500 });
  });
  file.onchange = () => { const fs = [...file.files]; if (!fs.length) return;
    addSource({ kind:'files', name:fs.length === 1 ? fs[0].name : fs.length + ' files', n:fs.length, unit:'files', chunks:fs.length * 12, ms:1500 }); file.value = ''; };
  // the preview's story: add the docs site, which waits its turn; the PDF turns out to be a scan, so read it with OCR, and
  // everything ends up indexed
  c.story(async s => {
    const pdf = S[2];
    await s.beat(3);
    await s.tap(add); await s.beat(2);
    await s.tap($('.mi[data-v="web"]', pop)); await s.beat(1);
    const inp = $('input', pop);
    await s.type(inp, 'designme.dev/docs', { n:.1, keep:true });
    await s.beat(1);
    await s.tap($('button[type="submit"]', pop)); s.away();
    await s.until(() => pdf.st === 'failed'); await s.beat(2);
    await s.tap($('.ksx-fix', pdf.el)); s.away();
    await s.until(() => S.every(x => x.st === 'ready')); await s.beat(1);
  });
});
06

Questions

What people ask about it.

Does the component index anything?
No. It shows what you tell it. Run the indexing in your own code and update each source's state and done as it goes; the bar and count follow.
How do I show the website being added?
Handle onAddWebsite: add a source with kind: 'web' and state: 'queued', then move it to indexing and ready as your crawler works. Pass address and onAddressChange if you want to fill in the field yourself.
Why doesn't the chunk count include a source that is still indexing?
The total adds up chunks, so leave a source's chunks out until it has been indexed. Keep it while a source re-syncs if its old index still answers questions.
Can a failed source offer something other than OCR?
Yes. fix is any label, like "Sign in again" or "Try a smaller file", and onFix gets the source's key.
07

More in Workspace

Navigation, search, settings, models and automations around the agent.

Automation canvasA trigger, a few agent steps, and a run you can watch flow through.
Canvas toolbarA floating toolbar for a workflow canvas: run, pick a tool, comment, fit and zoom.
Command paletteJump anywhere or hand a task to the agent from one box.
Memory panelWhat the agent remembers about your team, editable and removable.
Model compareRun one prompt on three models side by side and keep the best.
Agent settingsHow much the agent may do alone, with a save bar for changes.
Tools and connectionsThe tools the agent can reach, and how much it may do with each.
Scheduled runsTasks the agent runs on a schedule, with their last result and a switch.
All 11 in Workspace →