Compare commits
10
Commits
| 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"
|
<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. -->
|
||||||
|
|
||||||
@@ -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
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>
|
||||||
@@ -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 Avalonia;
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace Finder2eFoundryConverterCS;
|
namespace finder2e_foundry_converter;
|
||||||
|
|
||||||
sealed class Program
|
sealed class Program
|
||||||
{
|
{
|
||||||
@@ -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
|
||||||
{
|
{
|
||||||
@@ -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")]
|
||||||
@@ -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 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.",
|
||||||
@@ -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
|
||||||
{
|
{
|
||||||
@@ -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