diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index 8d99aaf..b4007a1 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -23,7 +23,7 @@ public static class ChatEndpoint 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(req.Body); 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 agentMemoryDir = memoryService.GetAgentMemoryDir(agent.AgentId); 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"); diff --git a/Endpoints/SessionEndpoint.cs b/Endpoints/SessionEndpoint.cs index c9599a9..b26d8cb 100644 --- a/Endpoints/SessionEndpoint.cs +++ b/Endpoints/SessionEndpoint.cs @@ -91,7 +91,7 @@ public static class SessionEndpoint 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(); if (string.IsNullOrEmpty(sessionId)) @@ -125,7 +125,8 @@ public static class SessionEndpoint var markedMessages = await db.GetMarkedMessages(sessionId); var agentMemoryDir = memoryService.GetAgentMemoryDir(agent.AgentId); 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"; await ctx.Response.WriteAsync(systemPrompt); diff --git a/Endpoints/SkillEndpoint.cs b/Endpoints/SkillEndpoint.cs new file mode 100644 index 0000000..18ce23d --- /dev/null +++ b/Endpoints/SkillEndpoint.cs @@ -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(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(); + + 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; + } +} diff --git a/Program.cs b/Program.cs index 1b2638e..aeb4e15 100644 --- a/Program.cs +++ b/Program.cs @@ -14,6 +14,7 @@ builder.Services.AddSingleton(new ToolService()); builder.Services.AddSingleton(new ImageService()); builder.Services.AddSingleton(new StreamingTracker()); builder.Services.AddSingleton(new MemoryService(dataDir)); +builder.Services.AddSingleton(new SkillService(dataDir)); builder.Services.AddSingleton(new DatabaseService(Path.Combine(dataDir, "gott.db"))); var app = builder.Build(); @@ -27,5 +28,6 @@ app.MapAgentEndpoints(); app.MapWorkspaceEndpoint(); app.MapInfoEndpoint(); app.MapBackupEndpoint(); +app.MapSkillEndpoints(); app.Run("http://0.0.0.0:19000"); diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index 6a74998..2fa8cde 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -134,6 +134,17 @@ public class DatabaseService 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_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) @@ -570,4 +581,51 @@ public class DatabaseService ORDER BY m.id", new { sessionId }); } + + // ── Agent Skills ── + + public async Task> GetAgentSkills(string agentId) + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT skill_slug FROM agent_skills WHERE agent_id = @agentId ORDER BY skill_slug", + new { agentId }); + } + + public async Task SetAgentSkills(string agentId, IEnumerable 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 }); + } } diff --git a/Services/SkillService.cs b/Services/SkillService.cs new file mode 100644 index 0000000..eb466c1 --- /dev/null +++ b/Services/SkillService.cs @@ -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); + } + + /// + /// Scannt den Skills-Ordner und gibt alle verfügbaren Skills zurück. + /// + public List GetAllSkills() + { + var skills = new List(); + 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(); + } + + /// + /// Lädt einen einzelnen Skill anhand seines Slugs. + /// + public SkillInfo GetSkill(string slug) + { + var dir = Path.Combine(_skillsDir, slug); + if (!Directory.Exists(dir)) return null; + return LoadSkill(dir); + } + + /// + /// Liest die SKILL.md eines Skills. Gibt null zurück wenn nicht vorhanden. + /// + 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; + } + + /// + /// Parst einen einfachen YAML-Frontmatter-Block (--- ... ---) aus einer Markdown-Datei. + /// + private Dictionary ParseFrontmatter(string content) + { + var result = new Dictionary(); + 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; } +} diff --git a/Services/SystemPromptBuilder.cs b/Services/SystemPromptBuilder.cs index d8d7c3b..d9bac6b 100644 --- a/Services/SystemPromptBuilder.cs +++ b/Services/SystemPromptBuilder.cs @@ -6,7 +6,7 @@ namespace Gott.Services; public static class SystemPromptBuilder { - public static string Build(AgentRecord agent, SessionRecord session, IEnumerable markedMessages = null, MemoryService memoryService = null, string agentMemoryDir = null, string dataDir = null) + public static string Build(AgentRecord agent, SessionRecord session, IEnumerable markedMessages = null, MemoryService memoryService = null, string agentMemoryDir = null, string dataDir = null, IEnumerable skillSlugs = null, SkillService skillService = null) { var parts = new List(); @@ -69,6 +69,30 @@ Wenn der User auf eine bestimmte Zeile in einer Datei verweist (z.B. `[Datei: fo if (!string.IsNullOrWhiteSpace(session.Context)) parts.Add($"## Kontext\n{session.Context}"); + // Skills + if (skillSlugs != null && skillService != null) + { + var skillParts = new List(); + 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 if (agent.UseMemory && memoryService != null && !string.IsNullOrWhiteSpace(agent.AgentId)) { diff --git a/wwwroot/css/form.css b/wwwroot/css/form.css index df7cf52..b736b1b 100644 --- a/wwwroot/css/form.css +++ b/wwwroot/css/form.css @@ -123,6 +123,63 @@ 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 { color: var(--dangerous); border-color: var(--dangerous); diff --git a/wwwroot/index.html b/wwwroot/index.html index af60dd3..87bcd5e 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -144,6 +144,10 @@ +
diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index 76cca49..9c7e622 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -144,6 +144,35 @@ async function apiGitStatus(sessionId) { 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() { const resp = await fetch('/api/backup', { method: 'POST' }); if (!resp.ok) throw new Error('Backup fehlgeschlagen'); diff --git a/wwwroot/js/pages/agent-page.js b/wwwroot/js/pages/agent-page.js index 1b23d4f..ba9b26a 100644 --- a/wwwroot/js/pages/agent-page.js +++ b/wwwroot/js/pages/agent-page.js @@ -35,6 +35,8 @@ registerPage('agent', { const addCommandBtn = document.getElementById('agentAddCommandBtn'); const filesSection = document.getElementById('agentFilesSection'); const filesBtn = document.getElementById('agentFilesBtn'); + const skillsSection = document.getElementById('agentSkillsSection'); + const skillList = document.getElementById('agentSkillList'); _agentAvatarData = null; @@ -59,6 +61,22 @@ registerPage('agent', { // Custom Commands laden 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 @@ -125,9 +143,17 @@ registerPage('agent', { }; 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 { await apiUpdateAgent(agentId, data); + // Skills aktualisieren + const selectedSlugs = getSelectedSkills(skillList); + await apiSetAgentSkills(agentId, selectedSlugs); } navigateTo('dashboard'); }); @@ -150,3 +176,29 @@ registerPage('agent', { _agentAvatarData = null; } }); + +function renderSkillCheckboxes(container, allSkills, assignedSlugs) { + container.innerHTML = ''; + if (allSkills.length === 0) { + container.innerHTML = '
Keine Skills verfügbar. Lege Skills in data/skills/ ab.
'; + return; + } + for (const skill of allSkills) { + const checked = assignedSlugs.includes(skill.slug) ? 'checked' : ''; + const version = skill.version ? ` v${skill.version}` : ''; + const desc = skill.description ? `${skill.description}` : ''; + const row = document.createElement('label'); + row.className = 'skill-item'; + row.innerHTML = ` + + ${skill.name}${version} + ${desc} + `; + container.appendChild(row); + } +} + +function getSelectedSkills(container) { + return Array.from(container.querySelectorAll('input[type="checkbox"]:checked')) + .map(cb => cb.value); +}