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,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>