Compare commits
10
Commits
22c2ef78fd
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c87d51f2d | ||
|
|
b94b23e46f | ||
|
|
c3f6cc2375 | ||
|
|
6c306a4a0f | ||
|
|
6520e215ba | ||
|
|
b178513870 | ||
|
|
0b86a52ec5 | ||
|
|
ba5e1ff154 | ||
|
|
99df32cb1f | ||
|
|
86e54906b4 |
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Finder2eFoundryConverterCS.App"
|
||||
xmlns:local="using:Finder2eFoundryConverterCS"
|
||||
x:Class="finder2e_foundry_converter.App"
|
||||
xmlns:local="using:finder2e_foundry_converter"
|
||||
RequestedThemeVariant="Default">
|
||||
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
|
||||
|
||||
@@ -4,10 +4,10 @@ using Avalonia.Data.Core;
|
||||
using Avalonia.Data.Core.Plugins;
|
||||
using System.Linq;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Finder2eFoundryConverterCS.ViewModels;
|
||||
using Finder2eFoundryConverterCS.Views;
|
||||
using finder2e_foundry_converter.ViewModels;
|
||||
using finder2e_foundry_converter.Views;
|
||||
|
||||
namespace Finder2eFoundryConverterCS;
|
||||
namespace finder2e_foundry_converter;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 172 KiB |
+1029
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Models\" />
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
<Folder Include="models\" />
|
||||
<AvaloniaResource Include="assets\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,7 +1,7 @@
|
||||
using Avalonia;
|
||||
using System;
|
||||
|
||||
namespace Finder2eFoundryConverterCS;
|
||||
namespace finder2e_foundry_converter;
|
||||
|
||||
sealed class Program
|
||||
{
|
||||
@@ -1,6 +1,6 @@
|
||||
using Avalonia.Media.Imaging;
|
||||
|
||||
namespace Finder2eFoundryConverterCS.Models
|
||||
namespace finder2e_foundry_converter.Models
|
||||
{
|
||||
public class ImageItem
|
||||
{
|
||||
@@ -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.Text.Json.Serialization;
|
||||
|
||||
namespace Finder2eFoundryConverterCS.Services
|
||||
namespace finder2e_foundry_converter.Services
|
||||
{
|
||||
public class LlmService
|
||||
public class LmStudioService : ILlmService
|
||||
{
|
||||
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
|
||||
{
|
||||
[JsonPropertyName("data")]
|
||||
@@ -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,9 +2,9 @@ using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Templates;
|
||||
using Finder2eFoundryConverterCS.ViewModels;
|
||||
using finder2e_foundry_converter.ViewModels;
|
||||
|
||||
namespace Finder2eFoundryConverterCS;
|
||||
namespace finder2e_foundry_converter;
|
||||
|
||||
[RequiresUnreferencedCode(
|
||||
"Default implementation of ViewLocator involves reflection which may be trimmed away.",
|
||||
@@ -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;
|
||||
|
||||
namespace Finder2eFoundryConverterCS.ViewModels;
|
||||
namespace finder2e_foundry_converter.ViewModels;
|
||||
|
||||
public abstract class ViewModelBase : ObservableObject
|
||||
{
|
||||
@@ -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>
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user