update Markdown skills

This commit is contained in:
2026-08-19 17:36:27 +05:30
parent aa2fb41871
commit d3f7f439f6
30 changed files with 2341 additions and 221 deletions

View File

@@ -1,14 +1,19 @@
import React, { useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { ChevronLeft, LayoutTemplate, Upload, X } from 'lucide-react';
import {
Badge, Button, Checkbox, Field, Input, MultiSelect, Select, SelectContent, SelectItem,
SelectTrigger, SelectValue, Surface, Switch, Textarea, toast,
SelectTrigger, SelectValue, Surface, Switch, Textarea,
} from '@/components/ds';
import { cn } from '@/lib/utils';
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
import { PAGE_KEYS, allSkills, parseSkill, validateSkillSource } from '@/lib/skills/registry';
import { customSkillSource, owliverSkillTemplate, upsertCustomSkill } from '@/lib/skills/customSkills';
import { reportSave } from '@/lib/skills/saveFeedback';
import {
EMPTY_OWLIVER_FIELDS, facetsFromSource, isReadableDefinition, normalizeUpload,
owliverFieldsFromSource, pagesAreDerived, patchFrontmatter,
} from '@/lib/skills/skillFields';
import {
DATA_SOURCES, OWLIVER_CAPABILITIES, PERIODS, dataSourceFor, dataSourceLabel,
owliverCapabilityLabel, periodLabel, sectionTypeLabel, surfaceFor,
@@ -31,48 +36,16 @@ import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
* door onto one system, never a second system.
*/
/** The fields this editor composes, before the Markdown is edited by hand. */
const emptyDraft = {
id: '',
name: '',
description: '',
pages: [],
triggers: [],
suggestions: [],
capabilities: [],
source: '',
periods: [],
};
/**
* A definition, read back into the fields.
* The fields this editor composes, and the reader that fills them.
*
* Editing starts from what the registry made of the file rather than from the
* Markdown text, so the form shows what is actually registered — including the
* capabilities whose reading was inherited from a `ui:` block rather than
* written out.
* Both moved to `lib/skills/skillFields.js` so the Board editor, the Add Skill
* dialog and every upload path reach the same reading. Two private copies of
* "read a definition into a form" was how uploading a file could populate
* neither of them.
*/
function draftFromSource(source) {
try {
const skill = parseSkill(source, { custom: true });
const first = skill.owliver.capabilities[0];
const response = first ? skill.owliver.responses[first] : null;
return {
id: skill.id,
name: skill.name,
description: skill.description,
pages: skill.pages,
triggers: skill.triggers,
suggestions: skill.owliver.suggestions.map((s) => s.label),
capabilities: skill.owliver.capabilities,
source: response?.source || '',
periods: response?.periods || [],
};
} catch {
return emptyDraft;
}
}
const emptyDraft = EMPTY_OWLIVER_FIELDS;
const draftFromSource = owliverFieldsFromSource;
/** A list of short lines an author edits one at a time. */
function LineList({ items, onChange, placeholder, addLabel, ariaLabel }) {
@@ -133,6 +106,7 @@ function LineList({ items, onChange, placeholder, addLabel, ariaLabel }) {
export default function OwliverSkillEditor() {
const navigate = useNavigate();
const location = useLocation();
const { id: routeId } = useParams();
const editingId = routeId && routeId !== 'new' ? routeId : null;
@@ -149,7 +123,9 @@ export default function OwliverSkillEditor() {
* understands, not a duplicate.
*/
const initial = useMemo(() => {
if (!editingId) return owliverSkillTemplate({});
/* A definition handed over from the Board editor, which noticed the file it
was given belongs here. Arrives whole — nothing is re-derived. */
if (!editingId) return location.state?.source || owliverSkillTemplate({});
const stored = customSkillSource(customSkills, editingId);
if (stored) return stored;
const shipped = allSkills(customSkills).find((s) => s.id === editingId);
@@ -158,10 +134,19 @@ export default function OwliverSkillEditor() {
by a preferences write elsewhere in the app. */
}, [editingId]);
const handedOver = Boolean(!editingId && location.state?.source);
const [source, setSource] = useState(initial);
const [draft, setDraft] = useState(() => (editingId ? draftFromSource(initial) : emptyDraft));
const [touched, setTouched] = useState(Boolean(editingId));
const [draft, setDraft] = useState(
() => (editingId || handedOver ? draftFromSource(initial) : emptyDraft)
);
const [touched, setTouched] = useState(Boolean(editingId) || handedOver);
const [error, setError] = useState('');
/* An uploaded file that declares a page section and no `owliver:` block
belongs in the Board editor. */
const [offerBoard, setOfferBoard] = useState(false);
/* The file the fields were last read from, so "did the upload work" is
answered on screen rather than by comparing a value against a placeholder. */
const [loadedFrom, setLoadedFrom] = useState('');
const fileRef = React.useRef(null);
/* What the registry will make of what is in the editor right now, so the
@@ -179,14 +164,54 @@ export default function OwliverSkillEditor() {
const active = declaredActive && !(preview?.id && disabledSkills.includes(preview.id));
/**
* Typing in the fields composes the Markdown until the Markdown itself is
* edited, after which it wins — it is the thing being saved, and silently
* regenerating over an author's own text would lose work they can see.
* A field edit, written into the definition.
*
* This used to stop dead once `touched` was set — and `touched` starts true
* for every existing skill, and is set by every upload. So on the two paths
* an author is most likely to be on, typing a new name changed React state
* that nothing ever saved: the Markdown is the artefact, and the Markdown had
* not moved. It looked exactly like a form that does not work, because it was.
*
* A fresh draft still regenerates from the template, because the body is
* being composed and there is nothing to lose. Everything else patches the
* keys the fields own and leaves the body, the comments and the key order
* exactly as written.
*/
const update = (patch) => {
const next = { ...draft, ...patch };
setDraft(next);
if (!touched) setSource(owliverSkillTemplate(next));
if (!touched) {
setSource(owliverSkillTemplate(next));
return;
}
/* Responses are only rewritten when this form has enough to say what they
are. A definition whose capabilities read from an inherited `ui:` section
declares no `responses:` block, and inventing one here would pin a
reading the author deliberately left to be inherited. */
const responses = next.capabilities.length && next.source
? Object.fromEntries(next.capabilities.map((capability) => [
capability,
next.periods.length
? { source: next.source, periods: next.periods }
: { source: next.source },
]))
: undefined;
setSource(patchFrontmatter(source, {
id: next.id || undefined,
name: next.name || undefined,
description: next.description || undefined,
/* A definition whose reach comes from its `ui:` entries shows those pages
as a read-out; writing them back would pin a list that then governs the
block. Same rule as the Board editor, for the same reason. */
pages: next.pages?.length && !pagesAreDerived(source) ? next.pages : undefined,
triggers: next.triggers?.length ? next.triggers : undefined,
'owliver.suggestions': next.suggestions?.length ? next.suggestions : undefined,
'owliver.capabilities': next.capabilities?.length ? next.capabilities : undefined,
'owliver.responses': responses,
}));
};
const setStatus = (nextActive) => {
@@ -197,14 +222,50 @@ export default function OwliverSkillEditor() {
setTouched(true);
};
/**
* A definition uploaded from a file.
*
* Filled the Markdown pane and nothing else, said nothing about whether the
* file was valid, and never noticed a file belonging in the other editor —
* so an upload looked like it had failed until Save refused it.
*/
const readFile = (event) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onerror = () => setError('That file could not be read.');
reader.onload = () => {
setSource(String(reader.result));
/* Normalized before anything looks at it: a byte-order mark, `\r\n`, or a
blank line above the opening fence are all things a real file arrives
with, and all three used to stop the frontmatter being seen at all. */
const raw = normalizeUpload(reader.result);
setSource(raw);
setTouched(true);
setError('');
/**
* A file with no readable frontmatter leaves the fields as they were.
*
* Hydrating from it would blank every box, and a blank box in this form
* shows a placeholder — which reads as a populated field. "It filled in
* the wrong values" and "it filled in nothing" would look identical, and
* the second is the one that happened.
*/
if (!isReadableDefinition(raw)) {
setError('That file has no frontmatter block, so there is nothing to read into the fields. '
+ 'A definition starts with `---` on its own line.');
setOfferBoard(false);
return;
}
/* Filled in either way past this point: a definition that will not save
is still one the author has to see in order to fix it. */
setDraft(draftFromSource(raw));
setLoadedFrom(file.name);
setError(validateSkillSource(raw) || '');
const facets = facetsFromSource(raw);
setOfferBoard(facets.length > 0 && !facets.includes('owliver'));
};
reader.readAsText(file);
event.target.value = '';
@@ -220,8 +281,10 @@ export default function OwliverSkillEditor() {
const nextDisabled = skill.status === 'inactive'
? [...new Set([...disabledSkills, skill.id])]
: disabledSkills.filter((id) => id !== skill.id);
updatePreferences.mutate({ customSkills: next, disabledSkills: nextDisabled });
toast.success(editingId ? `${skill.name} updated` : `${skill.name} added`);
updatePreferences.mutate(
{ customSkills: next, disabledSkills: nextDisabled },
reportSave(editingId ? `${skill.name} updated` : `${skill.name} added`)
);
navigate('/admin/workspace/skills?tab=owliver');
};
@@ -265,14 +328,14 @@ export default function OwliverSkillEditor() {
<Field label="Skill name" required>
<Input
value={draft.name}
placeholder="Hiring Activity Assistant"
placeholder="e.g. Hiring Activity Assistant"
onChange={(e) => update({ name: e.target.value })}
/>
</Field>
<Field label="Skill ID" required hint="Lower-case letters, numbers and dashes.">
<Input
value={draft.id}
placeholder="hiring-activity-assistant"
placeholder="e.g. hiring-activity-assistant"
onChange={(e) => update({ id: e.target.value })}
/>
</Field>
@@ -281,7 +344,7 @@ export default function OwliverSkillEditor() {
<Field label="Description" hint="Owliver reads this when deciding whether a question is for this skill.">
<Input
value={draft.description}
placeholder="Answer questions about recent hiring activity on a position."
placeholder="e.g. Answer questions about recent hiring activity on a position."
onChange={(e) => update({ description: e.target.value })}
/>
</Field>
@@ -468,9 +531,16 @@ export default function OwliverSkillEditor() {
<section className="space-y-3">
<SectionTitle title="Definition" meta="Markdown" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
{offerBoard && (
<p className="rounded-lg border border-krow-blue/30 bg-krow-blue-tint px-3 py-2 text-caption leading-relaxed text-krow-blue">
This file declares a page section and no Owliver block. It saves correctly from
here, but the fields that configure it are in the Board Skill Editor.
</p>
)}
<Field
label="Skill definition"
hint="This is what gets saved. Edit it directly and it stops being regenerated from the fields above."
hint="This is what gets saved. The fields above edit its frontmatter; the body is yours."
error={error}
>
<Textarea
@@ -479,8 +549,38 @@ export default function OwliverSkillEditor() {
spellCheck={false}
className="rounded-lg border-border/80 bg-surface-sunken/40 p-3.5 font-mono text-[12.5px] leading-relaxed transition-all focus:border-krow-blue"
onChange={(e) => {
setSource(e.target.value);
const next = e.target.value;
setSource(next);
setTouched(true);
/**
* Markdown → fields, the direction that was missing.
*
* The fields have always written into the definition; the
* definition never wrote back. So a reader who pastes a
* complete `.md` into this box — the other half of how this
* editor is actually used — got a correct Preview beside a
* form of empty boxes, because `source` had moved and
* nothing had read it. Same extractor the upload path uses,
* so the two ways of supplying a definition cannot disagree.
*
* Two conditions before hydrating, and both exist to keep a
* half-typed definition from wiping fields that are already
* filled in. `isReadableDefinition` is the fence: no `---`,
* nothing to read. The name check is the parse: the YAML
* subset raises on an indentation error, which is what
* every definition looks like for the keystroke between
* `ui:` and its first indented line, and the extractor
* reports that by returning its empty record. A definition
* that really parsed always carries a name, even if the
* registry had to supply `Untitled skill` — so an empty one
* means the read failed, not that the author cleared it.
*/
if (isReadableDefinition(next)) {
const fields = draftFromSource(next);
if (fields.name) setDraft(fields);
}
if (error) setError('');
}}
/>
@@ -498,8 +598,25 @@ export default function OwliverSkillEditor() {
<Button variant="outline" size="sm" shape="rounded" onClick={() => fileRef.current?.click()}>
<Upload aria-hidden="true" /> Upload .md
</Button>
{loadedFrom && (
<span className="text-caption text-ink-4">
Fields read from <span className="font-medium text-ink-2">{loadedFrom}</span>
</span>
)}
{offerBoard && (
<Button
variant="outline"
size="sm"
shape="rounded"
onClick={() => navigate('/admin/workspace/skills/new', { state: { source } })}
>
Open in Board editor
</Button>
)}
</div>
<p className="text-caption text-ink-4">Markdown is stored as data. It is never executed.</p>
<p className="text-caption text-ink-4">
Stored as data in this browser, for this workspace. It is never executed.
</p>
</div>
</Surface>
</section>

View File

@@ -1,17 +1,22 @@
import React, { useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { ChevronLeft, Sparkles, Upload } from 'lucide-react';
import {
Badge, Button, Field, Input, MultiSelect, Select, SelectContent, SelectItem,
SelectTrigger, SelectValue, Surface, Switch, Textarea, toast,
SelectTrigger, SelectValue, Surface, Switch, Textarea,
} from '@/components/ds';
import { cn } from '@/lib/utils';
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
import { PAGE_KEYS, allSkills, parseSkill, validateSkillSource } from '@/lib/skills/registry';
import { customSkillSource, uiSkillTemplate, upsertCustomSkill } from '@/lib/skills/customSkills';
import { reportSave } from '@/lib/skills/saveFeedback';
import {
DATA_SOURCES, PERIODS, SECTION_TYPES, dataSourceLabel, owliverCapabilityLabel, periodLabel,
placementLabel, sectionTypeLabel, surfaceFor,
EMPTY_BOARD_FIELDS, boardFieldsFromSource, facetsFromSource, isReadableDefinition,
normalizeUpload, pagesAreDerived, patchFrontmatter, uiIsEditableFromFields,
} from '@/lib/skills/skillFields';
import {
DATA_SOURCES, PERIODS, SECTION_TYPES, contextLabel, contextSuppliedBy, dataSourceLabel,
owliverCapabilityLabel, periodLabel, placementLabel, sectionTypeLabel, surfaceFor,
} from '@/lib/skills/surfaces';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
@@ -39,40 +44,20 @@ import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
* door, not a second system.
*/
/** The fields this editor composes, kept in step with the Markdown until it is edited. */
const emptyMeta = {
id: '', name: '', description: '', pages: [],
type: 'flow', placement: '', source: 'position.activity', periods: [],
};
/**
* Reads the fields back out of a definition, for editing an existing skill.
* The fields this editor composes, and the reader that fills them.
*
* The section is read from what the registry made of the file rather than from
* the Markdown text, so the form shows what is actually registered — including
* a placement the author left out and the vocabulary filled in.
* Both moved to `lib/skills/skillFields.js`: the Owliver editor and the Add
* Skill dialog need the same reading, and an upload has to reach it from all
* three. Two private copies of "read a definition into a form" was how uploading
* a file could populate none of them.
*/
function metaFromSource(source) {
try {
const skill = parseSkill(source, { custom: true });
const section = Object.values(skill.ui || {}).flatMap((p) => p.sections || [])[0];
return {
id: skill.id,
name: skill.name,
description: skill.description,
pages: skill.pages,
type: section?.type || emptyMeta.type,
placement: section?.placement || '',
source: section?.source || emptyMeta.source,
periods: section?.periods || [],
};
} catch {
return emptyMeta;
}
}
const emptyMeta = EMPTY_BOARD_FIELDS;
const metaFromSource = boardFieldsFromSource;
export default function SkillEditor() {
const navigate = useNavigate();
const location = useLocation();
/* `new` is the add route; anything else is a skill id being edited. */
const { id: routeId } = useParams();
const editingId = routeId && routeId !== 'new' ? routeId : null;
@@ -90,7 +75,9 @@ export default function SkillEditor() {
* override rather than a duplicate.
*/
const initial = useMemo(() => {
if (!editingId) return uiSkillTemplate({});
/* A definition handed over from the Owliver editor, which noticed the file
it was given belongs here. Arrives whole — nothing is re-derived. */
if (!editingId) return location.state?.source || uiSkillTemplate({});
const stored = customSkillSource(customSkills, editingId);
if (stored) return stored;
const shipped = allSkills(customSkills).find((s) => s.id === editingId);
@@ -99,11 +86,22 @@ export default function SkillEditor() {
overwritten by a preferences write elsewhere in the app. */
}, [editingId]);
const handedOver = Boolean(!editingId && location.state?.source);
const loaded = editingId || handedOver;
const [source, setSource] = useState(initial);
const [meta, setMeta] = useState(() => (editingId ? metaFromSource(initial) : emptyMeta));
const [pagesText, setPagesText] = useState(() => (editingId ? metaFromSource(initial).pages.join(', ') : ''));
const [touched, setTouched] = useState(Boolean(editingId));
const [meta, setMeta] = useState(() => (loaded ? metaFromSource(initial) : emptyMeta));
const [pagesText, setPagesText] = useState(
() => (loaded ? metaFromSource(initial).pages.join(', ') : '')
);
const [touched, setTouched] = useState(loaded);
const [error, setError] = useState('');
/* An uploaded file that declares no `ui:` block belongs in the Owliver
editor. Set by the upload handler, cleared as soon as the definition gains
a section here. */
const [offerOwliver, setOfferOwliver] = useState(false);
/* The file the fields were last read from, so "did the upload work" is
answered on screen rather than by comparing a value against a placeholder. */
const [loadedFrom, setLoadedFrom] = useState('');
const fileRef = React.useRef(null);
const selectedPages = useMemo(
@@ -119,6 +117,28 @@ export default function SkillEditor() {
[selectedPages]
);
/**
* The sources the chosen surface can actually resolve here.
*
* A source declares the record it needs and a placement either supplies one
* or does not — so offering all fifteen regardless is how a definition ends
* up reading `position.activity` above a grid with no position in it. The
* unavailable ones stay visible and disabled, with the reason: an author who
* simply cannot find a source they know exists will assume it was removed.
*
* `contextSuppliedBy` is the same helper `validateSkillSource` refuses on, so
* the picker and the validator cannot disagree about what is possible.
*/
const supplied = useMemo(
() => contextSuppliedBy(selectedPages, meta.placement),
[selectedPages, meta.placement]
);
const sourceUnavailable = (src) => (
!src.context || supplied.includes(src.context)
? null
: `needs ${contextLabel(src.context)}; this placement supplies none`
);
/* The parsed view of what is in the editor right now — what actually gets
registered, so the preview cannot flatter the definition. */
const preview = useMemo(() => {
@@ -145,14 +165,51 @@ export default function SkillEditor() {
for both the type line and the configuration block. */
const uiSections = Object.values(preview?.ui || {}).flatMap((page) => page.sections || []);
/* Typing in the fields composes frontmatter until the Markdown is edited by
hand, after which the Markdown wins — it is the thing being saved. */
/**
* A field edit, written into the definition.
*
* This used to stop dead once `touched` was set — and `touched` starts true
* for every existing skill, and is set by every upload. So on the two paths
* an author is most likely to be on, typing a new name changed React state
* that nothing ever saved: the Markdown is the artefact, and the Markdown had
* not moved. It looked exactly like a form that does not work, because it was.
*
* Now a fresh draft still regenerates from the template — the body is being
* composed and there is nothing to preserve — and everything else patches the
* key the field owns and leaves the rest of the file alone. `touched` stops
* meaning "the fields are dead" and means only "do not rewrite the body".
*/
const syncFromMeta = (next, nextPagesText = pagesText) => {
setMeta(next);
if (touched) return;
setSource(uiSkillTemplate({
...next,
pages: nextPagesText.split(',').map((p) => p.trim()).filter(Boolean),
const pages = nextPagesText.split(',').map((p) => p.trim()).filter(Boolean);
if (!touched) {
setSource(uiSkillTemplate({ ...next, pages }));
return;
}
/* Only the section keys this form can faithfully represent. A definition
written as a list, or addressing pages by name, may hold several sections
across several pages; overwriting that from four single-valued fields
would throw away everything but the first, so those fields are read-only
against it. Identity and reach still patch normally — a multi-page
definition must still be renameable. */
const section = uiIsEditableFromFields(source) ? {
'ui.type': next.type || undefined,
'ui.placement': next.placement || undefined,
'ui.source': next.source || undefined,
'ui.periods': next.periods?.length ? next.periods : undefined,
} : {};
setSource(patchFrontmatter(source, {
id: next.id || undefined,
name: next.name || undefined,
description: next.description || undefined,
/* Only when the definition owns a `pages:` key. A definition whose reach
comes from its `ui:` entries is showing a read-out here, and writing it
back would pin a list that governs the block from then on. */
pages: pages.length && !pagesAreDerived(source) ? pages : undefined,
...section,
}));
};
@@ -166,14 +223,53 @@ export default function SkillEditor() {
setTouched(true);
};
/**
* A definition uploaded from a file.
*
* Three things this did not do, and all three are why uploading felt like it
* had not worked: it never filled in a single field, it never said whether
* the file was valid, and it never noticed a file that belongs in the other
* editor. The Markdown appeared, the form stayed empty beside it, and the
* first sign of trouble was a refusal on Save.
*/
const readFile = (event) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onerror = () => setError('That file could not be read.');
reader.onload = () => {
setSource(String(reader.result));
/* Normalized before anything looks at it: a byte-order mark, `\r\n`, or a
blank line above the opening fence are all things a real file arrives
with, and all three used to stop the frontmatter being seen at all. */
const raw = normalizeUpload(reader.result);
setSource(raw);
setTouched(true);
setError('');
/* A file with no readable frontmatter leaves the fields as they were —
blanking them shows placeholders, and a placeholder reads as a value. */
if (!isReadableDefinition(raw)) {
setError('That file has no frontmatter block, so there is nothing to read into the fields. '
+ 'A definition starts with `---` on its own line.');
setOfferOwliver(false);
return;
}
/* The fields are filled in either way past this point. A definition that
will not save is still one the author has to see in order to fix it,
and an empty form beside a rejected file says nothing about which field
is wrong. */
const fields = metaFromSource(raw);
setMeta(fields);
setPagesText(fields.pages.join(', '));
setLoadedFrom(file.name);
setError(validateSkillSource(raw) || '');
/* A file declaring only the assistant half is being edited in the wrong
half of the product. Offered, never forced: both editors save the whole
definition, so staying here is a legitimate choice. */
const facets = facetsFromSource(raw);
setOfferOwliver(facets.length > 0 && !facets.includes('ui'));
};
reader.readAsText(file);
event.target.value = '';
@@ -191,8 +287,10 @@ export default function SkillEditor() {
const nextDisabled = skill.status === 'inactive'
? [...new Set([...disabledSkills, skill.id])]
: disabledSkills.filter((id) => id !== skill.id);
updatePreferences.mutate({ customSkills: next, disabledSkills: nextDisabled });
toast.success(editingId ? `${skill.name} updated` : `${skill.name} added`);
updatePreferences.mutate(
{ customSkills: next, disabledSkills: nextDisabled },
reportSave(editingId ? `${skill.name} updated` : `${skill.name} added`)
);
navigate('/admin/workspace/skills');
};
@@ -225,14 +323,14 @@ export default function SkillEditor() {
<Field label="Skill name" required>
<Input
value={meta.name}
placeholder="Chef Training"
placeholder="e.g. Chef Training"
onChange={(e) => syncFromMeta({ ...meta, name: e.target.value })}
/>
</Field>
<Field label="Skill ID" required hint="Lower-case letters, numbers and dashes.">
<Input
value={meta.id}
placeholder="chef-training"
placeholder="e.g. chef-training"
onChange={(e) => syncFromMeta({ ...meta, id: e.target.value })}
/>
</Field>
@@ -241,7 +339,7 @@ export default function SkillEditor() {
<Field label="Description">
<Input
value={meta.description}
placeholder="Helps Owliver explain chef training paths."
placeholder="e.g. Helps Owliver explain chef training paths."
onChange={(e) => syncFromMeta({ ...meta, description: e.target.value })}
/>
</Field>
@@ -249,7 +347,9 @@ export default function SkillEditor() {
<Field
label="Surfaces"
required
hint="Select the pages this skill attaches to from the options below or dropdown list."
hint={pagesAreDerived(source)
? 'Read from the `ui:` entries below, which name their own pages.'
: 'Select the pages this skill attaches to from the options below or dropdown list.'}
>
<div className="space-y-2.5">
<MultiSelect
@@ -357,9 +457,14 @@ export default function SkillEditor() {
<Select value={meta.source} onValueChange={(source) => syncFromMeta({ ...meta, source })}>
<SelectTrigger><SelectValue placeholder="Select a data source" /></SelectTrigger>
<SelectContent>
{DATA_SOURCES.map((src) => (
<SelectItem key={src.id} value={src.id}>{src.label} — {src.summary}</SelectItem>
))}
{DATA_SOURCES.map((src) => {
const why = sourceUnavailable(src);
return (
<SelectItem key={src.id} value={src.id} disabled={Boolean(why)}>
{src.label} — {why || src.summary}
</SelectItem>
);
})}
</SelectContent>
</Select>
</Field>
@@ -393,10 +498,12 @@ export default function SkillEditor() {
</div>
</Field>
{touched && (
{!uiIsEditableFromFields(source) && (
<p className="border-t border-border/50 pt-3 text-caption leading-relaxed text-ink-4">
The definition below has been edited by hand, so it is what gets saved — these
fields no longer compose it.
This definition declares a section per page, and its surfaces are read from
those entries. The single fields above cannot represent that, so they are left
out of what gets saved and every entry is preserved exactly as written — edit
the definition below directly. Name, ID and description still apply.
</p>
)}
</Surface>
@@ -405,6 +512,13 @@ export default function SkillEditor() {
<section className="space-y-3">
<SectionTitle title="Instructions" meta="Markdown" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
{offerOwliver && (
<p className="rounded-lg border border-krow-blue/30 bg-krow-blue-tint px-3 py-2 text-caption leading-relaxed text-krow-blue">
This file declares an Owliver skill and no page section. It saves correctly from
here, but the fields that configure it are in the Owliver Skill Editor.
</p>
)}
<Field
label="Skill definition"
hint="This is what gets saved. Frontmatter declares the skill; the body documents what it can do."
@@ -416,8 +530,41 @@ export default function SkillEditor() {
spellCheck={false}
className="font-mono text-[12.5px] leading-relaxed bg-surface-sunken/40 border-border/80 focus:border-krow-blue rounded-lg p-3.5 transition-all"
onChange={(e) => {
setSource(e.target.value);
const next = e.target.value;
setSource(next);
setTouched(true);
/**
* Markdown → fields, the direction that was missing.
*
* The fields have always written into the definition; the
* definition never wrote back. So a reader who pastes a
* complete `.md` into this box — the other half of how this
* editor is actually used — got a correct Preview beside a
* form of empty boxes, because `source` had moved and
* nothing had read it. Same extractor the upload path uses,
* so the two ways of supplying a definition cannot disagree.
*
* Two conditions before hydrating, and both exist to keep a
* half-typed definition from wiping fields that are already
* filled in. `isReadableDefinition` is the fence: no `---`,
* nothing to read. The name check is the parse: the YAML
* subset raises on an indentation error, which is what
* every definition looks like for the keystroke between
* `ui:` and its first indented line, and the extractor
* reports that by returning its empty record. A definition
* that really parsed always carries a name, even if the
* registry had to supply `Untitled skill` — so an empty one
* means the read failed, not that the author cleared it.
*/
if (isReadableDefinition(next)) {
const fields = metaFromSource(next);
if (fields.name) {
setMeta(fields);
setPagesText(fields.pages.join(', '));
}
}
if (error) setError('');
}}
/>
@@ -435,6 +582,21 @@ export default function SkillEditor() {
<Button variant="outline" size="sm" shape="rounded" onClick={() => fileRef.current?.click()}>
<Upload aria-hidden="true" /> Upload .md
</Button>
{loadedFrom && (
<span className="text-caption text-ink-4">
Fields read from <span className="font-medium text-ink-2">{loadedFrom}</span>
</span>
)}
{offerOwliver && (
<Button
variant="outline"
size="sm"
shape="rounded"
onClick={() => navigate('/admin/workspace/skills/owliver/new', { state: { source } })}
>
Open in Owliver editor
</Button>
)}
</div>
<p className="text-caption text-ink-4">Markdown is stored as data. It is never executed.</p>
</div>

View File

@@ -7,6 +7,7 @@ import {
import { usePreferences } from '@/lib/krowHooks';
import { aiAgentSkills, allSkills, skillsWithFacet } from '@/lib/skills/registry';
import { AdminPage } from '@/pages/admin/_shell';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { cn } from '@/lib/utils';
/**
@@ -100,7 +101,7 @@ export default function AdminWorkspace() {
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-6 shadow-xs space-y-5">
<div className="flex items-center justify-between border-b border-[#E0E4EA] pb-3">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-[#0838E0]" />
<OwliverAvatar className="w-6 h-6" rounded="rounded-md" />
<h2 className="text-[16px] font-bold text-[#101820]">Owliver Skills</h2>
</div>
<span className="text-[12px] font-semibold text-[#0838E0] bg-blue-50 px-3 py-1 rounded-full border border-blue-200/60">

View File

@@ -1,7 +1,8 @@
import React, { useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
ChevronDown, ChevronLeft, LayoutTemplate, MoreHorizontal, Pencil, Plus, Sparkles, Trash2,
ChevronDown, ChevronLeft, Download, LayoutTemplate, MoreHorizontal, Pencil, Plus, Sparkles,
Trash2, Upload,
} from 'lucide-react';
import {
Alert, Badge, Button, ConfirmModal, DropdownMenu, DropdownMenuContent, DropdownMenuItem,
@@ -10,8 +11,12 @@ import {
} from '@/components/ds';
import { cn } from '@/lib/utils';
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
import { PAGE_KEYS, parseSkill, readSkillRegistry, skillsWithFacet } from '@/lib/skills/registry';
import {
PAGE_KEYS, parseSkill, readSkillRegistry, skillsWithFacet, validateSkillSource,
} from '@/lib/skills/registry';
import { removeCustomSkill, upsertCustomSkill } from '@/lib/skills/customSkills';
import { reportSave } from '@/lib/skills/saveFeedback';
import { normalizeUpload } from '@/lib/skills/skillFields';
import { countSections } from '@/lib/skills/uiConfig';
import {
owliverCapabilityLabel, placementLabel, sectionTypeLabel, surfaceFor,
@@ -100,7 +105,7 @@ function contribution(skill, facet) {
}
/** One skill, as a row in the management list. */
function SkillRow({ skill, facet, enabled, onToggle, onEdit, onDelete }) {
function SkillRow({ skill, facet, enabled, onToggle, onEdit, onDelete, onExport }) {
const pages = surfaceLabels(skill);
const { type, detail } = contribution(skill, facet);
/* A definition doing both jobs appears on both lists. Saying so on the row is
@@ -161,6 +166,14 @@ function SkillRow({ skill, facet, enabled, onToggle, onEdit, onDelete }) {
<Pencil className="mr-2 h-4 w-4" />
{skill.custom ? 'Edit skill' : 'Duplicate & edit'}
</DropdownMenuItem>
{/* The definition, back out as the file it is. An account skill
lives in this browser and nowhere else; without a way to take
one out, "stored locally" means "stored until something goes
wrong". */}
<DropdownMenuItem onClick={() => onExport(skill)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" />
Export .md
</DropdownMenuItem>
{/* Offered for every definition. What it does differs — an account
definition is deleted, a shipped one is taken out of this
workspace — and the dialog says which. */}
@@ -194,6 +207,7 @@ export default function WorkspaceSkills() {
const [page, setPage] = useState('all');
const [pendingDelete, setPendingDelete] = useState(null);
const [showRemoved, setShowRemoved] = useState(false);
const importRef = React.useRef(null);
const disabledSkills = preferences.disabledSkills || [];
/**
@@ -296,8 +310,7 @@ export default function WorkspaceSkills() {
patch.customSkills = upsertCustomSkill(customSkills, source).next;
}
updatePreferences.mutate(patch);
toast.success(enabled ? 'Skill enabled' : 'Skill disabled');
updatePreferences.mutate(patch, reportSave(enabled ? 'Skill enabled' : 'Skill disabled'));
};
/**
@@ -319,9 +332,8 @@ export default function WorkspaceSkills() {
disabledSkills: [...new Set([...disabledSkills, skill.id])],
};
updatePreferences.mutate(patch);
updatePreferences.mutate(patch, reportSave(`${skill.name} removed`));
setPendingDelete(null);
toast.success(`${skill.name} removed`);
};
/**
@@ -337,10 +349,86 @@ export default function WorkspaceSkills() {
* list with its switch off, and one click is the whole cost.
*/
const restoreSkill = (skill) => {
updatePreferences.mutate({
removedSkills: removedSkills.filter((id) => id !== skill.id),
updatePreferences.mutate(
{ removedSkills: removedSkills.filter((id) => id !== skill.id) },
reportSave(`${skill.name} restored — switched off`)
);
};
/**
* A definition, downloaded as the file it already is.
*
* `skill.markdown` is the artefact — the same text a file in `src/skills/`
* holds — so this is a copy, not an export format. That is what makes it
* useful: the file that comes out can be uploaded into another browser,
* committed to the repository, or read by a person.
*/
const exportSkill = (skill) => {
const blob = new Blob([skill.markdown], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${skill.id}.md`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
};
/** Every definition this account authored, one file at a time. */
const exportAll = () => {
const mine = skills.filter((s) => s.custom);
if (!mine.length) {
toast.error('This workspace has no account-authored skills to export.');
return;
}
mine.forEach(exportSkill);
toast.success(`Exported ${mine.length} skill${mine.length === 1 ? '' : 's'}`);
};
/**
* Definitions read back in from files.
*
* The same validator and the same writer the editors use, so an imported file
* cannot enter the registry by a route that checks less than authoring does.
* Each file is reported on its own: one bad file in a selection of six must
* not cost the other five.
*/
const importFiles = (event) => {
const files = [...(event.target.files || [])];
event.target.value = '';
if (!files.length) return;
Promise.all(files.map((file) => file.text().then(
(raw) => ({ name: file.name, raw }),
() => ({ name: file.name, raw: null })
))).then((read) => {
let next = customSkills;
const added = [];
const refused = [];
for (const { name, raw: text } of read) {
/* Same normalisation the editors apply, so a file imported here and the
same file uploaded there become the identical stored definition. */
const raw = text === null ? null : normalizeUpload(text);
const problem = raw === null ? 'could not be read' : validateSkillSource(raw);
if (problem) {
refused.push(`${name}: ${problem}`);
continue;
}
const result = upsertCustomSkill(next, raw);
next = result.next;
added.push(result.skill.name);
}
if (added.length) {
updatePreferences.mutate(
{ customSkills: next },
reportSave(`Imported ${added.length} skill${added.length === 1 ? '' : 's'}`)
);
}
refused.forEach((message) => toast.error(message));
});
toast.success(`${skill.name} restored — switched off`);
};
const handleFixDiagnostic = (d) => {
@@ -356,15 +444,16 @@ export default function WorkspaceSkills() {
if (isCustom) {
const nextCustom = removeCustomSkill(customSkills, d.skillId);
updatePreferences.mutate({ customSkills: nextCustom });
toast.success(`Removed custom skill "${d.skillId}"`);
updatePreferences.mutate(
{ customSkills: nextCustom },
reportSave(`Removed custom skill "${d.skillId}"`)
);
} else {
const patch = {
removedSkills: [...new Set([...removedSkills, d.skillId])],
disabledSkills: [...new Set([...disabledSkills, d.skillId])],
};
updatePreferences.mutate(patch);
toast.success(`Removed "${d.skillId}" from workspace`);
updatePreferences.mutate(patch, reportSave(`Removed "${d.skillId}" from workspace`));
}
};
@@ -380,12 +469,10 @@ export default function WorkspaceSkills() {
nextDisabled = [...new Set([...nextDisabled, d.skillId])];
});
updatePreferences.mutate({
customSkills: nextCustom,
removedSkills: nextRemoved,
disabledSkills: nextDisabled,
});
toast.success('Resolved all skill problems');
updatePreferences.mutate(
{ customSkills: nextCustom, removedSkills: nextRemoved, disabledSkills: nextDisabled },
reportSave('Resolved all skill problems')
);
};
/** A row opens the editor its own kind is authored in. */
@@ -419,6 +506,24 @@ export default function WorkspaceSkills() {
<Button variant="outline" size="sm" shape="rounded" onClick={() => navigate('/admin/workspace')}>
<ChevronLeft aria-hidden="true" /> Workspace
</Button>
{/* Account definitions live in this browser's storage and nowhere
else. Import and export are what make that a place rather than a
trap: a skill can be moved to another machine, handed to someone,
or committed to `src/skills/`. */}
<input
ref={importRef}
type="file"
accept=".md,text/markdown"
multiple
onChange={importFiles}
className="hidden"
/>
<Button variant="outline" size="sm" shape="rounded" onClick={() => importRef.current?.click()}>
<Upload aria-hidden="true" /> Import .md
</Button>
<Button variant="outline" size="sm" shape="rounded" onClick={exportAll}>
<Download aria-hidden="true" /> Export all
</Button>
<Button size="sm" shape="rounded" onClick={() => navigate(current.route)}>
<Plus aria-hidden="true" /> {current.add}
</Button>
@@ -533,6 +638,7 @@ export default function WorkspaceSkills() {
onToggle={toggleSkill}
onEdit={(s) => navigate(editRoute(s))}
onDelete={setPendingDelete}
onExport={exportSkill}
/>
))}
</Surface>