WorkspaceKnowledge sources
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callKnowledge 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.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add knowledge-sourcesReact
npx designme add knowledge-sources --reactThe 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.
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.
- Each source in
sourcesshows its type's icon (kindoffiles,weborfolder), itsname, and a line under it that follows itsstate. - The dot on the icon is the state at a glance: grey while
queued, a turning ring whileindexing, green whenreadyand red whenfailed. - While indexing, the line becomes a progress bar and a count, like "64 of 142 pages", from
doneandcount;unitswitches pages to files. - A ready source reads like "142 pages · Synced 2m ago" from its
synced; a queued one reads "Queued", or your ownnote. - A failed source shows its
errorin red, with a button labelled with itsfixthat callsonFix, and a Remove button when you passonRemove. - Each row has a menu with Re-sync (
onResync, unavailable while the source waits or indexes) and Remove (onRemove). - Add source opens a menu with an option for each handler you pass: Upload files opens the file picker and calls
onUploadwith the files; Add a website asks for an address and callsonAddWebsitewith it, without http(s)://; Connect a folder listsfoldersand callsonConnectFolder. - The line under the title counts the sources and adds up their
chunks, like "4 sources · 1,204 chunks", or shows your ownstatus.
- The sources form a list, and each row's menu button is named for its source, like "Actions for Product docs".
- An indexing source's bar is a progressbar with its count as its value.
- The menus have role="menu"; their buttons expose aria-expanded and aria-haspopup, and Escape closes a menu and gives the focus back to its button.
- From the keyboard, Enter, Space or the down arrow opens a menu with the focus on its picked item, or its first (the up arrow, on its last); the arrow keys move through it and wrap, Home and End jump to the ends, Tab closes it and moves on, and closing it gives the focus back to its button.
- The line under the title is announced politely when it changes.
- With reduced motion, rows appear without their entrance and text changes without the crossfade.
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>
);
}
Props
Everything KnowledgeSources takes.
| Prop | Type | What it does |
|---|---|---|
sourcesrequired | KnowledgeSource[] | |
onUpload | (files: File[]) => void | Adds Upload files to the Add source menu; it opens the file picker and hands you what was picked |
onAddWebsite | (address: string) => void | Adds Add a website to the Add source menu; it asks for an address and hands it over without http(s):// |
address | string | The address being typed. Leave it out to let the field keep its own |
defaultAddress | string | |
onAddressChange | (address: string) => void | |
folders | KnowledgeSourcesFolder[] | Adds Connect a folder to the Add source menu, listing these |
onConnectFolder | (key: string) => void | |
onResync | (key: string) => void | Adds Re-sync to each source's menu. It is unavailable while the source waits or indexes |
onRemove | (key: string) => void | Adds Remove to each source's menu, and beside the fix of a failed one |
onFix | (key: string) => void | Called by the fix button of a failed source |
title | string | Defaults to "Knowledge sources" |
status | string | The line under the title. Defaults to "4 sources · 1,204 chunks" |
className | string |
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);
});
});
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
stateanddoneas it goes; the bar and count follow. - How do I show the website being added?
- Handle
onAddWebsite: add a source withkind: 'web'andstate: 'queued', then move it toindexingandreadyas your crawler works. PassaddressandonAddressChangeif 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'schunksout 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.
fixis any label, like "Sign in again" or "Try a smaller file", andonFixgets the source's key.
More in Workspace
Navigation, search, settings, models and automations around the agent.