using System; using System.Collections.ObjectModel; using System.IO; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using System.Windows.Input; using Avalonia.Media.Imaging; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using finder2e_foundry_converter.Models; using finder2e_foundry_converter.Services; using System.Text.Json.Nodes; using System.Text.Json; using finder2e_foundry_converter.Converters; namespace finder2e_foundry_converter.ViewModels { public partial class MainWindowViewModel : ViewModelBase { private readonly LmStudioService _lmStudioService = new(); private readonly OllamaService _ollamaService = new(); private ILlmService CurrentService => SelectedProvider == "Ollama" ? (ILlmService)_ollamaService : _lmStudioService; [ObservableProperty] [NotifyPropertyChangedFor(nameof(CurrentAddress))] private string _lmStudioAddress = "http://localhost:1234"; [ObservableProperty] [NotifyPropertyChangedFor(nameof(CurrentAddress))] private string _ollamaAddress = "http://localhost:11434"; public string CurrentAddress { get => SelectedProvider == "Ollama" ? OllamaAddress : LmStudioAddress; set { if (SelectedProvider == "Ollama") OllamaAddress = value; else LmStudioAddress = value; OnPropertyChanged(nameof(CurrentAddress)); } } [ObservableProperty] private bool _isAddressVisible = false; [ObservableProperty] [NotifyPropertyChangedFor(nameof(CurrentAddress))] private string _selectedProvider = "LM Studio"; public ObservableCollection Providers { get; } = new() { "LM Studio", "Ollama" }; [ObservableProperty] [NotifyPropertyChangedFor(nameof(CanGenerate))] private string? _selectedModel; public ObservableCollection Models { get; } = new(); [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsNpcSelected))] [NotifyPropertyChangedFor(nameof(CanGenerate))] private string? _selectedCategory; public ObservableCollection Categories { get; } = new() { "NPCs" }; [ObservableProperty] private int _selectedLevel = 1; public ObservableCollection Levels { get; } = new(); [ObservableProperty] private string _selectedSystem = "pf2e"; public ObservableCollection Systems { get; } = new() { "pf2e", "sf2e" }; [ObservableProperty] private string _name = string.Empty; [ObservableProperty] private string _description = string.Empty; public bool IsNpcSelected => SelectedCategory == "NPCs"; public ObservableCollection ImageItems { get; } = new(); [ObservableProperty] private string _statusMessage = string.Empty; [ObservableProperty] private bool _isErrorVisible = false; [ObservableProperty] private string _errorText = string.Empty; [ObservableProperty] private bool _isGenerating = false; [ObservableProperty] private double _progress = 0.0; [ObservableProperty] private string _generatedNpcJson = string.Empty; [ObservableProperty] private string _temporaryNpcPath = string.Empty; // Holds the raw TOML prompts and retry prefix private Dictionary _npcPrompts = new(); private string _npcRetryPrefix = ""; public MainWindowViewModel() { // Load saved preferences would go here if we had a settings service for (int i = -1; i <= 24; i++) { Levels.Add(i); } _ = StartModelRefreshLoop(); } public bool CanGenerate => !IsGenerating && !string.IsNullOrEmpty(SelectedModel); partial void OnIsGeneratingChanged(bool value) { OnPropertyChanged(nameof(CanGenerate)); } partial void OnSelectedProviderChanged(string value) { Models.Clear(); SelectedModel = null; _ = RefreshModels(); } [RelayCommand] private void ToggleAddress() { IsAddressVisible = !IsAddressVisible; } [RelayCommand] private async Task RefreshModels() { var models = await CurrentService.GetModelsAsync(CurrentAddress); Models.Clear(); foreach (var m in models) { Models.Add(m); } if (models.Count > 0 && !models[0].StartsWith("Error")) { IsErrorVisible = false; if (SelectedModel == null || !Models.Contains(SelectedModel)) { SelectedModel = Models.FirstOrDefault(); } } else { ErrorText = models.FirstOrDefault() ?? $"Error connecting to {SelectedProvider}"; IsErrorVisible = true; IsAddressVisible = true; // Auto-show on failure } } [RelayCommand] private async Task Generate() { if (string.IsNullOrEmpty(SelectedModel)) { StatusMessage = "Please select a model first."; return; } IsGenerating = true; Progress = 0.0; GeneratedNpcJson = string.Empty; StatusMessage = "Starting NPC generation..."; try { // Load the prompts from llmflow/npc.toml var cwd = Directory.GetCurrentDirectory(); var tomlPath = Path.Combine(cwd, "llmflow", "npc.toml"); _npcPrompts.Clear(); _npcRetryPrefix = ""; if (File.Exists(tomlPath)) { var lines = await File.ReadAllLinesAsync(tomlPath); string? current = null; foreach (var raw in lines) { var line = raw.Trim(); if (string.IsNullOrEmpty(line) || line.StartsWith("#")) continue; if (line.StartsWith("[") && line.EndsWith("]")) { current = line.Substring(1, line.Length - 2).Trim(); continue; } if (line.StartsWith("retry_prefix")) { var idx = line.IndexOf('='); if (idx >= 0) _npcRetryPrefix = line.Substring(idx + 1).Trim().Trim('"'); continue; } if (current != null && line.Contains("= ")) { var idx = line.IndexOf('='); var key = line.Substring(0, idx).Trim(); var val = line.Substring(idx + 1).Trim().Trim('"'); if (key == "question") { if (!_npcPrompts.ContainsKey(current)) _npcPrompts[current] = (val, string.Empty); else _npcPrompts[current] = (val, _npcPrompts[current].constraints); } else if (key == "constraints") { if (!_npcPrompts.ContainsKey(current)) _npcPrompts[current] = (string.Empty, val); else _npcPrompts[current] = (_npcPrompts[current].question, val); } } } } Progress = 0.05; // Ask LLM for tiers per-stat var statKeys = new[] { "ability_score_modifier", "perception_modifier", "skill_modifier", "ac", "saving_throw", "hp", "resistance_or_weakness", "strike_attack_bonus", "strike_damage_roll" }; var responses = new Dictionary(StringComparer.OrdinalIgnoreCase); int total = statKeys.Length; int done = 0; foreach (var key in statKeys) { string questionTemplate = _npcPrompts.ContainsKey(key) ? _npcPrompts[key].question : null; string constraints = _npcPrompts.ContainsKey(key) ? _npcPrompts[key].constraints : string.Empty; if (string.IsNullOrWhiteSpace(questionTemplate)) { // Fallback question questionTemplate = key switch { "ability_score_modifier" => "Based on the description, how would you rate ability scores for this character?", "perception_modifier" => "Based on the description, how would you rate perception for this character?", "skill_modifier" => "Based on the description, how would you rate skills for this character?", "ac" => "Based on the description, how would you rate the armor class (AC) for this character?", "saving_throw" => "Based on the description, how would you rate saving throws for this character?", "hp" => "Based on the description, how would you rate hit points for this character?", "resistance_or_weakness" => "Based on the description, does this character have notable resistances or weaknesses?", "strike_attack_bonus" => "Based on the description, how would you rate the strike attack bonus for this character?", "strike_damage_roll" => "Based on the description, what is the strike damage dice roll for this character?", _ => $"Based on the description, provide a value for {key}." }; } // Replace placeholders var prompt = questionTemplate.Replace("{level}", SelectedLevel.ToString()).Replace("{system}", SelectedSystem).Replace("{description}", Description ?? string.Empty).Replace("{name}", Name ?? string.Empty).Replace("{modifierTier}", ""); string lastResponse = string.Empty; bool ok = false; string finalResp = string.Empty; for (int attempt = 1; attempt <= 3; attempt++) { StatusMessage = $"Asking LLM for {key} (attempt {attempt})..."; var resp = await CurrentService.AskAsync(CurrentAddress, SelectedModel!, prompt, ImageItems.Select(i => i.Path).ToList()); var trimmed = resp?.Trim() ?? string.Empty; // If LLM returned an explicit Error: prefix, consider that a failure if (trimmed.StartsWith("Error:", StringComparison.OrdinalIgnoreCase)) { // feed back to next attempt prompt = _npcRetryPrefix + "\nPrevious error: " + trimmed + "\n" + prompt; lastResponse = trimmed; continue; } // Validate against constraints if available (simple check for listed words) if (!string.IsNullOrWhiteSpace(constraints)) { var allowed = constraints.Trim(); // extract words inside the constraint description (e.g., Respond with only a single word: Extreme, High, Moderate, or Low.) var optionsStart = allowed.IndexOf(":"); List options = new(); if (optionsStart >= 0) { var after = allowed.Substring(optionsStart + 1); // split by commas and 'or' var parts = after.Split(new[] { ',', '\n' }, StringSplitOptions.RemoveEmptyEntries); foreach (var p in parts) { var t = p.Replace("or", "", StringComparison.OrdinalIgnoreCase).Trim(); if (!string.IsNullOrEmpty(t)) options.Add(t.Trim().Trim('.')); } } if (options.Count > 0) { // check if trimmed matches one of options if (options.Any(o => string.Equals(o, trimmed, StringComparison.OrdinalIgnoreCase))) { ok = true; finalResp = trimmed; break; } else { // try extracting first word var firstWord = trimmed.Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? string.Empty; if (options.Any(o => string.Equals(o, firstWord, StringComparison.OrdinalIgnoreCase))) { ok = true; finalResp = firstWord; break; } } } else { // no options parsed, accept any non-empty response if (!string.IsNullOrWhiteSpace(trimmed)) { ok = true; finalResp = trimmed; break; } } // not valid: prepare retry prompt prompt = _npcRetryPrefix + "\nPrevious response: " + trimmed + "\nPlease respond with the format required: " + constraints + "\n" + questionTemplate.Replace("{level}", SelectedLevel.ToString()).Replace("{system}", SelectedSystem); lastResponse = trimmed; continue; } else { if (!string.IsNullOrWhiteSpace(trimmed)) { ok = true; finalResp = trimmed; break; } prompt = _npcRetryPrefix + "\nPrevious response: " + trimmed + "\n" + prompt; lastResponse = trimmed; } } if (!ok) { StatusMessage = $"LLM failed to provide valid response for {key} after 3 attempts. Last response: {lastResponse}"; IsGenerating = false; return; } responses[key] = finalResp; done++; Progress = 0.05 + 0.85 * ((double)done / total); } // Finished LLM prompts Progress = 0.95; // Load template JSON var templatePath = Path.Combine(cwd, "foundry_templates", "npc_template.json"); if (!File.Exists(templatePath)) { StatusMessage = $"Template not found at {templatePath}"; IsGenerating = false; return; } var templateText = await File.ReadAllTextAsync(templatePath); var node = JsonNode.Parse(templateText)!.AsObject(); // Fill basic fields node["name"] = string.IsNullOrWhiteSpace(Name) ? "NPC" : Name; node["img"] = node["img"] ?? "systems/pf2e/icons/default-icons/npc.svg"; // system defaults var systemNode = node["system"] as JsonObject ?? new JsonObject(); node["system"] = systemNode; // Set description/blurb and level if (!systemNode.TryGetPropertyValue("details", out var detailsNode) || detailsNode is null) { detailsNode = new JsonObject(); systemNode["details"] = detailsNode; } var details = detailsNode.AsObject(); details["blurb"] = Description ?? string.Empty; if (!details.TryGetPropertyValue("level", out var levelNode) || levelNode is null) { details["level"] = new JsonObject(); } details["level"].AsObject()["value"] = SelectedLevel; string gameSystem = SelectedSystem ?? "pf2e"; // Map tiers (normalize) string GetTier(string key) { if (!responses.TryGetValue(key, out var r)) return "moderate"; var t = r.Trim().ToLowerInvariant(); if (t.StartsWith("extreme")) return "extreme"; if (t.StartsWith("high")) return "high"; if (t.StartsWith("moderate")) return "moderate"; if (t.StartsWith("low")) return "low"; if (t.StartsWith("terrible")) return "terrible"; if (t.StartsWith("yes")) return "yes"; if (t.StartsWith("no")) return "no"; return t; } var abilityTier = GetTier("ability_score_modifier"); var perceptionTier = GetTier("perception_modifier"); var skillTier = GetTier("skill_modifier"); var acTier = GetTier("ac"); var savingTier = GetTier("saving_throw"); var hpTier = GetTier("hp"); var abilities = new JsonObject(); string[] abilityNames = new[] { "str", "dex", "con", "int", "wis", "cha" }; foreach (var ab in abilityNames) { var modRes = NpcConverter.AbilityScoreModifier(SelectedLevel, abilityTier, gameSystem); abilities[ab] = new JsonObject { ["mod"] = modRes.IsSuccess ? modRes.Value : 0 }; } systemNode["abilities"] = abilities; // Perception var percRes = NpcConverter.PerceptionModifier(SelectedLevel, perceptionTier, gameSystem); systemNode["perception"] = new JsonObject { ["mod"] = percRes.IsSuccess ? percRes.Value : 0, ["details"] = string.Empty }; // Saves var fort = NpcConverter.SavingThrow(SelectedLevel, savingTier, gameSystem); var reflex = NpcConverter.SavingThrow(SelectedLevel, savingTier, gameSystem); var will = NpcConverter.SavingThrow(SelectedLevel, savingTier, gameSystem); systemNode["saves"] = new JsonObject { ["fortitude"] = new JsonObject { ["value"] = fort.IsSuccess ? fort.Value : 0, ["saveDetail"] = string.Empty }, ["reflex"] = new JsonObject { ["value"] = reflex.IsSuccess ? reflex.Value : 0, ["saveDetail"] = string.Empty }, ["will"] = new JsonObject { ["value"] = will.IsSuccess ? will.Value : 0, ["saveDetail"] = string.Empty } }; // AC var acRes = NpcConverter.Ac(SelectedLevel, acTier, gameSystem); if (!systemNode.TryGetPropertyValue("attributes", out var attributesNode) || attributesNode is null) { attributesNode = new JsonObject(); systemNode["attributes"] = attributesNode; } var attributes = attributesNode.AsObject(); attributes["ac"] = new JsonObject { ["value"] = acRes.IsSuccess ? acRes.Value : 10, ["details"] = string.Empty }; // HP var hpRes = NpcConverter.Hp(SelectedLevel, hpTier, gameSystem); attributes["hp"] = new JsonObject { ["value"] = hpRes.IsSuccess ? hpRes.Value : 10, ["temp"] = 0, ["max"] = hpRes.IsSuccess ? hpRes.Value : 10, ["details"] = string.Empty }; // Skills: populate common PF2e skills using skillTier var skillsNode = new JsonObject(); var skillList = new[] { "acrobatics", "arcana", "athletics", "crafting", "deception", "diplomacy", "intimidation", "lore", "medicine", "nature", "occultism", "performance", "religion", "society", "stealth", "survival", "thievery" }; foreach (var sk in skillList) { var skRes = NpcConverter.SkillModifier(SelectedLevel, skillTier, gameSystem); skillsNode[sk] = new JsonObject { ["value"] = skRes.IsSuccess ? skRes.Value : 0 }; } systemNode["skills"] = skillsNode; // Items: create a basic Strike item using strike attack bonus and damage from LLM response tiers var items = new JsonArray(); var strikeTier = GetTier("strike_attack_bonus"); var strikeAttack = NpcConverter.StrikeAttackBonus(SelectedLevel, strikeTier, gameSystem); var strikeDamage = NpcConverter.StrikeDamageRoll(SelectedLevel, strikeTier, gameSystem); var strikeAvgRes = NpcConverter.StrikeDamageAverage(SelectedLevel, strikeTier, gameSystem); if (strikeAttack.IsSuccess || strikeDamage.IsSuccess) { var itemObj = new JsonObject { ["name"] = "Strike", ["type"] = "weapon", ["system"] = new JsonObject { ["attack"] = new JsonObject { ["value"] = strikeAttack.IsSuccess ? strikeAttack.Value : 0 }, ["damage"] = new JsonObject { ["dice"] = strikeDamage.IsSuccess ? strikeDamage.Value : string.Empty, ["average"] = strikeAvgRes.IsSuccess ? strikeAvgRes.Value : 0 } } }; items.Add(itemObj); } node["items"] = items; // Finalize JSON text GeneratedNpcJson = node.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); // Save to a temporary file while generating var tempDir = Path.Combine(Path.GetTempPath(), "Finder2eFoundryConverter"); if (!Directory.Exists(tempDir)) Directory.CreateDirectory(tempDir); var safeName = string.IsNullOrWhiteSpace(Name) ? "npc" : string.Concat(Name.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '_' : ch)); var tempPath = Path.Combine(tempDir, $"{safeName}_{DateTime.Now:yyyyMMddHHmmss}.json"); await File.WriteAllTextAsync(tempPath, GeneratedNpcJson); TemporaryNpcPath = tempPath; StatusMessage = $"NPC generated and saved to temporary path: {tempPath}"; Progress = 1.0; } catch (Exception ex) { StatusMessage = $"Error generating NPC: {ex.Message}"; } finally { IsGenerating = false; } } [RelayCommand] private void AddImagePath(string path) { if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) { if (ImageItems.Any(i => i.Path == path)) return; try { // Load thumbnail using var stream = File.OpenRead(path); var bitmap = new Bitmap(stream); // We could resize it here for efficiency if needed, but for now let's just use it ImageItems.Add(new ImageItem { Path = path, Thumbnail = bitmap }); } catch (Exception ex) { StatusMessage = $"Error loading image: {ex.Message}"; } } } [RelayCommand] private void RemoveImage(ImageItem item) { ImageItems.Remove(item); } private async Task StartModelRefreshLoop() { while (true) { await RefreshModels(); await Task.Delay(IsErrorVisible ? 3000 : 10000); } } } }