Compare commits

..
10 Commits
Author SHA1 Message Date
grimsace 4c87d51f2d added character stripping 2026-07-02 12:36:45 -05:00
grimsace b94b23e46f changed location of llm flow for npcs 2026-07-02 12:20:04 -05:00
grimsace c3f6cc2375 added basic flow and prompting 2026-07-01 15:33:12 -05:00
grimsace 6c306a4a0f json handling 2026-07-01 15:03:12 -05:00
grimsace 6520e215ba ui changes for modularity and npc generation 2026-06-30 10:43:15 -05:00
grimsace b178513870 added toml file with prompts and changed the ui slightly 2026-06-29 13:02:05 -05:00
grimsace 0b86a52ec5 flow for npc generation 2026-06-18 12:04:36 -05:00
grimsace ba5e1ff154 reformatting 2026-06-08 15:53:40 -05:00
grimsace 99df32cb1f renamed project to more sensible name 2026-06-08 15:18:19 -05:00
grimsace 86e54906b4 image thumbnails 2026-06-08 10:50:01 -05:00
22 changed files with 2297 additions and 376 deletions
-145
View File
@@ -1,145 +0,0 @@
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Input;
using Avalonia.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Finder2eFoundryConverterCS.Models;
using Finder2eFoundryConverterCS.Services;
namespace Finder2eFoundryConverterCS.ViewModels
{
public partial class MainWindowViewModel : ViewModelBase
{
private readonly LlmService _llmService = new LlmService();
[ObservableProperty]
private string _lmStudioAddress = "http://localhost:1234";
[ObservableProperty]
private bool _isAddressVisible = false;
[ObservableProperty]
private string _selectedProvider = "LM Studio";
public ObservableCollection<string> Providers { get; } = new() { "LM Studio" };
[ObservableProperty]
private string? _selectedModel;
public ObservableCollection<string> Models { get; } = new();
[ObservableProperty]
private string _description = string.Empty;
public ObservableCollection<ImageItem> ImageItems { get; } = new();
[ObservableProperty]
private string _statusMessage = string.Empty;
[ObservableProperty]
private bool _isErrorVisible = false;
[ObservableProperty]
private string _errorText = string.Empty;
public MainWindowViewModel()
{
// Load saved preferences would go here if we had a settings service
_ = StartModelRefreshLoop();
}
[RelayCommand]
private void ToggleAddress()
{
IsAddressVisible = !IsAddressVisible;
}
[RelayCommand]
private async Task RefreshModels()
{
if (SelectedProvider != "LM Studio")
{
IsErrorVisible = false;
return;
}
var models = await _llmService.GetModelsAsync(LmStudioAddress);
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 LM Studio";
IsErrorVisible = true;
IsAddressVisible = true; // Auto-show on failure
}
}
[RelayCommand]
private async Task Generate()
{
if (string.IsNullOrEmpty(SelectedModel))
{
StatusMessage = "Please select a model first.";
return;
}
StatusMessage = "Generating...";
var result = await _llmService.GenerateResponseAsync(LmStudioAddress, SelectedModel, Description, ImageItems.Select(i => i.Path).ToList());
StatusMessage = result;
}
[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);
}
}
}
}
-83
View File
@@ -1,83 +0,0 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Finder2eFoundryConverterCS.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="600"
x:Class="Finder2eFoundryConverterCS.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Icon="/Assets/avalonia-logo.ico"
Title="Finder2e Foundry Converter"
Width="500" Height="600"
DragDrop.AllowDrop="True">
<Design.DataContext>
<!-- This only sets the DataContext for the previewer in an IDE,
to set the actual DataContext at runtime, set the DataContext property in code (look at App.axaml.cs) -->
<vm:MainWindowViewModel/>
</Design.DataContext>
<ScrollViewer>
<StackPanel Margin="20" Spacing="10">
<Label Content="Provider:"/>
<Grid ColumnDefinitions="*,Auto">
<ComboBox Grid.Column="0" HorizontalAlignment="Stretch"
ItemsSource="{Binding Providers}"
SelectedItem="{Binding SelectedProvider}"/>
<Button Grid.Column="1" Content="Address" Command="{Binding ToggleAddressCommand}" Margin="5,0,0,0"/>
</Grid>
<TextBlock Text="{Binding ErrorText}" Foreground="Red" HorizontalAlignment="Center" IsVisible="{Binding IsErrorVisible}"/>
<StackPanel IsVisible="{Binding IsAddressVisible}" Spacing="5">
<Label Content="LM Studio Address:"/>
<TextBox Text="{Binding LmStudioAddress}" PlaceholderText="http://localhost:1234"/>
</StackPanel>
<Label Content="Model:"/>
<Grid ColumnDefinitions="*,Auto">
<ComboBox Grid.Column="0" HorizontalAlignment="Stretch"
ItemsSource="{Binding Models}"
SelectedItem="{Binding SelectedModel}"
PlaceholderText="Select Model"/>
<Button Grid.Column="1" Content="Refresh" Command="{Binding RefreshModelsCommand}" Margin="5,0,0,0"/>
</Grid>
<Label Content="Description:"/>
<TextBox Text="{Binding Description}" AcceptsReturn="True" Height="100" PlaceholderText="Enter description here..."/>
<Label Content="Images:"/>
<ScrollViewer Height="180">
<ItemsControl ItemsSource="{Binding ImageItems}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid Margin="5">
<Border BorderBrush="Gray" BorderThickness="1" CornerRadius="4" ClipToBounds="True">
<Image Source="{Binding Thumbnail}" Width="100" Height="100" Stretch="UniformToFill" />
</Border>
<Button Content="X"
Command="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).RemoveImageCommand}"
CommandParameter="{Binding}"
HorizontalAlignment="Right" VerticalAlignment="Top"
Background="#80000000" Foreground="White" Padding="5,2"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Button Content="Select Image" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center" Click="SelectImage_Click"/>
<Button Content="Generate" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Command="{Binding GenerateCommand}" FontWeight="Bold"/>
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" Margin="0,10,0,0"/>
</StackPanel>
</ScrollViewer>
</Window>
-134
View File
@@ -1,134 +0,0 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using Finder2eFoundryConverterCS.ViewModels;
namespace Finder2eFoundryConverterCS.Views
{
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
AddHandler(DragDrop.DropEvent, Drop);
KeyDown += OnKeyDown;
}
private async void OnKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key == Key.V && e.KeyModifiers == KeyModifiers.Control)
{
var topLevel = GetTopLevel(this);
if (topLevel?.Clipboard == null) return;
var vm = DataContext as MainWindowViewModel;
if (vm == null) return;
// Try to get text (for file paths)
var text = await topLevel.Clipboard.TryGetTextAsync();
if (!string.IsNullOrWhiteSpace(text))
{
var path = text.Replace("file://", "").Trim();
if (System.IO.File.Exists(path))
{
var ext = System.IO.Path.GetExtension(path).ToLower();
if (new[] { ".png", ".jpg", ".jpeg", ".webp" }.Contains(ext))
{
vm.AddImagePathCommand.Execute(path);
return;
}
}
}
// Try to get bitmap directly
var bitmap = await topLevel.Clipboard.TryGetBitmapAsync();
if (bitmap != null)
{
// Save bitmap to temp file
var tempDir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "Finder2eFoundryConverter");
if (!System.IO.Directory.Exists(tempDir)) System.IO.Directory.CreateDirectory(tempDir);
var tempPath = System.IO.Path.Combine(tempDir, $"pasted_image_{DateTime.Now.Ticks}.png");
bitmap.Save(tempPath);
vm.AddImagePathCommand.Execute(tempPath);
}
}
}
private async void SelectImage_Click(object? sender, RoutedEventArgs e)
{
var topLevel = GetTopLevel(this);
if (topLevel == null) return;
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Select Images",
AllowMultiple = true,
FileTypeFilter = new[]
{
new FilePickerFileType("Images")
{
Patterns = new[] { "*.png", "*.jpg", "*.jpeg", "*.webp" }
}
}
});
if (files.Count > 0)
{
var vm = DataContext as MainWindowViewModel;
if (vm == null) return;
foreach (var file in files)
{
vm.AddImagePathCommand.Execute(file.Path.LocalPath);
}
}
}
private async void Drop(object? sender, DragEventArgs e)
{
var files = e.DataTransfer.TryGetFiles();
if (files != null)
{
var vm = DataContext as MainWindowViewModel;
if (vm == null) return;
foreach (var file in files)
{
var path = file.Path.LocalPath;
if (path == null) continue;
var ext = System.IO.Path.GetExtension(path).ToLower();
if (new[] { ".png", ".jpg", ".jpeg", ".webp" }.Contains(ext))
{
vm.AddImagePathCommand.Execute(path);
}
}
}
// Try to get bitmap directly from drop
var bitmap = e.DataTransfer.TryGetBitmap();
if (bitmap != null)
{
var vm = DataContext as MainWindowViewModel;
if (vm != null)
{
var tempDir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "Finder2eFoundryConverter");
if (!System.IO.Directory.Exists(tempDir)) System.IO.Directory.CreateDirectory(tempDir);
var tempPath = System.IO.Path.Combine(tempDir, $"dropped_image_{DateTime.Now.Ticks}.png");
bitmap.Save(tempPath);
vm.AddImagePathCommand.Execute(tempPath);
}
}
}
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
<Application xmlns="https://github.com/avaloniaui" <Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Finder2eFoundryConverterCS.App" x:Class="finder2e_foundry_converter.App"
xmlns:local="using:Finder2eFoundryConverterCS" xmlns:local="using:finder2e_foundry_converter"
RequestedThemeVariant="Default"> RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. --> <!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
+3 -3
View File
@@ -4,10 +4,10 @@ using Avalonia.Data.Core;
using Avalonia.Data.Core.Plugins; using Avalonia.Data.Core.Plugins;
using System.Linq; using System.Linq;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Finder2eFoundryConverterCS.ViewModels; using finder2e_foundry_converter.ViewModels;
using Finder2eFoundryConverterCS.Views; using finder2e_foundry_converter.Views;
namespace Finder2eFoundryConverterCS; namespace finder2e_foundry_converter;
public partial class App : Application public partial class App : Application
{ {

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 172 KiB

+1029
View File
File diff suppressed because it is too large Load Diff
@@ -8,8 +8,8 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Folder Include="Models\" /> <Folder Include="models\" />
<AvaloniaResource Include="Assets\**" /> <AvaloniaResource Include="assets\**" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+224
View File
@@ -0,0 +1,224 @@
{
"name": "NPC",
"type": "npc",
"prototypeToken": {
"flags": {
"pf2e": {
"linkToActorSize": true,
"autoscale": true
}
},
"height": 1,
"width": 1,
"name": "NPC",
"displayName": 20,
"actorLink": false,
"texture": {
"src": "systems/pf2e/icons/default-icons/npc.svg",
"anchorX": 0.5,
"anchorY": 0.5,
"offsetX": 0,
"offsetY": 0,
"fit": "contain",
"scaleX": 1,
"scaleY": 1,
"rotation": 0,
"tint": "#ffffff",
"alphaThreshold": 0.75
},
"lockRotation": true,
"rotation": 0,
"alpha": 1,
"disposition": -1,
"displayBars": 20,
"bar1": {
"attribute": "attributes.hp"
},
"bar2": {
"attribute": null
},
"light": {
"negative": false,
"priority": 0,
"alpha": 0.5,
"angle": 360,
"bright": 0,
"color": null,
"coloration": 1,
"dim": 0,
"attenuation": 0.5,
"luminosity": 0.5,
"saturation": 0,
"contrast": 0,
"shadows": 0,
"animation": {
"type": null,
"speed": 5,
"intensity": 5,
"reverse": false
},
"darkness": {
"min": 0,
"max": 1
}
},
"sight": {
"enabled": false,
"range": 0,
"angle": 360,
"visionMode": "basic",
"color": null,
"attenuation": 0.1,
"brightness": 0,
"saturation": 0,
"contrast": 0
},
"detectionModes": [],
"occludable": {
"radius": 0
},
"ring": {
"enabled": false,
"colors": {
"ring": null,
"background": null
},
"effects": 1,
"subject": {
"scale": 1,
"texture": null
}
},
"turnMarker": {
"mode": 1,
"animation": null,
"src": null,
"disposition": false
},
"movementAction": null,
"randomImg": false,
"appendNumber": false,
"prependAdjective": false
},
"effects": [],
"system": {
"attributes": {
"hp": {
"value": 10,
"temp": 0,
"max": 10,
"details": ""
},
"ac": {
"value": 10,
"details": ""
},
"allSaves": {
"value": ""
},
"speed": {
"value": 25,
"otherSpeeds": [],
"details": ""
}
},
"initiative": {
"statistic": "perception"
},
"details": {
"languages": {
"value": [],
"details": ""
},
"level": {
"value": 1
},
"blurb": "",
"publicNotes": "",
"privateNotes": "",
"publication": {
"title": "",
"authors": "",
"license": "OGL",
"remaster": false
}
},
"resources": {},
"_migration": {
"version": 0.953,
"previous": null
},
"abilities": {
"str": {
"mod": 0
},
"dex": {
"mod": 0
},
"con": {
"mod": 0
},
"int": {
"mod": 0
},
"wis": {
"mod": 0
},
"cha": {
"mod": 0
}
},
"perception": {
"details": "",
"mod": 0,
"senses": [],
"vision": true
},
"saves": {
"fortitude": {
"value": 0,
"saveDetail": ""
},
"reflex": {
"value": 0,
"saveDetail": ""
},
"will": {
"value": 0,
"saveDetail": ""
}
},
"skills": {},
"traits": {
"value": [],
"rarity": "common",
"size": {
"value": "med"
}
}
},
"img": "systems/pf2e/icons/default-icons/npc.svg",
"items": [],
"folder": null,
"flags": {},
"_stats": {
"compendiumSource": null,
"duplicateSource": null,
"exportSource": {
"worldId": "ren-city",
"uuid": "Actor.C3z1yVH1bb8JOGJ5",
"coreVersion": "13.351",
"systemId": "pf2e",
"systemVersion": "7.8.0"
},
"coreVersion": "13.351",
"systemId": "pf2e",
"systemVersion": "7.8.0",
"createdTime": 1782755451922,
"modifiedTime": 1782755451922,
"lastModifiedBy": "lrcp8tpvQicPvXd6"
},
"ownership": {
"default": 0
}
}
View File
+76
View File
@@ -0,0 +1,76 @@
# Prompt Appendage for Retries
retry_prefix = "Previous attempts failed. Please try again and ensure you follow the strict formatting requirements."
# NPC Generation Prompts
[description_from_name_and_level]
question = "Based on the name '{name}' and character level {level}, write a short description for this character."
constraints = "Respond with a descriptive paragraph (2-4 sentences). Do not include any other text or metadata."
[name]
question = "Based on the description, what is a fitting name for this character?"
constraints = "Respond with only the name."
[name_from_description_and_level]
question = "Based on the description {description} and level {level}, what is a fitting name for this character?"
constraints = "Respond with only the name."
[name_from_level]
question = "Based on the character level {level}, what is a fitting name for this character?"
constraints = "Respond with only the name."
[ability_score_modifier]
question = "Based on the description: {description}. How would you rate the ability score modifier for a level {level} character in {system}?"
constraints = "Respond with only a single word: Extreme, High, Moderate, or Low."
[perception_modifier]
question = "Based on the description: {description}. How would you rate the perception modifier for a level {level} character in {system}?"
constraints = "Respond with only a single word: Extreme, High, Moderate, Low, or Terrible."
[skill_modifier]
question = "Based on the description: {description}. How would you rate the skill modifier for a level {level} character in {system}?"
constraints = "Respond with only a single word: Extreme, High, Moderate, or Low."
[ac]
question = "Based on the description: {description}. How would you rate the armor class (AC) for a level {level} character in {system}?"
constraints = "Respond with only a single word: Extreme, High, Moderate, or Low."
[saving_throw]
question = "Based on the description: {description}. How would you rate the saving throw for a level {level} character in {system}?"
constraints = "Respond with only a single word: Extreme, High, Moderate, Low, or Terrible."
[hp]
question = "Based on the description: {description}. How would you rate the hit points (HP) for a level {level} character in {system}?"
constraints = "Respond with only a single word: High, Moderate, or Low."
[resistance_or_weakness]
question = "Based on the description: {description}. Does this character have any notable resistances or weaknesses in {system}?"
constraints = "Respond with only a single word: Yes or No."
[strike_attack_bonus]
question = "Based on the description: {description}. How would you rate the strike attack bonus for a level {level} character in {system}?"
constraints = "Respond with only a single word: Extreme, High, Moderate, or Low."
[strike_damage_roll]
question = "Based on the description: {description}. What is the strike damage dice roll for a level {level} character in {system}?"
constraints = "Respond with only the dice notation (e.g., 1d8+3, 2d6)."
[strike_damage_average]
question = "Based on the description: {description}. What is the average strike damage for a level {level} character in {system}?"
constraints = "Respond with only the numerical value of the average damage."
[area_damage_roll]
question = "Based on the description: {description}. What is the area damage dice roll for a level {level} character in {system} (unlimited: {unlimited})?"
constraints = "Respond with only the dice notation (e.g., 2d6, 1d10)."
[area_damage_average]
question = "Based on the description: {description}. What is the average area damage for a level {level} character in {system} (unlimited: {unlimited})?"
constraints = "Respond with only the numerical value of the average damage."
[spell_dc]
question = "Based on the description: {description}. How would you rate the spell DC for a level {level} character in {system}?"
constraints = "Respond with only a single word: Extreme, High, or Moderate."
[spell_attack_bonus]
question = "Based on the description: {description}. How would you rate the spell attack bonus for a level {level} character in {system}?"
constraints = "Respond with only a single word: Extreme, High, or Moderate."
+1 -1
View File
@@ -1,7 +1,7 @@
using Avalonia; using Avalonia;
using System; using System;
namespace Finder2eFoundryConverterCS; namespace finder2e_foundry_converter;
sealed class Program sealed class Program
{ {
+1 -1
View File
@@ -1,6 +1,6 @@
using Avalonia.Media.Imaging; using Avalonia.Media.Imaging;
namespace Finder2eFoundryConverterCS.Models namespace finder2e_foundry_converter.Models
{ {
public class ImageItem public class ImageItem
{ {
View File
+14
View File
@@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace finder2e_foundry_converter.Services
{
public interface ILlmService
{
Task<List<string>> GetModelsAsync(string baseUrl);
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);
}
}
@@ -6,9 +6,9 @@ using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace Finder2eFoundryConverterCS.Services namespace finder2e_foundry_converter.Services
{ {
public class LlmService public class LmStudioService : ILlmService
{ {
private readonly HttpClient _httpClient = new HttpClient(); private readonly HttpClient _httpClient = new HttpClient();
@@ -93,6 +93,42 @@ namespace Finder2eFoundryConverterCS.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")]
+93
View File
@@ -0,0 +1,93 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading.Tasks;
using System.Text.Json.Serialization;
using System.Linq;
namespace finder2e_foundry_converter.Services
{
public class OllamaService : ILlmService
{
private readonly HttpClient _httpClient = new HttpClient();
public async Task<List<string>> GetModelsAsync(string baseUrl)
{
try
{
var response = await _httpClient.GetFromJsonAsync<OllamaModelsResponse>($"{baseUrl.TrimEnd('/')}/api/tags");
if (response?.Models == null) return new List<string>();
return response.Models.Select(m => m.Name).ToList();
}
catch
{
return new List<string> { "Error: Ollama not found" };
}
}
public async Task<string> GenerateResponseAsync(string baseUrl, string model, string text, List<string> imagePaths)
{
try
{
var images = new List<string>();
foreach (var path in imagePaths)
{
if (string.IsNullOrWhiteSpace(path)) continue;
var bytes = await System.IO.File.ReadAllBytesAsync(path);
images.Add(Convert.ToBase64String(bytes));
}
var requestBody = new
{
model = model,
messages = new[]
{
new { role = "user", content = text, images = images.Count > 0 ? images.ToArray() : null }
},
stream = false
};
var response = await _httpClient.PostAsJsonAsync($"{baseUrl.TrimEnd('/')}/api/chat", requestBody);
var body = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("error", out var error))
{
return $"Error: {error.GetString()}";
}
if (doc.RootElement.TryGetProperty("message", out var message))
{
return message.GetProperty("content").GetString() ?? "No content returned";
}
return "No response returned.";
}
catch (Exception ex)
{
return $"Error: {ex.Message}";
}
}
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
{
[JsonPropertyName("models")]
public List<OllamaModelData>? Models { get; set; }
}
private class OllamaModelData
{
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
}
}
}
+2 -2
View File
@@ -2,9 +2,9 @@ using System;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Templates; using Avalonia.Controls.Templates;
using Finder2eFoundryConverterCS.ViewModels; using finder2e_foundry_converter.ViewModels;
namespace Finder2eFoundryConverterCS; namespace finder2e_foundry_converter;
[RequiresUnreferencedCode( [RequiresUnreferencedCode(
"Default implementation of ViewLocator involves reflection which may be trimmed away.", "Default implementation of ViewLocator involves reflection which may be trimmed away.",
+473
View File
@@ -0,0 +1,473 @@
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 NpcGenerator? _npcGenerator;
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;
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
{
// Initialize NpcGenerator
if (_npcGenerator == null)
{
_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);
int total = statKeys.Length;
int done = 0;
foreach (var key in statKeys)
{
try
{
StatusMessage = $"Asking LLM for {key}...";
var replacements = new Dictionary<string, string>
{
{ "level", SelectedLevel.ToString() },
{ "system", SelectedSystem ?? "pf2e" },
{ "name", name },
{ "description", description }
};
var (success, response) = await _npcGenerator.AskLlmAsync(
CurrentAddress, SelectedModel!, key, replacements,
ImageItems.Select(i => i.Path).ToList());
if (!success)
{
StatusMessage = $"LLM failed to provide valid response for {key} after 3 attempts.";
IsGenerating = false;
return;
}
responses[key] = response;
done++;
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))
{
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;
details["publicNotes"] = description ?? string.Empty;
if (!details.TryGetPropertyValue("level", out var levelNode) || levelNode is null)
{
details["level"] = new JsonObject();
levelNode = details["level"];
}
levelNode!.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
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
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
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 successfully!";
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);
}
}
}
}
@@ -1,6 +1,6 @@
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
namespace Finder2eFoundryConverterCS.ViewModels; namespace finder2e_foundry_converter.ViewModels;
public abstract class ViewModelBase : ObservableObject public abstract class ViewModelBase : ObservableObject
{ {
+86
View File
@@ -0,0 +1,86 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:finder2e_foundry_converter.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="600"
x:Class="finder2e_foundry_converter.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Icon="/assets/avalonia-logo.ico"
Title="Finder2e Foundry Converter"
Width="500" Height="600"
DragDrop.AllowDrop="True">
<Design.DataContext>
<!-- This only sets the DataContext for the previewer in an IDE,
to set the actual DataContext at runtime, set the DataContext property in code (look at App.axaml.cs) -->
<vm:MainWindowViewModel/>
</Design.DataContext>
<ScrollViewer DragDrop.AllowDrop="True">
<StackPanel Margin="20" Spacing="10" DragDrop.AllowDrop="True">
<Label Content="Provider:"/>
<Grid ColumnDefinitions="*,Auto">
<ComboBox Grid.Column="0" HorizontalAlignment="Stretch"
ItemsSource="{Binding Providers}"
SelectedItem="{Binding SelectedProvider}"/>
<Button Grid.Column="1" Content="Address" Command="{Binding ToggleAddressCommand}" Margin="5,0,0,0"/>
</Grid>
<TextBlock Text="{Binding ErrorText}" Foreground="Red" HorizontalAlignment="Center" IsVisible="{Binding IsErrorVisible}"/>
<StackPanel IsVisible="{Binding IsAddressVisible}" Spacing="5">
<Label Content="{Binding SelectedProvider, StringFormat='{}{0} Address:'}"/>
<TextBox Text="{Binding CurrentAddress}" PlaceholderText="http://localhost:11434"/>
</StackPanel>
<Label Content="Model:"/>
<Grid ColumnDefinitions="*,Auto">
<ComboBox Grid.Column="0" HorizontalAlignment="Stretch"
ItemsSource="{Binding Models}"
SelectedItem="{Binding SelectedModel}"
PlaceholderText="Select Model"/>
<Button Grid.Column="1" Content="Refresh" Command="{Binding RefreshModelsCommand}" Margin="5,0,0,0"/>
</Grid>
<Label Content="Category:"/>
<ComboBox HorizontalAlignment="Stretch"
ItemsSource="{Binding Categories}"
SelectedItem="{Binding SelectedCategory}"/>
<StackPanel IsVisible="{Binding IsNpcSelected}" Spacing="10">
<Label Content="Name:"/>
<TextBox Text="{Binding Name}" PlaceholderText="Enter name..."/>
<Label Content="Level:"/>
<ComboBox HorizontalAlignment="Stretch"
ItemsSource="{Binding Levels}"
SelectedItem="{Binding SelectedLevel}"/>
<Label Content="System:"/>
<ComboBox HorizontalAlignment="Stretch"
ItemsSource="{Binding Systems}"
SelectedItem="{Binding SelectedSystem}"/>
<Label Content="Description:"/>
<TextBox Text="{Binding Description}" AcceptsReturn="True" Height="100" PlaceholderText="Enter description here..."/>
</StackPanel>
<Button Content="Generate" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
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"/>
<StackPanel Spacing="5" Margin="0,10,0,0" IsVisible="{Binding IsNpcSelected}">
<Label Content="Generated NPC JSON:"/>
<TextBox Text="{Binding GeneratedNpcJson}" AcceptsReturn="True" Height="200" IsReadOnly="True" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="5">
<Button Content="Save NPC..." Click="SaveNpc_Click"/>
<Button Content="Open Temp Folder" Click="OpenTempFolder_Click"/>
</StackPanel>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Window>
+252
View File
@@ -0,0 +1,252 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using System.IO;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using Avalonia.Media.Imaging;
using finder2e_foundry_converter.ViewModels;
using System.Diagnostics;
namespace finder2e_foundry_converter.Views
{
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
AddHandler(DragDrop.DropEvent, Drop);
AddHandler(DragDrop.DragOverEvent, DragOver);
AddHandler(KeyDownEvent, OnKeyDown, RoutingStrategies.Bubble, true);
}
private void DragOver(object? sender, DragEventArgs e)
{
if (e.DataTransfer.TryGetFiles() != null || e.DataTransfer.TryGetBitmap() != null)
{
e.DragEffects = DragDropEffects.Copy;
}
else
{
e.DragEffects = DragDropEffects.None;
}
}
private async void OnKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key == Key.V && e.KeyModifiers == KeyModifiers.Control)
{
var topLevel = GetTopLevel(this);
if (topLevel?.Clipboard == null) return;
var vm = DataContext as MainWindowViewModel;
if (vm == null) return;
// Try to get text (for file paths)
var text = await topLevel.Clipboard.TryGetTextAsync();
if (!string.IsNullOrWhiteSpace(text))
{
// Handle multiple lines (multiple files)
var lines = text.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
var path = line.Trim();
if (path.StartsWith("file://"))
{
try
{
var uri = new Uri(path);
path = uri.LocalPath;
}
catch { path = path.Replace("file://", ""); }
}
if (File.Exists(path))
{
var ext = Path.GetExtension(path).ToLower();
if (new[] { ".png", ".jpg", ".jpeg", ".webp" }.Contains(ext))
{
vm.AddImagePathCommand.Execute(path);
return;
}
}
}
}
// Try to get bitmap directly
var bitmap = await topLevel.Clipboard.TryGetBitmapAsync();
if (bitmap != null)
{
try
{
var tempDir = Path.Combine(Path.GetTempPath(), "Finder2eFoundryConverter");
if (!Directory.Exists(tempDir)) Directory.CreateDirectory(tempDir);
var tempPath = Path.Combine(tempDir, $"pasted_image_{DateTime.Now.Ticks}.png");
using (var stream = File.Create(tempPath))
{
bitmap.Save(stream);
}
vm.AddImagePathCommand.Execute(tempPath);
}
catch (Exception ex)
{
vm.StatusMessage = $"Error saving pasted image: {ex.Message}";
}
}
}
}
private async void SelectImage_Click(object? sender, RoutedEventArgs e)
{
var topLevel = GetTopLevel(this);
if (topLevel == null) return;
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Select Images",
AllowMultiple = true,
FileTypeFilter = new[]
{
new FilePickerFileType("Images")
{
Patterns = new[] { "*.png", "*.jpg", "*.jpeg", "*.webp" }
}
}
});
if (files.Count > 0)
{
var vm = DataContext as MainWindowViewModel;
if (vm == null) return;
foreach (var file in files)
{
vm.AddImagePathCommand.Execute(file.Path.LocalPath);
}
}
}
private async void Drop(object? sender, DragEventArgs e)
{
var vm = DataContext as MainWindowViewModel;
if (vm == null) return;
var files = e.DataTransfer.TryGetFiles();
if (files != null)
{
foreach (var file in files)
{
var path = file.Path.LocalPath;
if (path == null) continue;
var ext = Path.GetExtension(path).ToLower();
if (new[] { ".png", ".jpg", ".jpeg", ".webp" }.Contains(ext))
{
vm.AddImagePathCommand.Execute(path);
}
}
}
// Try to get bitmap directly from drop
var bitmap = e.DataTransfer.TryGetBitmap();
if (bitmap != null)
{
try
{
var tempDir = Path.Combine(Path.GetTempPath(), "Finder2eFoundryConverter");
if (!Directory.Exists(tempDir)) Directory.CreateDirectory(tempDir);
var tempPath = Path.Combine(tempDir, $"dropped_image_{DateTime.Now.Ticks}.png");
using (var stream = File.Create(tempPath))
{
bitmap.Save(stream);
}
vm.AddImagePathCommand.Execute(tempPath);
}
catch (Exception ex)
{
vm.StatusMessage = $"Error saving dropped image: {ex.Message}";
}
}
}
private async void SaveNpc_Click(object? sender, RoutedEventArgs e)
{
var vm = DataContext as MainWindowViewModel;
if (vm == null)
return;
if (string.IsNullOrWhiteSpace(vm.GeneratedNpcJson))
{
vm.StatusMessage = "No generated NPC to save.";
return;
}
var topLevel = GetTopLevel(this);
if (topLevel == null) return;
var save = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Save NPC JSON",
SuggestedFileName = string.IsNullOrWhiteSpace(vm.Name) ? "npc.json" : vm.Name + ".json",
FileTypeChoices = new[]
{
new FilePickerFileType("JSON") { Patterns = new[] { "*.json" } }
}
});
if (save != null)
{
try
{
using var stream = await save.OpenWriteAsync();
using var writer = new StreamWriter(stream);
await writer.WriteAsync(vm.GeneratedNpcJson);
vm.StatusMessage = $"Saved NPC to {save.Path?.LocalPath ?? "(unknown)"}";
}
catch (Exception ex)
{
vm.StatusMessage = $"Error saving NPC: {ex.Message}";
}
}
}
private void OpenTempFolder_Click(object? sender, RoutedEventArgs e)
{
var vm = DataContext as MainWindowViewModel;
if (vm == null || string.IsNullOrWhiteSpace(vm.TemporaryNpcPath))
{
if (vm != null) vm.StatusMessage = "No temporary NPC file available.";
return;
}
try
{
var folder = Path.GetDirectoryName(vm.TemporaryNpcPath);
if (!string.IsNullOrWhiteSpace(folder) && Directory.Exists(folder))
{
Process.Start(new ProcessStartInfo
{
FileName = folder,
UseShellExecute = true
});
}
else
{
vm.StatusMessage = "Temporary folder not found.";
}
}
catch (Exception ex)
{
vm.StatusMessage = $"Error opening folder: {ex.Message}";
}
}
}
}