update Markdown skills
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user