diff --git a/services/illmservice.cs b/services/illmservice.cs index 9373fd4..f04d82b 100644 --- a/services/illmservice.cs +++ b/services/illmservice.cs @@ -8,5 +8,7 @@ namespace finder2e_foundry_converter.Services { Task> GetModelsAsync(string baseUrl); Task GenerateResponseAsync(string baseUrl, string model, string text, List imagePaths); + // Ask a single prompt and return assistant's textual response (no streaming) + Task AskAsync(string baseUrl, string model, string prompt, List imagePaths); } } diff --git a/services/lmstudioservice.cs b/services/lmstudioservice.cs index ef7ad3d..5c51410 100644 --- a/services/lmstudioservice.cs +++ b/services/lmstudioservice.cs @@ -93,6 +93,42 @@ namespace finder2e_foundry_converter.Services } } + public async Task AskAsync(string baseUrl, string model, string prompt, List imagePaths) + { + // LM Studio expects messages content; reuse the same structure but with a simple text message + try + { + var requestBody = new + { + model = model, + messages = new[] + { + new { role = "user", content = new[] { new { type = "text", text = prompt } } } + } + }; + + var response = await _httpClient.PostAsJsonAsync($"{baseUrl.TrimEnd('/')}/v1/chat/completions", requestBody); + var body = await response.Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("error", out var error)) + { + return $"Error: {error.GetProperty("message").GetString()}"; + } + + if (doc.RootElement.TryGetProperty("choices", out var choices) && choices.GetArrayLength() > 0) + { + return choices[0].GetProperty("message").GetProperty("content").GetString() ?? "No content returned"; + } + + return "No response choices returned."; + } + catch (Exception ex) + { + return $"Error: {ex.Message}"; + } + } + private class ModelsResponse { [JsonPropertyName("data")] diff --git a/services/ollamaservice.cs b/services/ollamaservice.cs index 684d19a..d16e437 100644 --- a/services/ollamaservice.cs +++ b/services/ollamaservice.cs @@ -72,6 +72,12 @@ namespace finder2e_foundry_converter.Services } } + public async Task AskAsync(string baseUrl, string model, string prompt, List imagePaths) + { + // Reuse GenerateResponseAsync semantics for single-shot prompts + return await GenerateResponseAsync(baseUrl, model, prompt, imagePaths); + } + private class OllamaModelsResponse { [JsonPropertyName("models")] diff --git a/viewmodels/mainwindowviewmodel.cs b/viewmodels/mainwindowviewmodel.cs index a3e520f..294085e 100644 --- a/viewmodels/mainwindowviewmodel.cs +++ b/viewmodels/mainwindowviewmodel.cs @@ -1,299 +1,543 @@ -using System; -using System.Collections.ObjectModel; -using System.IO; -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] - private string? _selectedModel; - - public ObservableCollection Models { get; } = new(); - - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(IsNpcSelected))] - private string? _selectedCategory; - - public ObservableCollection Categories { get; } = new() { "NPCs" }; - - [ObservableProperty] - private int _selectedLevel = 1; - - public ObservableCollection Levels { get; } = new(); - - [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; - - 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(); - } - - 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 - } - } - - [ObservableProperty] - private string _generatedNpcJson = string.Empty; - - [ObservableProperty] - private string _temporaryNpcPath = string.Empty; - - [RelayCommand] - private async Task Generate() - { - // Minimal validation - if (string.IsNullOrEmpty(SelectedModel)) - { - StatusMessage = "Please select a model first."; - return; - } - - StatusMessage = "Generating NPC from template..."; - - try - { - // Load template JSON - var cwd = Directory.GetCurrentDirectory(); - var templatePath = Path.Combine(cwd, "foundry_templates", "npc_template.json"); - if (!File.Exists(templatePath)) - { - StatusMessage = $"Template not found at {templatePath}"; - 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; - - // Default to pf2e for now - string gameSystem = "pf2e"; - - // Compute numeric stats using NpcConverter helper functions with conservative tiers - var abilityTier = "moderate"; - var perceptionTier = "moderate"; - var acTier = "moderate"; - var savingTier = "moderate"; - var hpTier = "moderate"; - - 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 (leave empty for now) - systemNode["skills"] = new JsonObject(); - - // 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}"; - } - catch (Exception ex) - { - StatusMessage = $"Error generating NPC: {ex.Message}"; - } - } - - [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); - } - } - } -} +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); + } + } + } +} diff --git a/views/mainwindow.axaml b/views/mainwindow.axaml index 2fa5188..2035697 100644 --- a/views/mainwindow.axaml +++ b/views/mainwindow.axaml @@ -57,13 +57,19 @@ ItemsSource="{Binding Levels}" SelectedItem="{Binding SelectedLevel}"/> +