changed location of llm flow for npcs

This commit is contained in:
grimsace
2026-07-02 12:20:04 -05:00
parent c3f6cc2375
commit b94b23e46f
2 changed files with 373 additions and 153 deletions
+290
View File
@@ -1,6 +1,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using System.Threading.Tasks;
using finder2e_foundry_converter.Services;
namespace finder2e_foundry_converter.Converters namespace finder2e_foundry_converter.Converters
{ {
@@ -725,4 +728,291 @@ namespace finder2e_foundry_converter.Converters
return Result<int>.Failure($"invalid spell attack bonus value for system `{normalizedSystem}`, level `{level}`, column `{column}`"); return Result<int>.Failure($"invalid spell attack bonus value for system `{normalizedSystem}`, level `{level}`, column `{column}`");
} }
} }
public class NpcGenerator
{
private readonly ILlmService _llmService;
private Dictionary<string, (string question, string constraints)> _prompts = new();
private string _retryPrefix = "";
public NpcGenerator(ILlmService llmService)
{
_llmService = llmService;
}
/// <summary>
/// Load all prompts from llmflow/npc.toml
/// </summary>
public async Task LoadPromptsAsync()
{
_prompts.Clear();
_retryPrefix = "";
var cwd = Directory.GetCurrentDirectory();
var tomlPath = Path.Combine(cwd, "llmflow", "npc.toml");
if (!File.Exists(tomlPath))
{
Console.WriteLine($"Warning: TOML file not found at {tomlPath}");
return;
}
var lines = await File.ReadAllLinesAsync(tomlPath);
string? currentSection = null;
foreach (var rawLine in lines)
{
var line = rawLine.Trim();
if (string.IsNullOrEmpty(line) || line.StartsWith("#")) continue;
// Parse section headers [section_name]
if (line.StartsWith("[") && line.EndsWith("]"))
{
currentSection = line.Substring(1, line.Length - 2).Trim();
continue;
}
// Parse key = "value"
if (line.Contains("="))
{
var idx = line.IndexOf('=');
var key = line.Substring(0, idx).Trim();
var valStr = line.Substring(idx + 1).Trim().Trim('"');
if (key == "retry_prefix")
{
_retryPrefix = valStr;
Console.WriteLine($"Loaded retry_prefix");
}
else if (currentSection != null)
{
if (key == "question")
{
if (!_prompts.ContainsKey(currentSection))
_prompts[currentSection] = (valStr, string.Empty);
else
_prompts[currentSection] = (valStr, _prompts[currentSection].constraints);
}
else if (key == "constraints")
{
if (!_prompts.ContainsKey(currentSection))
_prompts[currentSection] = (string.Empty, valStr);
else
_prompts[currentSection] = (_prompts[currentSection].question, valStr);
}
}
}
}
Console.WriteLine($"Loaded {_prompts.Count} prompt sections from npc.toml");
}
/// <summary>
/// Ask LLM a question with up to 3 retry attempts and constraint validation
/// </summary>
public async Task<(bool success, string response)> AskLlmAsync(
string baseUrl,
string model,
string promptKey,
Dictionary<string, string> replacements,
List<string> imagePaths)
{
if (!_prompts.TryGetValue(promptKey, out var prompt_tuple))
{
Console.WriteLine($"Warning: Prompt '{promptKey}' not found in loaded prompts");
return (false, string.Empty);
}
var (questionTemplate, constraints) = prompt_tuple;
var prompt = questionTemplate;
foreach (var kvp in replacements)
{
prompt = prompt.Replace($"{{{kvp.Key}}}", kvp.Value);
}
string lastResponse = string.Empty;
for (int attempt = 1; attempt <= 3; attempt++)
{
Console.WriteLine($"\n=== Asking LLM for '{promptKey}' (attempt {attempt}/3) ===");
Console.WriteLine($"Prompt: {prompt}");
if (!string.IsNullOrWhiteSpace(constraints))
Console.WriteLine($"Constraints: {constraints}");
var resp = await _llmService.AskAsync(baseUrl, model, prompt, imagePaths);
var trimmed = resp?.Trim() ?? string.Empty;
Console.WriteLine($"LLM Response: '{trimmed}'");
// Check for error response
if (trimmed.StartsWith("Error:", StringComparison.OrdinalIgnoreCase))
{
Console.WriteLine($"-> Response is an error, will retry");
prompt = _retryPrefix + "\nPrevious error: " + trimmed + "\n\n" + questionTemplate;
foreach (var kvp in replacements)
{
prompt = prompt.Replace($"{{{kvp.Key}}}", kvp.Value);
}
lastResponse = trimmed;
continue;
}
// Validate against constraints if available
if (!string.IsNullOrWhiteSpace(constraints))
{
var (isValid, normalizedResponse) = ValidateResponse(trimmed, constraints);
if (isValid)
{
Console.WriteLine($"✓ Response is valid: '{normalizedResponse}'");
return (true, normalizedResponse);
}
else
{
Console.WriteLine($"✗ Response '{trimmed}' does not match constraints");
if (attempt < 3)
{
prompt = _retryPrefix + $"\nPrevious response was: '{trimmed}'\n\nPlease respond with the correct format:\n{constraints}\n\n{questionTemplate}";
foreach (var kvp in replacements)
{
prompt = prompt.Replace($"{{{kvp.Key}}}", kvp.Value);
}
}
lastResponse = trimmed;
continue;
}
}
else
{
// No constraints, accept any non-empty response
if (!string.IsNullOrWhiteSpace(trimmed))
{
Console.WriteLine($"✓ Response accepted (no constraints)");
return (true, trimmed);
}
else
{
Console.WriteLine($"✗ Empty response, will retry");
if (attempt < 3)
{
prompt = _retryPrefix + "\nPrevious response was empty.\n\n" + questionTemplate;
foreach (var kvp in replacements)
{
prompt = prompt.Replace($"{{{kvp.Key}}}", kvp.Value);
}
}
lastResponse = trimmed;
}
}
}
Console.WriteLine($"\n✗✗✗ Failed to get valid response for '{promptKey}' after 3 attempts. Last response: '{lastResponse}'");
return (false, lastResponse);
}
/// <summary>
/// Generate name if not provided. Tries name_from_description_and_level, then name_from_level, then name
/// </summary>
public async Task<string> GenerateNameAsync(
string baseUrl,
string model,
string description,
int level,
List<string> imagePaths)
{
if (!string.IsNullOrWhiteSpace(description))
{
// Try generating from description + level
var replacements = new Dictionary<string, string>
{
{ "description", description },
{ "level", level.ToString() }
};
var (success, name) = await AskLlmAsync(baseUrl, model, "name_from_description_and_level", replacements, imagePaths);
if (success && !string.IsNullOrWhiteSpace(name))
return name;
// Fallback to just description
replacements = new Dictionary<string, string>();
(success, name) = await AskLlmAsync(baseUrl, model, "name", replacements, imagePaths);
if (success && !string.IsNullOrWhiteSpace(name))
return name;
}
// If no description, generate from level alone
var levelReplacements = new Dictionary<string, string>
{
{ "level", level.ToString() }
};
var (levelSuccess, levelName) = await AskLlmAsync(baseUrl, model, "name_from_level", levelReplacements, imagePaths);
return (levelSuccess && !string.IsNullOrWhiteSpace(levelName)) ? levelName : "NPC";
}
/// <summary>
/// Generate description if not provided. Uses description_from_name_and_level
/// </summary>
public async Task<string> GenerateDescriptionAsync(
string baseUrl,
string model,
string name,
int level,
List<string> imagePaths)
{
var replacements = new Dictionary<string, string>
{
{ "name", name },
{ "level", level.ToString() }
};
var (success, description) = await AskLlmAsync(baseUrl, model, "description_from_name_and_level", replacements, imagePaths);
return (success && !string.IsNullOrWhiteSpace(description)) ? description : string.Empty;
}
/// <summary>
/// Validate a response against constraints and return normalized response
/// </summary>
private (bool isValid, string normalizedResponse) ValidateResponse(string response, string constraints)
{
var allowed = constraints.Trim();
var optionsStart = allowed.IndexOf(":");
List<string> options = new();
if (optionsStart >= 0)
{
var after = allowed.Substring(optionsStart + 1);
// Split by commas and newlines
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('.'));
}
}
Console.WriteLine($"Parsed allowed options: {string.Join(", ", options)}");
if (options.Count > 0)
{
// Check exact match
if (options.Any(o => string.Equals(o, response, StringComparison.OrdinalIgnoreCase)))
{
return (true, response);
}
// Try first word match
var firstWord = response.Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? string.Empty;
if (options.Any(o => string.Equals(o, firstWord, StringComparison.OrdinalIgnoreCase)))
{
return (true, firstWord);
}
return (false, response);
}
// No options parsed, accept any non-empty response
return (!string.IsNullOrWhiteSpace(response), response);
}
}
} }
+80 -150
View File
@@ -20,6 +20,7 @@ namespace finder2e_foundry_converter.ViewModels
{ {
private readonly LmStudioService _lmStudioService = new(); private readonly LmStudioService _lmStudioService = new();
private readonly OllamaService _ollamaService = new(); private readonly OllamaService _ollamaService = new();
private NpcGenerator? _npcGenerator;
private ILlmService CurrentService => SelectedProvider == "Ollama" ? (ILlmService)_ollamaService : _lmStudioService; private ILlmService CurrentService => SelectedProvider == "Ollama" ? (ILlmService)_ollamaService : _lmStudioService;
@@ -105,10 +106,6 @@ namespace finder2e_foundry_converter.ViewModels
[ObservableProperty] [ObservableProperty]
private string _temporaryNpcPath = string.Empty; 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() public MainWindowViewModel()
{ {
// Load saved preferences would go here if we had a settings service // Load saved preferences would go here if we had a settings service
@@ -182,51 +179,57 @@ namespace finder2e_foundry_converter.ViewModels
try try
{ {
// Load the prompts from llmflow/npc.toml // Initialize NpcGenerator
var cwd = Directory.GetCurrentDirectory(); if (_npcGenerator == null)
var tomlPath = Path.Combine(cwd, "llmflow", "npc.toml");
_npcPrompts.Clear();
_npcRetryPrefix = "";
if (File.Exists(tomlPath))
{ {
var lines = await File.ReadAllLinesAsync(tomlPath); _npcGenerator = new NpcGenerator(CurrentService);
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);
}
}
}
} }
// Load prompts from TOML
Console.WriteLine("Loading prompts from npc.toml...");
await _npcGenerator.LoadPromptsAsync();
Progress = 0.05; Progress = 0.05;
// 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;
}
// Ask LLM for tiers per-stat // 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 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 responses = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
@@ -235,121 +238,45 @@ namespace finder2e_foundry_converter.ViewModels
foreach (var key in statKeys) foreach (var key in statKeys)
{ {
string questionTemplate = _npcPrompts.ContainsKey(key) ? _npcPrompts[key].question : null; try
string constraints = _npcPrompts.ContainsKey(key) ? _npcPrompts[key].constraints : string.Empty;
if (string.IsNullOrWhiteSpace(questionTemplate))
{ {
// Fallback question StatusMessage = $"Asking LLM for {key}...";
questionTemplate = key switch var replacements = new Dictionary<string, string>
{ {
"ability_score_modifier" => "Based on the description, how would you rate ability scores for this character?", { "level", SelectedLevel.ToString() },
"perception_modifier" => "Based on the description, how would you rate perception for this character?", { "system", SelectedSystem ?? "pf2e" },
"skill_modifier" => "Based on the description, how would you rate skills for this character?", { "name", name },
"ac" => "Based on the description, how would you rate the armor class (AC) for this character?", { "description", description }
"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 (success, response) = await _npcGenerator.AskLlmAsync(
var prompt = questionTemplate.Replace("{level}", SelectedLevel.ToString()).Replace("{system}", SelectedSystem).Replace("{description}", Description ?? string.Empty).Replace("{name}", Name ?? string.Empty).Replace("{modifierTier}", ""); CurrentAddress, SelectedModel!, key, replacements,
string lastResponse = string.Empty; ImageItems.Select(i => i.Path).ToList());
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 (!success)
if (trimmed.StartsWith("Error:", StringComparison.OrdinalIgnoreCase))
{ {
// feed back to next attempt StatusMessage = $"LLM failed to provide valid response for {key} after 3 attempts.";
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; IsGenerating = false;
return; return;
} }
responses[key] = finalResp; responses[key] = response;
done++; done++;
Progress = 0.05 + 0.85 * ((double)done / total); Progress = 0.25 + 0.65 * ((double)done / total);
}
catch (Exception ex)
{
StatusMessage = $"Error while asking for {key}: {ex.Message}";
IsGenerating = false;
return;
}
} }
// Finished LLM prompts // Finished LLM prompts
Progress = 0.95; Progress = 0.95;
// Load template JSON // Load template JSON
var cwd = Directory.GetCurrentDirectory();
var templatePath = Path.Combine(cwd, "foundry_templates", "npc_template.json"); var templatePath = Path.Combine(cwd, "foundry_templates", "npc_template.json");
if (!File.Exists(templatePath)) if (!File.Exists(templatePath))
{ {
@@ -362,7 +289,7 @@ namespace finder2e_foundry_converter.ViewModels
var node = JsonNode.Parse(templateText)!.AsObject(); var node = JsonNode.Parse(templateText)!.AsObject();
// Fill basic fields // Fill basic fields
node["name"] = string.IsNullOrWhiteSpace(Name) ? "NPC" : Name; node["name"] = string.IsNullOrWhiteSpace(name) ? "NPC" : name;
node["img"] = node["img"] ?? "systems/pf2e/icons/default-icons/npc.svg"; node["img"] = node["img"] ?? "systems/pf2e/icons/default-icons/npc.svg";
// system defaults // system defaults
@@ -376,12 +303,14 @@ namespace finder2e_foundry_converter.ViewModels
systemNode["details"] = detailsNode; systemNode["details"] = detailsNode;
} }
var details = detailsNode.AsObject(); var details = detailsNode.AsObject();
details["blurb"] = Description ?? string.Empty; details["blurb"] = description ?? string.Empty;
details["publicNotes"] = description ?? string.Empty;
if (!details.TryGetPropertyValue("level", out var levelNode) || levelNode is null) if (!details.TryGetPropertyValue("level", out var levelNode) || levelNode is null)
{ {
details["level"] = new JsonObject(); details["level"] = new JsonObject();
levelNode = details["level"];
} }
details["level"].AsObject()["value"] = SelectedLevel; levelNode!.AsObject()["value"] = SelectedLevel;
string gameSystem = SelectedSystem ?? "pf2e"; string gameSystem = SelectedSystem ?? "pf2e";
@@ -445,7 +374,7 @@ namespace finder2e_foundry_converter.ViewModels
var hpRes = NpcConverter.Hp(SelectedLevel, hpTier, gameSystem); 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 }; 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 // Skills
var skillsNode = new JsonObject(); var skillsNode = new JsonObject();
var skillList = new[] { "acrobatics", "arcana", "athletics", "crafting", "deception", "diplomacy", "intimidation", "lore", "medicine", "nature", "occultism", "performance", "religion", "society", "stealth", "survival", "thievery" }; 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) foreach (var sk in skillList)
@@ -455,7 +384,7 @@ namespace finder2e_foundry_converter.ViewModels
} }
systemNode["skills"] = skillsNode; systemNode["skills"] = skillsNode;
// Items: create a basic Strike item using strike attack bonus and damage from LLM response tiers // Items: create a basic Strike item
var items = new JsonArray(); var items = new JsonArray();
var strikeTier = GetTier("strike_attack_bonus"); var strikeTier = GetTier("strike_attack_bonus");
var strikeAttack = NpcConverter.StrikeAttackBonus(SelectedLevel, strikeTier, gameSystem); var strikeAttack = NpcConverter.StrikeAttackBonus(SelectedLevel, strikeTier, gameSystem);
@@ -482,15 +411,15 @@ namespace finder2e_foundry_converter.ViewModels
// Finalize JSON text // Finalize JSON text
GeneratedNpcJson = node.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); GeneratedNpcJson = node.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
// Save to a temporary file while generating // Save to a temporary file
var tempDir = Path.Combine(Path.GetTempPath(), "Finder2eFoundryConverter"); var tempDir = Path.Combine(Path.GetTempPath(), "Finder2eFoundryConverter");
if (!Directory.Exists(tempDir)) Directory.CreateDirectory(tempDir); if (!Directory.Exists(tempDir)) Directory.CreateDirectory(tempDir);
var safeName = string.IsNullOrWhiteSpace(Name) ? "npc" : string.Concat(Name.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '_' : ch)); 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"); var tempPath = Path.Combine(tempDir, $"{safeName}_{DateTime.Now:yyyyMMddHHmmss}.json");
await File.WriteAllTextAsync(tempPath, GeneratedNpcJson); await File.WriteAllTextAsync(tempPath, GeneratedNpcJson);
TemporaryNpcPath = tempPath; TemporaryNpcPath = tempPath;
StatusMessage = $"NPC generated and saved to temporary path: {tempPath}"; StatusMessage = $"NPC generated successfully!";
Progress = 1.0; Progress = 1.0;
} }
catch (Exception ex) catch (Exception ex)
@@ -503,6 +432,7 @@ namespace finder2e_foundry_converter.ViewModels
} }
} }
[RelayCommand] [RelayCommand]
private void AddImagePath(string path) private void AddImagePath(string path)
{ {