2026-07-01 15:33:12 -05:00
using System ;
using System.Collections.ObjectModel ;
using System.IO ;
using System.Collections.Generic ;
using System.Linq ;
using System.Threading.Tasks ;
using System.Windows.Input ;
using Avalonia.Media.Imaging ;
using CommunityToolkit.Mvvm.ComponentModel ;
using CommunityToolkit.Mvvm.Input ;
using finder2e_foundry_converter.Models ;
using finder2e_foundry_converter.Services ;
using System.Text.Json.Nodes ;
using System.Text.Json ;
using finder2e_foundry_converter.Converters ;
namespace finder2e_foundry_converter.ViewModels
{
public partial class MainWindowViewModel : ViewModelBase
{
private readonly LmStudioService _lmStudioService = new ();
private readonly OllamaService _ollamaService = new ();
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 );
}
}
}
}