Initial commit: ClawdDotNet
Import des bestehenden Projektstands in Git. - .NET 10 WinForms Anwendung (Multi-Agent / Tool-System) - .gitignore fuer Build-Artefakte, Secrets und Runtime-Daten ergaenzt Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Models;
|
||||
|
||||
namespace ClawdDotNet.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Verwaltet die gesamte Verzeichnisstruktur für Instanzen und Agenten.
|
||||
///
|
||||
/// Layout:
|
||||
/// {InstancesDir}/
|
||||
/// ├── Instance-{Name}/
|
||||
/// │ ├── InstanceSettings.json
|
||||
/// │ ├── TokenUsage.json
|
||||
/// │ └── Agents/
|
||||
/// │ ├── AgentList.json
|
||||
/// │ └── Agent-{Name}/
|
||||
/// │ ├── AgentSettings.json
|
||||
/// │ ├── Soul.md
|
||||
/// │ ├── Identity.md
|
||||
/// │ ├── Logs/
|
||||
/// │ └── Workspace/
|
||||
/// </summary>
|
||||
public sealed class InstanceDirectoryManager
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly string _instancesDir;
|
||||
private readonly Lock _tokenUsageLock = new();
|
||||
|
||||
public InstanceDirectoryManager(string instancesDirectory)
|
||||
{
|
||||
_instancesDir = Path.GetFullPath(instancesDirectory);
|
||||
Directory.CreateDirectory(_instancesDir);
|
||||
}
|
||||
|
||||
public string InstancesDirectory => _instancesDir;
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// INSTANZ-OPERATIONEN
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
public static string BuildInstanceFolderName(string instanceName)
|
||||
=> $"Instance-{SanitizeName(instanceName)}";
|
||||
|
||||
public string GetInstancePath(string instanceName)
|
||||
=> Path.Combine(_instancesDir, BuildInstanceFolderName(instanceName));
|
||||
|
||||
public List<InstanceInfo> ListInstances()
|
||||
{
|
||||
var result = new List<InstanceInfo>();
|
||||
|
||||
if (!Directory.Exists(_instancesDir))
|
||||
return result;
|
||||
|
||||
foreach (var dir in Directory.GetDirectories(_instancesDir, "Instance-*"))
|
||||
{
|
||||
var folderName = Path.GetFileName(dir);
|
||||
var settingsPath = Path.Combine(dir, "InstanceSettings.json");
|
||||
|
||||
var info = new InstanceInfo
|
||||
{
|
||||
FolderName = folderName,
|
||||
FolderPath = dir,
|
||||
InstanceName = folderName.Replace("Instance-", "")
|
||||
};
|
||||
|
||||
if (File.Exists(settingsPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(settingsPath);
|
||||
var config = JsonSerializer.Deserialize<InstanceConfig>(json, JsonOpts);
|
||||
if (config is not null)
|
||||
{
|
||||
info.InstanceName = config.InstanceName;
|
||||
info.ApiKeyStatus = string.IsNullOrWhiteSpace(config.OpenRouterApiKey)
|
||||
? "Fehlt" : "Konfiguriert";
|
||||
}
|
||||
}
|
||||
catch { /* defekte Config → Standardwerte */ }
|
||||
}
|
||||
|
||||
// Agenten zählen
|
||||
var agentsDir = Path.Combine(dir, "Agents");
|
||||
if (Directory.Exists(agentsDir))
|
||||
info.AgentCount = Directory.GetDirectories(agentsDir, "Agent-*").Length;
|
||||
|
||||
result.Add(info);
|
||||
}
|
||||
|
||||
return result.OrderBy(i => i.InstanceName).ToList();
|
||||
}
|
||||
|
||||
public string CreateInstance(string instanceName)
|
||||
{
|
||||
var instanceDir = GetInstancePath(instanceName);
|
||||
|
||||
if (Directory.Exists(instanceDir))
|
||||
throw new InvalidOperationException($"Instanz '{instanceName}' existiert bereits.");
|
||||
|
||||
// Hauptverzeichnis
|
||||
Directory.CreateDirectory(instanceDir);
|
||||
|
||||
// Agents-Unterverzeichnis
|
||||
var agentsDir = Path.Combine(instanceDir, "Agents");
|
||||
Directory.CreateDirectory(agentsDir);
|
||||
|
||||
// SharedWorkspace-Verzeichnis
|
||||
Directory.CreateDirectory(Path.Combine(agentsDir, "SharedWorkspace"));
|
||||
|
||||
// InstanceSettings.json
|
||||
var config = new InstanceConfig
|
||||
{
|
||||
InstanceId = Guid.NewGuid().ToString("N")[..8],
|
||||
InstanceName = instanceName,
|
||||
LogDirectory = "./Logs",
|
||||
WorkingDirectory = instanceDir
|
||||
};
|
||||
SaveJson(Path.Combine(instanceDir, "InstanceSettings.json"), config);
|
||||
|
||||
// TokenUsage.json (leer)
|
||||
var tokenUsage = new TokenUsageFile
|
||||
{
|
||||
InstanceId = config.InstanceId,
|
||||
InstanceName = instanceName
|
||||
};
|
||||
SaveJson(Path.Combine(instanceDir, "TokenUsage.json"), tokenUsage);
|
||||
|
||||
// AgentList.json (leer)
|
||||
SaveJson(Path.Combine(agentsDir, "AgentList.json"), new AgentListFile());
|
||||
|
||||
return instanceDir;
|
||||
}
|
||||
|
||||
public InstanceConfig LoadInstanceConfig(string instanceDir)
|
||||
{
|
||||
var settingsPath = Path.Combine(instanceDir, "InstanceSettings.json");
|
||||
|
||||
if (!File.Exists(settingsPath))
|
||||
throw new FileNotFoundException($"InstanceSettings.json nicht gefunden in: {instanceDir}");
|
||||
|
||||
var json = File.ReadAllText(settingsPath);
|
||||
var config = JsonSerializer.Deserialize<InstanceConfig>(json, JsonOpts)
|
||||
?? throw new InvalidOperationException("InstanceSettings.json ist leer oder ungültig.");
|
||||
|
||||
// Agenten aus Verzeichnisstruktur laden
|
||||
config.Agents.Clear();
|
||||
var agentsDir = Path.Combine(instanceDir, "Agents");
|
||||
|
||||
// Sicherstellen, dass SharedWorkspace existiert (Migration)
|
||||
Directory.CreateDirectory(Path.Combine(agentsDir, "SharedWorkspace"));
|
||||
|
||||
// AgentList.json für Descriptions laden
|
||||
var agentListPath = Path.Combine(agentsDir, "AgentList.json");
|
||||
var agentList = File.Exists(agentListPath)
|
||||
? LoadJson<AgentListFile>(agentListPath) ?? new AgentListFile()
|
||||
: new AgentListFile();
|
||||
|
||||
if (Directory.Exists(agentsDir))
|
||||
{
|
||||
foreach (var agentDir in Directory.GetDirectories(agentsDir, "Agent-*").OrderBy(d => d))
|
||||
{
|
||||
var agent = LoadAgentConfig(agentDir);
|
||||
|
||||
var folderName = Path.GetFileName(agentDir);
|
||||
var listEntry = agentList.Agents.FirstOrDefault(a => a.FolderName == folderName);
|
||||
if (listEntry is not null && !string.IsNullOrWhiteSpace(listEntry.Description))
|
||||
agent.Description = listEntry.Description;
|
||||
|
||||
config.Agents.Add(agent);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
public void SaveInstanceConfig(string instanceDir, InstanceConfig config)
|
||||
{
|
||||
var settingsPath = Path.Combine(instanceDir, "InstanceSettings.json");
|
||||
SaveJson(settingsPath, config);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// AGENTEN-OPERATIONEN
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
public static string BuildAgentFolderName(string agentName)
|
||||
=> $"Agent-{SanitizeName(agentName)}";
|
||||
|
||||
public string GetAgentPath(string instanceDir, string agentName)
|
||||
=> Path.Combine(instanceDir, "Agents", BuildAgentFolderName(agentName));
|
||||
|
||||
public string CreateAgent(string instanceDir, string agentName, string description = "")
|
||||
{
|
||||
var agentDir = GetAgentPath(instanceDir, agentName);
|
||||
|
||||
if (Directory.Exists(agentDir))
|
||||
throw new InvalidOperationException($"Agent '{agentName}' existiert bereits in dieser Instanz.");
|
||||
|
||||
// Verzeichnisse anlegen
|
||||
Directory.CreateDirectory(agentDir);
|
||||
Directory.CreateDirectory(Path.Combine(agentDir, "Logs"));
|
||||
Directory.CreateDirectory(Path.Combine(agentDir, "Workspace"));
|
||||
|
||||
// AgentSettings.json
|
||||
var agentConfig = new AgentConfig
|
||||
{
|
||||
AgentId = SanitizeName(agentName).ToLowerInvariant(),
|
||||
DisplayName = agentName,
|
||||
Model = "anthropic/claude-sonnet-4-5"
|
||||
};
|
||||
SaveAgentSettings(agentDir, agentConfig);
|
||||
|
||||
// Identity.md
|
||||
File.WriteAllText(Path.Combine(agentDir, "Identity.md"),
|
||||
$"""
|
||||
# Identity: {agentName}
|
||||
|
||||
Du bist **{agentName}**, ein spezialisierter KI-Agent im ClawdDotNet-System.
|
||||
|
||||
## Rolle
|
||||
[Beschreibe hier die Rolle und Verantwortlichkeiten des Agenten]
|
||||
|
||||
## Expertise
|
||||
[Beschreibe hier die Fachgebiete und Fähigkeiten]
|
||||
|
||||
## Kontext
|
||||
[Beschreibe hier den Arbeitskontext und die Teamzugehörigkeit]
|
||||
""");
|
||||
|
||||
// Soul.md
|
||||
File.WriteAllText(Path.Combine(agentDir, "Soul.md"),
|
||||
$"""
|
||||
# Soul: {agentName}
|
||||
|
||||
## Persönlichkeit
|
||||
- Gründlich und zuverlässig
|
||||
- Klar und präzise in der Kommunikation
|
||||
- Proaktiv bei der Problemerkennung
|
||||
|
||||
## Arbeitsweise
|
||||
- Analysiere Aufgaben sorgfältig bevor du handelst
|
||||
- Dokumentiere deine Entscheidungen und Ergebnisse
|
||||
- Nutze die dir zugewiesenen Tools effizient
|
||||
|
||||
## Werte
|
||||
- Genauigkeit vor Geschwindigkeit
|
||||
- Transparenz in der Entscheidungsfindung
|
||||
- Sicherheit und Datenschutz haben Priorität
|
||||
""");
|
||||
|
||||
// AgentList.json aktualisieren
|
||||
UpdateAgentList(instanceDir, agentName, description, BuildAgentFolderName(agentName));
|
||||
|
||||
return agentDir;
|
||||
}
|
||||
|
||||
public AgentConfig LoadAgentConfig(string agentDir)
|
||||
{
|
||||
// AgentSettings.json laden
|
||||
var settingsPath = Path.Combine(agentDir, "AgentSettings.json");
|
||||
AgentConfig config;
|
||||
|
||||
if (File.Exists(settingsPath))
|
||||
{
|
||||
var json = File.ReadAllText(settingsPath);
|
||||
config = JsonSerializer.Deserialize<AgentConfig>(json, JsonOpts) ?? new AgentConfig();
|
||||
}
|
||||
else
|
||||
{
|
||||
config = new AgentConfig
|
||||
{
|
||||
AgentId = Path.GetFileName(agentDir).Replace("Agent-", "").ToLowerInvariant(),
|
||||
DisplayName = Path.GetFileName(agentDir).Replace("Agent-", "")
|
||||
};
|
||||
}
|
||||
|
||||
// Identity.md laden
|
||||
var identityPath = Path.Combine(agentDir, "Identity.md");
|
||||
if (File.Exists(identityPath))
|
||||
config.Identity = File.ReadAllText(identityPath);
|
||||
|
||||
// Soul.md laden
|
||||
var soulPath = Path.Combine(agentDir, "Soul.md");
|
||||
if (File.Exists(soulPath))
|
||||
config.Soul = File.ReadAllText(soulPath);
|
||||
|
||||
// Agent-Verzeichnis merken (stabil auch bei DisplayName-Änderungen)
|
||||
config.AgentDir = Path.GetFullPath(agentDir);
|
||||
|
||||
// Workspace-Pfad setzen
|
||||
config.WorkspacePath = Path.GetFullPath(Path.Combine(agentDir, "Workspace"));
|
||||
|
||||
// SharedWorkspace-Pfad setzen
|
||||
var agentsDir = Path.GetDirectoryName(agentDir); // Dies ist der /Agents Ordner
|
||||
if (agentsDir != null)
|
||||
{
|
||||
config.SharedWorkspacePath = Path.GetFullPath(Path.Combine(agentsDir, "SharedWorkspace"));
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
public void SaveAgentSettings(string agentDir, AgentConfig config)
|
||||
{
|
||||
var settingsPath = Path.Combine(agentDir, "AgentSettings.json");
|
||||
SaveJson(settingsPath, config);
|
||||
}
|
||||
|
||||
public void SaveAgentIdentity(string agentDir, string identity)
|
||||
{
|
||||
File.WriteAllText(Path.Combine(agentDir, "Identity.md"), identity);
|
||||
}
|
||||
|
||||
public void SaveAgentSoul(string agentDir, string soul)
|
||||
{
|
||||
File.WriteAllText(Path.Combine(agentDir, "Soul.md"), soul);
|
||||
}
|
||||
|
||||
public void RemoveAgent(string instanceDir, string agentFolderName)
|
||||
{
|
||||
var agentDir = Path.Combine(instanceDir, "Agents", agentFolderName);
|
||||
if (Directory.Exists(agentDir))
|
||||
Directory.Delete(agentDir, recursive: true);
|
||||
|
||||
// AgentList.json aktualisieren
|
||||
var agentListPath = Path.Combine(instanceDir, "Agents", "AgentList.json");
|
||||
if (File.Exists(agentListPath))
|
||||
{
|
||||
var list = LoadJson<AgentListFile>(agentListPath) ?? new AgentListFile();
|
||||
list.Agents.RemoveAll(a => a.FolderName == agentFolderName);
|
||||
SaveJson(agentListPath, list);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// TOKEN USAGE
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
public void AppendTokenUsage(string instanceDir, TokenUsageRecord record)
|
||||
{
|
||||
lock (_tokenUsageLock)
|
||||
{
|
||||
var path = Path.Combine(instanceDir, "TokenUsage.json");
|
||||
TokenUsageFile file;
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
try
|
||||
{
|
||||
file = LoadJson<TokenUsageFile>(path) ?? new TokenUsageFile();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Korrupte Datei: Backup erstellen, neu anfangen
|
||||
var backupPath = path + $".corrupt_{DateTime.Now:yyyyMMdd_HHmmss}";
|
||||
try { File.Copy(path, backupPath, overwrite: true); } catch { /* best effort */ }
|
||||
file = new TokenUsageFile();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
file = new TokenUsageFile();
|
||||
}
|
||||
|
||||
file.Records.Add(record);
|
||||
SaveJson(path, file);
|
||||
}
|
||||
}
|
||||
|
||||
public TokenUsageFile LoadTokenUsage(string instanceDir)
|
||||
{
|
||||
var path = Path.Combine(instanceDir, "TokenUsage.json");
|
||||
return File.Exists(path)
|
||||
? LoadJson<TokenUsageFile>(path) ?? new TokenUsageFile()
|
||||
: new TokenUsageFile();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// HILFSMETHODEN
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
private void UpdateAgentList(string instanceDir, string agentName, string description, string folderName)
|
||||
{
|
||||
var agentListPath = Path.Combine(instanceDir, "Agents", "AgentList.json");
|
||||
var list = File.Exists(agentListPath)
|
||||
? LoadJson<AgentListFile>(agentListPath) ?? new AgentListFile()
|
||||
: new AgentListFile();
|
||||
|
||||
// Duplikat-Check
|
||||
if (list.Agents.All(a => a.FolderName != folderName))
|
||||
{
|
||||
list.Agents.Add(new AgentListItem
|
||||
{
|
||||
Name = agentName,
|
||||
Description = description,
|
||||
FolderName = folderName
|
||||
});
|
||||
}
|
||||
|
||||
SaveJson(agentListPath, list);
|
||||
}
|
||||
|
||||
private static void SaveJson<T>(string path, T obj)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var json = JsonSerializer.Serialize(obj, JsonOpts);
|
||||
File.WriteAllText(path, json);
|
||||
}
|
||||
|
||||
private static T? LoadJson<T>(string path)
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<T>(json, JsonOpts);
|
||||
}
|
||||
|
||||
private static string SanitizeName(string name)
|
||||
{
|
||||
var sanitized = name.Trim();
|
||||
foreach (var c in Path.GetInvalidFileNameChars())
|
||||
sanitized = sanitized.Replace(c, '_');
|
||||
return sanitized.Replace(' ', '_');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Models;
|
||||
|
||||
namespace ClawdDotNet.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe JSON persistence for job execution history.
|
||||
/// Uses file locking to handle concurrent access from multiple schedulers.
|
||||
/// </summary>
|
||||
public sealed class JobHistoryService
|
||||
{
|
||||
private readonly string _filePath;
|
||||
private readonly Lock _lock = new();
|
||||
private readonly int _maxEntries;
|
||||
private List<JobHistoryEntry> _entries = new();
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public JobHistoryService(string instancePath, int maxEntries = 500)
|
||||
{
|
||||
_filePath = Path.Combine(instancePath, "job_history.json");
|
||||
_maxEntries = maxEntries;
|
||||
Load();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new entry to the history (thread-safe, persists immediately).
|
||||
/// </summary>
|
||||
public void Add(JobHistoryEntry entry)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_entries.Insert(0, entry); // newest first
|
||||
|
||||
// Trim old entries
|
||||
if (_entries.Count > _maxEntries)
|
||||
_entries = _entries.Take(_maxEntries).ToList();
|
||||
|
||||
Save();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a snapshot of all entries (newest first).
|
||||
/// </summary>
|
||||
public List<JobHistoryEntry> GetAll()
|
||||
{
|
||||
lock (_lock)
|
||||
return new List<JobHistoryEntry>(_entries);
|
||||
}
|
||||
|
||||
private void Load()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_filePath))
|
||||
{
|
||||
_entries = new List<JobHistoryEntry>();
|
||||
return;
|
||||
}
|
||||
|
||||
using var stream = new FileStream(_filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
_entries = JsonSerializer.Deserialize<List<JobHistoryEntry>>(stream, JsonOptions)
|
||||
?? new List<JobHistoryEntry>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
_entries = new List<JobHistoryEntry>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
var tmpPath = _filePath + ".tmp";
|
||||
using (var stream = new FileStream(tmpPath, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
JsonSerializer.Serialize(stream, _entries, JsonOptions);
|
||||
}
|
||||
|
||||
File.Move(tmpPath, _filePath, overwrite: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Silently ignore write failures — next save will retry
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ClawdDotNet.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Überwacht das Logs-Verzeichnis und liefert neue Log-Einträge gefiltert
|
||||
/// an eine RichTextBox. Bereinigt die RichTextBox automatisch wenn sie
|
||||
/// zu voll wird, damit das UI reaktionsfähig bleibt.
|
||||
///
|
||||
/// Struktur: Logs/{Datum}/{Modul}.log
|
||||
/// Log-Format: [{Timestamp}] [{LEVEL}] {Message}
|
||||
/// </summary>
|
||||
public sealed class LiveLogViewerService : IDisposable
|
||||
{
|
||||
private readonly string _logDirectory;
|
||||
private readonly RichTextBox _target;
|
||||
private readonly System.Windows.Forms.Timer _refreshTimer;
|
||||
private readonly int _maxLines;
|
||||
|
||||
// Tracking: pro Datei die letzte gelesene Position
|
||||
private readonly ConcurrentDictionary<string, long> _filePositions = new();
|
||||
|
||||
// Filter
|
||||
private string _moduleFilter = ""; // leer = alle
|
||||
private string _levelFilter = ""; // leer = alle
|
||||
|
||||
private static readonly Regex LevelRegex = new(
|
||||
@"\[(INF|WRN|ERR|DBG)\]",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
public LiveLogViewerService(string logDirectory, RichTextBox target, int refreshIntervalMs = 500, int maxLines = 2000)
|
||||
{
|
||||
_logDirectory = logDirectory;
|
||||
_target = target;
|
||||
_maxLines = maxLines;
|
||||
|
||||
_refreshTimer = new System.Windows.Forms.Timer { Interval = refreshIntervalMs };
|
||||
_refreshTimer.Tick += OnTimerTick;
|
||||
}
|
||||
|
||||
public void Start() => _refreshTimer.Start();
|
||||
public void Stop() => _refreshTimer.Stop();
|
||||
|
||||
public void SetModuleFilter(string module)
|
||||
{
|
||||
_moduleFilter = module;
|
||||
ClearAndResetPositions();
|
||||
}
|
||||
|
||||
public void SetLevelFilter(string level)
|
||||
{
|
||||
_levelFilter = level;
|
||||
ClearAndResetPositions();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gibt alle erkannten Modul-Namen zurück (basierend auf den vorhandenen .log-Dateien).
|
||||
/// </summary>
|
||||
public List<string> GetAvailableModules()
|
||||
{
|
||||
var modules = new HashSet<string> { "Alle" };
|
||||
|
||||
if (!Directory.Exists(_logDirectory))
|
||||
return modules.ToList();
|
||||
|
||||
// Alle Unterordner (Datum-Ordner) durchsuchen
|
||||
foreach (var dateDir in Directory.GetDirectories(_logDirectory))
|
||||
{
|
||||
foreach (var logFile in Directory.GetFiles(dateDir, "*.log"))
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(logFile);
|
||||
modules.Add(name);
|
||||
}
|
||||
}
|
||||
|
||||
return modules.OrderBy(m => m == "Alle" ? "" : m).ToList();
|
||||
}
|
||||
|
||||
private void OnTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
ReadNewEntries();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Logging-Viewer darf niemals das UI crashen
|
||||
}
|
||||
}
|
||||
|
||||
private void ReadNewEntries()
|
||||
{
|
||||
if (!Directory.Exists(_logDirectory))
|
||||
return;
|
||||
|
||||
// Heutiges Datum-Verzeichnis (und ggf. gestriges für Logs um Mitternacht)
|
||||
var today = DateTime.Now.ToString("yyyy-MM-dd");
|
||||
var todayDir = Path.Combine(_logDirectory, today);
|
||||
|
||||
if (!Directory.Exists(todayDir))
|
||||
return;
|
||||
|
||||
var logFiles = Directory.GetFiles(todayDir, "*.log");
|
||||
var newLines = new List<(DateTime time, string line, string module)>();
|
||||
|
||||
foreach (var filePath in logFiles)
|
||||
{
|
||||
var moduleName = Path.GetFileNameWithoutExtension(filePath);
|
||||
|
||||
// Modul-Filter
|
||||
if (!string.IsNullOrEmpty(_moduleFilter) && _moduleFilter != "Alle"
|
||||
&& !string.Equals(moduleName, _moduleFilter, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var lastPos = _filePositions.GetOrAdd(filePath, 0L);
|
||||
|
||||
try
|
||||
{
|
||||
using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
|
||||
if (fs.Length < lastPos)
|
||||
{
|
||||
// Datei wurde rotiert/gekürzt
|
||||
lastPos = 0;
|
||||
}
|
||||
|
||||
if (fs.Length == lastPos)
|
||||
continue;
|
||||
|
||||
fs.Seek(lastPos, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(fs);
|
||||
|
||||
while (reader.ReadLine() is { } line)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
continue;
|
||||
|
||||
// Level-Filter
|
||||
if (!string.IsNullOrEmpty(_levelFilter) && _levelFilter != "Alle")
|
||||
{
|
||||
if (!PassesLevelFilter(line))
|
||||
continue;
|
||||
}
|
||||
|
||||
newLines.Add((DateTime.Now, $"[{moduleName}] {line}", moduleName));
|
||||
}
|
||||
|
||||
_filePositions[filePath] = fs.Position;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Datei wird gerade geschrieben - nächstes Mal versuchen
|
||||
}
|
||||
}
|
||||
|
||||
if (newLines.Count == 0)
|
||||
return;
|
||||
|
||||
// In UI schreiben
|
||||
AppendToRichTextBox(newLines);
|
||||
}
|
||||
|
||||
private bool PassesLevelFilter(string line)
|
||||
{
|
||||
var match = LevelRegex.Match(line);
|
||||
if (!match.Success)
|
||||
return true; // Unbekanntes Format durchlassen
|
||||
|
||||
var level = match.Groups[1].Value;
|
||||
return _levelFilter switch
|
||||
{
|
||||
"Info" => level is "INF" or "WRN" or "ERR",
|
||||
"Warn" => level is "WRN" or "ERR",
|
||||
"Error" => level is "ERR",
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
|
||||
private void AppendToRichTextBox(List<(DateTime time, string line, string module)> lines)
|
||||
{
|
||||
if (_target.IsDisposed || !_target.IsHandleCreated)
|
||||
return;
|
||||
|
||||
_target.BeginInvoke(() =>
|
||||
{
|
||||
_target.SuspendLayout();
|
||||
|
||||
foreach (var (_, line, module) in lines)
|
||||
{
|
||||
var color = GetColorForLine(line);
|
||||
_target.SelectionStart = _target.TextLength;
|
||||
_target.SelectionLength = 0;
|
||||
_target.SelectionColor = color;
|
||||
_target.AppendText(line + Environment.NewLine);
|
||||
}
|
||||
|
||||
// Bereinigung: wenn zu viele Zeilen, die ältesten entfernen
|
||||
TrimIfNeeded();
|
||||
|
||||
// Auto-Scroll zum Ende
|
||||
_target.SelectionStart = _target.TextLength;
|
||||
_target.ScrollToCaret();
|
||||
|
||||
_target.ResumeLayout();
|
||||
});
|
||||
}
|
||||
|
||||
private static Color GetColorForLine(string line)
|
||||
{
|
||||
if (line.Contains("[ERR]"))
|
||||
return Color.Red;
|
||||
if (line.Contains("[WRN]"))
|
||||
return Color.Orange;
|
||||
if (line.Contains("[DBG]"))
|
||||
return Color.Gray;
|
||||
return Color.LightGreen; // INF
|
||||
}
|
||||
|
||||
private void TrimIfNeeded()
|
||||
{
|
||||
if (_target.Lines.Length <= _maxLines)
|
||||
return;
|
||||
|
||||
// Die älteste Hälfte entfernen
|
||||
var removeCount = _maxLines / 2;
|
||||
var removeEndIndex = _target.GetFirstCharIndexFromLine(removeCount);
|
||||
|
||||
if (removeEndIndex <= 0)
|
||||
return;
|
||||
|
||||
_target.SelectionStart = 0;
|
||||
_target.SelectionLength = removeEndIndex;
|
||||
_target.SelectedText = $"--- {removeCount} ältere Zeilen entfernt ---{Environment.NewLine}";
|
||||
}
|
||||
|
||||
private void ClearAndResetPositions()
|
||||
{
|
||||
_filePositions.Clear();
|
||||
|
||||
if (_target.IsHandleCreated && !_target.IsDisposed)
|
||||
{
|
||||
_target.BeginInvoke(() =>
|
||||
{
|
||||
_target.Clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_refreshTimer.Stop();
|
||||
_refreshTimer.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClawdDotNet.Services;
|
||||
|
||||
public sealed class OpenRouterStatusService : IDisposable
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly System.Windows.Forms.Timer _timer;
|
||||
|
||||
private readonly ConcurrentBag<UsageRecord> _usageRecords = new();
|
||||
|
||||
// Preise pro 1M Token (Input / Output) in USD — gängige OpenRouter-Modelle
|
||||
private static readonly Dictionary<string, (double InputPer1M, double OutputPer1M)> ModelPricing = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["anthropic/claude-sonnet-4"] = (3.00, 15.00),
|
||||
["anthropic/claude-haiku-4.5"] = (0.80, 4.00),
|
||||
["anthropic/claude-opus-4"] = (15.00, 75.00),
|
||||
["openai/gpt-4o"] = (2.50, 10.00),
|
||||
["openai/gpt-4o-mini"] = (0.15, 0.60),
|
||||
["openai/gpt-4.1"] = (2.00, 8.00),
|
||||
["openai/gpt-4.1-mini"] = (0.40, 1.60),
|
||||
["openai/gpt-4.1-nano"] = (0.10, 0.40),
|
||||
["google/gemini-2.5-flash"] = (0.15, 0.60),
|
||||
["google/gemini-2.5-pro"] = (1.25, 10.00),
|
||||
["google/gemini-3.1-flash-lite"] = (0.00, 0.00),
|
||||
["deepseek/deepseek-chat-v3-0324"] = (0.14, 0.28),
|
||||
};
|
||||
|
||||
private const double UsdToEur = 0.92;
|
||||
|
||||
public bool IsApiReachable { get; private set; }
|
||||
public string StatusText { get; private set; } = "Prüfe...";
|
||||
public string CreditsText { get; private set; } = "—";
|
||||
public string CreditsTooltip { get; private set; } = "";
|
||||
public double? CreditBalance { get; private set; }
|
||||
public double? CreditRemaining { get; private set; }
|
||||
|
||||
public event Action? OnStatusUpdated;
|
||||
|
||||
public OpenRouterStatusService(string apiKey, string baseUrl = "https://openrouter.ai/api/v1/", int checkIntervalSeconds = 60)
|
||||
{
|
||||
_http = new HttpClient { BaseAddress = new Uri(baseUrl) };
|
||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
||||
_http.DefaultRequestHeaders.Add("HTTP-Referer", "ClawdDotNet");
|
||||
|
||||
_timer = new System.Windows.Forms.Timer { Interval = checkIntervalSeconds * 1000 };
|
||||
_timer.Tick += async (_, _) => await CheckStatusAsync();
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_timer.Start();
|
||||
_ = CheckStatusAsync();
|
||||
}
|
||||
|
||||
public void Stop() => _timer.Stop();
|
||||
|
||||
public void RecordUsage(string model, int promptTokens, int completionTokens)
|
||||
{
|
||||
var cost = CalculateCost(model, promptTokens, completionTokens);
|
||||
_usageRecords.Add(new UsageRecord(DateTime.Now, model, promptTokens, completionTokens, cost));
|
||||
UpdateCreditsText();
|
||||
OnStatusUpdated?.Invoke();
|
||||
}
|
||||
|
||||
private static double CalculateCost(string model, int promptTokens, int completionTokens)
|
||||
{
|
||||
if (!ModelPricing.TryGetValue(model, out var pricing))
|
||||
return 0;
|
||||
|
||||
return (promptTokens / 1_000_000.0 * pricing.InputPer1M) +
|
||||
(completionTokens / 1_000_000.0 * pricing.OutputPer1M);
|
||||
}
|
||||
|
||||
private async Task CheckStatusAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var response = await _http.GetAsync("auth/key");
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
var doc = JsonDocument.Parse(body);
|
||||
|
||||
IsApiReachable = true;
|
||||
|
||||
if (doc.RootElement.TryGetProperty("data", out var data))
|
||||
{
|
||||
if (data.TryGetProperty("limit", out var limit))
|
||||
CreditBalance = limit.GetDouble();
|
||||
|
||||
if (data.TryGetProperty("usage", out var usage))
|
||||
{
|
||||
var used = usage.GetDouble();
|
||||
var remaining = (CreditBalance ?? 0) - used;
|
||||
CreditRemaining = remaining;
|
||||
StatusText = $"✓ API OK | Credits: ${remaining:F4} von ${CreditBalance:F2}";
|
||||
}
|
||||
else
|
||||
{
|
||||
StatusText = "✓ API erreichbar";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
StatusText = "✓ API erreichbar";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
IsApiReachable = false;
|
||||
StatusText = $"✗ API Fehler: {(int)response.StatusCode}";
|
||||
}
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
IsApiReachable = false;
|
||||
StatusText = $"✗ Nicht erreichbar: {ex.Message}";
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
IsApiReachable = false;
|
||||
StatusText = "✗ Timeout";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
IsApiReachable = false;
|
||||
StatusText = $"✗ Fehler: {ex.Message}";
|
||||
}
|
||||
|
||||
UpdateCreditsText();
|
||||
OnStatusUpdated?.Invoke();
|
||||
}
|
||||
|
||||
private void UpdateCreditsText()
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var oneHourAgo = now.AddHours(-1);
|
||||
var oneDayAgo = now.AddHours(-24);
|
||||
|
||||
var records = _usageRecords.ToArray();
|
||||
|
||||
var lastHour = records.Where(r => r.Timestamp >= oneHourAgo).ToArray();
|
||||
var last24h = records.Where(r => r.Timestamp >= oneDayAgo).ToArray();
|
||||
|
||||
var tokensLastHour = lastHour.Sum(r => r.PromptTokens + r.CompletionTokens);
|
||||
var tokensLast24h = last24h.Sum(r => r.PromptTokens + r.CompletionTokens);
|
||||
var costLastHour = lastHour.Sum(r => r.CostUsd);
|
||||
var costLast24h = last24h.Sum(r => r.CostUsd);
|
||||
|
||||
CreditsText = $"1h: {tokensLastHour:N0} Tok (~{costLastHour * UsdToEur:F4}€) | " +
|
||||
$"24h: {tokensLast24h:N0} Tok (~{costLast24h * UsdToEur:F4}€)";
|
||||
|
||||
// Detaillierter Tooltip: Pro-Model-Aufschlüsselung (letzte 24h)
|
||||
var modelGroups = last24h
|
||||
.GroupBy(r => r.Model)
|
||||
.OrderByDescending(g => g.Sum(r => r.CostUsd))
|
||||
.ToList();
|
||||
|
||||
if (modelGroups.Count == 0)
|
||||
{
|
||||
CreditsTooltip = "Keine Token-Nutzung in den letzten 24h";
|
||||
return;
|
||||
}
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("═══ Token-Verbrauch (24h) ═══");
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var group in modelGroups)
|
||||
{
|
||||
var modelName = group.Key;
|
||||
var shortName = modelName.Contains('/') ? modelName[(modelName.IndexOf('/') + 1)..] : modelName;
|
||||
var prompt = group.Sum(r => r.PromptTokens);
|
||||
var completion = group.Sum(r => r.CompletionTokens);
|
||||
var total = prompt + completion;
|
||||
var cost = group.Sum(r => r.CostUsd);
|
||||
var runs = group.Count();
|
||||
|
||||
sb.AppendLine($"▸ {shortName}");
|
||||
sb.AppendLine($" {runs}x Runs | {total:N0} Tokens ({prompt:N0} in / {completion:N0} out)");
|
||||
|
||||
if (ModelPricing.TryGetValue(modelName, out var pricing))
|
||||
sb.AppendLine($" Preis: ${pricing.InputPer1M}/1M in, ${pricing.OutputPer1M}/1M out");
|
||||
|
||||
sb.AppendLine($" Kosten: ${cost:F4} (~{cost * UsdToEur:F4}€)");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
var totalCost = last24h.Sum(r => r.CostUsd);
|
||||
sb.AppendLine($"═══ Gesamt: ${totalCost:F4} (~{totalCost * UsdToEur:F4}€) ═══");
|
||||
|
||||
CreditsTooltip = sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_timer.Stop();
|
||||
_timer.Dispose();
|
||||
_http.Dispose();
|
||||
}
|
||||
|
||||
private sealed record UsageRecord(
|
||||
DateTime Timestamp,
|
||||
string Model,
|
||||
int PromptTokens,
|
||||
int CompletionTokens,
|
||||
double CostUsd);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Models;
|
||||
|
||||
namespace ClawdDotNet.Services;
|
||||
|
||||
public sealed class SettingsManager
|
||||
{
|
||||
private const string SettingsFileName = "Settings.json";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly string _settingsPath;
|
||||
|
||||
public AppSettings AppSettings { get; private set; } = new();
|
||||
|
||||
public SettingsManager(string? basePath = null)
|
||||
{
|
||||
var dir = basePath ?? AppDomain.CurrentDomain.BaseDirectory;
|
||||
_settingsPath = Path.Combine(dir, SettingsFileName);
|
||||
}
|
||||
|
||||
public void Load()
|
||||
{
|
||||
if (!File.Exists(_settingsPath))
|
||||
{
|
||||
AppSettings = new AppSettings();
|
||||
Save(); // Defaults schreiben
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_settingsPath);
|
||||
AppSettings = JsonSerializer.Deserialize<AppSettings>(json, JsonOptions)
|
||||
?? new AppSettings();
|
||||
}
|
||||
catch
|
||||
{
|
||||
AppSettings = new AppSettings();
|
||||
}
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_settingsPath);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var json = JsonSerializer.Serialize(AppSettings, JsonOptions);
|
||||
File.WriteAllText(_settingsPath, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Logging ist hier ggf. noch nicht verfügbar – Fallback auf MessageBox
|
||||
MessageBox.Show(
|
||||
$"Settings konnten nicht gespeichert werden:\n{ex.Message}",
|
||||
"Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user