changed location of llm flow for npcs
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using finder2e_foundry_converter.Services;
|
||||
|
||||
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}`");
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ namespace finder2e_foundry_converter.ViewModels
|
||||
{
|
||||
private readonly LmStudioService _lmStudioService = new();
|
||||
private readonly OllamaService _ollamaService = new();
|
||||
private NpcGenerator? _npcGenerator;
|
||||
|
||||
private ILlmService CurrentService => SelectedProvider == "Ollama" ? (ILlmService)_ollamaService : _lmStudioService;
|
||||
|
||||
@@ -105,10 +106,6 @@ namespace finder2e_foundry_converter.ViewModels
|
||||
[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
|
||||
@@ -182,51 +179,57 @@ namespace finder2e_foundry_converter.ViewModels
|
||||
|
||||
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))
|
||||
// Initialize NpcGenerator
|
||||
if (_npcGenerator == null)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
_npcGenerator = new NpcGenerator(CurrentService);
|
||||
}
|
||||
|
||||
// Load prompts from TOML
|
||||
Console.WriteLine("Loading prompts from npc.toml...");
|
||||
await _npcGenerator.LoadPromptsAsync();
|
||||
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
|
||||
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);
|
||||
@@ -235,121 +238,45 @@ namespace finder2e_foundry_converter.ViewModels
|
||||
|
||||
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))
|
||||
try
|
||||
{
|
||||
// Fallback question
|
||||
questionTemplate = key switch
|
||||
StatusMessage = $"Asking LLM for {key}...";
|
||||
var replacements = new Dictionary<string, string>
|
||||
{
|
||||
"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}."
|
||||
{ "level", SelectedLevel.ToString() },
|
||||
{ "system", SelectedSystem ?? "pf2e" },
|
||||
{ "name", name },
|
||||
{ "description", description }
|
||||
};
|
||||
}
|
||||
|
||||
// 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;
|
||||
var (success, response) = await _npcGenerator.AskLlmAsync(
|
||||
CurrentAddress, SelectedModel!, key, replacements,
|
||||
ImageItems.Select(i => i.Path).ToList());
|
||||
|
||||
// If LLM returned an explicit Error: prefix, consider that a failure
|
||||
if (trimmed.StartsWith("Error:", StringComparison.OrdinalIgnoreCase))
|
||||
if (!success)
|
||||
{
|
||||
// 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}";
|
||||
StatusMessage = $"LLM failed to provide valid response for {key} after 3 attempts.";
|
||||
IsGenerating = false;
|
||||
return;
|
||||
}
|
||||
|
||||
responses[key] = finalResp;
|
||||
responses[key] = response;
|
||||
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
|
||||
Progress = 0.95;
|
||||
|
||||
// Load template JSON
|
||||
var cwd = Directory.GetCurrentDirectory();
|
||||
var templatePath = Path.Combine(cwd, "foundry_templates", "npc_template.json");
|
||||
if (!File.Exists(templatePath))
|
||||
{
|
||||
@@ -362,7 +289,7 @@ namespace finder2e_foundry_converter.ViewModels
|
||||
var node = JsonNode.Parse(templateText)!.AsObject();
|
||||
|
||||
// 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";
|
||||
|
||||
// system defaults
|
||||
@@ -376,12 +303,14 @@ namespace finder2e_foundry_converter.ViewModels
|
||||
systemNode["details"] = detailsNode;
|
||||
}
|
||||
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)
|
||||
{
|
||||
details["level"] = new JsonObject();
|
||||
levelNode = details["level"];
|
||||
}
|
||||
details["level"].AsObject()["value"] = SelectedLevel;
|
||||
levelNode!.AsObject()["value"] = SelectedLevel;
|
||||
|
||||
string gameSystem = SelectedSystem ?? "pf2e";
|
||||
|
||||
@@ -445,7 +374,7 @@ namespace finder2e_foundry_converter.ViewModels
|
||||
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
|
||||
// Skills
|
||||
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)
|
||||
@@ -455,7 +384,7 @@ namespace finder2e_foundry_converter.ViewModels
|
||||
}
|
||||
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 strikeTier = GetTier("strike_attack_bonus");
|
||||
var strikeAttack = NpcConverter.StrikeAttackBonus(SelectedLevel, strikeTier, gameSystem);
|
||||
@@ -482,15 +411,15 @@ namespace finder2e_foundry_converter.ViewModels
|
||||
// Finalize JSON text
|
||||
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");
|
||||
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");
|
||||
await File.WriteAllTextAsync(tempPath, GeneratedNpcJson);
|
||||
TemporaryNpcPath = tempPath;
|
||||
|
||||
StatusMessage = $"NPC generated and saved to temporary path: {tempPath}";
|
||||
StatusMessage = $"NPC generated successfully!";
|
||||
Progress = 1.0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -503,6 +432,7 @@ namespace finder2e_foundry_converter.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[RelayCommand]
|
||||
private void AddImagePath(string path)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user