update Markdown skills
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
"build": "vite build",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "node scripts/skill-check.mjs",
|
||||
"typecheck": "tsc -p ./jsconfig.json",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
|
||||
BIN
public/owliver-avatar.png
Normal file
BIN
public/owliver-avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
@@ -375,9 +375,432 @@ for (const n of [1, 4, 9]) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 6. Authoring: what the editors save, and what they refuse ────────────── */
|
||||
console.log('\n── Authoring ──');
|
||||
|
||||
const fields = await server.ssrLoadModule('/src/lib/skills/skillFields.js');
|
||||
const templates = await server.ssrLoadModule('/src/lib/skills/customSkills.js');
|
||||
const surfaces = await server.ssrLoadModule('/src/lib/skills/surfaces.js');
|
||||
|
||||
/* A template must produce something that works before it is edited. The Board
|
||||
template used to default to a source its own default placement cannot read. */
|
||||
record(
|
||||
'the Board template validates as written',
|
||||
reg.validateSkillSource(templates.uiSkillTemplate({
|
||||
id: 'template-check', name: 'Template Check', pages: ['positions'],
|
||||
})) === null,
|
||||
reg.validateSkillSource(templates.uiSkillTemplate({
|
||||
id: 'template-check', name: 'Template Check', pages: ['positions'],
|
||||
})) || 'valid'
|
||||
);
|
||||
|
||||
/* An Owliver block that can answer nothing is refused rather than saved and
|
||||
then reported as a skill that does not work. */
|
||||
record(
|
||||
'an Owliver skill with no capabilities is refused',
|
||||
Boolean(reg.validateSkillSource(templates.owliverSkillTemplate({
|
||||
id: 'empty-owliver', name: 'Empty Owliver', pages: ['positions'],
|
||||
}))),
|
||||
reg.validateSkillSource(templates.owliverSkillTemplate({
|
||||
id: 'empty-owliver', name: 'Empty Owliver', pages: ['positions'],
|
||||
})) || 'ACCEPTED — should have been refused'
|
||||
);
|
||||
|
||||
/* The section-can-never-resolve check, in both directions. */
|
||||
const boardOn = (page, placement) => `---
|
||||
id: context-check
|
||||
name: Context Check
|
||||
description: Reads position activity.
|
||||
pages:
|
||||
- ${page}
|
||||
status: active
|
||||
ui:
|
||||
type: flow
|
||||
placement: ${placement}
|
||||
source: position.activity
|
||||
---
|
||||
|
||||
# Context Check
|
||||
`;
|
||||
|
||||
record(
|
||||
'a position source on a page with no position is refused',
|
||||
Boolean(reg.validateSkillSource(boardOn('analytics', 'after-header'))),
|
||||
reg.validateSkillSource(boardOn('analytics', 'after-header')) || 'ACCEPTED — should have been refused'
|
||||
);
|
||||
record(
|
||||
'the same source is refused above the Positions grid',
|
||||
Boolean(reg.validateSkillSource(boardOn('positions', 'after-position-list'))),
|
||||
reg.validateSkillSource(boardOn('positions', 'after-position-list')) || 'ACCEPTED — should have been refused'
|
||||
);
|
||||
record(
|
||||
'and accepted inside a position card',
|
||||
reg.validateSkillSource(boardOn('positions', 'after-position-card')) === null,
|
||||
reg.validateSkillSource(boardOn('positions', 'after-position-card')) || 'accepted'
|
||||
);
|
||||
|
||||
/* Owliver responses are deliberately NOT subject to that rule: an unmet need is
|
||||
a question back, not a dead card. `hiring-activity-assistant` depends on it. */
|
||||
record(
|
||||
'a shipped Owliver skill reading one position still validates',
|
||||
reg.validateSkillSource(
|
||||
reg.SKILLS.find((s) => s.id === 'hiring-activity-assistant').markdown
|
||||
) === null
|
||||
);
|
||||
|
||||
record(
|
||||
'every shipped definition still validates',
|
||||
reg.SKILLS.every((s) => reg.validateSkillSource(s.markdown) === null),
|
||||
reg.SKILLS.filter((s) => reg.validateSkillSource(s.markdown)).map((s) => s.id).join(', ') || 'all valid'
|
||||
);
|
||||
|
||||
/* Reading a definition into the editor fields — the upload path. */
|
||||
for (const skill of reg.SKILLS.filter((s) => s.kind === 'assistant')) {
|
||||
const read = fields.owliverFieldsFromSource(skill.markdown);
|
||||
record(
|
||||
`\`${skill.id}\` reads back into the Owliver fields`,
|
||||
read.id === skill.id && read.name === skill.name
|
||||
&& read.pages.join(',') === skill.pages.join(','),
|
||||
`${read.name} / ${read.pages.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
record(
|
||||
'an unparseable file leaves the fields empty rather than throwing',
|
||||
fields.owliverFieldsFromSource('not a definition').id === ''
|
||||
);
|
||||
|
||||
/* Writing a field back — the half that used to do nothing. */
|
||||
const original = reg.SKILLS.find((s) => s.id === 'hiring-activity-assistant').markdown;
|
||||
const renamed = fields.patchFrontmatter(original, { name: 'Renamed Assistant' });
|
||||
|
||||
record(
|
||||
'patching a field changes what the registry reads',
|
||||
reg.parseSkill(renamed, { custom: true }).name === 'Renamed Assistant',
|
||||
reg.parseSkill(renamed, { custom: true }).name
|
||||
);
|
||||
record(
|
||||
'patching a field leaves the body untouched',
|
||||
reg.parseSkill(renamed, { custom: true }).body === reg.parseSkill(original, { custom: true }).body
|
||||
);
|
||||
record(
|
||||
'patching a field preserves frontmatter comments',
|
||||
renamed.includes('# One suggestion per capability.')
|
||||
);
|
||||
record(
|
||||
'patching a field changes nothing else',
|
||||
original.split('\n').filter((l) => !l.startsWith('name:')).join('\n')
|
||||
=== renamed.split('\n').filter((l) => !l.startsWith('name:')).join('\n')
|
||||
);
|
||||
record(
|
||||
'a patched definition still validates',
|
||||
reg.validateSkillSource(renamed) === null,
|
||||
reg.validateSkillSource(renamed) || 'valid'
|
||||
);
|
||||
|
||||
const repaged = fields.patchFrontmatter(original, { pages: ['analytics', 'activity'] });
|
||||
record(
|
||||
'patching a list replaces the whole block',
|
||||
reg.parseSkill(repaged, { custom: true }).pages.join(',') === 'analytics,activity',
|
||||
reg.parseSkill(repaged, { custom: true }).pages.join(',')
|
||||
);
|
||||
|
||||
const restatused = fields.patchFrontmatter(original, { status: 'inactive' });
|
||||
record(
|
||||
'patching a key the file never declared adds it',
|
||||
reg.parseSkill(restatused, { custom: true }).status === 'inactive'
|
||||
);
|
||||
|
||||
/* Both keys, because that is what the editor's own field handler writes: the
|
||||
registry reads capabilities as the union of the declared list and the keys of
|
||||
`responses:`, so patching one without the other changes nothing. */
|
||||
const recapped = fields.patchFrontmatter(original, {
|
||||
'owliver.capabilities': ['summary'],
|
||||
'owliver.responses': { summary: { source: 'position.activity', periods: ['today'] } },
|
||||
});
|
||||
record(
|
||||
'patching a nested block rewrites only that block',
|
||||
reg.parseSkill(recapped, { custom: true }).owliver.capabilities.join(',') === 'summary',
|
||||
reg.parseSkill(recapped, { custom: true }).owliver.capabilities.join(',')
|
||||
);
|
||||
/* Asserted on the text, not on the parse: with `flow` no longer offered, the
|
||||
registry correctly drops the suggestion that names it — which is the rule
|
||||
working, not the patch reaching a sibling key it should not have. */
|
||||
record(
|
||||
'and leaves its siblings in the same block alone',
|
||||
recapped.includes('Summarize hiring activity for this position')
|
||||
&& recapped.includes('Show hiring activity as a flow')
|
||||
&& recapped.includes(' enabled: true')
|
||||
);
|
||||
|
||||
/* The two `ui:` shapes, told apart — the per-page form must not be overwritten
|
||||
from four single-valued fields. */
|
||||
record(
|
||||
'the shorthand `ui:` form is recognised',
|
||||
fields.uiShape(boardOn('positions', 'after-position-card')) === 'shorthand'
|
||||
);
|
||||
record(
|
||||
'a definition with no `ui:` block reports none',
|
||||
fields.uiShape(original) === 'none'
|
||||
);
|
||||
|
||||
/* The picker and the validator read the same table. */
|
||||
record(
|
||||
'a position card supplies a position',
|
||||
surfaces.contextSuppliedBy(['positions'], 'after-position-card').includes('positionId')
|
||||
);
|
||||
record(
|
||||
'the Positions list supplies nothing',
|
||||
surfaces.contextSuppliedBy(['positions'], 'after-position-list').length === 0
|
||||
);
|
||||
record(
|
||||
'Analytics supplies nothing',
|
||||
surfaces.contextSuppliedBy(['analytics'], 'after-header').length === 0
|
||||
);
|
||||
record(
|
||||
'every surface declares what its placements provide',
|
||||
surfaces.SKILL_SURFACES.every((s) => s.provides && typeof s.provides === 'object'),
|
||||
surfaces.SKILL_SURFACES.filter((s) => !s.provides).map((s) => s.id).join(', ') || 'all declared'
|
||||
);
|
||||
|
||||
/* ── 7. Upload hydration: a file, into the fields ─────────────────────────── */
|
||||
console.log('\n── Upload hydration ──');
|
||||
|
||||
/**
|
||||
* The shapes people actually upload.
|
||||
*
|
||||
* Every case here is a definition that arrived from outside the editors, which
|
||||
* is the only way most definitions arrive. What is being asserted is not that
|
||||
* the parser is lenient — it is that the three identity fields, the pages and
|
||||
* the section a reader can see in the file are the ones the form shows.
|
||||
*/
|
||||
const COMPLETE = `---
|
||||
id: hiring-activity-assistant
|
||||
name: Hiring Activity Assistant
|
||||
description: Answer questions about recent hiring activity on a position.
|
||||
type: board
|
||||
ui:
|
||||
- page: Positions
|
||||
placement: grid-card
|
||||
source: position.activity
|
||||
---
|
||||
|
||||
# Hiring Activity Assistant
|
||||
`;
|
||||
|
||||
const NO_ID = `---
|
||||
name: Hiring Activity Assistant
|
||||
description: Answer questions about recent hiring activity.
|
||||
pages:
|
||||
- positions
|
||||
---
|
||||
|
||||
# Hiring Activity Assistant
|
||||
`;
|
||||
|
||||
const MULTI_PAGE = `---
|
||||
id: hiring-activity
|
||||
name: Hiring Activity
|
||||
description: Hiring activity across the workspace.
|
||||
ui:
|
||||
- page: Positions
|
||||
placement: grid-card
|
||||
source: candidates.activity
|
||||
- page: Analytics
|
||||
placement: panel
|
||||
source: hires.performance
|
||||
---
|
||||
|
||||
# Hiring Activity
|
||||
`;
|
||||
|
||||
/* Case 1 — a complete definition hydrates every field it declares. */
|
||||
{
|
||||
const f = fields.boardFieldsFromSource(COMPLETE);
|
||||
record('upload: name hydrates', f.name === 'Hiring Activity Assistant', f.name || 'EMPTY');
|
||||
record('upload: id hydrates', f.id === 'hiring-activity-assistant', f.id || 'EMPTY');
|
||||
record(
|
||||
'upload: description hydrates',
|
||||
f.description === 'Answer questions about recent hiring activity on a position.',
|
||||
f.description || 'EMPTY'
|
||||
);
|
||||
record('upload: pages come from the `ui:` entries', f.pages.join(',') === 'positions', f.pages.join(',') || 'EMPTY');
|
||||
record('upload: `grid-card` resolves to a real placement', f.placement === 'after-position-card', f.placement || 'EMPTY');
|
||||
record('upload: source hydrates', f.source === 'position.activity', f.source || 'EMPTY');
|
||||
record('upload: an undeclared type is inferred from the source', f.type === 'flow', f.type || 'EMPTY');
|
||||
record('upload: the definition validates as written', reg.validateSkillSource(COMPLETE) === null,
|
||||
reg.validateSkillSource(COMPLETE) || 'valid');
|
||||
record('upload: it is classified as a Board skill',
|
||||
fields.facetsFromSource(COMPLETE).join(',') === 'ui', fields.facetsFromSource(COMPLETE).join(','));
|
||||
|
||||
/* The same file read by the other editor's reader — one pipeline, two views. */
|
||||
const o = fields.owliverFieldsFromSource(COMPLETE);
|
||||
record('upload: the Owliver reader hydrates the same identity',
|
||||
o.id === f.id && o.name === f.name && o.description === f.description);
|
||||
}
|
||||
|
||||
/* Case 2 — no `id:`, so it is slugged from the name and never invented. */
|
||||
{
|
||||
const f = fields.boardFieldsFromSource(NO_ID);
|
||||
record('upload: a missing id slugs the name', f.id === 'hiring-activity-assistant', f.id || 'EMPTY');
|
||||
record('upload: a missing id does not become the placeholder path', f.id !== 'custom');
|
||||
record('upload: an explicit id is never replaced by a generated one',
|
||||
fields.boardFieldsFromSource(COMPLETE).id === 'hiring-activity-assistant');
|
||||
record('upload: a file with no frontmatter hydrates nothing',
|
||||
fields.boardFieldsFromSource('# Just a heading\n').id === '');
|
||||
}
|
||||
|
||||
/* Case 3 — several pages, and every entry survives a field edit. */
|
||||
{
|
||||
const f = fields.boardFieldsFromSource(MULTI_PAGE);
|
||||
record('upload: every page in the list is reported', f.pages.join(',') === 'positions,analytics', f.pages.join(','));
|
||||
|
||||
const parsed = reg.parseSkill(MULTI_PAGE, { custom: true });
|
||||
record('upload: every entry becomes a section',
|
||||
parsed.ui.positions.sections.length === 1 && parsed.ui.analytics.sections.length === 1);
|
||||
record('upload: each entry keeps its own source',
|
||||
parsed.ui.positions.sections[0].source === 'candidates.activity'
|
||||
&& parsed.ui.analytics.sections[0].source === 'hires.performance');
|
||||
record('upload: a multi-page definition validates', reg.validateSkillSource(MULTI_PAGE) === null,
|
||||
reg.validateSkillSource(MULTI_PAGE) || 'valid');
|
||||
|
||||
/* The fields must not be able to flatten it. */
|
||||
record('upload: the section fields are read-only against a list', !fields.uiIsEditableFromFields(MULTI_PAGE));
|
||||
record('upload: a single-section definition stays field-editable',
|
||||
fields.uiIsEditableFromFields(templates.uiSkillTemplate({ id: 'x', name: 'X', pages: ['positions'] })));
|
||||
|
||||
/* Renaming is identity, not structure: it must still work, and must not
|
||||
touch either entry. */
|
||||
const renamedMulti = fields.patchFrontmatter(MULTI_PAGE, { name: 'Renamed Multi' });
|
||||
const after = reg.parseSkill(renamedMulti, { custom: true });
|
||||
record('upload: renaming a multi-page definition keeps both entries',
|
||||
after.ui.positions?.sections.length === 1 && after.ui.analytics?.sections.length === 1,
|
||||
Object.keys(after.ui).join(','));
|
||||
record('upload: ...and actually renames it', after.name === 'Renamed Multi', after.name);
|
||||
record('upload: ...and leaves the `ui:` text byte-identical',
|
||||
renamedMulti.slice(renamedMulti.indexOf('ui:')) === MULTI_PAGE.slice(MULTI_PAGE.indexOf('ui:')));
|
||||
}
|
||||
|
||||
/* Files as they actually arrive: from Windows, from a download, from paste. */
|
||||
{
|
||||
const dirty = {
|
||||
'a byte-order mark': `${COMPLETE}`,
|
||||
'CRLF line endings': COMPLETE.replace(/\n/g, '\r\n'),
|
||||
'a BOM and CRLF': `${COMPLETE.replace(/\n/g, '\r\n')}`,
|
||||
'a blank line above the fence': `\n\n${COMPLETE}`,
|
||||
'trailing spaces on the fence': COMPLETE.replace(/^---$/gm, '--- '),
|
||||
};
|
||||
for (const [what, md] of Object.entries(dirty)) {
|
||||
const f = fields.boardFieldsFromSource(md);
|
||||
record(
|
||||
`upload: a file with ${what} still hydrates`,
|
||||
f.name === 'Hiring Activity Assistant' && f.id === 'hiring-activity-assistant'
|
||||
&& f.pages.join(',') === 'positions',
|
||||
`${f.name || 'EMPTY'} / ${f.id || 'EMPTY'} / ${f.pages.join(',') || 'EMPTY'}`
|
||||
);
|
||||
record(
|
||||
`upload: ...and the registry reads it the same way`,
|
||||
reg.parseSkill(md, { custom: true }).name === 'Hiring Activity Assistant',
|
||||
reg.parseSkill(md, { custom: true }).name
|
||||
);
|
||||
record(`upload: ...and it validates`, reg.validateSkillSource(md) === null,
|
||||
reg.validateSkillSource(md) || 'valid');
|
||||
}
|
||||
|
||||
/* Normalising on the way in is what keeps `patchFrontmatter` safe: an
|
||||
unrecognised fence would have it write a second one above the first. */
|
||||
const patchedDirty = fields.patchFrontmatter(fields.normalizeUpload(`${COMPLETE}`), { name: 'Clean' });
|
||||
record('upload: patching a normalised file writes one frontmatter block',
|
||||
(patchedDirty.match(/^---$/gm) || []).length === 2,
|
||||
`${(patchedDirty.match(/^---$/gm) || []).length} fences`);
|
||||
record('upload: ...and it still parses', reg.parseSkill(patchedDirty, { custom: true }).name === 'Clean');
|
||||
|
||||
/* A file with nothing to read must be refused, not read as a blank skill. */
|
||||
record('upload: a file with no fence is not readable', !fields.isReadableDefinition('# Just prose\n'));
|
||||
record('upload: a real definition is readable', fields.isReadableDefinition(COMPLETE));
|
||||
}
|
||||
|
||||
/* Page names and placements as they are written in the product, not as the
|
||||
vocabulary spells them internally. */
|
||||
record('upload: `Positions` resolves to the positions surface', surfaces.canonicalPage('Positions') === 'positions');
|
||||
record('upload: `Talent Pool` resolves to the talent-pool surface', surfaces.canonicalPage('Talent Pool') === 'talent-pool');
|
||||
record('upload: `panel` on Analytics resolves to a real placement',
|
||||
surfaces.placementFor('analytics', 'panel') === 'after-header');
|
||||
record('upload: an alias never resolves onto a surface that lacks it',
|
||||
surfaces.placementFor('analytics', 'grid-card') === null);
|
||||
record('upload: a canonical placement still resolves to itself',
|
||||
surfaces.placementFor('positions', 'after-position-card') === 'after-position-card');
|
||||
|
||||
/* Case 4 — an existing definition reopened for editing. */
|
||||
for (const skill of reg.SKILLS) {
|
||||
const f = skill.facets?.includes('ui')
|
||||
? fields.boardFieldsFromSource(skill.markdown)
|
||||
: fields.owliverFieldsFromSource(skill.markdown);
|
||||
record(
|
||||
`reopening \`${skill.id}\` loads its identity unchanged`,
|
||||
f.id === skill.id && f.name === skill.name && f.description === skill.description
|
||||
&& f.pages.join(',') === skill.pages.join(','),
|
||||
`${f.id} / ${f.name} / ${f.pages.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
/* Case 5 — a manual edit is not reverted by later synchronisation. */
|
||||
{
|
||||
/* The editor's own handler, in miniature: hydrate from the file, edit one
|
||||
field, then edit an unrelated one. The first edit must survive the second. */
|
||||
let draft = fields.owliverFieldsFromSource(COMPLETE);
|
||||
let src = COMPLETE;
|
||||
const edit = (patch) => {
|
||||
draft = { ...draft, ...patch };
|
||||
src = fields.patchFrontmatter(src, {
|
||||
id: draft.id || undefined,
|
||||
name: draft.name || undefined,
|
||||
description: draft.description || undefined,
|
||||
pages: draft.pages?.length && !fields.pagesAreDerived(src) ? draft.pages : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
edit({ name: 'My Own Name' });
|
||||
record('edit: a manual name reaches the artefact',
|
||||
reg.parseSkill(src, { custom: true }).name === 'My Own Name');
|
||||
|
||||
edit({ description: 'My own description.' });
|
||||
record('edit: a later edit does not revert the earlier one',
|
||||
reg.parseSkill(src, { custom: true }).name === 'My Own Name',
|
||||
reg.parseSkill(src, { custom: true }).name);
|
||||
record('edit: ...and applies itself',
|
||||
reg.parseSkill(src, { custom: true }).description === 'My own description.');
|
||||
record('edit: ...and the `ui:` block is untouched throughout',
|
||||
src.slice(src.indexOf('ui:')) === COMPLETE.slice(COMPLETE.indexOf('ui:')));
|
||||
|
||||
/* A second upload replaces the draft outright — it is a new source. */
|
||||
const rehydrated = fields.owliverFieldsFromSource(MULTI_PAGE);
|
||||
record('edit: a derived `pages:` is never written back',
|
||||
!src.includes('\npages:'), src.includes('\npages:') ? 'pages: was inserted' : 'not written');
|
||||
|
||||
record('edit: a declared `pages:` still patches normally',
|
||||
reg.parseSkill(
|
||||
fields.patchFrontmatter(NO_ID, { pages: ['analytics'] }), { custom: true }
|
||||
).pages.join(',') === 'analytics');
|
||||
|
||||
record('edit: an inherited trigger is not written into the file',
|
||||
!src.includes('triggers:'), src.includes('triggers:') ? 'triggers: was materialised' : 'not written');
|
||||
record('edit: a declared trigger is still read into the fields',
|
||||
fields.owliverFieldsFromSource(
|
||||
reg.SKILLS.find((x) => x.id === 'hiring-activity-assistant').markdown
|
||||
).triggers.includes('hiring activity'));
|
||||
record('edit: a definition with no triggers reads none',
|
||||
fields.owliverFieldsFromSource(COMPLETE).triggers.length === 0,
|
||||
JSON.stringify(fields.owliverFieldsFromSource(COMPLETE).triggers));
|
||||
|
||||
record('edit: a second upload hydrates from the new file',
|
||||
rehydrated.id === 'hiring-activity' && rehydrated.name === 'Hiring Activity',
|
||||
`${rehydrated.id} / ${rehydrated.name}`);
|
||||
}
|
||||
|
||||
await server.close();
|
||||
|
||||
/* ── 6. Production bundle ─────────────────────────────────────────────────── */
|
||||
/* ── 8. Production bundle ─────────────────────────────────────────────────── */
|
||||
if (process.argv.includes('--dist')) {
|
||||
console.log('\n── Production bundle ──');
|
||||
const dir = join(ROOT, 'dist/assets');
|
||||
|
||||
26
skill-examples/board-invalid-context.md
Normal file
26
skill-examples/board-invalid-context.md
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
id: board-invalid-context
|
||||
name: Board (invalid context)
|
||||
description: Negative test — a position-scoped source on a placement that supplies no position.
|
||||
pages:
|
||||
- positions
|
||||
status: active
|
||||
ui:
|
||||
type: card
|
||||
placement: after-position-list-summary
|
||||
title: Board
|
||||
source: position.activity
|
||||
periods:
|
||||
- today
|
||||
- last-7-days
|
||||
- previous-month
|
||||
---
|
||||
|
||||
# Board (invalid context)
|
||||
|
||||
## Purpose
|
||||
|
||||
Deliberately wrong, and kept so the refusal stays tested. `position.activity`
|
||||
needs one position to read; `after-position-list-summary` renders once above the
|
||||
grid with no position in context. Saving this must be refused with a message that
|
||||
names both halves of the mismatch.
|
||||
31
skill-examples/board-page-level.md
Normal file
31
skill-examples/board-page-level.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
id: board
|
||||
name: Board
|
||||
description: Helps Owliver understand, analyze, and act on the current task board.
|
||||
pages:
|
||||
- positions
|
||||
status: active
|
||||
ui:
|
||||
type: card
|
||||
placement: after-position-list-summary
|
||||
title: Board
|
||||
source: candidates.activity
|
||||
periods:
|
||||
- today
|
||||
- last-7-days
|
||||
- previous-month
|
||||
---
|
||||
|
||||
# Board
|
||||
|
||||
## Purpose
|
||||
|
||||
Helps Owliver understand the current task board by summarizing relevant work activity, identifying items that need attention, and explaining the current status of tasks shown on the board.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Summarize relevant activity from the current board.
|
||||
- Identify tasks or work items that need attention.
|
||||
- Explain the current status of tasks and work items.
|
||||
- Highlight recent activity and meaningful changes.
|
||||
- Help Owliver analyze board activity using the available board data.
|
||||
31
skill-examples/board-position-card.md
Normal file
31
skill-examples/board-position-card.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
id: board-position-card
|
||||
name: Board (per position)
|
||||
description: Control test — the same Board card, rendered inside every position card.
|
||||
pages:
|
||||
- positions
|
||||
status: active
|
||||
ui:
|
||||
type: card
|
||||
placement: grid-card
|
||||
title: Board
|
||||
source: position.activity
|
||||
periods:
|
||||
- today
|
||||
- last-7-days
|
||||
- previous-month
|
||||
---
|
||||
|
||||
# Board (per position)
|
||||
|
||||
## Purpose
|
||||
|
||||
The position-aware control for the page-level Board card. `grid-card` resolves to
|
||||
`after-position-card`, which renders inside each position card and supplies that
|
||||
position as context — so `position.activity` reads and the card is drawn once per
|
||||
role rather than once for the page.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Summarize activity for the position the card belongs to.
|
||||
- Show that activity across today, the last 7 days and the previous month.
|
||||
40
skill-examples/board-ui-test.md
Normal file
40
skill-examples/board-ui-test.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
id: board-ui-test
|
||||
name: Board
|
||||
description: Renders the Board card once above the position list on the Positions page.
|
||||
pages:
|
||||
- positions
|
||||
status: active
|
||||
ui:
|
||||
type: card
|
||||
placement: after-position-list-summary
|
||||
title: Board
|
||||
source: candidates.activity
|
||||
periods:
|
||||
- today
|
||||
- last-7-days
|
||||
- previous-month
|
||||
---
|
||||
|
||||
# Board
|
||||
|
||||
## Purpose
|
||||
|
||||
The page half of the Board, and only that half. One card, above the position
|
||||
grid, reading activity across the workspace.
|
||||
|
||||
`after-position-list-summary` renders once for the page and supplies no
|
||||
position — `surfaces.js` records that placement as providing nothing — so the
|
||||
card reads `candidates.activity`, which declares `context: null` and counts
|
||||
applications across the workspace over the periods named here.
|
||||
`position.activity` is refused at this placement by `unresolvableSections`, and
|
||||
correctly: it needs one position, and this placement has none to give.
|
||||
|
||||
This definition declares no `owliver:` block, so it contributes no capability,
|
||||
no suggestion chip and no conversational answer. It is a page extension and
|
||||
nothing else.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Show workspace application activity above the position list.
|
||||
- Compare today, the last 7 days and the previous month in one card.
|
||||
63
skill-examples/owliver-conversation-test.md
Normal file
63
skill-examples/owliver-conversation-test.md
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
id: owliver-conversation-test
|
||||
name: Workspace Activity Assistant
|
||||
description: Answer questions about applications across the workspace and the roles still open.
|
||||
pages:
|
||||
- positions
|
||||
status: active
|
||||
triggers:
|
||||
- workspace applications
|
||||
- applications across the workspace
|
||||
- roles still open
|
||||
- roles needing attention
|
||||
owliver:
|
||||
enabled: true
|
||||
# Every suggestion names the capability it asks for, so a chip and an answer
|
||||
# are one-to-one rather than both falling through to the first declared
|
||||
# capability.
|
||||
suggestions:
|
||||
- label: Summarize workspace applications
|
||||
capability: summary
|
||||
- label: List the roles still open
|
||||
capability: list
|
||||
capabilities:
|
||||
- summary
|
||||
- list
|
||||
responses:
|
||||
summary:
|
||||
title: Workspace Applications
|
||||
source: candidates.activity
|
||||
periods:
|
||||
- today
|
||||
- last-7-days
|
||||
- previous-month
|
||||
list:
|
||||
title: Roles Still Open
|
||||
source: positions.demand
|
||||
limit: 5
|
||||
---
|
||||
|
||||
# Workspace Activity Assistant
|
||||
|
||||
## Purpose
|
||||
|
||||
The panel half, and only that half. No `ui:` block, so nothing is drawn on the
|
||||
Positions page and no card appears above or inside the grid.
|
||||
|
||||
Each response names its own `source:` directly, which is what a definition
|
||||
without a `ui:` section must do — `normalizeSkillOwliver` inherits a source from
|
||||
the first `ui:` section only when one exists, and reports
|
||||
`owliver.responses.<capability>: a response needs a source, or a ui: section to
|
||||
read from` when neither is present.
|
||||
|
||||
Both sources declare `context: null`, so neither answer asks which position is
|
||||
meant: `resolveEntity` returns immediately and no
|
||||
"This section needs a position to read" is possible. The shapes match what each
|
||||
source offers — `summary` is prose and is exempt from the shape check, and
|
||||
`list` is a shape `positions.demand` declares.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Summarize applications across the workspace over today, the last 7 days and
|
||||
the previous month.
|
||||
- List the open positions and how many applicants each has.
|
||||
@@ -43,6 +43,37 @@ function loadUser() {
|
||||
|
||||
let currentUser = loadUser();
|
||||
|
||||
/**
|
||||
* Writes the session user, and says whether it actually landed.
|
||||
*
|
||||
* The old version was `try { setItem } catch {}` — a swallowed
|
||||
* `QuotaExceededError` or a private-browsing refusal, and the caller was handed
|
||||
* a user object indistinguishable from a successful write. For preferences that
|
||||
* is invisible; for `customSkills`, which is where every account-authored skill
|
||||
* definition lives, it is the whole "I saved it and it was gone" report: the
|
||||
* toast said added, the list showed it, the reload did not.
|
||||
*
|
||||
* The read-back matters as much as the catch. A write can be accepted and then
|
||||
* evicted, and a serialisation can land truncated; comparing what came back
|
||||
* with what went in is the only way to know the record is really there.
|
||||
*/
|
||||
function persistUser() {
|
||||
const payload = JSON.stringify(currentUser);
|
||||
try {
|
||||
localStorage.setItem(SESSION_KEY, payload);
|
||||
} catch (error) {
|
||||
return { persisted: false, error };
|
||||
}
|
||||
try {
|
||||
if (localStorage.getItem(SESSION_KEY) !== payload) {
|
||||
return { persisted: false, error: new Error('The session record did not survive the write.') };
|
||||
}
|
||||
} catch (error) {
|
||||
return { persisted: false, error };
|
||||
}
|
||||
return { persisted: true, error: null };
|
||||
}
|
||||
|
||||
const auth = {
|
||||
/** The demo is always signed in as the seeded employer/admin user. */
|
||||
async me() {
|
||||
@@ -51,11 +82,7 @@ const auth = {
|
||||
|
||||
async updateMe(patch) {
|
||||
currentUser = { ...currentUser, ...patch };
|
||||
try {
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(currentUser));
|
||||
} catch {
|
||||
// Non-persistent session is fine.
|
||||
}
|
||||
persistUser();
|
||||
return { ...currentUser };
|
||||
},
|
||||
|
||||
@@ -72,9 +99,18 @@ const auth = {
|
||||
return { ...DEMO_USER.preferences, ...(currentUser.preferences || {}) };
|
||||
},
|
||||
|
||||
/** Merges into the stored preferences and persists with the rest of the user. */
|
||||
/**
|
||||
* Merges into the stored preferences and persists with the rest of the user.
|
||||
*
|
||||
* Returns the write's outcome alongside the record, rather than the record
|
||||
* alone. Preferences are where account-authored skills live, so "did this
|
||||
* survive the reload" is a question the caller has to be able to answer —
|
||||
* see `persistUser`.
|
||||
*/
|
||||
async updatePreferences(patch) {
|
||||
return auth.updateMe({ preferences: { ...auth.preferences(), ...patch } });
|
||||
currentUser = { ...currentUser, preferences: { ...auth.preferences(), ...patch } };
|
||||
const write = persistUser();
|
||||
return { user: { ...currentUser }, ...write };
|
||||
},
|
||||
|
||||
isAuthenticated() {
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from 'react';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ResponseDocument } from './ResponseBlocks';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
|
||||
/**
|
||||
* The thinking state, shown between sending and the first block arriving.
|
||||
@@ -12,9 +13,7 @@ import { ResponseDocument } from './ResponseBlocks';
|
||||
export function ThinkingIndicator() {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-lg bg-krow-blue-tint text-krow-blue">
|
||||
<Sparkles className="h-3 w-3" aria-hidden="true" />
|
||||
</span>
|
||||
<OwliverAvatar className="h-6 w-6" rounded="rounded-full" />
|
||||
<span className="inline-flex items-center gap-1 rounded-xl rounded-tl-sm border border-border bg-surface px-2.5 py-2 shadow-xs">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
@@ -53,9 +52,7 @@ export const Message = React.memo(
|
||||
return (
|
||||
<div className="animate-slide-up">
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-krow-blue-tint text-krow-blue">
|
||||
<Sparkles className="h-2.5 w-2.5" aria-hidden="true" />
|
||||
</span>
|
||||
<OwliverAvatar className="h-5 w-5" rounded="rounded-full" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Owliver</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cn } from '@/lib/utils';
|
||||
import KrowAssistant from './KrowAssistant';
|
||||
import { useAssistantPanel } from './AssistantPanelContext';
|
||||
import { ResizeDivider } from './ResizeDivider';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
|
||||
const EXPANDED_WIDTH = 620;
|
||||
/**
|
||||
@@ -68,9 +69,7 @@ function CollapsedTrigger({ page, onRestore }) {
|
||||
hover:border-krow-blue/40 hover:shadow-lg
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-white overflow-hidden shadow-brand border border-border">
|
||||
<img src="/krow logo.jpeg" alt="KROW Logo" className="h-full w-full object-cover" />
|
||||
</span>
|
||||
<OwliverAvatar className="h-7 w-7" rounded="rounded-full" />
|
||||
<span className="text-body-sm font-semibold text-ink-1">Owliver</span>
|
||||
<PanelLeftOpen className="h-3.5 w-3.5 shrink-0 text-ink-4 group-hover:text-krow-blue" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useAssistantPanel } from './AssistantPanelContext';
|
||||
import { usePageContext } from './PageContext';
|
||||
import { useAssistantFacts, useConversation, useCurrentUserName } from './useAssistant';
|
||||
import { buildIntro, buildPrompts } from './dynamic';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
|
||||
import { PromptInput } from './PromptInput';
|
||||
import { PromptChips } from './PromptChips';
|
||||
@@ -613,9 +614,7 @@ export default function KrowAssistant({
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-3.5 py-2.5">
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-white overflow-hidden shadow-brand border border-border">
|
||||
<img src="/krow logo.jpeg" alt="KROW Logo" className="h-full w-full object-cover" />
|
||||
</span>
|
||||
<OwliverAvatar className="h-7 w-7" rounded="rounded-lg" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="font-heading text-body-sm font-semibold leading-tight text-ink-1">Owliver</h2>
|
||||
|
||||
@@ -12,8 +12,9 @@ import {
|
||||
resolveDraft, weightsFromQuestion, weightsReply, weightsUnchangedReply, whichDraftReply,
|
||||
} from '@/lib/skills/draftFlow';
|
||||
import {
|
||||
appliedToday, askWhichPosition, assignmentPreview, assignmentToExecute, availability,
|
||||
candidateDetail, candidateMatches, candidateRoute, interviewReady, matchWorkforceIntent,
|
||||
appliedToday, askHeadcount, askWhichPosition, assignmentPreview, assignmentToExecute,
|
||||
availability, candidateDetail, candidateMatches, candidateRoute, headcountFrom,
|
||||
interviewReady, matchWorkforceIntent,
|
||||
namedAssignmentPreview,
|
||||
interviewPreview, interviewToExecute,
|
||||
namedAssignmentToExecute, positionPriority, resolveCandidate, resolvePosition,
|
||||
@@ -303,6 +304,22 @@ function resolveWorkforce(question, workforce) {
|
||||
: { kind: 'answer-doc', ...assignmentPreview(position, context) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer to "how many people do you need?".
|
||||
*
|
||||
* The only write in the workforce path that is not an assignment, and it
|
||||
* follows the same contract: the routing layer decides *what* should happen
|
||||
* and hands it back, and `useAssistant` performs it through the mutation the
|
||||
* Create Position form already uses. A sentence with no number in it is not a
|
||||
* failure — it is the question being asked again.
|
||||
*/
|
||||
if (intent === 'set_headcount') {
|
||||
const count = headcountFrom(question);
|
||||
return count
|
||||
? { kind: 'workforce', headcount: { position, count } }
|
||||
: { kind: 'answer-doc', ...askHeadcount(position, context) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Interview setup, for one named person against this role. Both steps resolve
|
||||
* the name the same way assignment does, so the conversation stays on the same
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
publishedReply, weightsSetReply, weightsUnchangedReply,
|
||||
} from '@/lib/skills/draftFlow';
|
||||
import {
|
||||
assignmentDone, assignmentFailed, assignmentFollowUp, interviewDone, interviewFailed,
|
||||
assignmentDone, assignmentFailed, assignmentFollowUp, headcountFailed, headcountSet,
|
||||
interviewDone, interviewFailed,
|
||||
} from '@/lib/skills/workforceFlow';
|
||||
import {
|
||||
newConversationId, readHistory, removeConversation, saveConversation,
|
||||
@@ -517,6 +518,34 @@ export function useConversation({
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The headcount a position never stated, supplied by the reader.
|
||||
*
|
||||
* Written through `useUpdateJobPosting` — the same mutation the Create
|
||||
* Position form and the Positions page call — so there is one position
|
||||
* record and one demand figure, not a number the panel remembers. The reply
|
||||
* is the assignment proposal the reader originally asked for, re-derived
|
||||
* against the saved record rather than against the number they typed, so
|
||||
* what they see is what was actually stored.
|
||||
*/
|
||||
if (intent.kind === 'workforce' && intent.headcount) {
|
||||
const { position: target, count } = intent.headcount;
|
||||
let saved = null;
|
||||
try {
|
||||
saved = await onUpdatePosition?.(target.id, { headcount: count });
|
||||
} catch {
|
||||
saved = null;
|
||||
}
|
||||
|
||||
intent = {
|
||||
...intent,
|
||||
kind: 'answer-doc',
|
||||
...(saved
|
||||
? headcountSet(saved, workforce?.context || {})
|
||||
: headcountFailed(target)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The interview step. Same contract as the assignment above: it runs before
|
||||
* the reply, only on a confirmed turn, and through the app's existing
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Image } from '@/components/ui/image';
|
||||
import React from 'react';
|
||||
|
||||
const OWLIVER_URL = 'https://media.base44.com/images/public/6a653ab9436c05c43a00994f/620b75f07_WhatsAppImage2026-07-31at35919PM.jpeg';
|
||||
const OWLIVER_URL = '/owliver-avatar.png';
|
||||
|
||||
export default function OwliverAvatar({ className = 'w-16 h-16', rounded = 'rounded-full' }) {
|
||||
return (
|
||||
<Image
|
||||
<img
|
||||
src={OWLIVER_URL}
|
||||
alt="Owliver"
|
||||
fittingType="fill"
|
||||
className={`shrink-0 overflow-hidden ${rounded} ${className}`}
|
||||
className={`shrink-0 overflow-hidden object-cover ${rounded} ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Image } from '@/components/ui/image';
|
||||
import { Sparkles, X } from 'lucide-react';
|
||||
import Owliver from '@/pages/Owliver';
|
||||
|
||||
const OWLIVER_URL = 'https://media.base44.com/images/public/6a653ab9436c05c43a00994f/2005ea00e_WhatsAppImage2026-07-31at35919PM.jpeg';
|
||||
const OWLIVER_URL = '/owliver-avatar.png';
|
||||
|
||||
export default function OwliverChatBubble({ open: externalOpen, onOpenChange, showTrigger = true }) {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import React from 'react';
|
||||
import { Sparkles, Upload } from 'lucide-react';
|
||||
import { Button, Field, Input, Modal, Textarea, toast } from '@/components/ds';
|
||||
import { Button, Field, Input, Modal, Textarea } from '@/components/ds';
|
||||
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
|
||||
import { PAGE_KEYS, validateSkillSource } from '@/lib/skills/registry';
|
||||
import { skillTemplate, upsertCustomSkill } from '@/lib/skills/customSkills';
|
||||
import {
|
||||
isReadableDefinition, normalizeUpload, owliverFieldsFromSource, patchFrontmatter,
|
||||
} from '@/lib/skills/skillFields';
|
||||
import { reportSave } from '@/lib/skills/saveFeedback';
|
||||
|
||||
/**
|
||||
* Add an Owliver skill.
|
||||
@@ -41,27 +45,71 @@ export function AddSkillDialog({ open, onOpenChange }) {
|
||||
setError('');
|
||||
}, [open]);
|
||||
|
||||
/* The fields drive the Markdown until the author edits the Markdown, after
|
||||
which the Markdown wins — it is the thing being saved. */
|
||||
/**
|
||||
* A field edit, written into the definition.
|
||||
*
|
||||
* A fresh draft is composed from the template; once there is a definition to
|
||||
* preserve — an uploaded file, or Markdown typed by hand — the field patches
|
||||
* the key it owns and leaves everything else alone. It used to stop here
|
||||
* entirely, so after an upload these four boxes edited nothing that was saved.
|
||||
*/
|
||||
const syncFromMeta = (next) => {
|
||||
setMeta(next);
|
||||
if (touched) return;
|
||||
setSource(skillTemplate({
|
||||
id: next.id,
|
||||
name: next.name,
|
||||
description: next.description,
|
||||
pages: next.pages.split(',').map((p) => p.trim()).filter(Boolean),
|
||||
const pages = next.pages.split(',').map((p) => p.trim()).filter(Boolean);
|
||||
|
||||
if (!touched) {
|
||||
setSource(skillTemplate({
|
||||
id: next.id,
|
||||
name: next.name,
|
||||
description: next.description,
|
||||
pages,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setSource(patchFrontmatter(source, {
|
||||
id: next.id || undefined,
|
||||
name: next.name || undefined,
|
||||
description: next.description || undefined,
|
||||
pages: pages.length ? pages : undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* A definition uploaded from a file.
|
||||
*
|
||||
* Filled the Markdown box and left every field beside it empty, and said
|
||||
* nothing about whether the file was usable until Add was pressed. Both are
|
||||
* fixed here: the frontmatter fills the fields, and an unusable file says so
|
||||
* at the moment it arrives.
|
||||
*/
|
||||
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));
|
||||
const raw = normalizeUpload(reader.result);
|
||||
setSource(raw);
|
||||
setTouched(true);
|
||||
setError('');
|
||||
|
||||
/* Nothing readable: the fields stay as they were rather than emptying to
|
||||
a row of placeholders that read as values. */
|
||||
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.');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(validateSkillSource(raw) || '');
|
||||
const fields = owliverFieldsFromSource(raw);
|
||||
setMeta({
|
||||
id: fields.id,
|
||||
name: fields.name,
|
||||
description: fields.description,
|
||||
pages: fields.pages.join(', '),
|
||||
});
|
||||
};
|
||||
reader.readAsText(file);
|
||||
event.target.value = '';
|
||||
@@ -76,8 +124,7 @@ export function AddSkillDialog({ open, onOpenChange }) {
|
||||
|
||||
const { skill, next } = upsertCustomSkill(preferences.customSkills || [], source);
|
||||
|
||||
updatePreferences.mutate({ customSkills: next });
|
||||
toast.success(`${skill.name} added`);
|
||||
updatePreferences.mutate({ customSkills: next }, reportSave(`${skill.name} added`));
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
@@ -101,14 +148,14 @@ export function AddSkillDialog({ open, onOpenChange }) {
|
||||
<Field label="Skill name">
|
||||
<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" hint="Lower-case, dashes.">
|
||||
<Input
|
||||
value={meta.id}
|
||||
placeholder="chef-training"
|
||||
placeholder="e.g. chef-training"
|
||||
onChange={(e) => syncFromMeta({ ...meta, id: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
@@ -117,7 +164,7 @@ export function AddSkillDialog({ open, onOpenChange }) {
|
||||
<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>
|
||||
@@ -142,8 +189,26 @@ export function AddSkillDialog({ open, onOpenChange }) {
|
||||
className="font-mono text-[12px] leading-relaxed"
|
||||
placeholder="--- id: my-skill name: My Skill pages: - positions ---"
|
||||
onChange={(e) => {
|
||||
setSource(e.target.value);
|
||||
const next = e.target.value;
|
||||
setSource(next);
|
||||
setTouched(true);
|
||||
|
||||
/* Markdown → fields, the direction that was missing. Same
|
||||
extractor the upload path uses; guarded so a half-typed
|
||||
definition cannot wipe fields that are already filled in.
|
||||
See the fuller note in the two skill editors. */
|
||||
if (isReadableDefinition(next)) {
|
||||
const fields = owliverFieldsFromSource(next);
|
||||
if (fields.name) {
|
||||
setMeta({
|
||||
id: fields.id,
|
||||
name: fields.name,
|
||||
description: fields.description,
|
||||
pages: fields.pages.join(', '),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (error) setError('');
|
||||
}}
|
||||
/>
|
||||
@@ -154,7 +219,9 @@ export function AddSkillDialog({ open, onOpenChange }) {
|
||||
<Button variant="outline" size="sm" onClick={() => fileRef.current?.click()}>
|
||||
<Upload aria-hidden="true" /> Upload .md
|
||||
</Button>
|
||||
<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>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -42,13 +42,21 @@ export function usePreferences() {
|
||||
return { ...base44.auth.preferences(), ...(user?.preferences || {}) };
|
||||
}
|
||||
|
||||
/** Writes one or more preferences and refreshes every consumer of `['user']`. */
|
||||
/**
|
||||
* Writes one or more preferences and refreshes every consumer of `['user']`.
|
||||
*
|
||||
* The mutation resolves to `{ user, persisted, error }`, not to the user — a
|
||||
* caller storing something it needs back after a reload, which is every caller
|
||||
* writing `customSkills`, has to be able to tell a write that landed from one
|
||||
* the browser refused. The cache is updated either way: the change is real for
|
||||
* this session even when it could not be stored.
|
||||
*/
|
||||
export function useUpdatePreferences() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: /** @param {any} patch */ (patch) => base44.auth.updatePreferences(patch),
|
||||
onSuccess: (user) => {
|
||||
queryClient.setQueryData(['user'], user);
|
||||
onSuccess: /** @param {any} result */ (result) => {
|
||||
queryClient.setQueryData(['user'], result.user);
|
||||
queryClient.invalidateQueries({ queryKey: ['user'] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -31,7 +31,16 @@ const frontMatter = ({ id, name, description, pages, fallback }) => [
|
||||
/** A definition that draws a section on the pages it names. */
|
||||
export const uiSkillTemplate = ({
|
||||
id = '', name = '', description = '', pages = [],
|
||||
type = 'flow', placement = '', source = 'position.activity', periods = [],
|
||||
/**
|
||||
* `candidates.activity`, not `position.activity`.
|
||||
*
|
||||
* The default placement on the default page is `after-position-list-summary`
|
||||
* — above the grid, with no position in context — so the old default composed
|
||||
* a definition that drew a titled card and then reported that it needed a
|
||||
* record the page never had. A template must produce something that works
|
||||
* before it is edited, so the default reads a source that needs nothing.
|
||||
*/
|
||||
type = 'flow', placement = '', source = 'candidates.activity', periods = [],
|
||||
} = {}) => `---
|
||||
${frontMatter({
|
||||
id,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement';
|
||||
import { parseYaml } from './yaml';
|
||||
import { normalizeSkillUi } from './uiConfig';
|
||||
import { normalizeSkillUi, slugify } from './uiConfig';
|
||||
import { normalizeSkillOwliver } from './owliverConfig';
|
||||
import { SUPPORTED_SKILL_PAGES, canonicalPage, surfaceFor, surfaceForRoute } from './surfaces';
|
||||
import {
|
||||
SUPPORTED_SKILL_PAGES, canonicalPage, contextLabel, placementProvides, surfaceFor,
|
||||
surfaceForRoute,
|
||||
} from './surfaces';
|
||||
|
||||
/**
|
||||
* Owliver skill registry.
|
||||
@@ -30,14 +33,39 @@ const FILES = import.meta.glob('/src/skills/**/*.md', { query: '?raw', import: '
|
||||
* A file whose frontmatter cannot be read raises rather than registering a
|
||||
* half-understood definition; `parseSkill` decides what to do with that.
|
||||
*/
|
||||
function parseFrontmatter(raw) {
|
||||
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
|
||||
if (!match) return { data: {}, body: raw };
|
||||
/**
|
||||
* A definition's text, as the parser needs to see it.
|
||||
*
|
||||
* Files arrive from editors, from Windows, from copy-paste and from downloads,
|
||||
* and four of the things they arrive with used to take the entire frontmatter
|
||||
* block down: a UTF-8 byte-order mark before the opening fence, a blank line
|
||||
* above it, `\r\n` line endings, and trailing spaces after `---`. In every one
|
||||
* of those cases the fence did not match, `parseFrontmatter` returned an empty
|
||||
* record, and the definition registered as `Untitled skill` with no pages —
|
||||
* the file was read, and none of it was believed.
|
||||
*
|
||||
* None of this is a lenient parser: the YAML subset inside the fences is as
|
||||
* strict as it ever was. This is only about recognising that a fence is a
|
||||
* fence.
|
||||
*/
|
||||
export const normalizeDefinition = (raw) => String(raw ?? '')
|
||||
.replace(/^\uFEFF/, '')
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.replace(/^\s*\n+/, '');
|
||||
|
||||
/** Whether this text opens with a frontmatter block at all. */
|
||||
export const hasFrontmatter = (raw) => /^---[ \t]*\n[\s\S]*?\n---[ \t]*(?=\n|$)/
|
||||
.test(normalizeDefinition(raw));
|
||||
|
||||
export function parseFrontmatter(raw) {
|
||||
const text = normalizeDefinition(raw);
|
||||
const match = /^---[ \t]*\n([\s\S]*?)\n---[ \t]*(?=\n|$)/.exec(text);
|
||||
if (!match) return { data: {}, body: text };
|
||||
|
||||
const data = parseYaml(match[1]);
|
||||
return {
|
||||
data: data && typeof data === 'object' && !Array.isArray(data) ? data : {},
|
||||
body: raw.slice(match[0].length).trim(),
|
||||
body: text.slice(match[0].length).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -261,8 +289,21 @@ export function skillFacets({ data = {}, kind, ui = {}, owliver, conversation =
|
||||
export function parseSkill(raw, { path = 'custom', custom = false } = {}) {
|
||||
{
|
||||
const { data, body } = parseFrontmatter(raw);
|
||||
const pages = Array.isArray(data.pages) ? data.pages : [];
|
||||
const id = data.id || path.split('/').pop().replace(/\.md$/, '');
|
||||
const declaredPages = Array.isArray(data.pages) ? data.pages : [];
|
||||
/**
|
||||
* The definition's id.
|
||||
*
|
||||
* `id:` when it is written, and it always wins — an explicit id is an
|
||||
* address other definitions and stored preferences refer to, and deriving
|
||||
* over the top of one would silently rename a skill.
|
||||
*
|
||||
* The fallback used to be the filename, which is right for a file in
|
||||
* `src/skills/` and wrong for everything else: an uploaded definition is
|
||||
* parsed with the placeholder path `custom`, so a file omitting `id:` was
|
||||
* registered as the skill `custom` and the ID field filled in with the word
|
||||
* "custom". Slugging the name is what an author means by leaving it out.
|
||||
*/
|
||||
const id = data.id || slugify(data.name) || path.split('/').pop().replace(/\.md$/, '');
|
||||
const levels = sectionLevels(body);
|
||||
|
||||
/**
|
||||
@@ -280,10 +321,21 @@ export function parseSkill(raw, { path = 'custom', custom = false } = {}) {
|
||||
closed vocabulary in `surfaces.js`. A definition with no `ui:` block is
|
||||
exactly what it was before this existed. */
|
||||
const { ui, errors: uiErrors } = normalizeSkillUi(data.ui, {
|
||||
declaredPages: pages,
|
||||
declaredPages,
|
||||
skillId: id,
|
||||
});
|
||||
|
||||
/**
|
||||
* Where this definition applies.
|
||||
*
|
||||
* `pages:` when it is written. When it is not, the pages its `ui:` entries
|
||||
* name — because a definition that says "put this on Positions and that on
|
||||
* Analytics" has already declared its reach, and making it repeat the list
|
||||
* above the block is the format asking twice. A definition that declares
|
||||
* neither still has none, which is what `validateSkillSource` refuses on.
|
||||
*/
|
||||
const pages = declaredPages.length ? declaredPages : Object.keys(ui);
|
||||
|
||||
/* The same definition's second consumer. `owliver:` declares what can be
|
||||
asked for in the panel, reading the source the page section already
|
||||
names — so one file answers "what does this page show" and "what can
|
||||
@@ -474,6 +526,27 @@ export function readSkillRegistry(customSources = []) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A section that registered perfectly and can never draw anything.
|
||||
*
|
||||
* The same check `validateSkillSource` refuses on, run again at load — because
|
||||
* a definition stored before the rule existed was validated under the old one
|
||||
* and is never re-checked. Without this it stays in the workspace as a titled
|
||||
* card reporting that it needs a record the page has no way of giving it, and
|
||||
* the Skills page calls the workspace healthy.
|
||||
*/
|
||||
for (const skill of skills) {
|
||||
for (const problem of unresolvableSections(skill)) {
|
||||
diagnostics.push({
|
||||
level: 'error',
|
||||
kind: 'unresolvable',
|
||||
path: skill.path,
|
||||
skillId: skill.id,
|
||||
message: problem.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Two definitions claiming one phrase on one page.
|
||||
*
|
||||
@@ -530,6 +603,44 @@ export function skillDiagnostics(customSources = []) {
|
||||
return readSkillRegistry(customSources).diagnostics;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Sections that can never resolve where they are attached.
|
||||
*
|
||||
* A source declares the record it needs; a placement either hands one over or
|
||||
* does not. Nothing compared the two, so the commonest authoring mistake in the
|
||||
* product — `position.activity`, the Board editor's own default, on a page with
|
||||
* no position — validated cleanly, registered, drew its title and then reported
|
||||
* "This section needs a position to read" for good. The definition was never
|
||||
* wrong about anything the product had told it to care about.
|
||||
*
|
||||
* Deliberately *only* the `ui:` half. An Owliver response with an unmet need is
|
||||
* not a dead panel: `resolveEntity` asks which position is meant, and answers
|
||||
* once told — which is why `hiring-activity-assistant` reads `position.activity`
|
||||
* on Positions and works. A card cannot ask. That asymmetry is the reason one
|
||||
* is refused and the other is left alone.
|
||||
*/
|
||||
export function unresolvableSections(skill) {
|
||||
const problems = [];
|
||||
|
||||
for (const [page, config] of Object.entries(skill?.ui || {})) {
|
||||
for (const section of config.sections || []) {
|
||||
if (!section.context) continue;
|
||||
if (placementProvides(page, section.placement).includes(section.context)) continue;
|
||||
problems.push({
|
||||
page,
|
||||
placement: section.placement,
|
||||
source: section.source,
|
||||
message: `\`${section.source}\` needs ${contextLabel(section.context)} to read, and `
|
||||
+ `\`${page}\` supplies none at \`${section.placement}\`. `
|
||||
+ `Attach it to a placement that does, or read a source that needs nothing.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a definition before it is stored. Returns an error string or null.
|
||||
*
|
||||
@@ -565,6 +676,29 @@ export function validateSkillSource(raw) {
|
||||
something Owliver offers and then cannot answer. */
|
||||
if (skill.owliverErrors?.length) return skill.owliverErrors[0];
|
||||
|
||||
/* A section attached where its source can never be read. See
|
||||
`unresolvableSections` — this is the difference between a definition that
|
||||
is wrong and one that merely looks right. */
|
||||
const unresolvable = unresolvableSections(skill);
|
||||
if (unresolvable.length) return unresolvable[0].message;
|
||||
|
||||
/**
|
||||
* An Owliver block that can answer nothing.
|
||||
*
|
||||
* `owliver: enabled: true` with no capabilities is what the template produces
|
||||
* before an author fills anything in. It registers, claims its own name as a
|
||||
* trigger, can take a question from a definition written to answer it, and
|
||||
* then reads its own description back. Refused here rather than saved and
|
||||
* reported later as "the skill does not work".
|
||||
*/
|
||||
if (skill.owliver?.enabled
|
||||
&& !skill.owliver.capabilities.length
|
||||
&& !skill.conversation.length
|
||||
&& !skill.actions.length) {
|
||||
return 'This skill declares no capabilities, so Owliver could only read its description '
|
||||
+ 'back. Add a capability, or remove the `owliver:` block.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
32
src/lib/skills/saveFeedback.js
Normal file
32
src/lib/skills/saveFeedback.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { toast } from '@/components/ds';
|
||||
|
||||
/**
|
||||
* Mutation options that tell the truth about whether a write survived.
|
||||
*
|
||||
* Every skill save reported success the moment the in-memory record changed,
|
||||
* because that is all `updatePreferences` used to be able to report. A browser
|
||||
* that refused the write — quota, private browsing, an eviction — produced a
|
||||
* green toast, a populated list, and nothing at all after a reload. A definition
|
||||
* an author spent ten minutes on disappeared with no event they could point at.
|
||||
*
|
||||
* The change is not made conditional on the write: it is real for this session
|
||||
* either way, and refusing to apply it would be worse. What changes is that the
|
||||
* author is told which of the two happened while they can still do something
|
||||
* about it.
|
||||
*/
|
||||
export function reportSave(message) {
|
||||
return {
|
||||
/** @param {any} result */
|
||||
onSuccess: (result) => {
|
||||
if (result?.persisted === false) {
|
||||
toast.error(
|
||||
`${message} — but this browser would not store it, so it will be gone on reload. `
|
||||
+ 'Export it from the Skills page first.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
toast.success(message);
|
||||
},
|
||||
onError: () => toast.error('That could not be saved.'),
|
||||
};
|
||||
}
|
||||
406
src/lib/skills/skillFields.js
Normal file
406
src/lib/skills/skillFields.js
Normal file
@@ -0,0 +1,406 @@
|
||||
import { hasFrontmatter, normalizeDefinition, parseFrontmatter, parseSkill } from './registry';
|
||||
|
||||
/**
|
||||
* A definition, read into editor fields — and edited fields, written back.
|
||||
*
|
||||
* Two things used to be missing here, and between them they are the whole of
|
||||
* "uploading a `.md` does nothing" and "the form fields do not work":
|
||||
*
|
||||
* - **Nothing read a file into the fields.** All three upload buttons set the
|
||||
* Markdown and stopped, so an author who uploaded a complete definition was
|
||||
* looking at a form full of empty boxes beside it. The two editors each had
|
||||
* a reader that did exactly this job — `metaFromSource` and
|
||||
* `draftFromSource` — and neither was reachable from the upload path. They
|
||||
* live here now, so one file is read the same way wherever it arrives.
|
||||
* - **Nothing wrote the fields back.** Both editors regenerated the whole
|
||||
* definition from a template while the Markdown was untouched, and stopped
|
||||
* the moment it was touched — which is every edit of an existing skill,
|
||||
* because `touched` starts true there. Typing a new name updated React
|
||||
* state that nothing saved. `patchFrontmatter` is the missing half: a field
|
||||
* edits the key it owns, in place, and the body is never rewritten.
|
||||
*
|
||||
* Everything here reads the *registered* form of a definition — what
|
||||
* `parseSkill` made of it — rather than the raw text. The form therefore shows
|
||||
* what the product will actually do, including the placement an author left out
|
||||
* and the vocabulary that filled it in.
|
||||
*/
|
||||
|
||||
/* ── Reading a definition into fields ───────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* A definition, or nothing.
|
||||
*
|
||||
* `parseSkill` is deliberately forgiving: a file with no frontmatter at all
|
||||
* still parses, taking its id from the path it was given. That is right for the
|
||||
* registry, and wrong here — a text file dropped on the upload button would
|
||||
* otherwise fill the ID field with `custom` and the name with `Untitled skill`,
|
||||
* which reads as a definition the product understood. A form is filled from a
|
||||
* definition or it is left alone.
|
||||
*/
|
||||
function readSkill(source) {
|
||||
/* `hasFrontmatter`, not a second regex of this module's own. The two used to
|
||||
be written out separately and drifted: a file the registry was willing to
|
||||
read could be one this rejected, and rejecting it here empties a form the
|
||||
preview beside it has just filled in. One detector, one answer. */
|
||||
if (!hasFrontmatter(source)) return null;
|
||||
return parseSkill(source, { custom: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* A definition, cleaned up on the way in.
|
||||
*
|
||||
* Re-exported under the name the editors use, so an uploaded file is stored in
|
||||
* the form every reader already agrees on rather than carrying a byte-order
|
||||
* mark into `patchFrontmatter`, which would not recognise the fence and would
|
||||
* write a second one above it.
|
||||
*/
|
||||
export const normalizeUpload = normalizeDefinition;
|
||||
|
||||
/** Whether a definition can be read into the fields at all. */
|
||||
export const isReadableDefinition = (source) => hasFrontmatter(source);
|
||||
|
||||
/** The Board (UI) editor's fields, before a definition is loaded into them. */
|
||||
export const EMPTY_BOARD_FIELDS = {
|
||||
id: '', name: '', description: '', pages: [],
|
||||
type: 'flow', placement: '', source: 'candidates.activity', periods: [],
|
||||
};
|
||||
|
||||
/** The Owliver editor's fields, before a definition is loaded into them. */
|
||||
export const EMPTY_OWLIVER_FIELDS = {
|
||||
id: '', name: '', description: '', pages: [],
|
||||
triggers: [], suggestions: [], capabilities: [], source: '', periods: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* A Board skill, read back into the fields that compose one.
|
||||
*
|
||||
* The section comes from the registry's reading rather than from the Markdown
|
||||
* text, so a definition that omitted `placement:` shows the placement it will
|
||||
* actually render at.
|
||||
*/
|
||||
export function boardFieldsFromSource(source) {
|
||||
try {
|
||||
const skill = readSkill(source);
|
||||
if (!skill) throw new Error('no frontmatter');
|
||||
/* The first section, in declaration order — what the single-valued fields
|
||||
can show. `pages` still carries every page the definition reaches, and
|
||||
`uiIsEditableFromFields` is what stops those fields writing over the
|
||||
ones this record cannot represent. */
|
||||
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 || EMPTY_BOARD_FIELDS.type,
|
||||
placement: section?.placement || '',
|
||||
source: section?.source || EMPTY_BOARD_FIELDS.source,
|
||||
periods: section?.periods || [],
|
||||
};
|
||||
} catch {
|
||||
return EMPTY_BOARD_FIELDS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An Owliver skill, read back into the fields that compose one.
|
||||
*
|
||||
* Reads the resolved capabilities, which includes the ones whose source was
|
||||
* inherited from a `ui:` block rather than written out — so the form shows what
|
||||
* Owliver can be asked for, not what the file happened to spell.
|
||||
*/
|
||||
export function owliverFieldsFromSource(source) {
|
||||
try {
|
||||
const skill = readSkill(source);
|
||||
if (!skill) throw new Error('no frontmatter');
|
||||
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,
|
||||
/**
|
||||
* Only the triggers the definition actually claimed.
|
||||
*
|
||||
* `skill.triggers` falls back to the skill's own name when none are
|
||||
* declared, which is right for matching and wrong to put in a form: the
|
||||
* field would fill with an inherited phrase, and the next edit would
|
||||
* write it into the file as a declared one. That flips
|
||||
* `declaredTriggers`, which the matcher weighs differently — and it would
|
||||
* pin the *old* name's phrase the moment the skill is renamed.
|
||||
*/
|
||||
triggers: skill.declaredTriggers ? skill.triggers : [],
|
||||
suggestions: skill.owliver.suggestions.map((s) => s.label),
|
||||
capabilities: skill.owliver.capabilities,
|
||||
source: response?.source || '',
|
||||
periods: response?.periods || [],
|
||||
};
|
||||
} catch {
|
||||
return EMPTY_OWLIVER_FIELDS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which management surfaces this definition belongs to, for the upload handoff.
|
||||
*
|
||||
* A file dropped into the Board editor that declares only an `owliver:` block
|
||||
* is being edited in the wrong half of the product. Knowing that lets the
|
||||
* editor offer the other one rather than showing a form none of whose fields
|
||||
* apply.
|
||||
*/
|
||||
export function facetsFromSource(source) {
|
||||
try {
|
||||
return readSkill(source)?.facets || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the `ui:` shapes a definition uses.
|
||||
*
|
||||
* `shorthand` is one section applying to every declared page — the form the
|
||||
* editors compose, and the only one whose four fields can represent the whole
|
||||
* block. `list` and `per-page` can each hold several sections across several
|
||||
* pages, so the fields would have to throw away everything but the first to
|
||||
* write them back. Telling them apart is what lets the editor show a multi-page
|
||||
* definition without being able to flatten it.
|
||||
*/
|
||||
export function uiShape(source) {
|
||||
let data;
|
||||
try {
|
||||
({ data } = parseFrontmatter(String(source)));
|
||||
} catch {
|
||||
return 'none';
|
||||
}
|
||||
const ui = data?.ui;
|
||||
if (!ui || typeof ui !== 'object') return 'none';
|
||||
if (Array.isArray(ui)) return ui.length ? 'list' : 'none';
|
||||
const SECTION_KEYS = ['type', 'source', 'data', 'placement', 'position', 'periods', 'title'];
|
||||
return SECTION_KEYS.some((key) => key in ui) ? 'shorthand' : 'per-page';
|
||||
}
|
||||
|
||||
/** Whether the section fields can write this definition's `ui:` block back. */
|
||||
export const uiIsEditableFromFields = (source) => uiShape(source) !== 'list'
|
||||
&& uiShape(source) !== 'per-page';
|
||||
|
||||
/**
|
||||
* Whether this definition's pages come from its `ui:` block rather than a
|
||||
* `pages:` key of its own.
|
||||
*
|
||||
* When they do, the Pages field is a read-out and must not be written back.
|
||||
* Writing it would add a `pages:` list that agrees with the block today and
|
||||
* silently governs it tomorrow: `normalizeSkillUi` refuses an entry naming a
|
||||
* page that `pages:` does not list, so the next entry the author adds to the
|
||||
* block would be rejected by a key they never wrote. A derived value is shown,
|
||||
* not owned.
|
||||
*/
|
||||
export function pagesAreDerived(source) {
|
||||
let data;
|
||||
try {
|
||||
({ data } = parseFrontmatter(String(source)));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(data?.pages) && data.pages.length) return false;
|
||||
return uiShape(source) !== 'none';
|
||||
}
|
||||
|
||||
/* ── Writing fields back into the frontmatter ───────────────────────────── */
|
||||
|
||||
/**
|
||||
* A scalar, written so the parser reads back what was meant.
|
||||
*
|
||||
* Quoted whenever the plain form would be read as something else — a value
|
||||
* containing `:` or `#`, one with edge whitespace, or one that looks like a
|
||||
* number, a boolean or null but is a string.
|
||||
*/
|
||||
function writeScalar(value) {
|
||||
if (value === null || value === undefined) return 'null';
|
||||
if (typeof value === 'boolean' || typeof value === 'number') return String(value);
|
||||
|
||||
const text = String(value);
|
||||
const ambiguous = text === ''
|
||||
|| /[:#]/.test(text)
|
||||
|| text !== text.trim()
|
||||
|| /^(true|false|null|~)$/.test(text)
|
||||
|| /^-?\d+$/.test(text)
|
||||
|| /^-?\d*\.\d+$/.test(text)
|
||||
|| /^['"-]/.test(text);
|
||||
|
||||
return ambiguous ? `'${text.replace(/'/g, "''")}'` : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* A value, as the block lines that follow its key.
|
||||
*
|
||||
* Returns `null` for a scalar, which is written on the key's own line instead.
|
||||
* Only the subset in `yaml.js` is emitted — block maps and block sequences,
|
||||
* nested to any depth — because that is the only subset the parser reads back.
|
||||
*/
|
||||
function writeBlock(value, indent) {
|
||||
const pad = ' '.repeat(indent);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => {
|
||||
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
||||
const entries = Object.entries(item).filter(([, v]) => v !== undefined);
|
||||
if (!entries.length) return `${pad}- {}`;
|
||||
return entries
|
||||
.map(([k, v], i) => {
|
||||
const prefix = i === 0 ? `${pad}- ` : `${pad} `;
|
||||
const nested = writeBlock(v, indent + 4);
|
||||
return nested === null ? `${prefix}${k}: ${writeScalar(v)}` : `${prefix}${k}:\n${nested}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
return `${pad}- ${writeScalar(item)}`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.entries(value)
|
||||
.filter(([, v]) => v !== undefined)
|
||||
.map(([k, v]) => {
|
||||
const nested = writeBlock(v, indent + 2);
|
||||
return nested === null ? `${pad}${k}: ${writeScalar(v)}` : `${pad}${k}:\n${nested}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The lines belonging to the key at `start`: everything indented under it. */
|
||||
function blockEnd(lines, start, indent) {
|
||||
let end = start + 1;
|
||||
while (end < lines.length) {
|
||||
const line = lines[end];
|
||||
if (line.trim() === '' || /^\s*#/.test(line)) { end += 1; continue; }
|
||||
if (line.match(/^\s*/)[0].replace(/\t/g, ' ').length <= indent) break;
|
||||
end += 1;
|
||||
}
|
||||
/* Trailing blanks and comments belong to whatever comes next, not to this
|
||||
key — a comment written above the following key must not be swallowed by
|
||||
the block above it. */
|
||||
while (end > start + 1 && lines[end - 1].trim() === '') end -= 1;
|
||||
return end;
|
||||
}
|
||||
|
||||
/** The index of `key` at `indent` within `[from, to)`, or -1. */
|
||||
function findKey(lines, key, indent, from, to) {
|
||||
const pattern = new RegExp(`^${' '.repeat(indent)}${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:`);
|
||||
for (let i = from; i < to; i += 1) {
|
||||
if (pattern.test(lines[i])) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* One dotted path, set in a frontmatter line array.
|
||||
*
|
||||
* Scalars replace the value on their own line; anything else replaces the block
|
||||
* beneath it. A key that is not there is appended to the end of its parent, so
|
||||
* a definition that never declared `status:` gains one rather than being
|
||||
* refused. Every line the path does not touch — including comments and key
|
||||
* order — is left exactly as written, which is why this is a patch and not a
|
||||
* re-serialisation.
|
||||
*/
|
||||
function setPath(lines, path, value) {
|
||||
const parts = path.split('.');
|
||||
let from = 0;
|
||||
let to = lines.length;
|
||||
let indent = 0;
|
||||
|
||||
for (let depth = 0; depth < parts.length - 1; depth += 1) {
|
||||
const at = findKey(lines, parts[depth], indent, from, to);
|
||||
/* A parent that does not exist is created empty, then descended into. */
|
||||
if (at === -1) {
|
||||
lines.splice(to, 0, `${' '.repeat(indent)}${parts[depth]}:`);
|
||||
from = to + 1;
|
||||
to = from;
|
||||
indent += 2;
|
||||
continue;
|
||||
}
|
||||
const end = blockEnd(lines, at, indent);
|
||||
from = at + 1;
|
||||
to = end;
|
||||
indent += 2;
|
||||
}
|
||||
|
||||
const key = parts[parts.length - 1];
|
||||
const at = findKey(lines, key, indent, from, to);
|
||||
const block = writeBlock(value, indent + 2);
|
||||
const replacement = block === null
|
||||
? [`${' '.repeat(indent)}${key}: ${writeScalar(value)}`]
|
||||
: [`${' '.repeat(indent)}${key}:`, ...block.split('\n')];
|
||||
|
||||
if (at === -1) {
|
||||
lines.splice(to, 0, ...replacement);
|
||||
return replacement.length;
|
||||
}
|
||||
|
||||
const end = blockEnd(lines, at, indent);
|
||||
lines.splice(at, end - at, ...replacement);
|
||||
return replacement.length - (end - at);
|
||||
}
|
||||
|
||||
/**
|
||||
* A definition with some frontmatter keys changed, and nothing else touched.
|
||||
*
|
||||
* `patch` is keyed by dotted path — `name`, `pages`, `ui.source`,
|
||||
* `owliver.capabilities` — and a value of `undefined` leaves that path alone.
|
||||
* `null` writes `null`; to remove a key, pass `REMOVE`.
|
||||
*
|
||||
* The body below the closing `---` is never read and never rewritten. That is
|
||||
* the property that makes this safe to run on every keystroke: an author's
|
||||
* prose, their comments and the order they wrote their keys in all survive a
|
||||
* change to a field they did not write.
|
||||
*/
|
||||
export const REMOVE = Symbol('remove');
|
||||
|
||||
export function patchFrontmatter(source, patch = {}) {
|
||||
/* Normalized first, for the same reason the parser is: a fence this does not
|
||||
recognise is a fence it writes a second copy of. */
|
||||
const raw = normalizeDefinition(source);
|
||||
const match = /^---[ \t]*\n([\s\S]*?)\n---[ \t]*(?=\n|$)/.exec(raw);
|
||||
|
||||
/* No frontmatter to patch: give the definition one rather than silently
|
||||
dropping the edit. */
|
||||
if (!match) {
|
||||
const lines = [];
|
||||
for (const [path, value] of Object.entries(patch)) {
|
||||
if (value === undefined || value === REMOVE) continue;
|
||||
setPath(lines, path, value);
|
||||
}
|
||||
return `---\n${lines.join('\n')}\n---\n\n${raw.trim()}\n`;
|
||||
}
|
||||
|
||||
const lines = match[1].split('\n');
|
||||
|
||||
for (const [path, value] of Object.entries(patch)) {
|
||||
if (value === undefined) continue;
|
||||
if (value === REMOVE) {
|
||||
const parts = path.split('.');
|
||||
const indent = (parts.length - 1) * 2;
|
||||
/* Only a top-level or one-deep key is removable, which is every key the
|
||||
editors own. */
|
||||
let from = 0;
|
||||
let to = lines.length;
|
||||
if (parts.length > 1) {
|
||||
const parent = findKey(lines, parts[0], 0, 0, lines.length);
|
||||
if (parent === -1) continue;
|
||||
from = parent + 1;
|
||||
to = blockEnd(lines, parent, 0);
|
||||
}
|
||||
const at = findKey(lines, parts[parts.length - 1], indent, from, to);
|
||||
if (at !== -1) lines.splice(at, blockEnd(lines, at, indent) - at);
|
||||
continue;
|
||||
}
|
||||
setPath(lines, path, value);
|
||||
}
|
||||
|
||||
return `---\n${lines.join('\n')}\n---${raw.slice(match[0].length)}`;
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export const SKILL_SURFACES = [
|
||||
label: 'Control Center',
|
||||
route: '/admin',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
provides: {},
|
||||
},
|
||||
{
|
||||
id: 'positions',
|
||||
@@ -53,6 +54,26 @@ export const SKILL_SURFACES = [
|
||||
'after-candidates',
|
||||
'before-footer',
|
||||
],
|
||||
/**
|
||||
* What each of those placements actually hands a section, read off the
|
||||
* `<SkillSurface>` call sites rather than assumed from the surface.
|
||||
*
|
||||
* The distinction is the whole point: the two list placements render above
|
||||
* and below the grid with no position in context, while every other one
|
||||
* renders inside a card, a drawer or the position page and passes the
|
||||
* record. A section reading `position.*` is answerable at one and not at
|
||||
* the other, and until this was written down both validated identically.
|
||||
*/
|
||||
provides: {
|
||||
'after-position-list-summary': [],
|
||||
'after-position-list': [],
|
||||
'after-header': ['positionId'],
|
||||
'after-position-card': ['positionId'],
|
||||
'after-position-summary': ['positionId'],
|
||||
'before-candidates': ['positionId'],
|
||||
'after-candidates': ['positionId'],
|
||||
'before-footer': ['positionId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
/* The authoring form, which is a surface in its own right: what a skill has
|
||||
@@ -69,12 +90,24 @@ export const SKILL_SURFACES = [
|
||||
'after-vetting-weights',
|
||||
'before-footer',
|
||||
],
|
||||
/* The draft in the form is the position, so every placement here supplies
|
||||
one — which is what lets `position.vetting` be read and written while the
|
||||
role is still being specified. */
|
||||
provides: {
|
||||
'after-header': ['positionId'],
|
||||
'after-job-description': ['positionId'],
|
||||
'after-vetting-weights': ['positionId'],
|
||||
'before-footer': ['positionId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'candidates',
|
||||
label: 'Candidates',
|
||||
route: '/admin/candidates',
|
||||
placements: ['after-header', 'after-candidate-summary', 'before-footer'],
|
||||
/* Only the summary placement renders against one person — it is mounted on
|
||||
the candidate profile, not on the list. */
|
||||
provides: { 'after-candidate-summary': ['candidateId'] },
|
||||
},
|
||||
{
|
||||
id: 'hired-history',
|
||||
@@ -82,12 +115,15 @@ export const SKILL_SURFACES = [
|
||||
route: '/admin/hired',
|
||||
aliases: ['hired'],
|
||||
placements: ['after-header', 'before-footer'],
|
||||
/* `before-footer` is inside the record drawer; `after-header` is the page. */
|
||||
provides: { 'before-footer': ['candidateId'] },
|
||||
},
|
||||
{
|
||||
id: 'talent-pool',
|
||||
label: 'Talent Pool',
|
||||
route: '/admin/talent-pool',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
provides: {},
|
||||
},
|
||||
{
|
||||
id: 'krow-forge',
|
||||
@@ -95,18 +131,21 @@ export const SKILL_SURFACES = [
|
||||
route: '/admin/university',
|
||||
aliases: ['university', 'forge'],
|
||||
placements: ['after-header', 'before-footer'],
|
||||
provides: {},
|
||||
},
|
||||
{
|
||||
id: 'analytics',
|
||||
label: 'Analytics',
|
||||
route: '/admin/analytics',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
provides: {},
|
||||
},
|
||||
{
|
||||
id: 'activity',
|
||||
label: 'Activity',
|
||||
route: '/admin/activity',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
provides: {},
|
||||
},
|
||||
/* Not in the eight product surfaces, but skills already attach to it and the
|
||||
account page reads them. Kept so nothing that works today stops working. */
|
||||
@@ -115,12 +154,14 @@ export const SKILL_SURFACES = [
|
||||
label: 'Profile',
|
||||
route: '/admin/profile',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
provides: {},
|
||||
},
|
||||
{
|
||||
id: 'candidates-analysis',
|
||||
label: 'Candidate Analysis',
|
||||
route: '/admin/candidates-analysis',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
provides: {},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -133,12 +174,111 @@ for (const surface of SKILL_SURFACES) {
|
||||
/** Every name a definition may use for a surface, for error messages. */
|
||||
export const SUPPORTED_SKILL_PAGES = SKILL_SURFACES.map((s) => s.id);
|
||||
|
||||
/** The surface a declared page name refers to, or null. */
|
||||
export const surfaceFor = (page) => BY_KEY.get(String(page || '').trim()) || null;
|
||||
/**
|
||||
* The surface a declared page name refers to, or null.
|
||||
*
|
||||
* Matched on the normalized name, not the literal one. A definition writing
|
||||
* `Positions` or `Talent Pool` — which is how the page is spelled everywhere in
|
||||
* the product — used to resolve to nothing, and a page name that resolves to
|
||||
* nothing takes the whole `ui:` block with it. The keys stay canonical; only
|
||||
* what an author may type to reach them widens.
|
||||
*/
|
||||
const normalizeKey = (page) => String(page ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_]+/g, '-');
|
||||
|
||||
export const surfaceFor = (page) => BY_KEY.get(normalizeKey(page)) || null;
|
||||
|
||||
/**
|
||||
* Placement names a definition may use, beyond the canonical ones.
|
||||
*
|
||||
* A placement is a position in a page's layout, and the canonical names are
|
||||
* written from the page's point of view — `after-position-card`. Authors write
|
||||
* them from the definition's: `grid-card` is where it goes, `panel` is what it
|
||||
* looks like. An alias only ever resolves to a placement the surface really
|
||||
* offers, so the vocabulary stays exactly as closed as it was; what changes is
|
||||
* how many ways there are to name a member of it.
|
||||
*/
|
||||
const PLACEMENT_ALIASES = {
|
||||
'grid-card': 'after-position-card',
|
||||
card: 'after-position-card',
|
||||
panel: 'after-header',
|
||||
top: 'after-header',
|
||||
header: 'after-header',
|
||||
footer: 'before-footer',
|
||||
bottom: 'before-footer',
|
||||
list: 'after-position-list',
|
||||
summary: 'after-position-summary',
|
||||
};
|
||||
|
||||
/**
|
||||
* The canonical placement a declared name refers to on this surface, or null.
|
||||
*
|
||||
* An alias that points at a placement this surface does not offer resolves to
|
||||
* null rather than to some other surface's placement — `panel` means
|
||||
* `after-header` where there is one and nothing where there is not.
|
||||
*/
|
||||
export function placementFor(page, placement) {
|
||||
const surface = surfaceFor(page);
|
||||
if (!surface) return null;
|
||||
|
||||
const declared = normalizeKey(placement);
|
||||
if (!declared) return null;
|
||||
if (surface.placements.includes(declared)) return declared;
|
||||
|
||||
const aliased = PLACEMENT_ALIASES[declared];
|
||||
return aliased && surface.placements.includes(aliased) ? aliased : null;
|
||||
}
|
||||
|
||||
/** The canonical id for a declared page name — `hired` → `hired-history`. */
|
||||
export const canonicalPage = (page) => surfaceFor(page)?.id || null;
|
||||
|
||||
/**
|
||||
* What a placement hands a section, as the page actually mounts it.
|
||||
*
|
||||
* A source declares the record it needs — a position, a candidate, or nothing —
|
||||
* and until this existed nothing compared that need against the pages a
|
||||
* definition named. So a section reading `position.activity` could be attached
|
||||
* to Analytics, validate cleanly, register, draw its title, and then report
|
||||
* "This section needs a position to read" forever. That is the whole of the
|
||||
* "the skill saved but does nothing" report, and it is an authoring mistake the
|
||||
* product can catch rather than one the reader has to discover.
|
||||
*
|
||||
* Read per *placement*, never per surface: the Positions grid renders one
|
||||
* section inside each card, with a position, and two more above and below the
|
||||
* grid, with none. Both are `positions`.
|
||||
*
|
||||
* With no placement named, a definition is answered for the surface's default —
|
||||
* `surface.placements[0]`, which is what `normalizeSection` fills in.
|
||||
*/
|
||||
export function placementProvides(page, placement = null) {
|
||||
const surface = surfaceFor(page);
|
||||
if (!surface) return [];
|
||||
const at = String(placement || '').trim() || surface.placements[0];
|
||||
return surface.provides?.[at] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the declared pages can supply at this placement, deduplicated.
|
||||
*
|
||||
* One page supplying a position is enough — a definition naming several pages
|
||||
* is saying it belongs on all of them, and refusing it because one cannot
|
||||
* answer would refuse the definition for the pages that can. The load-time
|
||||
* diagnostic is where the partial case is reported.
|
||||
*/
|
||||
export function contextSuppliedBy(pages = [], placement = null) {
|
||||
return [...new Set(
|
||||
(Array.isArray(pages) ? pages : [pages])
|
||||
.flatMap((page) => placementProvides(page, placement))
|
||||
)];
|
||||
}
|
||||
|
||||
/** "a position" / "a candidate", for a message an author can act on. */
|
||||
export const contextLabel = (need) => (
|
||||
need === 'positionId' ? 'a position' : need === 'candidateId' ? 'a candidate' : 'nothing'
|
||||
);
|
||||
|
||||
/**
|
||||
* The surface an Admin route belongs to — `/admin/positions/new` →
|
||||
* `create-position`.
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import {
|
||||
SUPPORTED_DATA_SOURCES, SUPPORTED_PERIODS, SUPPORTED_SECTION_TYPES,
|
||||
SUPPORTED_SKILL_PAGES, canonicalPage, dataSourceFor, isSourceWritable, surfaceFor,
|
||||
SUPPORTED_SKILL_PAGES, canonicalPage, dataSourceFor, isSourceWritable, placementFor,
|
||||
surfaceFor,
|
||||
} from './surfaces';
|
||||
|
||||
/**
|
||||
* A name, as an id.
|
||||
*
|
||||
* Exported because two places need the same rule and were carrying their own:
|
||||
* a section falling back to its title, and a definition falling back to its
|
||||
* name when it declares no `id:`. Two slug functions that agree today is a
|
||||
* definition whose id changes the day they stop agreeing.
|
||||
*/
|
||||
export const slugify = (value) => String(value || '')
|
||||
.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
|
||||
/**
|
||||
* The `ui:` block of a skill definition, checked and normalized.
|
||||
*
|
||||
@@ -53,9 +65,7 @@ export function normalizeSection(raw, {
|
||||
/* An id is how a section is keyed and de-duplicated, not something an author
|
||||
should have to invent for a definition that declares exactly one. Falls
|
||||
back to the title, then to the skill's own id. */
|
||||
const slug = (value) => String(value || '')
|
||||
.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
const id = slug(raw.id) || slug(raw.title) || slug(fallbackId);
|
||||
const id = slugify(raw.id) || slugify(raw.title) || slugify(fallbackId);
|
||||
if (!id) {
|
||||
errors.push(`${where}: a section needs an \`id\`.`);
|
||||
return null;
|
||||
@@ -70,7 +80,20 @@ export function normalizeSection(raw, {
|
||||
}
|
||||
seen.add(id);
|
||||
|
||||
const type = String(raw.type || '').trim();
|
||||
/**
|
||||
* The shape this section is drawn with.
|
||||
*
|
||||
* Inferred from the source when it is not written, the same way a placement
|
||||
* left out is filled in from the surface. A source declares the shapes it can
|
||||
* fill, in the order they suit it, so the first is the reading its author
|
||||
* would have chosen — and a definition naming a page, a placement and a
|
||||
* source has already said everything that needs saying. Refusing it for the
|
||||
* one field it can derive would be the format asking for ceremony.
|
||||
*/
|
||||
const declaredSource = String(raw.data?.source ?? raw.source ?? '').trim();
|
||||
const type = String(raw.type || '').trim()
|
||||
|| dataSourceFor(declaredSource)?.shapes?.find((shape) => types.includes(shape))
|
||||
|| '';
|
||||
if (!type) {
|
||||
errors.push(`${at || `${where}.${id}`}: a section needs a \`type\`.`);
|
||||
return null;
|
||||
@@ -89,10 +112,16 @@ export function normalizeSection(raw, {
|
||||
if (wantPlacement) {
|
||||
const surface = surfaceFor(page);
|
||||
const declaredPlacement = String(raw.position || raw.placement || '').trim();
|
||||
placement = declaredPlacement || surface.placements[0];
|
||||
if (!surface.placements.includes(placement)) {
|
||||
/* `placementFor` reads the surface's own list and the alias table, so
|
||||
`grid-card` and `after-position-card` are one placement written two ways
|
||||
and neither resolves to something the surface does not offer. */
|
||||
placement = declaredPlacement
|
||||
? placementFor(page, declaredPlacement)
|
||||
: surface.placements[0];
|
||||
if (!placement) {
|
||||
errors.push(
|
||||
`Unsupported placement: ${placement} on ${page}. Supported placements: ${surface.placements.join(', ')}.`
|
||||
`Unsupported placement: ${declaredPlacement} on ${page}. `
|
||||
+ `Supported placements: ${surface.placements.join(', ')}.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -102,7 +131,7 @@ export function normalizeSection(raw, {
|
||||
groups options when a section grows; the flat form is what a one-section
|
||||
definition actually reads like, and refusing it would be the format being
|
||||
precious about punctuation. */
|
||||
const source = String(raw.data?.source ?? raw.source ?? '').trim();
|
||||
const source = declaredSource;
|
||||
if (!source) {
|
||||
errors.push(`${at || `${where}.${id}`}: a section needs \`data.source\`.`);
|
||||
return null;
|
||||
@@ -190,6 +219,22 @@ export function normalizeSection(raw, {
|
||||
* and the difference is structural — an object carrying `type` or `source` is a
|
||||
* section, an object whose keys are page names is a page map. Nothing is
|
||||
* decided by a skill id, and neither shape is privileged.
|
||||
*
|
||||
* There is a third, and it is the one people actually write:
|
||||
*
|
||||
* ui:
|
||||
* - page: Positions
|
||||
* placement: grid-card
|
||||
* source: position.activity
|
||||
* - page: Analytics
|
||||
* placement: panel
|
||||
* source: hires.performance
|
||||
*
|
||||
* A list of sections, each naming its own page. It reads the way the thing
|
||||
* reads — "this skill puts this here, and that there" — and it was the one
|
||||
* shape the parser refused, with a single error that took the whole block down
|
||||
* and left the definition declaring no pages at all. Several entries may name
|
||||
* the same page; they become several sections on it, in the order written.
|
||||
*/
|
||||
const SECTION_KEYS = new Set([
|
||||
'id', 'type', 'title', 'description', 'placement', 'position', 'data', 'source', 'periods',
|
||||
@@ -223,12 +268,68 @@ export function normalizeSkillUi(rawUi, { declaredPages = [], skillId = '' } = {
|
||||
|
||||
if (rawUi == null) return { ui, errors };
|
||||
|
||||
if (typeof rawUi !== 'object' || Array.isArray(rawUi)) {
|
||||
return { ui, errors: ['`ui` must be a section, or a mapping of page names to sections.'] };
|
||||
if (typeof rawUi !== 'object') {
|
||||
return {
|
||||
ui,
|
||||
errors: ['`ui` must be a section, a list of sections, or a mapping of page names to sections.'],
|
||||
};
|
||||
}
|
||||
|
||||
const pages = declaredPages.map(canonicalPage).filter(Boolean);
|
||||
|
||||
/**
|
||||
* A list, where every entry names the page it belongs to.
|
||||
*
|
||||
* Grouped rather than keyed, so two entries naming one page are two sections
|
||||
* on it rather than the second quietly replacing the first — which is what a
|
||||
* page map would have done. `seen` is shared across the whole list because
|
||||
* section ids are unique per definition, not per page.
|
||||
*/
|
||||
if (Array.isArray(rawUi)) {
|
||||
const seen = new Set();
|
||||
|
||||
rawUi.forEach((entry, index) => {
|
||||
const where = `ui[${index}]`;
|
||||
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
errors.push(`${where}: each entry must be a mapping of options.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const rawPage = entry.page ?? entry.pages ?? entry.surface;
|
||||
if (rawPage == null || Array.isArray(rawPage)) {
|
||||
errors.push(`${where}: an entry needs a \`page\`.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const page = canonicalPage(rawPage);
|
||||
if (!page) {
|
||||
errors.push(
|
||||
`Unsupported page: ${rawPage}. Supported pages: ${SUPPORTED_SKILL_PAGES.join(', ')}.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Declared `pages:` still governs reach when it is written: a definition
|
||||
may not render onto a surface it did not say it applied to. When it is
|
||||
not written, the list is the declaration — see `parseSkill`. */
|
||||
if (pages.length && !pages.includes(page)) {
|
||||
errors.push(`\`${where}\` names \`${rawPage}\`, which is not listed under \`pages\`.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const section = normalizeSection(entry, {
|
||||
page, errors, seen, fallbackId: `${skillId}-${index + 1}`, where,
|
||||
});
|
||||
if (!section) return;
|
||||
|
||||
if (!ui[page]) ui[page] = { sections: [] };
|
||||
ui[page].sections.push(section);
|
||||
});
|
||||
|
||||
return { ui, errors };
|
||||
}
|
||||
|
||||
/* Shorthand: one section, applied to every page the skill declares. The keys
|
||||
inside it are section options and are never read as page names — which is
|
||||
exactly what this branch exists to prevent. */
|
||||
|
||||
@@ -45,6 +45,12 @@ export function matchWorkforceIntent(question) {
|
||||
if (has(q, 'set up an interview', 'set up interview', 'schedule an interview', 'schedule interview',
|
||||
'interview for ', 'set up their interview')) return 'interview_setup';
|
||||
|
||||
/* Answering "how many people do you need?" — checked before assignment,
|
||||
because the reply that carries the number also carries the position title
|
||||
and would otherwise read as a fresh request to assign against it. */
|
||||
if (has(q, 'set headcount', 'set the headcount', 'headcount for', 'headcount to',
|
||||
'headcount of')) return 'set_headcount';
|
||||
|
||||
if (has(q, 'confirm assignment', 'yes, assign', 'confirm the assignment')) return 'confirm_assign';
|
||||
if (has(q, 'assign ', 'fill this position', 'fill the position', 'assign the best', 'assign them')) return 'assign';
|
||||
if (has(q, 'ready for interview', 'who should i interview', 'interview ready')) return 'interview_ready';
|
||||
@@ -57,6 +63,26 @@ export function matchWorkforceIntent(question) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The headcount a sentence states, or null.
|
||||
*
|
||||
* Deliberately narrow. A bare integer anywhere in the question would also match
|
||||
* the digits in a position title, a pay band or a date, and setting demand from
|
||||
* a misread number is not a recoverable mistake — the whole point of asking is
|
||||
* that nobody wants a figure nobody chose. So the number has to be attached to
|
||||
* a phrase that means "this many people".
|
||||
*/
|
||||
export function headcountFrom(question) {
|
||||
const q = String(question).toLowerCase();
|
||||
const match = /\bto\s+(\d{1,3})\b/.exec(q)
|
||||
|| /\bheadcount\s+(?:of\s+|to\s+)?(\d{1,3})\b/.exec(q)
|
||||
|| /\b(\d{1,3})\s+(?:people|person|staff|workers?)\b/.exec(q);
|
||||
if (!match) return null;
|
||||
|
||||
const count = Number(match[1]);
|
||||
return Number.isFinite(count) && count >= 1 && count <= 999 ? count : null;
|
||||
}
|
||||
|
||||
/* ── Which position? ───────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
@@ -423,6 +449,83 @@ export function positionPriority(positions, context) {
|
||||
};
|
||||
}
|
||||
|
||||
/* ── The missing headcount ─────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* What to say when a position never stated how many people it wants.
|
||||
*
|
||||
* This used to be the end of the conversation: a sentence explaining that there
|
||||
* was no gap to fill, and no follow-up, on a request the reader had just been
|
||||
* offered a chip for. The engine had in fact already picked somebody — the
|
||||
* refusal was thrown away in front of a viable plan — but the count it would
|
||||
* have been proposing against was the schema's fallback of 1, not a number the
|
||||
* employer had given. Presenting that as their answer is the thing the demand
|
||||
* model exists to avoid.
|
||||
*
|
||||
* So the missing value is asked for rather than assumed. What is offered is
|
||||
* read off this position: one, two, and the number of people who are actually
|
||||
* qualified and free for it, so the shortcut with the most information behind it
|
||||
* is on the list. Nothing is written until one is chosen, and typing a different
|
||||
* number works exactly as well as clicking a chip.
|
||||
*/
|
||||
export function askHeadcount(position, context) {
|
||||
const demand = demandFor(position, context);
|
||||
const status = workforceStatusFor(position, context);
|
||||
|
||||
const strong = status.strong.length;
|
||||
const options = [...new Set([1, 2, strong])]
|
||||
.filter((n) => n >= 1 && n <= 99)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const onIt = demand.assigned
|
||||
? `${demand.assigned} ${demand.assigned === 1 ? 'person is' : 'people are'} already on it`
|
||||
: 'Nobody is on it yet';
|
||||
const free = strong
|
||||
? `${strong} ${strong === 1 ? 'person is' : 'people are'} qualified and free when it starts`
|
||||
: 'nobody is currently both qualified and free';
|
||||
|
||||
return {
|
||||
doc: doc(
|
||||
text(`How many people do you need for **${position.title}**?`),
|
||||
note(`${onIt}, and ${free}. It has never stated a headcount, and I will not `
|
||||
+ 'guess one — tell me the number and I will propose against it.')
|
||||
),
|
||||
followUp: options.map((count) => ({
|
||||
label: `${count} ${count === 1 ? 'person' : 'people'}`,
|
||||
prompt: `Set headcount for ${position.title} to ${count}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The turn after the number is written: the proposal it was asked for.
|
||||
*
|
||||
* Composed from `assignmentPreview` rather than restating it, so the answer the
|
||||
* reader gets here is the same answer the same question gives from then on.
|
||||
*/
|
||||
export function headcountSet(position, context) {
|
||||
const count = Number(position.headcount);
|
||||
const preview = assignmentPreview(position, context);
|
||||
|
||||
return {
|
||||
doc: doc(
|
||||
text(`**${position.title}** now asks for **${count}** ${count === 1 ? 'person' : 'people'}.`),
|
||||
preview.doc.blocks
|
||||
),
|
||||
followUp: preview.followUp,
|
||||
};
|
||||
}
|
||||
|
||||
/** The write did not land. Nothing is claimed that did not happen. */
|
||||
export function headcountFailed(position) {
|
||||
return {
|
||||
doc: doc(
|
||||
text(`I could not set the headcount on **${position.title}**.`),
|
||||
note('Nothing was changed. The position still states no demand.')
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Proposing an assignment ───────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
@@ -437,21 +540,60 @@ export function assignmentPreview(position, context) {
|
||||
const plan = prepareAssignment(position, context);
|
||||
const demand = demandFor(position, context);
|
||||
|
||||
if (!demand.declared) {
|
||||
return {
|
||||
doc: doc(
|
||||
text(`**${position.title}** does not state how many people it needs, so there is no gap for me to fill.`),
|
||||
note('Set a headcount on the position and I can propose an assignment against it.')
|
||||
),
|
||||
};
|
||||
}
|
||||
if (!demand.declared) return askHeadcount(position, context);
|
||||
|
||||
if (plan.blocked === 'full') {
|
||||
return { doc: doc(text(`**${position.title}** is already fully staffed — ${demand.assigned} of ${demand.required}.`)) };
|
||||
}
|
||||
|
||||
if (plan.blocked === 'no_available_matches') {
|
||||
const blocked = poolFor(position, context).filter((r) => !r.availability.available);
|
||||
const pool = poolFor(position, context);
|
||||
|
||||
/**
|
||||
* People the engine will not propose automatically, but would accept.
|
||||
*
|
||||
* `prepareAssignment` selects from `strong`, and `strong` requires
|
||||
* *verified* evidence — a deliberate rule, and the right one: a stated
|
||||
* requirement is a claim on an application, not proof somebody can do the
|
||||
* work. What was wrong was the sentence it produced. On Bartender, three
|
||||
* people meet every stated requirement, have no gaps and are free when it
|
||||
* starts, and the reply said nobody was qualified or free. That is not a
|
||||
* cautious answer, it is an untrue one — and it ended the conversation,
|
||||
* because the branch offered no follow-up at all.
|
||||
*
|
||||
* So the rule stands and the reply changes: say which kind of evidence is
|
||||
* missing, show who is actually there, and offer them by name. Naming one
|
||||
* goes through `namedAssignmentPreview`, which already re-checks them and
|
||||
* asks for confirmation before anything is written — the admin makes the
|
||||
* judgement the verified/stated distinction exists to protect, instead of
|
||||
* the distinction quietly making it for them.
|
||||
*/
|
||||
const ready = pool.filter(
|
||||
(row) => row.availability.available && row.match && !row.match.gaps.length
|
||||
);
|
||||
|
||||
if (ready.length) {
|
||||
const shown = ready.slice(0, 3);
|
||||
return {
|
||||
doc: doc(
|
||||
text(`Nobody with **verified** skills is free for **${position.title}**, so I will not `
|
||||
+ 'propose an assignment on my own.'),
|
||||
text(`${ready.length} ${ready.length === 1 ? 'person meets' : 'people meet'} every stated `
|
||||
+ `requirement and ${ready.length === 1 ? 'is' : 'are'} free when it starts:`),
|
||||
...shown.map((row, i) => candidateBlock(row, i + 1, {
|
||||
applications: context.applications, position,
|
||||
})),
|
||||
note('Scored from their applications rather than verified training. Name one and I will '
|
||||
+ 'check them again before anything is written.')
|
||||
),
|
||||
followUp: shown.map((row) => ({
|
||||
label: `Assign ${row.name}`,
|
||||
prompt: `Assign ${row.name} to ${position.title}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const blocked = pool.filter((r) => !r.availability.available);
|
||||
return {
|
||||
doc: doc(
|
||||
text(`I cannot propose anyone for **${position.title}** — nobody is both qualified and free when it starts.`),
|
||||
@@ -464,6 +606,11 @@ export function assignmentPreview(position, context) {
|
||||
: null,
|
||||
note(`${demand.remaining} of ${demand.required} still unfilled.`)
|
||||
),
|
||||
/* Even with nobody to offer, the conversation has somewhere to go. */
|
||||
followUp: [
|
||||
{ label: 'Who is available next?', prompt: `Who is available next for ${position.title}?` },
|
||||
{ label: 'Who matches this role?', prompt: `Who matches ${position.title}?` },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ROLE_CATEGORIES } from '@/lib/roleCategories';
|
||||
import { CERT_OPTIONS, CRITERIA_LABELS, ENGLISH_LEVELS, defaultPosition } from '@/lib/positionModel';
|
||||
import { SkillRequirementsField } from '@/components/krow/SkillRequirementsField';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
import { usePublishPageActions, usePublishPageContext } from '@/components/ai-assistant';
|
||||
|
||||
/**
|
||||
@@ -204,9 +205,7 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
|
||||
: 'bg-white border border-[#E5E7EB] rounded-2xl p-6 md:p-8 space-y-6'}>
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-[#EEF3FE] flex items-center justify-center">
|
||||
<Sparkles className="w-4 h-4 text-[#0838E0]" />
|
||||
</div>
|
||||
<OwliverAvatar className="w-7 h-7" rounded="rounded-lg" />
|
||||
<h2 className="text-[16px] font-semibold text-[#111827]">Create Job Posting</h2>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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