Files

474 lines
19 KiB
C#
Raw Permalink Normal View History

2026-07-01 15:33:12 -05:00
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();
2026-07-02 12:20:04 -05:00
private NpcGenerator? _npcGenerator;
2026-07-01 15:33:12 -05:00
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<string> Providers { get; } = new() { "LM Studio", "Ollama" };
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanGenerate))]
private string? _selectedModel;
public ObservableCollection<string> Models { get; } = new();
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsNpcSelected))]
[NotifyPropertyChangedFor(nameof(CanGenerate))]
private string? _selectedCategory;
public ObservableCollection<string> Categories { get; } = new() { "NPCs" };
[ObservableProperty]
private int _selectedLevel = 1;
public ObservableCollection<int> Levels { get; } = new();
[ObservableProperty]
private string _selectedSystem = "pf2e";
public ObservableCollection<string> Systems { get; } = new() { "pf2e", "sf2e" };
[ObservableProperty]
private string _name = string.Empty;
[ObservableProperty]
private string _description = string.Empty;
public bool IsNpcSelected => SelectedCategory == "NPCs";
public ObservableCollection<ImageItem> 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;
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
{
2026-07-02 12:20:04 -05:00
// Initialize NpcGenerator
if (_npcGenerator == null)
2026-07-01 15:33:12 -05:00
{
2026-07-02 12:20:04 -05:00
_npcGenerator = new NpcGenerator(CurrentService);
2026-07-01 15:33:12 -05:00
}
2026-07-02 12:20:04 -05:00
// Load prompts from TOML
Console.WriteLine("Loading prompts from npc.toml...");
await _npcGenerator.LoadPromptsAsync();
2026-07-01 15:33:12 -05:00
Progress = 0.05;
2026-07-02 12:20:04 -05:00
// Handle name/description generation
var name = Name ?? string.Empty;
var description = Description ?? string.Empty;
if (string.IsNullOrWhiteSpace(name) && string.IsNullOrWhiteSpace(description))
{
// Generate both: name first, then description
StatusMessage = "Generating NPC name...";
name = await _npcGenerator.GenerateNameAsync(
CurrentAddress, SelectedModel!, description, SelectedLevel,
ImageItems.Select(i => i.Path).ToList());
if (string.IsNullOrWhiteSpace(name)) name = "NPC";
Progress = 0.15;
StatusMessage = "Generating NPC description...";
description = await _npcGenerator.GenerateDescriptionAsync(
CurrentAddress, SelectedModel!, name, SelectedLevel,
ImageItems.Select(i => i.Path).ToList());
Progress = 0.25;
}
else if (string.IsNullOrWhiteSpace(name))
{
// Generate name from description
StatusMessage = "Generating NPC name...";
name = await _npcGenerator.GenerateNameAsync(
CurrentAddress, SelectedModel!, description, SelectedLevel,
ImageItems.Select(i => i.Path).ToList());
if (string.IsNullOrWhiteSpace(name)) name = "NPC";
Progress = 0.15;
}
else if (string.IsNullOrWhiteSpace(description))
{
// Generate description from name
StatusMessage = "Generating NPC description...";
description = await _npcGenerator.GenerateDescriptionAsync(
CurrentAddress, SelectedModel!, name, SelectedLevel,
ImageItems.Select(i => i.Path).ToList());
Progress = 0.25;
}
2026-07-01 15:33:12 -05:00
// 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<string, string>(StringComparer.OrdinalIgnoreCase);
int total = statKeys.Length;
int done = 0;
foreach (var key in statKeys)
{
2026-07-02 12:20:04 -05:00
try
2026-07-01 15:33:12 -05:00
{
2026-07-02 12:20:04 -05:00
StatusMessage = $"Asking LLM for {key}...";
var replacements = new Dictionary<string, string>
2026-07-01 15:33:12 -05:00
{
2026-07-02 12:20:04 -05:00
{ "level", SelectedLevel.ToString() },
{ "system", SelectedSystem ?? "pf2e" },
{ "name", name },
{ "description", description }
2026-07-01 15:33:12 -05:00
};
2026-07-02 12:20:04 -05:00
var (success, response) = await _npcGenerator.AskLlmAsync(
CurrentAddress, SelectedModel!, key, replacements,
ImageItems.Select(i => i.Path).ToList());
if (!success)
{
StatusMessage = $"LLM failed to provide valid response for {key} after 3 attempts.";
IsGenerating = false;
return;
}
responses[key] = response;
done++;
Progress = 0.25 + 0.65 * ((double)done / total);
2026-07-01 15:33:12 -05:00
}
2026-07-02 12:20:04 -05:00
catch (Exception ex)
2026-07-01 15:33:12 -05:00
{
2026-07-02 12:20:04 -05:00
StatusMessage = $"Error while asking for {key}: {ex.Message}";
2026-07-01 15:33:12 -05:00
IsGenerating = false;
return;
}
}
// Finished LLM prompts
Progress = 0.95;
// Load template JSON
2026-07-02 12:20:04 -05:00
var cwd = Directory.GetCurrentDirectory();
2026-07-01 15:33:12 -05:00
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
2026-07-02 12:20:04 -05:00
node["name"] = string.IsNullOrWhiteSpace(name) ? "NPC" : name;
2026-07-01 15:33:12 -05:00
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();
2026-07-02 12:20:04 -05:00
details["blurb"] = description ?? string.Empty;
details["publicNotes"] = description ?? string.Empty;
2026-07-01 15:33:12 -05:00
if (!details.TryGetPropertyValue("level", out var levelNode) || levelNode is null)
{
details["level"] = new JsonObject();
2026-07-02 12:20:04 -05:00
levelNode = details["level"];
2026-07-01 15:33:12 -05:00
}
2026-07-02 12:20:04 -05:00
levelNode!.AsObject()["value"] = SelectedLevel;
2026-07-01 15:33:12 -05:00
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 };
2026-07-02 12:20:04 -05:00
// Skills
2026-07-01 15:33:12 -05:00
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;
2026-07-02 12:20:04 -05:00
// Items: create a basic Strike item
2026-07-01 15:33:12 -05:00
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 });
2026-07-02 12:20:04 -05:00
// Save to a temporary file
2026-07-01 15:33:12 -05:00
var tempDir = Path.Combine(Path.GetTempPath(), "Finder2eFoundryConverter");
if (!Directory.Exists(tempDir)) Directory.CreateDirectory(tempDir);
2026-07-02 12:20:04 -05:00
var safeName = string.IsNullOrWhiteSpace(name) ? "npc" : string.Concat(name.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '_' : ch));
2026-07-01 15:33:12 -05:00
var tempPath = Path.Combine(tempDir, $"{safeName}_{DateTime.Now:yyyyMMddHHmmss}.json");
await File.WriteAllTextAsync(tempPath, GeneratedNpcJson);
TemporaryNpcPath = tempPath;
2026-07-02 12:20:04 -05:00
StatusMessage = $"NPC generated successfully!";
2026-07-01 15:33:12 -05:00
Progress = 1.0;
}
catch (Exception ex)
{
StatusMessage = $"Error generating NPC: {ex.Message}";
}
finally
{
IsGenerating = false;
}
}
2026-07-02 12:20:04 -05:00
2026-07-01 15:33:12 -05:00
[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);
}
}
}
}