Implement Skills feature: filesystem-based skills with agent assignment

- Add SkillService: scans data/skills/ directory, parses _meta.json and SKILL.md frontmatter
- Add SkillEndpoint: REST API for listing skills and managing agent-skill assignments
- Add agent_skills DB table for agent-to-skill mapping (only relation in DB, skills live on filesystem)
- Extend SystemPromptBuilder: inject assigned skills' SKILL.md content into system prompt
- Extend ChatEndpoint + SessionEndpoint: load agent skills and pass to prompt builder
- Frontend: skill checkboxes in agent form, API functions for skill management
- Add form.css styling for skill list items
This commit is contained in:
Meleki committed 2026-03-01 17:22:14 +01:00
1 parent b18dc78651
commit cf6caea2dc
11 files changed
+454 -6

No files matched your search

+3 -2
View File
@@ -23,7 +23,7 @@ public static class ChatEndpoint
app.MapPost("/api/chat", HandleChat); app.MapPost("/api/chat", HandleChat);
} }
private static async Task HandleChat(HttpRequest req, HttpResponse res, IHttpClientFactory httpFactory, IConfiguration config, ToolService toolService, DatabaseService db, StreamingTracker streamingTracker, MemoryService memoryService, CancellationToken cancellationToken) private static async Task HandleChat(HttpRequest req, HttpResponse res, IHttpClientFactory httpFactory, IConfiguration config, ToolService toolService, DatabaseService db, StreamingTracker streamingTracker, MemoryService memoryService, SkillService skillService, CancellationToken cancellationToken)
{ {
var body = await JsonSerializer.DeserializeAsync<ChatRequest>(req.Body); var body = await JsonSerializer.DeserializeAsync<ChatRequest>(req.Body);
if (body == null || body.Messages == null || body.Messages.Count == 0) if (body == null || body.Messages == null || body.Messages.Count == 0)
@@ -80,7 +80,8 @@ public static class ChatEndpoint
var markedMessages = await db.GetMarkedMessages(sessionId); var markedMessages = await db.GetMarkedMessages(sessionId);
var agentMemoryDir = memoryService.GetAgentMemoryDir(agent.AgentId); var agentMemoryDir = memoryService.GetAgentMemoryDir(agent.AgentId);
var dataDir = memoryService.DataDir; var dataDir = memoryService.DataDir;
var systemPromptText = SystemPromptBuilder.Build(agent, session, markedMessages, memoryService, agentMemoryDir, dataDir); var agentSkills = await db.GetAgentSkills(agent.AgentId);
var systemPromptText = SystemPromptBuilder.Build(agent, session, markedMessages, memoryService, agentMemoryDir, dataDir, agentSkills, skillService);
var chainId = Guid.NewGuid().ToString("N"); var chainId = Guid.NewGuid().ToString("N");
+3 -2
View File
@@ -91,7 +91,7 @@ public static class SessionEndpoint
ctx.Response.StatusCode = 204; ctx.Response.StatusCode = 204;
} }
private static async Task HandleSystemPrompt(HttpContext ctx, DatabaseService db, MemoryService memoryService) private static async Task HandleSystemPrompt(HttpContext ctx, DatabaseService db, MemoryService memoryService, SkillService skillService)
{ {
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString(); var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
if (string.IsNullOrEmpty(sessionId)) if (string.IsNullOrEmpty(sessionId))
@@ -125,7 +125,8 @@ public static class SessionEndpoint
var markedMessages = await db.GetMarkedMessages(sessionId); var markedMessages = await db.GetMarkedMessages(sessionId);
var agentMemoryDir = memoryService.GetAgentMemoryDir(agent.AgentId); var agentMemoryDir = memoryService.GetAgentMemoryDir(agent.AgentId);
var dataDir = memoryService.DataDir; var dataDir = memoryService.DataDir;
var systemPrompt = SystemPromptBuilder.Build(agent, session, markedMessages, memoryService, agentMemoryDir, dataDir); var agentSkills = await db.GetAgentSkills(agent.AgentId);
var systemPrompt = SystemPromptBuilder.Build(agent, session, markedMessages, memoryService, agentMemoryDir, dataDir, agentSkills, skillService);
ctx.Response.ContentType = "text/plain; charset=utf-8"; ctx.Response.ContentType = "text/plain; charset=utf-8";
await ctx.Response.WriteAsync(systemPrompt); await ctx.Response.WriteAsync(systemPrompt);
+82
View File
@@ -0,0 +1,82 @@
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Gott.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Gott.Endpoints;
public static class SkillEndpoint
{
public static void MapSkillEndpoints(this IEndpointRouteBuilder app)
{
// Skills (Dateisystem-basiert)
app.MapGet("/api/skills", HandleListSkills);
// Agent ↔ Skill Zuordnung
app.MapGet("/api/agents/{agentId}/skills", HandleGetAgentSkills);
app.MapPut("/api/agents/{agentId}/skills", HandleSetAgentSkills);
app.MapPost("/api/agents/{agentId}/skills/{slug}", HandleAddAgentSkill);
app.MapDelete("/api/agents/{agentId}/skills/{slug}", HandleRemoveAgentSkill);
}
private static async Task HandleListSkills(HttpContext ctx, SkillService skillService)
{
var skills = skillService.GetAllSkills();
await ctx.Response.WriteAsJsonAsync(skills);
}
private static async Task HandleGetAgentSkills(HttpContext ctx, DatabaseService db, SkillService skillService)
{
var agentId = ctx.Request.RouteValues["agentId"]?.ToString();
if (string.IsNullOrEmpty(agentId)) { ctx.Response.StatusCode = 400; return; }
var slugs = await db.GetAgentSkills(agentId);
var allSkills = skillService.GetAllSkills();
// Nur Skills zurückgeben die auch wirklich existieren
var result = allSkills
.Where(s => slugs.Contains(s.Slug))
.ToList();
await ctx.Response.WriteAsJsonAsync(result);
}
private static async Task HandleSetAgentSkills(HttpContext ctx, DatabaseService db)
{
var agentId = ctx.Request.RouteValues["agentId"]?.ToString();
if (string.IsNullOrEmpty(agentId)) { ctx.Response.StatusCode = 400; return; }
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
var slugs = body.TryGetProperty("skills", out var arr)
? arr.EnumerateArray().Select(e => e.GetString()).Where(s => !string.IsNullOrEmpty(s)).ToList()
: new System.Collections.Generic.List<string>();
await db.SetAgentSkills(agentId, slugs);
var updated = await db.GetAgentSkills(agentId);
await ctx.Response.WriteAsJsonAsync(updated);
}
private static async Task HandleAddAgentSkill(HttpContext ctx, DatabaseService db)
{
var agentId = ctx.Request.RouteValues["agentId"]?.ToString();
var slug = ctx.Request.RouteValues["slug"]?.ToString();
if (string.IsNullOrEmpty(agentId) || string.IsNullOrEmpty(slug)) { ctx.Response.StatusCode = 400; return; }
await db.AddAgentSkill(agentId, slug);
ctx.Response.StatusCode = 204;
}
private static async Task HandleRemoveAgentSkill(HttpContext ctx, DatabaseService db)
{
var agentId = ctx.Request.RouteValues["agentId"]?.ToString();
var slug = ctx.Request.RouteValues["slug"]?.ToString();
if (string.IsNullOrEmpty(agentId) || string.IsNullOrEmpty(slug)) { ctx.Response.StatusCode = 400; return; }
await db.RemoveAgentSkill(agentId, slug);
ctx.Response.StatusCode = 204;
}
}
+2
View File
@@ -14,6 +14,7 @@ builder.Services.AddSingleton(new ToolService());
builder.Services.AddSingleton(new ImageService()); builder.Services.AddSingleton(new ImageService());
builder.Services.AddSingleton(new StreamingTracker()); builder.Services.AddSingleton(new StreamingTracker());
builder.Services.AddSingleton(new MemoryService(dataDir)); builder.Services.AddSingleton(new MemoryService(dataDir));
builder.Services.AddSingleton(new SkillService(dataDir));
builder.Services.AddSingleton(new DatabaseService(Path.Combine(dataDir, "gott.db"))); builder.Services.AddSingleton(new DatabaseService(Path.Combine(dataDir, "gott.db")));
var app = builder.Build(); var app = builder.Build();
@@ -27,5 +28,6 @@ app.MapAgentEndpoints();
app.MapWorkspaceEndpoint(); app.MapWorkspaceEndpoint();
app.MapInfoEndpoint(); app.MapInfoEndpoint();
app.MapBackupEndpoint(); app.MapBackupEndpoint();
app.MapSkillEndpoints();
app.Run("http://0.0.0.0:19000"); app.Run("http://0.0.0.0:19000");
+58
View File
@@ -134,6 +134,17 @@ public class DatabaseService
TryAddColumn(conn, "agents", "use_identity", "INTEGER NOT NULL DEFAULT 1"); TryAddColumn(conn, "agents", "use_identity", "INTEGER NOT NULL DEFAULT 1");
TryAddColumn(conn, "agents", "use_user_info", "INTEGER NOT NULL DEFAULT 1"); TryAddColumn(conn, "agents", "use_user_info", "INTEGER NOT NULL DEFAULT 1");
TryAddColumn(conn, "agents", "use_memory", "INTEGER NOT NULL DEFAULT 1"); TryAddColumn(conn, "agents", "use_memory", "INTEGER NOT NULL DEFAULT 1");
conn.Execute(@"
CREATE TABLE IF NOT EXISTS agent_skills (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT NOT NULL,
skill_slug TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(agent_id, skill_slug)
);
CREATE INDEX IF NOT EXISTS idx_agent_skills_agent ON agent_skills(agent_id);
");
} }
private void TryAddColumn(SqliteConnection conn, string table, string column, string type) private void TryAddColumn(SqliteConnection conn, string table, string column, string type)
@@ -570,4 +581,51 @@ public class DatabaseService
ORDER BY m.id", ORDER BY m.id",
new { sessionId }); new { sessionId });
} }
// ── Agent Skills ──
public async Task<IEnumerable<string>> GetAgentSkills(string agentId)
{
using var conn = CreateConnection();
return await conn.QueryAsync<string>(
"SELECT skill_slug FROM agent_skills WHERE agent_id = @agentId ORDER BY skill_slug",
new { agentId });
}
public async Task SetAgentSkills(string agentId, IEnumerable<string> skillSlugs)
{
using var conn = CreateConnection();
using var tx = conn.BeginTransaction();
await conn.ExecuteAsync("DELETE FROM agent_skills WHERE agent_id = @agentId", new { agentId }, tx);
var now = DateTime.UtcNow.ToString("o");
foreach (var slug in skillSlugs)
{
await conn.ExecuteAsync(@"
INSERT INTO agent_skills (agent_id, skill_slug, created_at)
VALUES (@agentId, @slug, @now)",
new { agentId, slug, now }, tx);
}
tx.Commit();
}
public async Task AddAgentSkill(string agentId, string skillSlug)
{
using var conn = CreateConnection();
var now = DateTime.UtcNow.ToString("o");
await conn.ExecuteAsync(@"
INSERT OR IGNORE INTO agent_skills (agent_id, skill_slug, created_at)
VALUES (@agentId, @skillSlug, @now)",
new { agentId, skillSlug, now });
}
public async Task RemoveAgentSkill(string agentId, string skillSlug)
{
using var conn = CreateConnection();
await conn.ExecuteAsync(
"DELETE FROM agent_skills WHERE agent_id = @agentId AND skill_slug = @skillSlug",
new { agentId, skillSlug });
}
} }
+138
View File
@@ -0,0 +1,138 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
namespace Gott.Services;
public class SkillService
{
private readonly string _skillsDir;
public string SkillsDir => _skillsDir;
public SkillService(string dataDir)
{
_skillsDir = Path.Combine(dataDir, "skills");
Directory.CreateDirectory(_skillsDir);
}
/// <summary>
/// Scannt den Skills-Ordner und gibt alle verfügbaren Skills zurück.
/// </summary>
public List<SkillInfo> GetAllSkills()
{
var skills = new List<SkillInfo>();
if (!Directory.Exists(_skillsDir)) return skills;
foreach (var dir in Directory.GetDirectories(_skillsDir))
{
var skill = LoadSkill(dir);
if (skill != null) skills.Add(skill);
}
return skills.OrderBy(s => s.Name).ToList();
}
/// <summary>
/// Lädt einen einzelnen Skill anhand seines Slugs.
/// </summary>
public SkillInfo GetSkill(string slug)
{
var dir = Path.Combine(_skillsDir, slug);
if (!Directory.Exists(dir)) return null;
return LoadSkill(dir);
}
/// <summary>
/// Liest die SKILL.md eines Skills. Gibt null zurück wenn nicht vorhanden.
/// </summary>
public string GetSkillPrompt(string slug)
{
var skillMd = Path.Combine(_skillsDir, slug, "SKILL.md");
if (!File.Exists(skillMd)) return null;
return File.ReadAllText(skillMd);
}
private SkillInfo LoadSkill(string dir)
{
var slug = Path.GetFileName(dir);
var metaFile = Path.Combine(dir, "_meta.json");
var skillMd = Path.Combine(dir, "SKILL.md");
var info = new SkillInfo
{
Slug = slug,
Path = Path.GetFullPath(dir),
HasSkillMd = File.Exists(skillMd)
};
// Name/Description aus SKILL.md Frontmatter lesen
if (File.Exists(skillMd))
{
var content = File.ReadAllText(skillMd);
var frontmatter = ParseFrontmatter(content);
if (frontmatter.TryGetValue("name", out var name)) info.Name = name;
if (frontmatter.TryGetValue("description", out var desc)) info.Description = desc;
if (frontmatter.TryGetValue("version", out var ver)) info.Version = ver;
}
// _meta.json als Fallback/Ergänzung
if (File.Exists(metaFile))
{
try
{
var json = File.ReadAllText(metaFile);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (string.IsNullOrEmpty(info.Name) && root.TryGetProperty("name", out var n))
info.Name = n.GetString();
if (string.IsNullOrEmpty(info.Version) && root.TryGetProperty("version", out var v))
info.Version = v.GetString();
if (root.TryGetProperty("slug", out var s) && !string.IsNullOrEmpty(s.GetString()))
info.Slug = s.GetString();
}
catch { /* Fehler in _meta.json ignorieren */ }
}
// Fallback: Name = Slug
if (string.IsNullOrEmpty(info.Name)) info.Name = slug;
return info;
}
/// <summary>
/// Parst einen einfachen YAML-Frontmatter-Block (--- ... ---) aus einer Markdown-Datei.
/// </summary>
private Dictionary<string, string> ParseFrontmatter(string content)
{
var result = new Dictionary<string, string>();
if (!content.StartsWith("---")) return result;
var end = content.IndexOf("---", 3, StringComparison.Ordinal);
if (end < 0) return result;
var frontmatter = content.Substring(3, end - 3).Trim();
foreach (var line in frontmatter.Split('\n'))
{
var colon = line.IndexOf(':');
if (colon < 0) continue;
var key = line.Substring(0, colon).Trim();
var value = line.Substring(colon + 1).Trim();
result[key] = value;
}
return result;
}
}
public class SkillInfo
{
public string Slug { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public string Version { get; set; }
public string Path { get; set; }
public bool HasSkillMd { get; set; }
}
+25 -1
View File
@@ -6,7 +6,7 @@ namespace Gott.Services;
public static class SystemPromptBuilder public static class SystemPromptBuilder
{ {
public static string Build(AgentRecord agent, SessionRecord session, IEnumerable<MessageRecord> markedMessages = null, MemoryService memoryService = null, string agentMemoryDir = null, string dataDir = null) public static string Build(AgentRecord agent, SessionRecord session, IEnumerable<MessageRecord> markedMessages = null, MemoryService memoryService = null, string agentMemoryDir = null, string dataDir = null, IEnumerable<string> skillSlugs = null, SkillService skillService = null)
{ {
var parts = new List<string>(); var parts = new List<string>();
@@ -69,6 +69,30 @@ Wenn der User auf eine bestimmte Zeile in einer Datei verweist (z.B. `[Datei: fo
if (!string.IsNullOrWhiteSpace(session.Context)) if (!string.IsNullOrWhiteSpace(session.Context))
parts.Add($"## Kontext\n{session.Context}"); parts.Add($"## Kontext\n{session.Context}");
// Skills
if (skillSlugs != null && skillService != null)
{
var skillParts = new List<string>();
foreach (var slug in skillSlugs)
{
var skillPrompt = skillService.GetSkillPrompt(slug);
if (string.IsNullOrEmpty(skillPrompt)) continue;
var skill = skillService.GetSkill(slug);
var header = skill != null ? $"### {skill.Name}" : $"### {slug}";
if (skill?.Version != null) header += $" (v{skill.Version})";
header += $"\nPfad: `{skill?.Path ?? slug}`";
skillParts.Add($"{header}\n\n{skillPrompt}");
}
if (skillParts.Count > 0)
{
var skillSection = "## Skills\nDu hast folgende Skills zur Verfügung. Nutze sie über das `execute`-Tool wenn der User etwas anfragt, das ein Skill abdeckt.\n\n"
+ string.Join("\n\n---\n\n", skillParts);
parts.Add(skillSection);
}
}
// Memory-System // Memory-System
if (agent.UseMemory && memoryService != null && !string.IsNullOrWhiteSpace(agent.AgentId)) if (agent.UseMemory && memoryService != null && !string.IsNullOrWhiteSpace(agent.AgentId))
{ {
+57
View File
@@ -123,6 +123,63 @@
font-size: var(--font-size); font-size: var(--font-size);
} }
/* ── Skills ── */
.skill-list {
display: flex;
flex-direction: column;
gap: var(--badge-spacing);
}
.skill-item {
display: flex;
align-items: center;
gap: var(--badge-spacing);
cursor: pointer;
padding: var(--badge-spacing) var(--box-spacing);
background: var(--f2-bg);
border: var(--border-width) solid var(--f2-border);
border-radius: var(--border-radius);
margin-top: 0;
}
.skill-item:hover {
border-color: var(--accent);
}
.skill-item input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--accent);
cursor: pointer;
}
.skill-name {
font-weight: 600;
}
.skill-version {
opacity: 0.5;
font-weight: 400;
}
.skill-desc {
opacity: 0.6;
font-size: var(--font-size);
margin-left: auto;
}
.skill-empty {
opacity: 0.5;
padding: var(--box-spacing);
}
.skill-empty code {
background: var(--f2-bg);
padding: 2px 6px;
border-radius: var(--border-radius);
}
.danger-btn { .danger-btn {
color: var(--dangerous); color: var(--dangerous);
border-color: var(--dangerous); border-color: var(--dangerous);
+4
View File
@@ -144,6 +144,10 @@
<div id="agentFilesSection" hidden> <div id="agentFilesSection" hidden>
<button type="button" id="agentFilesBtn" class="add-cmd-btn"><i class="fa fa-folder"></i> Dateien verwalten</button> <button type="button" id="agentFilesBtn" class="add-cmd-btn"><i class="fa fa-folder"></i> Dateien verwalten</button>
</div> </div>
<div id="agentSkillsSection" hidden>
<label>Skills</label>
<div class="skill-list" id="agentSkillList"></div>
</div>
<label>Benutzerdefinierte Befehle</label> <label>Benutzerdefinierte Befehle</label>
<div class="custom-commands" id="agentCommandsList"></div> <div class="custom-commands" id="agentCommandsList"></div>
<button type="button" id="agentAddCommandBtn" class="add-cmd-btn">+ Befehl hinzufügen</button> <button type="button" id="agentAddCommandBtn" class="add-cmd-btn">+ Befehl hinzufügen</button>
+29
View File
@@ -144,6 +144,35 @@ async function apiGitStatus(sessionId) {
return resp.json(); return resp.json();
} }
// ── Skills API ──
async function apiGetSkills() {
const resp = await fetch('/api/skills');
return resp.json();
}
async function apiGetAgentSkills(agentId) {
const resp = await fetch(`/api/agents/${agentId}/skills`);
return resp.json();
}
async function apiSetAgentSkills(agentId, skills) {
const resp = await fetch(`/api/agents/${agentId}/skills`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ skills })
});
return resp.json();
}
async function apiAddAgentSkill(agentId, slug) {
return fetch(`/api/agents/${agentId}/skills/${slug}`, { method: 'POST' });
}
async function apiRemoveAgentSkill(agentId, slug) {
return fetch(`/api/agents/${agentId}/skills/${slug}`, { method: 'DELETE' });
}
async function apiBackupDb() { async function apiBackupDb() {
const resp = await fetch('/api/backup', { method: 'POST' }); const resp = await fetch('/api/backup', { method: 'POST' });
if (!resp.ok) throw new Error('Backup fehlgeschlagen'); if (!resp.ok) throw new Error('Backup fehlgeschlagen');
+53 -1
View File
@@ -35,6 +35,8 @@ registerPage('agent', {
const addCommandBtn = document.getElementById('agentAddCommandBtn'); const addCommandBtn = document.getElementById('agentAddCommandBtn');
const filesSection = document.getElementById('agentFilesSection'); const filesSection = document.getElementById('agentFilesSection');
const filesBtn = document.getElementById('agentFilesBtn'); const filesBtn = document.getElementById('agentFilesBtn');
const skillsSection = document.getElementById('agentSkillsSection');
const skillList = document.getElementById('agentSkillList');
_agentAvatarData = null; _agentAvatarData = null;
@@ -59,6 +61,22 @@ registerPage('agent', {
// Custom Commands laden // Custom Commands laden
loadCommandsIntoUI(commandsList, agent.customCommands); loadCommandsIntoUI(commandsList, agent.customCommands);
// Skills laden
const [allSkills, agentSkills] = await Promise.all([
apiGetSkills(),
apiGetAgentSkills(agentId)
]);
const assignedSlugs = agentSkills.map(s => s.slug);
skillsSection.hidden = false;
renderSkillCheckboxes(skillList, allSkills, assignedSlugs);
} else {
// Neue Agents: Skills-Liste auch anzeigen
const allSkills = await apiGetSkills();
if (allSkills.length > 0) {
skillsSection.hidden = false;
renderSkillCheckboxes(skillList, allSkills, []);
}
} }
// Agent-Dateien öffnen // Agent-Dateien öffnen
@@ -125,9 +143,17 @@ registerPage('agent', {
}; };
if (isNew) { if (isNew) {
await apiCreateAgent(data); const created = await apiCreateAgent(data);
// Skills zuweisen falls welche ausgewählt
const selectedSlugs = getSelectedSkills(skillList);
if (selectedSlugs.length > 0) {
await apiSetAgentSkills(created.agentId, selectedSlugs);
}
} else { } else {
await apiUpdateAgent(agentId, data); await apiUpdateAgent(agentId, data);
// Skills aktualisieren
const selectedSlugs = getSelectedSkills(skillList);
await apiSetAgentSkills(agentId, selectedSlugs);
} }
navigateTo('dashboard'); navigateTo('dashboard');
}); });
@@ -150,3 +176,29 @@ registerPage('agent', {
_agentAvatarData = null; _agentAvatarData = null;
} }
}); });
function renderSkillCheckboxes(container, allSkills, assignedSlugs) {
container.innerHTML = '';
if (allSkills.length === 0) {
container.innerHTML = '<div class="skill-empty">Keine Skills verfügbar. Lege Skills in <code>data/skills/</code> ab.</div>';
return;
}
for (const skill of allSkills) {
const checked = assignedSlugs.includes(skill.slug) ? 'checked' : '';
const version = skill.version ? ` <span class="skill-version">v${skill.version}</span>` : '';
const desc = skill.description ? `<span class="skill-desc">${skill.description}</span>` : '';
const row = document.createElement('label');
row.className = 'skill-item';
row.innerHTML = `
<input type="checkbox" value="${skill.slug}" ${checked}>
<span class="skill-name">${skill.name}${version}</span>
${desc}
`;
container.appendChild(row);
}
}
function getSelectedSkills(container) {
return Array.from(container.querySelectorAll('input[type="checkbox"]:checked'))
.map(cb => cb.value);
}