feat: add Skills management settings UI and API routes (AIIA-97)

- Skill types (src/types/skill.ts) with form data, slugify helper
- Skills settings component with list view (drag-and-drop reorder),
  editor form (name, slug, prompt, output type, JSON schema, context
  requirements, active toggle), system skill protection
- API routes: GET/POST /api/settings/skills, GET/PATCH/DELETE
  /api/settings/skills/[id], PATCH /api/settings/skills/reorder
- Integrated into /einstellungen page (admin only)
- API routes depend on `skills` table from AIIA-94 schema migration

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Frontend Engineer
2026-04-13 19:46:38 +00:00
parent e60b27cbd4
commit d15476f5e9
6 changed files with 831 additions and 0 deletions

View File

@@ -6,6 +6,7 @@ import { tenants, users } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import AISettingsForm from './ai-settings';
import ApiKeySettings from './api-key-settings';
import SkillsSettings from './skills-settings';
const ROLE_LABELS: Record<string, string> = {
admin: 'Administrator',
@@ -76,6 +77,8 @@ export default async function EinstellungenPage() {
{isAdmin && <ApiKeySettings />}
{isAdmin && <SkillsSettings />}
{isAdmin && tenantUsers.length > 0 && (
<div className="bg-card-bg border border-card-border rounded-xl p-6">
<h3 className="text-sm font-semibold text-foreground mb-4">Benutzer</h3>

View File

@@ -0,0 +1,496 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import type { Skill, SkillFormData } from '@/types/skill';
import { emptySkillForm, slugify } from '@/types/skill';
const OUTPUT_TYPE_LABELS: Record<string, string> = {
analysis: 'Analyse',
structured_data: 'Strukturierte Daten',
};
export default function SkillsSettings() {
const [skills, setSkills] = useState<Skill[]>([]);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Editor state
const [editing, setEditing] = useState<Skill | null>(null); // null = creating new
const [showEditor, setShowEditor] = useState(false);
const [form, setForm] = useState<SkillFormData>(emptySkillForm());
const [saving, setSaving] = useState(false);
const [slugManual, setSlugManual] = useState(false);
// Drag state
const [dragIdx, setDragIdx] = useState<number | null>(null);
const loadSkills = useCallback(async () => {
try {
const res = await fetch('/api/settings/skills');
if (res.ok) {
const data = await res.json();
setSkills(data);
}
} catch {
// ignore
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadSkills();
}, [loadSkills]);
function openCreate() {
setEditing(null);
setForm(emptySkillForm());
setSlugManual(false);
setShowEditor(true);
setMessage(null);
}
function openEdit(skill: Skill) {
setEditing(skill);
setForm({
name: skill.name,
slug: skill.slug,
description: skill.description ?? '',
systemPrompt: skill.systemPrompt,
outputType: skill.outputType,
outputSchema: skill.outputSchema ? JSON.stringify(skill.outputSchema, null, 2) : '',
requiresNorms: skill.requiresNorms,
requiresDecisions: skill.requiresDecisions,
isActive: skill.isActive,
});
setSlugManual(true);
setShowEditor(true);
setMessage(null);
}
function closeEditor() {
setShowEditor(false);
setEditing(null);
setForm(emptySkillForm());
setMessage(null);
}
function handleNameChange(name: string) {
setForm((f) => ({
...f,
name,
...(!slugManual && { slug: slugify(name) }),
}));
}
function handleSlugChange(slug: string) {
setSlugManual(true);
setForm((f) => ({ ...f, slug }));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.name.trim() || !form.slug.trim() || !form.systemPrompt.trim()) {
setMessage({ type: 'error', text: 'Name, Slug und System-Prompt sind Pflichtfelder.' });
return;
}
// Validate JSON schema if structured data
let outputSchema: Record<string, unknown> | null = null;
if (form.outputType === 'structured_data') {
if (!form.outputSchema.trim()) {
setMessage({ type: 'error', text: 'JSON Schema ist bei strukturierten Daten erforderlich.' });
return;
}
try {
outputSchema = JSON.parse(form.outputSchema);
} catch {
setMessage({ type: 'error', text: 'Ungültiges JSON im Schema-Feld.' });
return;
}
}
setSaving(true);
setMessage(null);
const payload = {
name: form.name.trim(),
slug: form.slug.trim(),
description: form.description.trim() || null,
systemPrompt: form.systemPrompt,
outputType: form.outputType,
outputSchema,
requiresNorms: form.requiresNorms,
requiresDecisions: form.requiresDecisions,
isActive: form.isActive,
};
try {
const url = editing
? `/api/settings/skills/${editing.id}`
: '/api/settings/skills';
const method = editing ? 'PATCH' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (res.ok) {
setMessage({ type: 'success', text: editing ? 'Skill aktualisiert.' : 'Skill erstellt.' });
closeEditor();
await loadSkills();
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error ?? 'Fehler beim Speichern.' });
}
} catch {
setMessage({ type: 'error', text: 'Netzwerkfehler.' });
} finally {
setSaving(false);
}
}
async function handleDelete(skill: Skill) {
if (skill.isSystem) return;
if (!confirm(`Skill "${skill.name}" wirklich löschen?`)) return;
try {
const res = await fetch(`/api/settings/skills/${skill.id}`, { method: 'DELETE' });
if (res.ok) {
setMessage({ type: 'success', text: 'Skill deaktiviert.' });
await loadSkills();
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error ?? 'Fehler beim Löschen.' });
}
} catch {
setMessage({ type: 'error', text: 'Netzwerkfehler.' });
}
}
async function handleToggleActive(skill: Skill) {
try {
const res = await fetch(`/api/settings/skills/${skill.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive: !skill.isActive }),
});
if (res.ok) await loadSkills();
} catch {
// ignore
}
}
function handleResetToDefault(skill: Skill) {
if (!skill.isSystem) return;
// Prefill with current values — the backend handles "reset" by restoring original system prompt
// For now, we just re-fetch. A future enhancement could add a dedicated reset endpoint.
setMessage({ type: 'error', text: 'Zurücksetzen auf Standard wird noch nicht unterstützt.' });
}
// Drag-and-drop reorder
function handleDragStart(idx: number) {
setDragIdx(idx);
}
function handleDragOver(e: React.DragEvent, idx: number) {
e.preventDefault();
if (dragIdx === null || dragIdx === idx) return;
const reordered = [...skills];
const [moved] = reordered.splice(dragIdx, 1);
reordered.splice(idx, 0, moved);
setSkills(reordered);
setDragIdx(idx);
}
async function handleDragEnd() {
if (dragIdx === null) return;
setDragIdx(null);
const order = skills.map((s, i) => ({ id: s.id, sortOrder: i }));
try {
await fetch('/api/settings/skills/reorder', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order }),
});
} catch {
await loadSkills(); // revert on error
}
}
if (loading) {
return (
<div className="bg-card-bg border border-card-border rounded-xl p-6">
<p className="text-sm text-muted">Lade Skills...</p>
</div>
);
}
return (
<div className="bg-card-bg border border-card-border rounded-xl p-6 space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">Skills (Analysemodi)</h3>
{!showEditor && (
<button
type="button"
onClick={openCreate}
className="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-white hover:bg-primary/90"
>
+ Neuer Skill
</button>
)}
</div>
{/* Message */}
{message && !showEditor && (
<p className={`text-sm ${message.type === 'success' ? 'text-green-500' : 'text-red-500'}`}>
{message.text}
</p>
)}
{/* Editor */}
{showEditor && (
<form onSubmit={handleSubmit} className="space-y-4 border-t border-card-border pt-4">
<p className="text-xs font-medium text-muted uppercase tracking-wide">
{editing ? `Skill bearbeiten: ${editing.name}` : 'Neuen Skill erstellen'}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-sm text-muted mb-1">Name *</label>
<input
type="text"
value={form.name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="z.B. Rechtsgutachten"
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted/50"
/>
</div>
<div>
<label className="block text-sm text-muted mb-1">Slug *</label>
<input
type="text"
value={form.slug}
onChange={(e) => handleSlugChange(e.target.value)}
placeholder="z.B. rechtsgutachten"
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground font-mono placeholder:text-muted/50"
/>
<p className="text-xs text-muted mt-1">Eindeutiger Bezeichner (Kleinbuchstaben, Bindestriche)</p>
</div>
</div>
<div>
<label className="block text-sm text-muted mb-1">Beschreibung</label>
<input
type="text"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="Kurze Beschreibung des Skills"
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted/50"
/>
</div>
<div>
<label className="block text-sm text-muted mb-1">System-Prompt *</label>
<textarea
value={form.systemPrompt}
onChange={(e) => setForm({ ...form, systemPrompt: e.target.value })}
rows={20}
placeholder="Der System-Prompt, der an das AI-Modell gesendet wird..."
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground font-mono placeholder:text-muted/50 resize-y"
/>
</div>
<div>
<label className="block text-sm text-muted mb-1">Ausgabetyp</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
<input
type="radio"
name="outputType"
value="analysis"
checked={form.outputType === 'analysis'}
onChange={() => setForm({ ...form, outputType: 'analysis' })}
className="accent-primary"
/>
Analyse (Freitext)
</label>
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
<input
type="radio"
name="outputType"
value="structured_data"
checked={form.outputType === 'structured_data'}
onChange={() => setForm({ ...form, outputType: 'structured_data' })}
className="accent-primary"
/>
Strukturierte Daten
</label>
</div>
</div>
{form.outputType === 'structured_data' && (
<div>
<label className="block text-sm text-muted mb-1">JSON Schema *</label>
<textarea
value={form.outputSchema}
onChange={(e) => setForm({ ...form, outputSchema: e.target.value })}
rows={10}
placeholder='{"type": "object", "properties": {...}}'
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground font-mono placeholder:text-muted/50 resize-y"
/>
</div>
)}
<div className="flex flex-wrap gap-6">
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
<input
type="checkbox"
checked={form.requiresNorms}
onChange={(e) => setForm({ ...form, requiresNorms: e.target.checked })}
className="accent-primary"
/>
Normen benötigt
</label>
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
<input
type="checkbox"
checked={form.requiresDecisions}
onChange={(e) => setForm({ ...form, requiresDecisions: e.target.checked })}
className="accent-primary"
/>
Entscheidungen benötigt
</label>
</div>
<div>
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
<input
type="checkbox"
checked={form.isActive}
onChange={(e) => setForm({ ...form, isActive: e.target.checked })}
className="accent-primary"
/>
Aktiv
</label>
</div>
{message && showEditor && (
<p className={`text-sm ${message.type === 'success' ? 'text-green-500' : 'text-red-500'}`}>
{message.text}
</p>
)}
<div className="flex gap-2">
<button
type="submit"
disabled={saving}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
<button
type="button"
onClick={closeEditor}
className="rounded-lg border border-card-border px-4 py-2 text-sm font-medium text-muted hover:text-foreground"
>
Abbrechen
</button>
{editing?.isSystem && (
<button
type="button"
onClick={() => handleResetToDefault(editing)}
className="rounded-lg border border-amber-300 px-4 py-2 text-sm font-medium text-amber-600 hover:bg-amber-50"
>
Standard wiederherstellen
</button>
)}
</div>
</form>
)}
{/* Skills list */}
{!showEditor && skills.length === 0 && (
<p className="text-sm text-muted">
Keine Skills vorhanden. Erstellen Sie einen neuen Skill oder warten Sie auf die System-Skills.
</p>
)}
{!showEditor && skills.length > 0 && (
<div className="divide-y divide-card-border">
{skills.map((skill, idx) => (
<div
key={skill.id}
draggable
onDragStart={() => handleDragStart(idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={handleDragEnd}
className={`py-3 flex items-center justify-between gap-3 ${
dragIdx === idx ? 'opacity-50' : ''
}`}
>
<div className="flex items-center gap-3 min-w-0">
<span
className="cursor-grab text-muted hover:text-foreground shrink-0"
title="Ziehen zum Sortieren"
>
</span>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">{skill.name}</span>
<span className="text-xs text-muted font-mono">({skill.slug})</span>
<span
className={`inline-block w-2 h-2 rounded-full shrink-0 ${
skill.isActive ? 'bg-green-500' : 'bg-gray-400'
}`}
title={skill.isActive ? 'Aktiv' : 'Inaktiv'}
/>
{skill.isSystem && (
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
System
</span>
)}
</div>
<p className="text-xs text-muted mt-0.5 truncate">
{skill.description ?? '—'} · {OUTPUT_TYPE_LABELS[skill.outputType] ?? skill.outputType}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={() => handleToggleActive(skill)}
className="text-xs px-2 py-1 rounded border border-card-border text-muted hover:text-foreground"
>
{skill.isActive ? 'Deaktivieren' : 'Aktivieren'}
</button>
<button
type="button"
onClick={() => openEdit(skill)}
className="text-xs px-2 py-1 rounded border border-card-border text-muted hover:text-foreground"
>
Bearbeiten
</button>
{!skill.isSystem && (
<button
type="button"
onClick={() => handleDelete(skill)}
className="text-xs px-2 py-1 rounded border border-red-300 text-red-500 hover:bg-red-50"
>
Löschen
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,142 @@
// GET /api/settings/skills/[id] — Get skill detail
// PATCH /api/settings/skills/[id] — Update a skill
// DELETE /api/settings/skills/[id] — Soft-delete (set isActive = false)
import { db } from '@/lib/db';
import { skills } from '@/lib/db/schema';
import { eq, and } from 'drizzle-orm';
import { requirePermission } from '@/lib/auth/rbac';
async function findSkillForTenant(skillId: string, tenantId: string) {
const [skill] = await db
.select()
.from(skills)
.where(and(eq(skills.id, skillId), eq(skills.tenantId, tenantId)))
.limit(1);
return skill ?? null;
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const auth = await requirePermission('settings:manage');
if ('response' in auth) return auth.response;
const { id } = await params;
const skill = await findSkillForTenant(id, auth.ctx.tenantId);
if (!skill) {
return Response.json({ error: 'Skill nicht gefunden.' }, { status: 404 });
}
return Response.json(skill);
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const auth = await requirePermission('settings:manage');
if ('response' in auth) return auth.response;
const { ctx } = auth;
const { id } = await params;
const skill = await findSkillForTenant(id, ctx.tenantId);
if (!skill) {
return Response.json({ error: 'Skill nicht gefunden.' }, { status: 404 });
}
const body = await request.json();
const {
name, slug, description, systemPrompt, outputType,
outputSchema, requiresNorms, requiresDecisions, isActive,
} = body as {
name?: string;
slug?: string;
description?: string | null;
systemPrompt?: string;
outputType?: string;
outputSchema?: Record<string, unknown> | null;
requiresNorms?: boolean;
requiresDecisions?: boolean;
isActive?: boolean;
};
// Validate slug if changed
if (slug !== undefined && slug !== skill.slug) {
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(slug)) {
return Response.json(
{ error: 'Slug muss Kleinbuchstaben, Ziffern und Bindestriche enthalten.' },
{ status: 400 },
);
}
const existing = await db
.select({ id: skills.id })
.from(skills)
.where(and(eq(skills.tenantId, ctx.tenantId), eq(skills.slug, slug)))
.limit(1);
if (existing.length > 0 && existing[0].id !== id) {
return Response.json(
{ error: 'Ein Skill mit diesem Slug existiert bereits.' },
{ status: 409 },
);
}
}
if (outputType === 'structured_data' && outputSchema === null) {
return Response.json(
{ error: 'JSON Schema ist bei strukturierten Daten erforderlich.' },
{ status: 400 },
);
}
const updates: Record<string, unknown> = { updatedAt: new Date() };
if (name !== undefined) updates.name = name;
if (slug !== undefined) updates.slug = slug;
if (description !== undefined) updates.description = description;
if (systemPrompt !== undefined) updates.systemPrompt = systemPrompt;
if (outputType !== undefined) updates.outputType = outputType;
if (outputSchema !== undefined) updates.outputSchema = outputSchema;
if (requiresNorms !== undefined) updates.requiresNorms = requiresNorms;
if (requiresDecisions !== undefined) updates.requiresDecisions = requiresDecisions;
if (isActive !== undefined) updates.isActive = isActive;
const [updated] = await db
.update(skills)
.set(updates)
.where(eq(skills.id, id))
.returning();
return Response.json(updated);
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const auth = await requirePermission('settings:manage');
if ('response' in auth) return auth.response;
const { ctx } = auth;
const { id } = await params;
const skill = await findSkillForTenant(id, ctx.tenantId);
if (!skill) {
return Response.json({ error: 'Skill nicht gefunden.' }, { status: 404 });
}
if (skill.isSystem) {
return Response.json(
{ error: 'System-Skills können nicht gelöscht werden. Deaktivieren Sie den Skill stattdessen.' },
{ status: 400 },
);
}
// Soft-delete: set isActive = false
const [updated] = await db
.update(skills)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(skills.id, id))
.returning();
return Response.json(updated);
}

View File

@@ -0,0 +1,32 @@
// PATCH /api/settings/skills/reorder — Update sort_order for drag-and-drop reordering
import { db } from '@/lib/db';
import { skills } from '@/lib/db/schema';
import { eq, and } from 'drizzle-orm';
import { requirePermission } from '@/lib/auth/rbac';
export async function PATCH(request: Request) {
const auth = await requirePermission('settings:manage');
if ('response' in auth) return auth.response;
const { ctx } = auth;
const body = await request.json();
const { order } = body as {
order?: Array<{ id: string; sortOrder: number }>;
};
if (!order || !Array.isArray(order)) {
return Response.json({ error: 'order Array ist erforderlich.' }, { status: 400 });
}
// Update each skill's sortOrder within a transaction-like loop
// Verify tenant ownership for each skill
for (const item of order) {
await db
.update(skills)
.set({ sortOrder: item.sortOrder, updatedAt: new Date() })
.where(and(eq(skills.id, item.id), eq(skills.tenantId, ctx.tenantId)));
}
return Response.json({ ok: true });
}

View File

@@ -0,0 +1,101 @@
// GET /api/settings/skills — List all skills for tenant (sorted by sortOrder)
// POST /api/settings/skills — Create a new skill
import { db } from '@/lib/db';
import { skills } from '@/lib/db/schema';
import { eq, and, asc } from 'drizzle-orm';
import { requirePermission } from '@/lib/auth/rbac';
export async function GET() {
const auth = await requirePermission('settings:manage');
if ('response' in auth) return auth.response;
const rows = await db
.select()
.from(skills)
.where(eq(skills.tenantId, auth.ctx.tenantId))
.orderBy(asc(skills.sortOrder), asc(skills.createdAt));
return Response.json(rows);
}
export async function POST(request: Request) {
const auth = await requirePermission('settings:manage');
if ('response' in auth) return auth.response;
const { ctx } = auth;
const body = await request.json();
const { name, slug, description, systemPrompt, outputType, outputSchema, requiresNorms, requiresDecisions, isActive } = body as {
name?: string;
slug?: string;
description?: string | null;
systemPrompt?: string;
outputType?: string;
outputSchema?: Record<string, unknown> | null;
requiresNorms?: boolean;
requiresDecisions?: boolean;
isActive?: boolean;
};
if (!name || !slug || !systemPrompt) {
return Response.json(
{ error: 'Name, Slug und System-Prompt sind erforderlich.' },
{ status: 400 },
);
}
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(slug)) {
return Response.json(
{ error: 'Slug muss Kleinbuchstaben, Ziffern und Bindestriche enthalten.' },
{ status: 400 },
);
}
if (outputType === 'structured_data' && !outputSchema) {
return Response.json(
{ error: 'JSON Schema ist bei strukturierten Daten erforderlich.' },
{ status: 400 },
);
}
// Check slug uniqueness within tenant
const existing = await db
.select({ id: skills.id })
.from(skills)
.where(and(eq(skills.tenantId, ctx.tenantId), eq(skills.slug, slug)))
.limit(1);
if (existing.length > 0) {
return Response.json(
{ error: 'Ein Skill mit diesem Slug existiert bereits.' },
{ status: 409 },
);
}
// Get max sort order for positioning
const allSkills = await db
.select({ sortOrder: skills.sortOrder })
.from(skills)
.where(eq(skills.tenantId, ctx.tenantId));
const maxOrder = allSkills.reduce((max, s) => Math.max(max, s.sortOrder), -1);
const [created] = await db
.insert(skills)
.values({
tenantId: ctx.tenantId,
name,
slug,
description: description || null,
systemPrompt,
outputType: (outputType as 'analysis' | 'structured_data') || 'analysis',
outputSchema: outputSchema || null,
requiresNorms: requiresNorms ?? false,
requiresDecisions: requiresDecisions ?? false,
isSystem: false,
sortOrder: maxOrder + 1,
isActive: isActive ?? true,
})
.returning();
return Response.json(created, { status: 201 });
}

57
src/types/skill.ts Normal file
View File

@@ -0,0 +1,57 @@
// Skill types for the Skills management feature
export interface Skill {
id: string;
tenantId: string;
slug: string;
name: string;
description: string | null;
systemPrompt: string;
outputType: 'analysis' | 'structured_data';
outputSchema: Record<string, unknown> | null;
requiresNorms: boolean;
requiresDecisions: boolean;
isSystem: boolean;
sortOrder: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface SkillFormData {
name: string;
slug: string;
description: string;
systemPrompt: string;
outputType: 'analysis' | 'structured_data';
outputSchema: string; // JSON string for the textarea
requiresNorms: boolean;
requiresDecisions: boolean;
isActive: boolean;
}
export function emptySkillForm(): SkillFormData {
return {
name: '',
slug: '',
description: '',
systemPrompt: '',
outputType: 'analysis',
outputSchema: '',
requiresNorms: false,
requiresDecisions: false,
isActive: true,
};
}
/** Generate a URL-safe slug from a name */
export function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[äÄ]/g, 'ae')
.replace(/[öÖ]/g, 'oe')
.replace(/[üÜ]/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}