added basic flow and prompting

This commit is contained in:
grimsace
2026-07-01 15:33:12 -05:00
parent 6c306a4a0f
commit c3f6cc2375
5 changed files with 595 additions and 301 deletions
+2
View File
@@ -8,5 +8,7 @@ namespace finder2e_foundry_converter.Services
{ {
Task<List<string>> GetModelsAsync(string baseUrl); Task<List<string>> GetModelsAsync(string baseUrl);
Task<string> GenerateResponseAsync(string baseUrl, string model, string text, List<string> imagePaths); Task<string> GenerateResponseAsync(string baseUrl, string model, string text, List<string> imagePaths);
// Ask a single prompt and return assistant's textual response (no streaming)
Task<string> AskAsync(string baseUrl, string model, string prompt, List<string> imagePaths);
} }
} }
+36
View File
@@ -93,6 +93,42 @@ namespace finder2e_foundry_converter.Services
} }
} }
public async Task<string> AskAsync(string baseUrl, string model, string prompt, List<string> 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 private class ModelsResponse
{ {
[JsonPropertyName("data")] [JsonPropertyName("data")]
+6
View File
@@ -72,6 +72,12 @@ namespace finder2e_foundry_converter.Services
} }
} }
public async Task<string> AskAsync(string baseUrl, string model, string prompt, List<string> imagePaths)
{
// Reuse GenerateResponseAsync semantics for single-shot prompts
return await GenerateResponseAsync(baseUrl, model, prompt, imagePaths);
}
private class OllamaModelsResponse private class OllamaModelsResponse
{ {
[JsonPropertyName("models")] [JsonPropertyName("models")]
+543 -299
View File
@@ -1,299 +1,543 @@
using System; using System;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.IO; using System.IO;
using System.Linq; using System.Collections.Generic;
using System.Threading.Tasks; using System.Linq;
using System.Windows.Input; using System.Threading.Tasks;
using Avalonia.Media.Imaging; using System.Windows.Input;
using CommunityToolkit.Mvvm.ComponentModel; using Avalonia.Media.Imaging;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.ComponentModel;
using finder2e_foundry_converter.Models; using CommunityToolkit.Mvvm.Input;
using finder2e_foundry_converter.Services; using finder2e_foundry_converter.Models;
using System.Text.Json.Nodes; using finder2e_foundry_converter.Services;
using System.Text.Json; using System.Text.Json.Nodes;
using finder2e_foundry_converter.Converters; using System.Text.Json;
using finder2e_foundry_converter.Converters;
namespace finder2e_foundry_converter.ViewModels
{ namespace finder2e_foundry_converter.ViewModels
public partial class MainWindowViewModel : ViewModelBase {
{ public partial class MainWindowViewModel : ViewModelBase
private readonly LmStudioService _lmStudioService = new(); {
private readonly OllamaService _ollamaService = new(); private readonly LmStudioService _lmStudioService = new();
private readonly OllamaService _ollamaService = new();
private ILlmService CurrentService => SelectedProvider == "Ollama" ? (ILlmService)_ollamaService : _lmStudioService;
private ILlmService CurrentService => SelectedProvider == "Ollama" ? (ILlmService)_ollamaService : _lmStudioService;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CurrentAddress))] [ObservableProperty]
private string _lmStudioAddress = "http://localhost:1234"; [NotifyPropertyChangedFor(nameof(CurrentAddress))]
private string _lmStudioAddress = "http://localhost:1234";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CurrentAddress))] [ObservableProperty]
private string _ollamaAddress = "http://localhost:11434"; [NotifyPropertyChangedFor(nameof(CurrentAddress))]
private string _ollamaAddress = "http://localhost:11434";
public string CurrentAddress
{ public string CurrentAddress
get => SelectedProvider == "Ollama" ? OllamaAddress : LmStudioAddress; {
set get => SelectedProvider == "Ollama" ? OllamaAddress : LmStudioAddress;
{ set
if (SelectedProvider == "Ollama") OllamaAddress = value; {
else LmStudioAddress = value; if (SelectedProvider == "Ollama") OllamaAddress = value;
OnPropertyChanged(nameof(CurrentAddress)); else LmStudioAddress = value;
} OnPropertyChanged(nameof(CurrentAddress));
} }
}
[ObservableProperty]
private bool _isAddressVisible = false; [ObservableProperty]
private bool _isAddressVisible = false;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CurrentAddress))] [ObservableProperty]
private string _selectedProvider = "LM Studio"; [NotifyPropertyChangedFor(nameof(CurrentAddress))]
private string _selectedProvider = "LM Studio";
public ObservableCollection<string> Providers { get; } = new() { "LM Studio", "Ollama" };
public ObservableCollection<string> Providers { get; } = new() { "LM Studio", "Ollama" };
[ObservableProperty]
private string? _selectedModel; [ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanGenerate))]
public ObservableCollection<string> Models { get; } = new(); private string? _selectedModel;
[ObservableProperty] public ObservableCollection<string> Models { get; } = new();
[NotifyPropertyChangedFor(nameof(IsNpcSelected))]
private string? _selectedCategory; [ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsNpcSelected))]
public ObservableCollection<string> Categories { get; } = new() { "NPCs" }; [NotifyPropertyChangedFor(nameof(CanGenerate))]
private string? _selectedCategory;
[ObservableProperty]
private int _selectedLevel = 1; public ObservableCollection<string> Categories { get; } = new() { "NPCs" };
public ObservableCollection<int> Levels { get; } = new(); [ObservableProperty]
private int _selectedLevel = 1;
[ObservableProperty]
private string _name = string.Empty; public ObservableCollection<int> Levels { get; } = new();
[ObservableProperty] [ObservableProperty]
private string _description = string.Empty; private string _selectedSystem = "pf2e";
public bool IsNpcSelected => SelectedCategory == "NPCs"; public ObservableCollection<string> Systems { get; } = new() { "pf2e", "sf2e" };
public ObservableCollection<ImageItem> ImageItems { get; } = new(); [ObservableProperty]
private string _name = string.Empty;
[ObservableProperty]
private string _statusMessage = string.Empty; [ObservableProperty]
private string _description = string.Empty;
[ObservableProperty]
private bool _isErrorVisible = false; public bool IsNpcSelected => SelectedCategory == "NPCs";
[ObservableProperty] public ObservableCollection<ImageItem> ImageItems { get; } = new();
private string _errorText = string.Empty;
[ObservableProperty]
public MainWindowViewModel() private string _statusMessage = string.Empty;
{
// Load saved preferences would go here if we had a settings service [ObservableProperty]
for (int i = -1; i <= 24; i++) private bool _isErrorVisible = false;
{
Levels.Add(i); [ObservableProperty]
} private string _errorText = string.Empty;
_ = StartModelRefreshLoop();
} [ObservableProperty]
private bool _isGenerating = false;
partial void OnSelectedProviderChanged(string value)
{ [ObservableProperty]
Models.Clear(); private double _progress = 0.0;
SelectedModel = null;
_ = RefreshModels(); [ObservableProperty]
} private string _generatedNpcJson = string.Empty;
[RelayCommand] [ObservableProperty]
private void ToggleAddress() private string _temporaryNpcPath = string.Empty;
{
IsAddressVisible = !IsAddressVisible; // Holds the raw TOML prompts and retry prefix
} private Dictionary<string, (string question, string constraints)> _npcPrompts = new();
private string _npcRetryPrefix = "";
[RelayCommand]
private async Task RefreshModels() public MainWindowViewModel()
{ {
var models = await CurrentService.GetModelsAsync(CurrentAddress); // Load saved preferences would go here if we had a settings service
for (int i = -1; i <= 24; i++)
Models.Clear(); {
foreach (var m in models) Levels.Add(i);
{ }
Models.Add(m); _ = StartModelRefreshLoop();
} }
if (models.Count > 0 && !models[0].StartsWith("Error")) public bool CanGenerate => !IsGenerating && !string.IsNullOrEmpty(SelectedModel);
{
IsErrorVisible = false; partial void OnIsGeneratingChanged(bool value)
if (SelectedModel == null || !Models.Contains(SelectedModel)) {
{ OnPropertyChanged(nameof(CanGenerate));
SelectedModel = Models.FirstOrDefault(); }
} partial void OnSelectedProviderChanged(string value)
} {
else Models.Clear();
{ SelectedModel = null;
ErrorText = models.FirstOrDefault() ?? $"Error connecting to {SelectedProvider}"; _ = RefreshModels();
IsErrorVisible = true; }
IsAddressVisible = true; // Auto-show on failure
} [RelayCommand]
} private void ToggleAddress()
{
[ObservableProperty] IsAddressVisible = !IsAddressVisible;
private string _generatedNpcJson = string.Empty; }
[ObservableProperty] [RelayCommand]
private string _temporaryNpcPath = string.Empty; private async Task RefreshModels()
{
[RelayCommand] var models = await CurrentService.GetModelsAsync(CurrentAddress);
private async Task Generate()
{ Models.Clear();
// Minimal validation foreach (var m in models)
if (string.IsNullOrEmpty(SelectedModel)) {
{ Models.Add(m);
StatusMessage = "Please select a model first."; }
return;
} if (models.Count > 0 && !models[0].StartsWith("Error"))
{
StatusMessage = "Generating NPC from template..."; IsErrorVisible = false;
if (SelectedModel == null || !Models.Contains(SelectedModel))
try {
{ SelectedModel = Models.FirstOrDefault();
// Load template JSON }
var cwd = Directory.GetCurrentDirectory(); }
var templatePath = Path.Combine(cwd, "foundry_templates", "npc_template.json"); else
if (!File.Exists(templatePath)) {
{ ErrorText = models.FirstOrDefault() ?? $"Error connecting to {SelectedProvider}";
StatusMessage = $"Template not found at {templatePath}"; IsErrorVisible = true;
return; IsAddressVisible = true; // Auto-show on failure
} }
}
var templateText = await File.ReadAllTextAsync(templatePath);
var node = JsonNode.Parse(templateText)!.AsObject();
[RelayCommand]
// Fill basic fields private async Task Generate()
node["name"] = string.IsNullOrWhiteSpace(Name) ? "NPC" : Name; {
node["img"] = node["img"] ?? "systems/pf2e/icons/default-icons/npc.svg"; if (string.IsNullOrEmpty(SelectedModel))
{
// system defaults StatusMessage = "Please select a model first.";
var systemNode = node["system"] as JsonObject ?? new JsonObject(); return;
node["system"] = systemNode; }
// Set description/blurb and level IsGenerating = true;
if (!systemNode.TryGetPropertyValue("details", out var detailsNode) || detailsNode is null) Progress = 0.0;
{ GeneratedNpcJson = string.Empty;
detailsNode = new JsonObject(); StatusMessage = "Starting NPC generation...";
systemNode["details"] = detailsNode;
} try
var details = detailsNode.AsObject(); {
details["blurb"] = Description ?? string.Empty; // Load the prompts from llmflow/npc.toml
if (!details.TryGetPropertyValue("level", out var levelNode) || levelNode is null) var cwd = Directory.GetCurrentDirectory();
{ var tomlPath = Path.Combine(cwd, "llmflow", "npc.toml");
details["level"] = new JsonObject(); _npcPrompts.Clear();
} _npcRetryPrefix = "";
details["level"].AsObject()["value"] = SelectedLevel; if (File.Exists(tomlPath))
{
// Default to pf2e for now var lines = await File.ReadAllLinesAsync(tomlPath);
string gameSystem = "pf2e"; string? current = null;
foreach (var raw in lines)
// Compute numeric stats using NpcConverter helper functions with conservative tiers {
var abilityTier = "moderate"; var line = raw.Trim();
var perceptionTier = "moderate"; if (string.IsNullOrEmpty(line) || line.StartsWith("#")) continue;
var acTier = "moderate"; if (line.StartsWith("[") && line.EndsWith("]"))
var savingTier = "moderate"; {
var hpTier = "moderate"; current = line.Substring(1, line.Length - 2).Trim();
continue;
var abilities = new JsonObject(); }
string[] abilityNames = new[] { "str", "dex", "con", "int", "wis", "cha" }; if (line.StartsWith("retry_prefix"))
foreach (var ab in abilityNames) {
{ var idx = line.IndexOf('=');
var modRes = NpcConverter.AbilityScoreModifier(SelectedLevel, abilityTier, gameSystem); if (idx >= 0) _npcRetryPrefix = line.Substring(idx + 1).Trim().Trim('"');
abilities[ab] = new JsonObject { ["mod"] = modRes.IsSuccess ? modRes.Value : 0 }; continue;
} }
systemNode["abilities"] = abilities; if (current != null && line.Contains("= "))
{
// Perception var idx = line.IndexOf('=');
var percRes = NpcConverter.PerceptionModifier(SelectedLevel, perceptionTier, gameSystem); var key = line.Substring(0, idx).Trim();
systemNode["perception"] = new JsonObject { ["mod"] = percRes.IsSuccess ? percRes.Value : 0, ["details"] = string.Empty }; var val = line.Substring(idx + 1).Trim().Trim('"');
if (key == "question")
// Saves {
var fort = NpcConverter.SavingThrow(SelectedLevel, savingTier, gameSystem); if (!_npcPrompts.ContainsKey(current)) _npcPrompts[current] = (val, string.Empty);
var reflex = NpcConverter.SavingThrow(SelectedLevel, savingTier, gameSystem); else _npcPrompts[current] = (val, _npcPrompts[current].constraints);
var will = NpcConverter.SavingThrow(SelectedLevel, savingTier, gameSystem); }
systemNode["saves"] = new JsonObject else if (key == "constraints")
{ {
["fortitude"] = new JsonObject { ["value"] = fort.IsSuccess ? fort.Value : 0, ["saveDetail"] = string.Empty }, if (!_npcPrompts.ContainsKey(current)) _npcPrompts[current] = (string.Empty, val);
["reflex"] = new JsonObject { ["value"] = reflex.IsSuccess ? reflex.Value : 0, ["saveDetail"] = string.Empty }, else _npcPrompts[current] = (_npcPrompts[current].question, val);
["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) Progress = 0.05;
{
attributesNode = new JsonObject(); // Ask LLM for tiers per-stat
systemNode["attributes"] = attributesNode; 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);
var attributes = attributesNode.AsObject(); int total = statKeys.Length;
attributes["ac"] = new JsonObject { ["value"] = acRes.IsSuccess ? acRes.Value : 10, ["details"] = string.Empty }; int done = 0;
// HP foreach (var key in statKeys)
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 }; string questionTemplate = _npcPrompts.ContainsKey(key) ? _npcPrompts[key].question : null;
string constraints = _npcPrompts.ContainsKey(key) ? _npcPrompts[key].constraints : string.Empty;
// Skills (leave empty for now) if (string.IsNullOrWhiteSpace(questionTemplate))
systemNode["skills"] = new JsonObject(); {
// Fallback question
// Finalize JSON text questionTemplate = key switch
GeneratedNpcJson = node.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); {
"ability_score_modifier" => "Based on the description, how would you rate ability scores for this character?",
// Save to a temporary file while generating "perception_modifier" => "Based on the description, how would you rate perception for this character?",
var tempDir = Path.Combine(Path.GetTempPath(), "Finder2eFoundryConverter"); "skill_modifier" => "Based on the description, how would you rate skills for this character?",
if (!Directory.Exists(tempDir)) Directory.CreateDirectory(tempDir); "ac" => "Based on the description, how would you rate the armor class (AC) for this character?",
var safeName = string.IsNullOrWhiteSpace(Name) ? "npc" : string.Concat(Name.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '_' : ch)); "saving_throw" => "Based on the description, how would you rate saving throws for this character?",
var tempPath = Path.Combine(tempDir, $"{safeName}_{DateTime.Now:yyyyMMddHHmmss}.json"); "hp" => "Based on the description, how would you rate hit points for this character?",
await File.WriteAllTextAsync(tempPath, GeneratedNpcJson); "resistance_or_weakness" => "Based on the description, does this character have notable resistances or weaknesses?",
TemporaryNpcPath = tempPath; "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?",
StatusMessage = $"NPC generated and saved to temporary path: {tempPath}"; _ => $"Based on the description, provide a value for {key}."
} };
catch (Exception ex) }
{
StatusMessage = $"Error generating NPC: {ex.Message}"; // 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;
[RelayCommand] string finalResp = string.Empty;
private void AddImagePath(string path) for (int attempt = 1; attempt <= 3; attempt++)
{ {
if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) StatusMessage = $"Asking LLM for {key} (attempt {attempt})...";
{ var resp = await CurrentService.AskAsync(CurrentAddress, SelectedModel!, prompt, ImageItems.Select(i => i.Path).ToList());
if (ImageItems.Any(i => i.Path == path)) return; var trimmed = resp?.Trim() ?? string.Empty;
try // If LLM returned an explicit Error: prefix, consider that a failure
{ if (trimmed.StartsWith("Error:", StringComparison.OrdinalIgnoreCase))
// Load thumbnail {
using var stream = File.OpenRead(path); // feed back to next attempt
var bitmap = new Bitmap(stream); prompt = _npcRetryPrefix + "\nPrevious error: " + trimmed + "\n" + prompt;
// We could resize it here for efficiency if needed, but for now let's just use it lastResponse = trimmed;
ImageItems.Add(new ImageItem { Path = path, Thumbnail = bitmap }); continue;
} }
catch (Exception ex)
{ // Validate against constraints if available (simple check for listed words)
StatusMessage = $"Error loading image: {ex.Message}"; 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(":");
[RelayCommand] List<string> options = new();
private void RemoveImage(ImageItem item) if (optionsStart >= 0)
{ {
ImageItems.Remove(item); var after = allowed.Substring(optionsStart + 1);
} // split by commas and 'or'
var parts = after.Split(new[] { ',', '\n' }, StringSplitOptions.RemoveEmptyEntries);
private async Task StartModelRefreshLoop() foreach (var p in parts)
{ {
while (true) var t = p.Replace("or", "", StringComparison.OrdinalIgnoreCase).Trim();
{ if (!string.IsNullOrEmpty(t)) options.Add(t.Trim().Trim('.'));
await RefreshModels(); }
await Task.Delay(IsErrorVisible ? 3000 : 10000); }
}
} 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);
}
}
}
}
+8 -2
View File
@@ -57,13 +57,19 @@
ItemsSource="{Binding Levels}" ItemsSource="{Binding Levels}"
SelectedItem="{Binding SelectedLevel}"/> SelectedItem="{Binding SelectedLevel}"/>
<Label Content="System:"/>
<ComboBox HorizontalAlignment="Stretch"
ItemsSource="{Binding Systems}"
SelectedItem="{Binding SelectedSystem}"/>
<Label Content="Description:"/> <Label Content="Description:"/>
<TextBox Text="{Binding Description}" AcceptsReturn="True" Height="100" PlaceholderText="Enter description here..."/> <TextBox Text="{Binding Description}" AcceptsReturn="True" Height="100" PlaceholderText="Enter description here..."/>
</StackPanel> </StackPanel>
<Button Content="Generate" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center" <Button Content="Generate" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Command="{Binding GenerateCommand}" FontWeight="Bold"/> Command="{Binding GenerateCommand}" FontWeight="Bold" IsEnabled="{Binding CanGenerate}"/>
<ProgressBar Minimum="0" Maximum="1" Value="{Binding Progress}" IsVisible="{Binding IsGenerating}" Height="10"/>
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" Margin="0,10,0,0"/> <TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" Margin="0,10,0,0"/>
<StackPanel Spacing="5" Margin="0,10,0,0" IsVisible="{Binding IsNpcSelected}"> <StackPanel Spacing="5" Margin="0,10,0,0" IsVisible="{Binding IsNpcSelected}">