mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
524 lines
22 KiB
TypeScript
524 lines
22 KiB
TypeScript
/**
|
|
* CreateWorkflowForm
|
|
* ----------------
|
|
*
|
|
* Body of the "create a new SKILL.md" flow, shared between
|
|
* `CreateSkillModal` (modal chrome) and the `/skills/new` page wrapper.
|
|
*
|
|
* Owns:
|
|
* - All form fields (name, description, scope, license, author,
|
|
* tags, allowed-tools).
|
|
* - Slug preview + validation (name and description required).
|
|
* - Submit handler that calls `workflowsApi.createWorkflow` and surfaces
|
|
* the result via `onCreated(skill)` / error string via inline
|
|
* `<div role="alert">`.
|
|
*
|
|
* Does NOT own:
|
|
* - The submit/cancel buttons (the wrapper provides them so the
|
|
* modal can use a footer bar and the page can render a top-right
|
|
* primary action).
|
|
* - Modal-specific concerns (focus capture, Escape-to-close,
|
|
* backdrop click). Those stay in `CreateSkillModal`.
|
|
*
|
|
* The wrapper drives submission by either calling the imperative
|
|
* handle exposed via a ref (`<CreateWorkflowForm ref={ref} ... />` →
|
|
* `ref.current.submit()`) OR by reading `formValid` + `submitting`
|
|
* from the props the form raises and wiring its own submit button to
|
|
* the underlying `<form>` via the standard `form="..."` attribute.
|
|
* Both modal and page use the latter, so the form mounts a real
|
|
* `<form id={formId}>` and they bind `<button form={formId}>`.
|
|
*/
|
|
import debug from 'debug';
|
|
import {
|
|
forwardRef,
|
|
useCallback,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
|
|
import { useT } from '../../lib/i18n/I18nContext';
|
|
import {
|
|
type CreateWorkflowInput,
|
|
type CreateWorkflowInputDef,
|
|
workflowsApi,
|
|
type WorkflowScope,
|
|
type WorkflowSummary,
|
|
} from '../../services/api/workflowsApi';
|
|
import Button from '../ui/Button';
|
|
|
|
/** Mirrors `SkillCreateInputDef` shape used as wire payload, with one
|
|
* extra `localId` for stable React keys across re-renders (the wire
|
|
* payload strips this field at submit time). */
|
|
interface InputRow {
|
|
localId: string;
|
|
name: string;
|
|
description: string;
|
|
required: boolean;
|
|
type: 'string' | 'integer' | 'boolean';
|
|
}
|
|
|
|
const NAME_RE = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
|
|
let nextLocalId = 0;
|
|
function newRow(): InputRow {
|
|
nextLocalId += 1;
|
|
return {
|
|
localId: `row-${nextLocalId}`,
|
|
name: '',
|
|
description: '',
|
|
// Additional inputs are optional by default — the author opts a row INTO
|
|
// required via the checkbox. (A workflow run only blocks on inputs the
|
|
// author explicitly marks required.)
|
|
required: false,
|
|
type: 'string',
|
|
};
|
|
}
|
|
|
|
const log = debug('skills:create-form');
|
|
|
|
export interface CreateSkillFormHandle {
|
|
/** True iff name+description are present and no submit is in flight. */
|
|
isValid: () => boolean;
|
|
/** True while workflowsApi.createWorkflow is in flight. */
|
|
isSubmitting: () => boolean;
|
|
/** Imperatively trigger submit. Resolves once the round-trip finishes. */
|
|
submit: () => Promise<void>;
|
|
}
|
|
|
|
export interface CreateSkillFormProps {
|
|
/**
|
|
* The id assigned to the underlying `<form>` element. Wrappers that
|
|
* render their submit button outside the form (modal footer / page
|
|
* header) set `<button form={formId}>` to fire submit via this id.
|
|
*/
|
|
formId: string;
|
|
/** Called with the freshly-created skill on success. */
|
|
onCreated: (skill: WorkflowSummary) => void;
|
|
/**
|
|
* Called whenever validity / submission state changes so the
|
|
* wrapper can sync its submit button's disabled state without
|
|
* needing to introspect via a ref every render.
|
|
*/
|
|
onStateChange?: (state: { valid: boolean; submitting: boolean }) => void;
|
|
/** If true, autofocus the first field on mount (modal default). */
|
|
autoFocus?: boolean;
|
|
/**
|
|
* When set, the form is in EDIT mode for this workflow: fields are
|
|
* pre-filled (name read-only — the slug is identity), and submit calls
|
|
* `workflows_update` instead of `workflows_create`. Tags / author /
|
|
* allowed-tools (not exposed as editable fields) are carried through so
|
|
* they're preserved on save.
|
|
*/
|
|
editing?: WorkflowSummary;
|
|
}
|
|
|
|
/**
|
|
* Client-side slug preview — mirrors the Rust `slugify_skill_name`
|
|
* heuristic (lowercase, ASCII alphanumerics + `-`, collapse repeats,
|
|
* trim hyphens at the edges). The preview is advisory only; the Rust
|
|
* side is authoritative when the skill is persisted.
|
|
*/
|
|
export function previewSlug(name: string): string {
|
|
const lower = name.normalize('NFKD').toLowerCase();
|
|
let out = '';
|
|
let prevHyphen = false;
|
|
for (const ch of lower) {
|
|
if ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')) {
|
|
out += ch;
|
|
prevHyphen = false;
|
|
continue;
|
|
}
|
|
if ((ch === '-' || ch === '_' || /\s/.test(ch)) && !prevHyphen) {
|
|
out += '-';
|
|
prevHyphen = true;
|
|
}
|
|
}
|
|
return out.replace(/^-+|-+$/g, '');
|
|
}
|
|
|
|
const CreateWorkflowForm = forwardRef<CreateSkillFormHandle, CreateSkillFormProps>(
|
|
({ formId, onCreated, onStateChange, autoFocus = false, editing }, ref) => {
|
|
const { t } = useT();
|
|
const isEdit = !!editing;
|
|
// Fields the form doesn't expose but must preserve across an edit
|
|
// (otherwise workflows_update would regenerate frontmatter without them).
|
|
const preservedRef = useRef<{ tags?: string[]; author?: string; allowedTools?: string[] }>({});
|
|
const [name, setName] = useState('');
|
|
const [description, setDescription] = useState('');
|
|
// The workflow half of the unified form: *when* an agent should reach for
|
|
// this workflow. Optional — falls back to the description on the backend.
|
|
const [whenToUse, setWhenToUse] = useState('');
|
|
// Scope is fixed to 'user' — the form previously exposed a radio
|
|
// toggle for user/project plus license/author/tags/allowed-tools
|
|
// fields. None of those were useful in practice and they cluttered
|
|
// the create flow; user-scoped is the only sensible default for
|
|
// dashboard-created skills. Project-scoped skills are still
|
|
// creatable by editing the workspace skill files directly. The
|
|
// backend payload still requires `scope` so we hold it as a const.
|
|
const scope: WorkflowScope = 'user';
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [inputs, setInputs] = useState<InputRow[]>([]);
|
|
|
|
const firstFieldRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
const slug = useMemo(() => previewSlug(name), [name]);
|
|
|
|
const nameValid = slug.length > 0;
|
|
const descriptionValid = description.trim().length > 0;
|
|
// Each row must have a non-empty, regex-valid name AND a non-empty
|
|
// description. Empty/under-specified rows block submission so the user
|
|
// explicitly removes them rather than shipping a malformed or
|
|
// undocumented [[inputs]] entry. The description is what the agent (and
|
|
// the run form) shows to explain the input, so it's mandatory per row.
|
|
const inputsValid = inputs.every(
|
|
r => NAME_RE.test(r.name.trim()) && r.description.trim().length > 0
|
|
);
|
|
const formValid = nameValid && descriptionValid && inputsValid && !submitting;
|
|
|
|
const addRow = useCallback(() => {
|
|
setInputs(cur => [...cur, newRow()]);
|
|
}, []);
|
|
const removeRow = useCallback((localId: string) => {
|
|
setInputs(cur => cur.filter(r => r.localId !== localId));
|
|
}, []);
|
|
const updateRow = useCallback((localId: string, patch: Partial<InputRow>) => {
|
|
setInputs(cur => cur.map(r => (r.localId === localId ? { ...r, ...patch } : r)));
|
|
}, []);
|
|
|
|
// Surface state to the wrapper for its submit button's disabled prop.
|
|
useEffect(() => {
|
|
onStateChange?.({ valid: formValid, submitting });
|
|
}, [formValid, submitting, onStateChange]);
|
|
|
|
useEffect(() => {
|
|
if (!autoFocus) return;
|
|
const raf = window.requestAnimationFrame(() => {
|
|
firstFieldRef.current?.focus();
|
|
});
|
|
return () => {
|
|
window.cancelAnimationFrame(raf);
|
|
};
|
|
}, [autoFocus]);
|
|
|
|
// Edit mode: pre-fill from the existing workflow. Name + description come
|
|
// from the summary; when_to_use + declared inputs come from describe.
|
|
// tags / author / allowed-tools are stashed for pass-through on save.
|
|
useEffect(() => {
|
|
if (!editing) return;
|
|
setName(editing.name);
|
|
setDescription(editing.description);
|
|
preservedRef.current = {
|
|
tags: editing.tags,
|
|
author: editing.author ?? undefined,
|
|
allowedTools: editing.tools,
|
|
};
|
|
let cancelled = false;
|
|
void (async () => {
|
|
try {
|
|
const desc = await workflowsApi.describeWorkflow(editing.id);
|
|
if (cancelled) return;
|
|
if (desc.when_to_use) setWhenToUse(desc.when_to_use);
|
|
setInputs(
|
|
desc.inputs.map(i => {
|
|
nextLocalId += 1;
|
|
return {
|
|
localId: `row-${nextLocalId}`,
|
|
name: i.name,
|
|
description: i.description ?? '',
|
|
required: i.required,
|
|
type: (i.type as InputRow['type']) ?? 'string',
|
|
};
|
|
})
|
|
);
|
|
} catch {
|
|
// Pre-fill is best-effort; the user can still edit the visible fields.
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [editing]);
|
|
|
|
const submit = useCallback(async () => {
|
|
if (!formValid) return;
|
|
const payload: CreateWorkflowInput = {
|
|
name: name.trim(),
|
|
description: description.trim(),
|
|
scope,
|
|
};
|
|
if (whenToUse.trim()) {
|
|
payload.whenToUse = whenToUse.trim();
|
|
}
|
|
if (editing) {
|
|
// Preserve fields the form doesn't expose as editable.
|
|
if (preservedRef.current.tags && preservedRef.current.tags.length > 0) {
|
|
payload.tags = preservedRef.current.tags;
|
|
}
|
|
if (preservedRef.current.author) {
|
|
payload.author = preservedRef.current.author;
|
|
}
|
|
if (preservedRef.current.allowedTools && preservedRef.current.allowedTools.length > 0) {
|
|
payload.allowedTools = preservedRef.current.allowedTools;
|
|
}
|
|
}
|
|
if (inputs.length > 0) {
|
|
payload.inputs = inputs.map<CreateWorkflowInputDef>(r => {
|
|
const def: CreateWorkflowInputDef = { name: r.name.trim(), required: r.required };
|
|
const desc = r.description.trim();
|
|
if (desc) def.description = desc;
|
|
// Default 'string' on the Rust side, omit to keep payload tidy.
|
|
if (r.type !== 'string') def.type = r.type;
|
|
return def;
|
|
});
|
|
}
|
|
|
|
log('submit name=%s scope=%s inputs=%d', payload.name, payload.scope, inputs.length);
|
|
setSubmitting(true);
|
|
setError(null);
|
|
try {
|
|
const saved = editing
|
|
? await workflowsApi.updateWorkflow(payload)
|
|
: await workflowsApi.createWorkflow(payload);
|
|
log('submit-ok id=%s edit=%s', saved.id, isEdit);
|
|
onCreated(saved);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
log('submit-err %s', message);
|
|
setError(message);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}, [description, formValid, inputs, name, whenToUse, onCreated, editing, isEdit]);
|
|
|
|
useImperativeHandle(
|
|
ref,
|
|
() => ({ isValid: () => formValid, isSubmitting: () => submitting, submit }),
|
|
[formValid, submitting, submit]
|
|
);
|
|
|
|
const handleFormSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
void submit();
|
|
};
|
|
|
|
return (
|
|
<form id={formId} onSubmit={handleFormSubmit} className="space-y-4">
|
|
{/* Name */}
|
|
<div>
|
|
<label
|
|
htmlFor="create-skill-name"
|
|
className="block text-xs font-medium text-stone-600 dark:text-neutral-300">
|
|
{t('skills.create.name')}
|
|
<span className="text-coral-500"> *</span>
|
|
</label>
|
|
<input
|
|
id="create-skill-name"
|
|
ref={firstFieldRef}
|
|
type="text"
|
|
value={name}
|
|
onChange={e => setName(e.target.value)}
|
|
required
|
|
readOnly={isEdit}
|
|
maxLength={128}
|
|
className={`mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30 ${
|
|
isEdit
|
|
? 'cursor-not-allowed bg-stone-50 dark:bg-neutral-800'
|
|
: 'bg-white dark:bg-neutral-900'
|
|
}`}
|
|
placeholder={t('skills.create.namePlaceholder')}
|
|
/>
|
|
<p className="mt-1 text-[11px] text-stone-500 dark:text-neutral-400">
|
|
{t('skills.create.slugLabel')}{' '}
|
|
<code className="rounded bg-stone-100 dark:bg-neutral-800 px-1 py-[1px] font-mono text-stone-700 dark:text-neutral-200">
|
|
{slug || '—'}
|
|
</code>
|
|
</p>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div>
|
|
<label
|
|
htmlFor="create-skill-description"
|
|
className="block text-xs font-medium text-stone-600 dark:text-neutral-300">
|
|
{t('skills.create.description')}
|
|
<span className="text-coral-500"> *</span>
|
|
</label>
|
|
<textarea
|
|
id="create-skill-description"
|
|
value={description}
|
|
onChange={e => setDescription(e.target.value)}
|
|
required
|
|
rows={3}
|
|
maxLength={500}
|
|
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
|
|
placeholder={t('skills.create.descriptionPlaceholder')}
|
|
/>
|
|
</div>
|
|
|
|
{/* When to use — the workflow's trigger/goal. Optional; the backend
|
|
falls back to the description. This is what makes a workflow more
|
|
than a bare procedure: it tells the agent WHEN to run it. */}
|
|
<div>
|
|
<label
|
|
htmlFor="create-skill-when-to-use"
|
|
className="block text-xs font-medium text-stone-600 dark:text-neutral-300">
|
|
{t('skills.create.whenToUse')}
|
|
<span className="ml-1 font-normal text-stone-400 dark:text-neutral-500">
|
|
{t('skills.create.optional')}
|
|
</span>
|
|
</label>
|
|
<textarea
|
|
id="create-skill-when-to-use"
|
|
value={whenToUse}
|
|
onChange={e => setWhenToUse(e.target.value)}
|
|
rows={2}
|
|
maxLength={500}
|
|
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
|
|
placeholder={t('skills.create.whenToUsePlaceholder')}
|
|
/>
|
|
<p className="mt-1 text-[11px] text-stone-500 dark:text-neutral-400">
|
|
{t('skills.create.whenToUseHelp')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Inputs (optional) — declare [[inputs]] for the generated
|
|
skill.toml. The Skills Runner reads this to render dynamic
|
|
form controls per input (text / number / checkbox). The
|
|
section stays optional — formValid doesn't depend on
|
|
non-empty rows — but every row that exists must have a
|
|
valid, non-empty name (regex enforced) so the Rust side
|
|
never receives a malformed [[inputs]] entry. */}
|
|
<div>
|
|
<div className="flex items-baseline justify-between">
|
|
<label className="block text-xs font-medium text-stone-600 dark:text-neutral-300">
|
|
{t('skills.create.inputs.heading')}
|
|
<span className="ml-1 font-normal text-stone-400 dark:text-neutral-500">
|
|
{t('skills.create.optional')}
|
|
</span>
|
|
</label>
|
|
<Button
|
|
variant="tertiary"
|
|
size="xs"
|
|
data-testid="create-skill-add-input"
|
|
onClick={addRow}
|
|
className="px-0 text-primary-600 hover:bg-transparent hover:text-primary-700">
|
|
+ {t('skills.create.inputs.add')}
|
|
</Button>
|
|
</div>
|
|
<p className="mt-0.5 text-[11px] text-stone-500 dark:text-neutral-400">
|
|
{t('skills.create.inputs.help')}
|
|
</p>
|
|
{inputs.length > 0 && (
|
|
<div className="mt-2 space-y-2">
|
|
{inputs.map(row => {
|
|
const trimmed = row.name.trim();
|
|
const showNameErr = row.name.length > 0 && !NAME_RE.test(trimmed);
|
|
// A row is "in use" once it has a name; at that point its
|
|
// description is required (see `inputsValid`).
|
|
const showDescErr =
|
|
row.name.trim().length > 0 && row.description.trim().length === 0;
|
|
return (
|
|
<div
|
|
key={row.localId}
|
|
data-testid={`create-skill-input-row-${row.localId}`}
|
|
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-950/40 p-3">
|
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[1fr_1fr_auto]">
|
|
<div>
|
|
<input
|
|
type="text"
|
|
value={row.name}
|
|
onChange={e => updateRow(row.localId, { name: e.target.value })}
|
|
maxLength={64}
|
|
placeholder={t('skills.create.inputs.row.namePlaceholder')}
|
|
aria-label={t('skills.create.inputs.row.name')}
|
|
className={`w-full rounded-md border bg-white dark:bg-neutral-900 px-2 py-1.5 text-xs text-stone-900 dark:text-neutral-100 shadow-sm focus:outline-none focus:ring-2 focus:ring-primary-500/30 ${showNameErr ? 'border-coral-400' : 'border-stone-200 dark:border-neutral-800 focus:border-primary-500'}`}
|
|
/>
|
|
{showNameErr && (
|
|
<p className="mt-0.5 text-[10px] text-coral-600">
|
|
{t('skills.create.inputs.row.nameError')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<input
|
|
type="text"
|
|
value={row.description}
|
|
onChange={e => updateRow(row.localId, { description: e.target.value })}
|
|
maxLength={256}
|
|
placeholder={t('skills.create.inputs.row.descriptionPlaceholder')}
|
|
aria-label={t('skills.create.inputs.row.description')}
|
|
className={`w-full rounded-md border bg-white dark:bg-neutral-900 px-2 py-1.5 text-xs text-stone-900 dark:text-neutral-100 shadow-sm focus:outline-none focus:ring-2 focus:ring-primary-500/30 ${showDescErr ? 'border-coral-400' : 'border-stone-200 dark:border-neutral-800 focus:border-primary-500'}`}
|
|
/>
|
|
{showDescErr && (
|
|
<p className="mt-0.5 text-[10px] text-coral-600">
|
|
{t('skills.create.inputs.row.descriptionError')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<Button
|
|
iconOnly
|
|
variant="tertiary"
|
|
tone="danger"
|
|
size="sm"
|
|
data-testid={`create-skill-remove-input-${row.localId}`}
|
|
onClick={() => removeRow(row.localId)}
|
|
aria-label={t('skills.create.inputs.row.remove')}
|
|
className="self-center">
|
|
🗑
|
|
</Button>
|
|
</div>
|
|
<div className="mt-2 flex items-center gap-3 text-[11px]">
|
|
<label className="flex items-center gap-1">
|
|
<span className="text-stone-500 dark:text-neutral-400">
|
|
{t('skills.create.inputs.row.type')}:
|
|
</span>
|
|
<select
|
|
value={row.type}
|
|
onChange={e =>
|
|
updateRow(row.localId, { type: e.target.value as InputRow['type'] })
|
|
}
|
|
aria-label={t('skills.create.inputs.row.type')}
|
|
className="rounded border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-1 py-0.5 text-[11px] text-stone-900 dark:text-neutral-100">
|
|
<option value="string">{t('skills.create.inputs.type.string')}</option>
|
|
<option value="integer">{t('skills.create.inputs.type.integer')}</option>
|
|
<option value="boolean">{t('skills.create.inputs.type.boolean')}</option>
|
|
</select>
|
|
</label>
|
|
<label className="flex items-center gap-1">
|
|
<input
|
|
type="checkbox"
|
|
checked={row.required}
|
|
onChange={e => updateRow(row.localId, { required: e.target.checked })}
|
|
className="h-3 w-3 accent-primary-500"
|
|
/>
|
|
<span className="text-stone-500 dark:text-neutral-400">
|
|
{t('skills.create.inputs.row.required')}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Error */}
|
|
{error ? (
|
|
<div
|
|
role="alert"
|
|
className="rounded-xl border border-coral-200 bg-coral-50 p-3 text-xs text-coral-900">
|
|
<p className="font-semibold">{t('workflows.create.createError')}</p>
|
|
<p className="mt-1 whitespace-pre-wrap font-mono">{error}</p>
|
|
</div>
|
|
) : null}
|
|
</form>
|
|
);
|
|
}
|
|
);
|
|
|
|
export default CreateWorkflowForm;
|