|
|
|
@@ -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<string> Providers { get; } = new() { "LM Studio", "Ollama" };
|
|
|
|
|
|
|
|
|
|
[ObservableProperty]
|
|
|
|
|
private string? _selectedModel;
|
|
|
|
|
|
|
|
|
|
public ObservableCollection<string> Models { get; } = new();
|
|
|
|
|
|
|
|
|
|
[ObservableProperty]
|
|
|
|
|
[NotifyPropertyChangedFor(nameof(IsNpcSelected))]
|
|
|
|
|
private string? _selectedCategory;
|
|
|
|
|
|
|
|
|
|
public ObservableCollection<string> Categories { get; } = new() { "NPCs" };
|
|
|
|
|
|
|
|
|
|
[ObservableProperty]
|
|
|
|
|
private int _selectedLevel = 1;
|
|
|
|
|
|
|
|
|
|
public ObservableCollection<int> Levels { get; } = new();
|
|
|
|
|
|
|
|
|
|
[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;
|
|
|
|
|
|
|
|
|
|
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<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;
|
|
|
|
|
|
|
|
|
|
// Holds the raw TOML prompts and retry prefix
|
|
|
|
|
private Dictionary<string, (string question, string constraints)> _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<string, string>(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<string> 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|