Initial Dansori EQ workspace

This commit is contained in:
eKeerar
2026-07-04 10:34:46 +09:00
commit 5369ab8525
1350 changed files with 327985 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
// PRE-GENERATED (design session). Builds the active IAiEqProvider from AppSettings + stored key.
using DansoriEQ.Core.Ai;
using DansoriEQ.Core.Security;
namespace DansoriEQ.App.Ai;
public static class AiProviderFactory
{
/// <summary>Returns the configured provider, or null if a cloud key is missing.</summary>
public static IAiEqProvider? Create(ISecretStore store)
{
var s = AppSettings.Load();
var provider = s.AiProvider ?? "claude";
var model = s.AiModel;
return provider switch
{
"openai" => store.Load(ProviderKeys.OpenAi) is { Length: > 0 } ok ? new OpenAiProvider(ok, model) : null,
"gemini" => store.Load(ProviderKeys.Gemini) is { Length: > 0 } gk ? new GeminiProvider(gk, model) : null,
"ollama" => new OllamaProvider(model ?? "phi3.5"),
_ => store.Load(ProviderKeys.Claude) is { Length: > 0 } ck ? new ClaudeClient(ck, model) : null
};
}
}
+95
View File
@@ -0,0 +1,95 @@
// PRE-GENERATED (design session). Fetches the models available to the user's ACCOUNT
// from each cloud provider's list-models API, using the stored API key. Untested here.
// Returns [] on failure (caller falls back to a static list).
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
namespace DansoriEQ.App.Ai;
public static class CloudModelLister
{
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(20) };
public static Task<List<string>> ListAsync(string provider, string apiKey) => provider switch
{
"openai" => OpenAiAsync(apiKey),
"gemini" => GeminiAsync(apiKey),
_ => ClaudeAsync(apiKey)
};
private static async Task<List<string>> ClaudeAsync(string key)
{
var result = new List<string>();
try
{
using var req = new HttpRequestMessage(HttpMethod.Get, "https://api.anthropic.com/v1/models?limit=100");
req.Headers.Add("x-api-key", key);
req.Headers.Add("anthropic-version", "2023-06-01");
using var resp = await Http.SendAsync(req);
if (!resp.IsSuccessStatusCode) return result;
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
if (doc.RootElement.TryGetProperty("data", out var arr))
foreach (var m in arr.EnumerateArray())
if (m.TryGetProperty("id", out var id)) result.Add(id.GetString() ?? "");
}
catch { }
return result;
}
private static async Task<List<string>> OpenAiAsync(string key)
{
var result = new List<string>();
try
{
using var req = new HttpRequestMessage(HttpMethod.Get, "https://api.openai.com/v1/models");
req.Headers.Add("Authorization", "Bearer " + key);
using var resp = await Http.SendAsync(req);
if (!resp.IsSuccessStatusCode) return result;
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
if (doc.RootElement.TryGetProperty("data", out var arr))
foreach (var m in arr.EnumerateArray())
if (m.TryGetProperty("id", out var idEl))
{
var id = idEl.GetString() ?? "";
// chat-capable families only (skip embeddings/tts/whisper/image…)
if (id.StartsWith("gpt") || id.StartsWith("o1") || id.StartsWith("o3") ||
id.StartsWith("o4") || id.StartsWith("chatgpt"))
result.Add(id);
}
result.Sort();
}
catch { }
return result;
}
private static async Task<List<string>> GeminiAsync(string key)
{
var result = new List<string>();
try
{
var url = "https://generativelanguage.googleapis.com/v1beta/models?key=" + Uri.EscapeDataString(key);
using var resp = await Http.GetAsync(url);
if (!resp.IsSuccessStatusCode) return result;
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
if (doc.RootElement.TryGetProperty("models", out var arr))
foreach (var m in arr.EnumerateArray())
{
var supportsGen = false;
if (m.TryGetProperty("supportedGenerationMethods", out var methods))
foreach (var meth in methods.EnumerateArray())
if (meth.GetString() == "generateContent") { supportsGen = true; break; }
if (supportsGen && m.TryGetProperty("name", out var nameEl))
{
var name = nameEl.GetString() ?? "";
if (name.StartsWith("models/")) name = name.Substring("models/".Length);
result.Add(name);
}
}
}
catch { }
return result;
}
}
+21
View File
@@ -0,0 +1,21 @@
// PRE-GENERATED (design session). Free space on the drive where local models are stored.
using System;
using System.IO;
namespace DansoriEQ.App.Ai;
public static class DiskInfo
{
/// <summary>Free space (GB) on the drive that holds Ollama models (~%USERPROFILE%\.ollama).</summary>
public static (double freeGb, string drive) ModelDriveFree()
{
var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ollama");
var root = Path.GetPathRoot(path) ?? "C:\\";
try
{
var di = new DriveInfo(root);
return (di.AvailableFreeSpace / 1024.0 / 1024.0 / 1024.0, root);
}
catch { return (0, root); }
}
}
+68
View File
@@ -0,0 +1,68 @@
// PRE-GENERATED (design session). Talks to a local Ollama server (localhost:11434).
// Detect / list models / pull with progress. Untested here.
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace DansoriEQ.App.Ai;
public sealed class OllamaService
{
private const string Base = "http://localhost:11434";
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(60) };
public async Task<bool> IsRunningAsync()
{
try { var r = await _http.GetAsync($"{Base}/api/tags"); return r.IsSuccessStatusCode; }
catch { return false; }
}
public async Task<List<string>> ListModelsAsync()
{
var models = new List<string>();
try
{
var json = await _http.GetStringAsync($"{Base}/api/tags");
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("models", out var arr))
foreach (var m in arr.EnumerateArray())
if (m.TryGetProperty("name", out var n)) models.Add(n.GetString() ?? "");
}
catch { }
return models;
}
public async Task PullModelAsync(string model, IProgress<double>? progress, CancellationToken ct = default)
{
var body = JsonSerializer.Serialize(new { name = model, stream = true });
using var req = new HttpRequestMessage(HttpMethod.Post, $"{Base}/api/pull")
{ Content = new StringContent(body, Encoding.UTF8, "application/json") };
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);
resp.EnsureSuccessStatusCode();
using var stream = await resp.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream);
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (string.IsNullOrWhiteSpace(line)) continue;
try
{
using var doc = JsonDocument.Parse(line);
if (doc.RootElement.TryGetProperty("total", out var tot) &&
doc.RootElement.TryGetProperty("completed", out var comp))
{
double t = tot.GetInt64(), c = comp.GetInt64();
if (t > 0) progress?.Report(c / t);
}
}
catch { /* non-progress status line */ }
}
progress?.Report(1.0);
}
}
+37
View File
@@ -0,0 +1,37 @@
// PRE-GENERATED (design session). Silent install of local AI runtimes via winget.
// Untested here. winget must be present (Win10 1809+/Win11 usually have it).
using System.Diagnostics;
using System.Threading.Tasks;
namespace DansoriEQ.App.Ai;
public static class WingetInstaller
{
public static bool IsAvailable()
{
try
{
var psi = new ProcessStartInfo("winget", "--version")
{ UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true };
using var p = Process.Start(psi);
if (p == null) return false;
p.WaitForExit(4000);
return p.ExitCode == 0;
}
catch { return false; }
}
public static async Task<bool> InstallAsync(string wingetId)
{
var args = $"install --id {wingetId} -e --silent --accept-package-agreements --accept-source-agreements";
var psi = new ProcessStartInfo("winget", args) { UseShellExecute = true }; // may prompt UAC
try
{
using var p = Process.Start(psi);
if (p == null) return false;
await p.WaitForExitAsync();
return p.ExitCode == 0;
}
catch { return false; }
}
}
+126
View File
@@ -0,0 +1,126 @@
<!-- PRE-GENERATED (design session). AI 관리: 활성 모델 선택 + 로컬 런타임 설치 + 모델 다운로드 + 저장공간 경고. -->
<ui:FluentWindow x:Class="DansoriEQ.App.AiManagerWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
Title="AI 관리" Height="680" Width="640"
WindowStartupLocation="CenterOwner"
ExtendsContentIntoTitleBar="True" WindowBackdropType="Mica"
Background="#1B1B1F" Foreground="#F2F3F5"
FontFamily="Segoe UI Variable, Segoe UI" FontSize="14">
<Window.Resources>
<SolidColorBrush x:Key="Card" Color="#26262B"/>
<SolidColorBrush x:Key="Card2" Color="#2D2D33"/>
<SolidColorBrush x:Key="Stroke" Color="#3A3A42"/>
<SolidColorBrush x:Key="Dim" Color="#A8ABB4"/>
<SolidColorBrush x:Key="Mute" Color="#767A83"/>
<SolidColorBrush x:Key="Accent" Color="#4CC2FF"/>
</Window.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<ui:TitleBar Grid.Row="0" Title="AI 관리" Foreground="#F2F3F5" Padding="14,6"/>
<Border Grid.Row="1" Style="{StaticResource NeonPopup}">
<DockPanel Margin="20">
<TextBlock DockPanel.Dock="Top" Text="AI 관리" FontSize="21" FontWeight="Bold" Margin="0,0,0,14"/>
<Button DockPanel.Dock="Bottom" Content="닫기" Click="Close_Click" HorizontalAlignment="Right"
Height="36" Padding="18,0" Margin="0,14,0,0"
Background="{StaticResource Card}" Foreground="{StaticResource Dim}" BorderBrush="{StaticResource Stroke}"/>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel>
<!-- 1. 활성 AI -->
<Border Background="{StaticResource Card}" BorderBrush="{StaticResource Stroke}" BorderThickness="1" CornerRadius="12" Padding="16" Margin="0,0,0,14">
<StackPanel>
<TextBlock Text="활성 AI 선택" FontWeight="Bold" Foreground="{StaticResource Dim}" Margin="0,0,0,10"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/><ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/><ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Text="제공자" VerticalAlignment="Center" Foreground="{StaticResource Dim}" Margin="0,0,8,0"/>
<ComboBox x:Name="ProviderCombo" Grid.Column="1" SelectionChanged="Provider_Changed" Background="#2D2D33" Foreground="#F2F3F5" Margin="0,0,12,0">
<ComboBoxItem>Claude (클라우드)</ComboBoxItem>
<ComboBoxItem>ChatGPT · OpenAI (클라우드)</ComboBoxItem>
<ComboBoxItem>Gemini (클라우드)</ComboBoxItem>
<ComboBoxItem>Ollama (로컬)</ComboBoxItem>
</ComboBox>
<TextBlock Text="모델" Grid.Column="2" VerticalAlignment="Center" Foreground="{StaticResource Dim}" Margin="0,0,8,0"/>
<ComboBox x:Name="ModelCombo" Grid.Column="3" IsEditable="True" SelectionChanged="Model_Changed" Background="#2D2D33" Foreground="#F2F3F5"/>
</Grid>
<StackPanel Orientation="Horizontal" Margin="0,10,0,0">
<Button Content="🔄 계정 모델 불러오기" Click="RefreshModels_Click" Height="30" Padding="12,0"
Background="{StaticResource Card2}" Foreground="{StaticResource Dim}" BorderBrush="{StaticResource Stroke}"/>
<Button Content="🔌 연결 테스트" Click="TestConnection_Click" Height="30" Padding="12,0" Margin="8,0,0,0"
Background="{StaticResource Card2}" Foreground="{StaticResource Dim}" BorderBrush="{StaticResource Stroke}"/>
<TextBlock x:Name="ModelStatus" VerticalAlignment="Center" Foreground="{StaticResource Mute}" FontSize="12" Margin="10,0,0,0"/>
</StackPanel>
<TextBlock Foreground="{StaticResource Mute}" FontSize="12" Margin="0,8,0,0" TextWrapping="Wrap"
Text="클라우드는 API 키가 있으면 '계정에서 사용 가능한 모델'을 불러오고, 없으면 기본 목록을 씁니다. 직접 입력도 가능하며, 로컬(Ollama)은 다운로드된 모델만 표시됩니다."/>
</StackPanel>
</Border>
<!-- 2. 로컬 런타임 설치 -->
<Border Background="{StaticResource Card}" BorderBrush="{StaticResource Stroke}" BorderThickness="1" CornerRadius="12" Padding="16" Margin="0,0,0,14">
<StackPanel>
<TextBlock Text="로컬 AI 런타임 (자동 설치)" FontWeight="Bold" Foreground="{StaticResource Dim}" Margin="0,0,0,4"/>
<TextBlock x:Name="WingetNote" Foreground="{StaticResource Mute}" FontSize="12" Margin="0,0,0,10"
Text="winget으로 자동 설치합니다."/>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
<StackPanel Grid.Column="0"><TextBlock Text="Ollama" FontWeight="SemiBold"/><TextBlock Text="OpenAI 호환 · per-user(무권한·무재부팅)" Foreground="{StaticResource Mute}" FontSize="12"/></StackPanel>
<TextBlock x:Name="OllamaStatus" Grid.Column="1" VerticalAlignment="Center" Foreground="{StaticResource Dim}" FontSize="12" Margin="0,0,10,0" Text="확인 중…"/>
<Button Grid.Column="2" Content="설치" Tag="Ollama.Ollama" Click="Install_Click" Height="30" Padding="16,0" Background="{StaticResource Accent}" Foreground="#04222F" FontWeight="Bold" BorderThickness="0"/>
</Grid>
<TextBlock Foreground="{StaticResource Mute}" FontSize="12" Margin="0,8,0,0" TextWrapping="Wrap"
Text="※ 현재 무인 자동설치가 확실한 로컬 런타임은 Ollama뿐입니다. LM Studio·GPT4All 등은 검증 후 추가 예정."/>
</StackPanel>
</Border>
<!-- 3. Ollama 모델 다운로드 -->
<Border Background="{StaticResource Card}" BorderBrush="{StaticResource Stroke}" BorderThickness="1" CornerRadius="12" Padding="16" Margin="0,0,0,14">
<StackPanel>
<TextBlock Text="로컬 모델 (Ollama)" FontWeight="Bold" Foreground="{StaticResource Dim}" Margin="0,0,0,10"/>
<Grid>
<Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
<ComboBox x:Name="OllamaModelCombo" Grid.Column="0" IsEditable="True" Background="#2D2D33" Foreground="#F2F3F5">
<ComboBoxItem>phi3.5</ComboBoxItem>
<ComboBoxItem>llama3.2</ComboBoxItem>
<ComboBoxItem>qwen2.5:3b</ComboBoxItem>
<ComboBoxItem>gemma2:2b</ComboBoxItem>
</ComboBox>
<Button x:Name="PullBtn" Grid.Column="1" Content="⬇ 다운로드" Click="Pull_Click" Height="32" Padding="14,0" Margin="8,0,0,0" Background="{StaticResource Accent}" Foreground="#04222F" FontWeight="Bold" BorderThickness="0"/>
</Grid>
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
<ProgressBar x:Name="PullProgress" Grid.Column="0" Height="8" Minimum="0" Maximum="100" Background="#2D2D33" Foreground="{StaticResource Accent}" BorderThickness="0"/>
<TextBlock x:Name="PullStatus" Grid.Column="1" VerticalAlignment="Center" Foreground="{StaticResource Mute}" FontSize="12" Margin="10,0,0,0" Text="대기"/>
</Grid>
<TextBlock Text="설치된 모델" Foreground="{StaticResource Mute}" FontSize="12" Margin="0,12,0,4"/>
<ListBox x:Name="InstalledList" Height="80" BorderThickness="0" Background="#2D2D33" Foreground="{StaticResource Dim}"/>
</StackPanel>
</Border>
<!-- 4. 저장공간 경고 -->
<Border Background="#2A2420" BorderBrush="#5a4a30" BorderThickness="1" CornerRadius="12" Padding="16">
<StackPanel>
<TextBlock Text="⚠️ 저장 공간 안내" FontWeight="Bold" Foreground="#FFB454"/>
<TextBlock Foreground="{StaticResource Dim}" FontSize="13" TextWrapping="Wrap" Margin="0,8,0,0"
Text="로컬 AI 모델은 개당 약 2~8GB이며, 여러 모델을 받거나 큰 모델(수십B급)을 쓰면 전체적으로 수십~수백 GB가 필요할 수 있습니다. 넉넉한 여유 공간(최소 20GB 이상, 여러 모델 사용 시 100GB+)을 권장합니다."/>
<TextBlock x:Name="DiskText" Foreground="#FFB454" FontWeight="SemiBold" Margin="0,10,0,0" Text="여유 공간 확인 중…"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</DockPanel>
</Border>
</Grid>
</ui:FluentWindow>
+181
View File
@@ -0,0 +1,181 @@
// PRE-GENERATED (design session). AI 관리 창 code-behind (계정 모델 목록 동적 로드 포함).
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using DansoriEQ.App.Ai;
using DansoriEQ.Core.Ai;
using DansoriEQ.Core.Security;
namespace DansoriEQ.App;
public partial class AiManagerWindow : Wpf.Ui.Controls.FluentWindow
{
private readonly OllamaService _ollama = new();
private readonly ISecretStore _store;
private List<string> _installedOllama = new();
public AiManagerWindow(ISecretStore store)
{
InitializeComponent();
_store = store;
Loaded += OnLoaded;
}
private async void OnLoaded(object sender, RoutedEventArgs e)
{
var (free, drive) = DiskInfo.ModelDriveFree();
DiskText.Text = $"현재 여유 공간: 약 {free:0.0} GB (드라이브 {drive})";
if (!WingetInstaller.IsAvailable())
WingetNote.Text = "⚠ winget이 감지되지 않습니다. 자동 설치가 제한될 수 있어요(수동 설치 필요).";
var s = AppSettings.Load();
ProviderCombo.SelectedIndex = s.AiProvider switch { "openai" => 1, "gemini" => 2, "ollama" => 3, _ => 0 };
await RefreshOllamaAsync();
await RefreshModelsAsync();
}
private static string ProviderId(int index) => index switch { 1 => "openai", 2 => "gemini", 3 => "ollama", _ => "claude" };
private static string ProviderSecretKey(string provider) => provider switch
{
"openai" => ProviderKeys.OpenAi,
"gemini" => ProviderKeys.Gemini,
_ => ProviderKeys.Claude
};
private static string[] StaticModels(string provider) => provider switch
{
"openai" => new[] { "gpt-4o", "gpt-4o-mini" },
"gemini" => new[] { "gemini-2.0-flash", "gemini-2.0-pro" },
_ => new[] { "claude-sonnet-4-6", "claude-opus-4-8" }
};
private async void Provider_Changed(object sender, SelectionChangedEventArgs e) => await RefreshModelsAsync();
private async void RefreshModels_Click(object sender, RoutedEventArgs e) => await RefreshModelsAsync();
private async void TestConnection_Click(object sender, RoutedEventArgs e)
{
var prov = ProviderId(ProviderCombo.SelectedIndex);
ModelStatus.Text = "연결 테스트 중…";
if (prov == "ollama")
{
ModelStatus.Text = await _ollama.IsRunningAsync() ? "✓ Ollama 연결됨" : "✗ Ollama 미실행";
return;
}
var key = _store.Load(ProviderSecretKey(prov));
if (string.IsNullOrEmpty(key)) { ModelStatus.Text = "✗ API 키 없음(설정에서 입력)"; return; }
var models = await CloudModelLister.ListAsync(prov, key);
ModelStatus.Text = models.Count > 0 ? $"✓ 연결 성공 (모델 {models.Count}개)" : "✗ 연결 실패 (키/네트워크 확인)";
}
private void Model_Changed(object sender, SelectionChangedEventArgs e) => SaveSelection();
/// <summary>Populate ModelCombo: local = downloaded models; cloud = account models (via key) with static fallback.</summary>
private async Task RefreshModelsAsync()
{
var prov = ProviderId(ProviderCombo.SelectedIndex);
if (prov == "ollama")
{
ModelCombo.ItemsSource = _installedOllama.Count > 0
? (IEnumerable<string>)_installedOllama.ToArray()
: new[] { "(모델 없음 — 아래에서 다운로드)" };
ModelStatus.Text = _installedOllama.Count > 0 ? $"로컬 모델 {_installedOllama.Count}개" : "다운로드 필요";
RestoreSelection();
SaveSelection();
return;
}
var key = _store.Load(ProviderSecretKey(prov));
if (string.IsNullOrEmpty(key))
{
ModelCombo.ItemsSource = StaticModels(prov);
ModelStatus.Text = "API 키 없음 — 기본 목록(설정에서 키 입력)";
RestoreSelection();
SaveSelection();
return;
}
ModelStatus.Text = "계정 모델 불러오는 중…";
var models = await CloudModelLister.ListAsync(prov, key);
if (models.Count > 0)
{
ModelCombo.ItemsSource = models;
ModelStatus.Text = $"계정 모델 {models.Count}개";
}
else
{
ModelCombo.ItemsSource = StaticModels(prov);
ModelStatus.Text = "불러오기 실패 — 기본 목록(직접 입력도 가능)";
}
RestoreSelection();
SaveSelection();
}
private void RestoreSelection()
{
var saved = AppSettings.Load().AiModel;
if (saved != null && ModelCombo.Items.Contains(saved)) ModelCombo.SelectedItem = saved;
else if (ModelCombo.Items.Count > 0) ModelCombo.SelectedIndex = 0;
}
private void SaveSelection()
{
var s = AppSettings.Load();
s.AiProvider = ProviderId(ProviderCombo.SelectedIndex);
s.AiModel = string.IsNullOrWhiteSpace(ModelCombo.Text) ? ModelCombo.SelectedItem as string : ModelCombo.Text;
s.Save();
}
private async Task RefreshOllamaAsync()
{
if (await _ollama.IsRunningAsync())
{
OllamaStatus.Text = "감지됨 (실행 중)";
_installedOllama = await _ollama.ListModelsAsync();
}
else
{
OllamaStatus.Text = "미설치 / 미실행";
_installedOllama = new();
}
InstalledList.ItemsSource = _installedOllama;
if (ProviderId(ProviderCombo.SelectedIndex) == "ollama") await RefreshModelsAsync();
}
private async void Install_Click(object sender, RoutedEventArgs e)
{
if (sender is not FrameworkElement fe || fe.Tag is not string wingetId) return;
if (!WingetInstaller.IsAvailable())
{
MessageBox.Show(this, "winget이 없어 자동 설치할 수 없습니다. 수동 설치가 필요합니다.", "안내");
return;
}
if (sender is Button b) b.IsEnabled = false;
var ok = await WingetInstaller.InstallAsync(wingetId);
if (sender is Button b2) b2.IsEnabled = true;
MessageBox.Show(this, ok ? "설치가 완료되었습니다." : "설치가 취소되었거나 실패했습니다.", "설치");
await RefreshOllamaAsync();
}
private async void Pull_Click(object sender, RoutedEventArgs e)
{
var model = OllamaModelCombo.Text?.Trim();
if (string.IsNullOrEmpty(model)) return;
if (!await _ollama.IsRunningAsync())
{
MessageBox.Show(this, "Ollama가 실행 중이 아닙니다. 먼저 위에서 Ollama를 설치/실행하세요.", "안내");
return;
}
PullBtn.IsEnabled = false;
var progress = new Progress<double>(p => { PullProgress.Value = p * 100; PullStatus.Text = $"다운로드 {p * 100:0}%"; });
try { await _ollama.PullModelAsync(model, progress); PullStatus.Text = "완료 ✓"; await RefreshOllamaAsync(); }
catch (Exception ex) { PullStatus.Text = "실패: " + ex.Message; }
finally { PullBtn.IsEnabled = true; }
}
private void Close_Click(object sender, RoutedEventArgs e) => Close();
}
@@ -0,0 +1,73 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
namespace DansoriEQ.App.Apo;
public static class ApoConfigInstaller
{
private const string IncludeLine = "Include: ai_eq.txt";
public static bool IsIncluded(string configDir)
{
var path = Path.Combine(configDir, "config.txt");
if (!File.Exists(path)) return false;
return File.ReadAllLines(path)
.Any(l => l.Trim().Equals(IncludeLine, StringComparison.OrdinalIgnoreCase));
}
/// <summary>Tries direct write first; falls back to an elevated PowerShell call.</summary>
public static bool TryAddInclude(string configDir)
{
if (IsIncluded(configDir)) return true;
var configTxt = Path.Combine(configDir, "config.txt");
try
{
File.AppendAllText(configTxt, Environment.NewLine + IncludeLine + Environment.NewLine);
return true;
}
catch (UnauthorizedAccessException)
{
return TryAddIncludeElevated(configTxt);
}
catch
{
return false;
}
}
/// <summary>Ensure an empty ai_eq.txt exists so APO doesn't log a missing-file warning.</summary>
public static void EnsureAiEqFile(string configDir)
{
var target = Path.Combine(configDir, "ai_eq.txt");
if (!File.Exists(target))
File.WriteAllText(target, "# Dansori EQ — managed automatically\n");
}
private static bool TryAddIncludeElevated(string configTxtPath)
{
// Escape single-quotes in path for PowerShell
var escaped = configTxtPath.Replace("'", "''");
var psCmd = $"Add-Content -Encoding UTF8 -Path '{escaped}' -Value \"`r`nInclude: ai_eq.txt`r`n\"";
try
{
var psi = new ProcessStartInfo("powershell.exe",
$"-NonInteractive -WindowStyle Hidden -Command \"{psCmd}\"")
{
Verb = "runas",
UseShellExecute = true,
WindowStyle = ProcessWindowStyle.Hidden
};
using var proc = Process.Start(psi)
?? throw new InvalidOperationException("Process.Start returned null");
proc.WaitForExit(15_000);
return IsIncluded(Path.GetDirectoryName(configTxtPath)!);
}
catch
{
return false;
}
}
}
+25
View File
@@ -0,0 +1,25 @@
// PRE-GENERATED (design session). Writes the live APO include file (ai_eq.txt).
// NOTE: config.txt must Include this file + the file must be user-writable — that is the
// one-time elevated setup (M3/M6). Untested on this machine (no APO installed here).
using System.IO;
using DansoriEQ.Core.Apo;
namespace DansoriEQ.App.Apo;
public sealed class ApoIncludeWriter : IApoWriter
{
private readonly string _includePath;
public ApoIncludeWriter(string configDir)
=> _includePath = Path.Combine(configDir, "ai_eq.txt");
public bool IsLive => true;
public string TargetPath => _includePath;
public string? LastConfig { get; private set; }
public void Apply(string apoConfigText)
{
File.WriteAllText(_includePath, apoConfigText); // APO live-reloads on save
LastConfig = apoConfigText;
}
}
+46
View File
@@ -0,0 +1,46 @@
// PRE-GENERATED (design session). Downloads the official Equalizer APO installer and launches it.
// NOTE: APO install is NOT silent — it needs device selection + a REBOOT. So "auto install"
// = download official installer + launch it; the user completes the wizard and reboots.
// Untested here (needs network + admin). Verify the download URL in the dev session.
using System;
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace DansoriEQ.App.Apo;
public static class ApoInstaller
{
// SourceForge "latest" redirect for Equalizer APO. VERIFY current link in dev session.
public const string DownloadUrl = "https://sourceforge.net/projects/equalizerapo/files/latest/download";
public static async Task<string> DownloadAsync(IProgress<double>? progress = null, CancellationToken ct = default)
{
var target = Path.Combine(Path.GetTempPath(), "EqualizerAPO-setup.exe");
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("User-Agent", "DansoriEQ");
using var resp = await http.GetAsync(DownloadUrl, HttpCompletionOption.ResponseHeadersRead, ct);
resp.EnsureSuccessStatusCode();
var total = resp.Content.Headers.ContentLength ?? -1L;
await using var src = await resp.Content.ReadAsStreamAsync(ct);
await using var dst = File.Create(target);
var buffer = new byte[81920];
long readTotal = 0;
int n;
while ((n = await src.ReadAsync(buffer, ct)) > 0)
{
await dst.WriteAsync(buffer.AsMemory(0, n), ct);
readTotal += n;
if (total > 0) progress?.Report((double)readTotal / total);
}
return target;
}
public static void Launch(string installerPath)
=> Process.Start(new ProcessStartInfo(installerPath) { UseShellExecute = true }); // triggers UAC
}
+26
View File
@@ -0,0 +1,26 @@
// PRE-GENERATED (design session). Locates the Equalizer APO config dir via default paths.
// Dev session may add a registry lookup (HKLM\SOFTWARE\EqualizerAPO) for robustness.
using System;
using System.IO;
namespace DansoriEQ.App.Apo;
public static class ApoLocator
{
public static string? FindConfigDir()
{
foreach (var pf in new[]
{
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86)
})
{
if (string.IsNullOrEmpty(pf)) continue;
var dir = Path.Combine(pf, "EqualizerAPO", "config");
if (Directory.Exists(dir)) return dir;
}
return null;
}
public static bool IsInstalled => FindConfigDir() != null;
}
+23
View File
@@ -0,0 +1,23 @@
// PRE-GENERATED (design session). Picks the live writer if APO is present, else preview mode.
// Resolution order: user-set path (settings) -> default install location -> null (preview).
using System.IO;
using DansoriEQ.Core.Apo;
namespace DansoriEQ.App.Apo;
public static class ApoWriterFactory
{
public static string? ResolveConfigDir()
{
var s = AppSettings.Load();
if (!string.IsNullOrEmpty(s.ApoConfigDir) && Directory.Exists(s.ApoConfigDir))
return s.ApoConfigDir;
return ApoLocator.FindConfigDir();
}
public static IApoWriter Create()
{
var dir = ResolveConfigDir();
return dir != null ? new ApoIncludeWriter(dir) : new NullApoWriter();
}
}
+31
View File
@@ -0,0 +1,31 @@
// PRE-GENERATED (design session). Preview/dry-run writer used when APO is NOT installed.
// Lets the whole EQ chain run offline-of-APO and writes the generated config to a preview
// file so you can verify exactly what WOULD be applied.
using System;
using System.IO;
using DansoriEQ.Core.Apo;
namespace DansoriEQ.App.Apo;
public sealed class NullApoWriter : IApoWriter
{
private readonly string _previewPath;
public NullApoWriter()
{
var dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "DansoriEQ");
Directory.CreateDirectory(dir);
_previewPath = Path.Combine(dir, "preview_apo_config.txt");
}
public bool IsLive => false;
public string TargetPath => _previewPath;
public string? LastConfig { get; private set; }
public void Apply(string apoConfigText)
{
LastConfig = apoConfigText;
try { File.WriteAllText(_previewPath, apoConfigText); } catch { /* preview only */ }
}
}
+52
View File
@@ -0,0 +1,52 @@
<ui:FluentWindow x:Class="DansoriEQ.App.ApoLinkWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
Title="APO 연동 설정" Height="300" Width="500"
WindowStartupLocation="CenterScreen" ResizeMode="NoResize"
ExtendsContentIntoTitleBar="True" WindowBackdropType="Mica"
Background="#1B1B1F" Foreground="#F2F3F5"
FontFamily="Segoe UI Variable, Segoe UI">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<ui:TitleBar Grid.Row="0" Title="APO 연동 설정" Foreground="#F2F3F5" Padding="14,6"/>
<Border Grid.Row="1" Style="{StaticResource NeonPopup}">
<DockPanel Margin="24">
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal"
HorizontalAlignment="Right" Margin="0,16,0,0">
<Button x:Name="BtnNo" Content="나중에" Width="90" Height="36"
Click="BtnNo_Click" Margin="0,0,10,0"
Background="#26262B" Foreground="#A8ABB4" BorderBrush="#3A3A42"/>
<Button x:Name="BtnYes" Content="예, 연동 설정" Width="130" Height="36"
Click="BtnYes_Click"
Background="#4CC2FF" Foreground="#04222F"
FontWeight="Bold" BorderThickness="0"/>
</StackPanel>
<StackPanel>
<TextBlock Text="Equalizer APO 연동 설정"
FontSize="16" FontWeight="Bold" Margin="0,0,0,16"/>
<Border Background="#26262B" BorderBrush="#3A3A42"
BorderThickness="1" CornerRadius="10" Padding="16,14">
<StackPanel>
<TextBlock TextWrapping="Wrap" Foreground="#A8ABB4" FontSize="13" LineHeight="20"
Text="EQ를 실제 소리에 반영하려면 APO config.txt에 Include 줄을 한 번만 추가해야 합니다."/>
<TextBlock Foreground="#767A83" FontSize="11.5" Margin="0,10,0,0"
Text="※ 관리자 권한(UAC)이 요청됩니다. 이 작업은 최초 1회만 필요하며, 이후 EQ 변경은 권한 없이 자동 적용됩니다."/>
</StackPanel>
</Border>
<TextBlock x:Name="StatusText" Foreground="#767A83" FontSize="11"
Margin="0,14,0,0" TextWrapping="Wrap"/>
</StackPanel>
</DockPanel>
</Border>
</Grid>
</ui:FluentWindow>
+43
View File
@@ -0,0 +1,43 @@
using System.Windows;
using DansoriEQ.App.Apo;
namespace DansoriEQ.App;
public partial class ApoLinkWindow : Wpf.Ui.Controls.FluentWindow
{
private readonly string _configDir;
public ApoLinkWindow(string configDir)
{
InitializeComponent();
_configDir = configDir;
}
private void BtnYes_Click(object sender, RoutedEventArgs e)
{
BtnYes.IsEnabled = false;
BtnNo.IsEnabled = false;
StatusText.Text = "관리자 권한 요청 중…";
bool ok = ApoConfigInstaller.TryAddInclude(_configDir);
if (ok)
{
ApoConfigInstaller.EnsureAiEqFile(_configDir);
StatusText.Text = "✓ 연동 완료. EQ 변경이 즉시 소리에 반영됩니다.";
}
else
{
StatusText.Foreground = System.Windows.Media.Brushes.OrangeRed;
StatusText.Text =
$"자동 설정 실패. 수동으로 아래 파일에 'Include: ai_eq.txt'를 추가해주세요.\n{_configDir}\\config.txt";
}
BtnYes.Content = "닫기";
BtnYes.IsEnabled = true;
BtnYes.Click -= BtnYes_Click;
BtnYes.Click += (_, __) => Close();
BtnNo.Visibility = Visibility.Collapsed;
}
private void BtnNo_Click(object sender, RoutedEventArgs e) => Close();
}
+59
View File
@@ -0,0 +1,59 @@
<!-- PRE-GENERATED (design session). Shown at startup when APO isn't found.
Modern chrome: FluentWindow + ui:TitleBar (Mica), matching DbManagerWindow. -->
<ui:FluentWindow x:Class="DansoriEQ.App.ApoSetupWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
Title="Equalizer APO 설정" Height="430" Width="560"
WindowStartupLocation="CenterScreen" ResizeMode="NoResize"
ExtendsContentIntoTitleBar="True" WindowBackdropType="Mica"
Background="#1B1B1F" Foreground="#F2F3F5"
FontFamily="Segoe UI Variable, Segoe UI">
<Window.Resources>
<SolidColorBrush x:Key="Card" Color="#26262B"/>
<SolidColorBrush x:Key="Stroke" Color="#3A3A42"/>
<SolidColorBrush x:Key="TextDim" Color="#A8ABB4"/>
<SolidColorBrush x:Key="TextMute" Color="#767A83"/>
</Window.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<ui:TitleBar Grid.Row="0" Title="Equalizer APO 설정"
Foreground="#F2F3F5" Padding="14,6">
<ui:TitleBar.Icon>
<ui:SymbolIcon Symbol="Options24"/>
</ui:TitleBar.Icon>
</ui:TitleBar>
<Border Grid.Row="1" Style="{StaticResource NeonPopup}">
<StackPanel Margin="24,8,24,24">
<TextBlock Text="Equalizer APO가 감지되지 않았습니다" FontSize="18" FontWeight="Bold"/>
<TextBlock Foreground="{StaticResource TextDim}" FontSize="13" TextWrapping="Wrap" Margin="0,8,0,18"
Text="이 앱은 Equalizer APO를 통해 EQ를 적용합니다. 아래에서 설치하거나, 이미 설치돼 있다면 config 폴더 경로를 지정하세요. 지금은 미리보기 모드로 계속할 수도 있습니다."/>
<Border Background="{StaticResource Card}" BorderBrush="{StaticResource Stroke}" BorderThickness="1" CornerRadius="12" Padding="16">
<StackPanel>
<Button x:Name="BtnAutoInstall" Content="⬇ APO 자동 설치 (공식 설치 파일 다운로드·실행)" Click="BtnAutoInstall_Click"
Height="40" HorizontalContentAlignment="Left" Padding="12,0" Margin="0,0,0,8"
Background="#4CC2FF" Foreground="#04222F" FontWeight="Bold" BorderThickness="0"/>
<Button x:Name="BtnSetPath" Content="📁 APO config 폴더 직접 지정" Click="BtnSetPath_Click"
Height="40" HorizontalContentAlignment="Left" Padding="12,0" Margin="0,0,0,8"
Background="#2D2D33" Foreground="{StaticResource TextDim}" BorderBrush="{StaticResource Stroke}"/>
<Button x:Name="BtnLater" Content="나중에 (미리보기 모드로 계속)" Click="BtnLater_Click"
Height="36" HorizontalContentAlignment="Left" Padding="12,0"
Background="Transparent" Foreground="{StaticResource TextMute}" BorderThickness="0"/>
</StackPanel>
</Border>
<ProgressBar x:Name="Prog" Height="8" Minimum="0" Maximum="100" Value="0" Margin="0,16,0,6"
Background="#2D2D33" Foreground="#4CC2FF" BorderThickness="0"/>
<TextBlock x:Name="TxtStatus" Foreground="{StaticResource TextMute}" FontSize="12"
Text="APO 설치는 장치 선택과 재부팅이 필요합니다. 재부팅 후 앱을 다시 실행하세요."/>
</StackPanel>
</Border>
</Grid>
</ui:FluentWindow>
+47
View File
@@ -0,0 +1,47 @@
// PRE-GENERATED (design session). Startup APO setup dialog code-behind.
using System;
using System.Windows;
using DansoriEQ.App.Apo;
namespace DansoriEQ.App;
public partial class ApoSetupWindow : Wpf.Ui.Controls.FluentWindow
{
public ApoSetupWindow() => InitializeComponent();
private async void BtnAutoInstall_Click(object sender, RoutedEventArgs e)
{
BtnAutoInstall.IsEnabled = false;
var progress = new Progress<double>(p =>
{
Prog.Value = p * 100;
TxtStatus.Text = $"설치 파일 다운로드 중… {p * 100:0}%";
});
try
{
var path = await ApoInstaller.DownloadAsync(progress);
TxtStatus.Text = "설치 관리자를 실행합니다. 장치 선택 후 재부팅하고, 재부팅 후 앱을 다시 실행하세요.";
ApoInstaller.Launch(path);
}
catch (Exception ex)
{
TxtStatus.Text = "다운로드 실패: " + ex.Message;
BtnAutoInstall.IsEnabled = true;
}
}
private void BtnSetPath_Click(object sender, RoutedEventArgs e)
{
var dlg = new Microsoft.Win32.OpenFolderDialog { Title = "Equalizer APO의 config 폴더를 선택하세요" };
if (dlg.ShowDialog() == true)
{
var s = AppSettings.Load();
s.ApoConfigDir = dlg.FolderName;
s.Save();
TxtStatus.Text = "APO 경로가 설정되었습니다.";
DialogResult = true;
}
}
private void BtnLater_Click(object sender, RoutedEventArgs e) => Close();
}
+27
View File
@@ -0,0 +1,27 @@
<Application x:Class="DansoriEQ.App.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ui:ThemesDictionary Theme="Dark" />
<ui:ControlsDictionary />
</ResourceDictionary.MergedDictionaries>
<!-- Neon popup frame: bright accent border so dialogs separate from the dark background. -->
<Style x:Key="NeonPopup" TargetType="Border">
<Setter Property="Background" Value="#1B1B1F"/>
<Setter Property="BorderBrush" Value="#4CC2FF"/>
<Setter Property="BorderThickness" Value="1.5"/>
<Setter Property="CornerRadius" Value="10"/>
<Setter Property="Margin" Value="6"/>
<Setter Property="Effect">
<Setter.Value>
<DropShadowEffect Color="#4CC2FF" BlurRadius="20" ShadowDepth="0" Opacity="0.45"/>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
</Application.Resources>
</Application>
+70
View File
@@ -0,0 +1,70 @@
using System.Windows;
using DansoriEQ.App.Apo;
using Wpf.Ui.Appearance;
namespace DansoriEQ.App;
public partial class App : Application
{
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// PoC hook (Path B / Lottie). Run "DansoriEQ.App.exe --lottie" to open only the
// Lottie test window, bypassing the normal splash/APO/main flow. Remove after decision.
if (e.Args.Length > 0 && e.Args[0] == "--lottie")
{
ShutdownMode = ShutdownMode.OnMainWindowClose;
var poc = new LottiePocWindow();
MainWindow = poc;
poc.Show();
return;
}
// Keep the app alive while pre-main dialogs (splash / APO setup) open and close;
// otherwise closing the splash with no other window would shut the app down.
ShutdownMode = ShutdownMode.OnExplicitShutdown;
// Surface unhandled UI-thread exceptions instead of silently terminating.
DispatcherUnhandledException += (_, args) =>
{
System.Windows.MessageBox.Show(
args.Exception.ToString(),
"오류가 발생했습니다",
MessageBoxButton.OK, MessageBoxImage.Error);
args.Handled = true; // keep the app alive
};
var s = AppSettings.Load();
var theme = s.Theme switch
{
"light" => ApplicationTheme.Light,
_ => ApplicationTheme.Dark
};
ApplicationThemeManager.Apply(theme);
// Brand splash (이소리·이단) — brief, before the setup flow.
try { new SplashWindow().ShowDialog(); } catch { /* splash is non-essential */ }
var apoDir = ApoWriterFactory.ResolveConfigDir();
if (apoDir is null)
{
new ApoSetupWindow().ShowDialog();
}
else if (!ApoConfigInstaller.IsIncluded(apoDir))
{
new ApoLinkWindow(apoDir).ShowDialog();
}
else
{
ApoConfigInstaller.EnsureAiEqFile(apoDir);
}
var main = new MainWindow();
MainWindow = main;
main.Show();
// Now that the main window exists, tie app lifetime to it.
ShutdownMode = ShutdownMode.OnMainWindowClose;
}
}
+22
View File
@@ -0,0 +1,22 @@
// PRE-GENERATED (design session). Common local paths.
using System;
using System.IO;
namespace DansoriEQ.App;
public static class AppPaths
{
public static string Root
{
get
{
var d = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "DansoriEQ");
Directory.CreateDirectory(d);
return d;
}
}
public static string PresetsDir => Path.Combine(Root, "presets");
public static string DbPath => Path.Combine(Root, "dansorieq.db");
public static string SwitchRulesFile => Path.Combine(Root, "switch_rules.json");
}
+39
View File
@@ -0,0 +1,39 @@
// PRE-GENERATED (design session). Small persisted app settings (APO path override, etc.).
using System;
using System.IO;
using System.Text.Json;
namespace DansoriEQ.App;
public sealed class AppSettings
{
public string? ApoConfigDir { get; set; }
public string? AiProvider { get; set; } // claude | openai | gemini | ollama
public string? AiModel { get; set; }
public bool AutoSwitchEnabled { get; set; }
public string? Theme { get; set; } // "dark" | "light" | "system" (default: dark)
private static string FilePath
{
get
{
var dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "DansoriEQ");
Directory.CreateDirectory(dir);
return Path.Combine(dir, "settings.json");
}
}
public static AppSettings Load()
{
try
{
return File.Exists(FilePath)
? JsonSerializer.Deserialize<AppSettings>(File.ReadAllText(FilePath)) ?? new()
: new();
}
catch { return new(); }
}
public void Save() => File.WriteAllText(FilePath, JsonSerializer.Serialize(this));
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

@@ -0,0 +1,31 @@
{
"name": "LeeSori",
"status": "legacy_canvas_reference",
"live2dNote": "Final WPF mascot target is Live2D Cubism. This file is kept only as Canvas FK/A-pose reference; use live2d_parameters.json for Cubism rigging.",
"canvas": { "width": 520, "height": 900 },
"imageBase": "../03_Assets/Parts/Images/",
"mode": "fullcanvas",
"note": "Full-canvas parts: each PNG is 520x900 with the part already at its master position. Renderer draws each image at the ORIGIN (0,0) and rotates it about its pivot (the joint). pivot = auto-derived centroid of the opaque overlap between the part and its parent. At rest (all rot/tx/ty = 0) every world matrix is identity, so stacking reproduces the master.",
"bones": [
{ "name": "pelvis", "parent": null, "pivot": [259.6, 363.4], "z": 6, "image": "sori_part_pelvis.png" },
{ "name": "chest", "parent": "pelvis", "pivot": [259.6, 363.4], "z": 8, "image": "sori_part_chest.png" },
{ "name": "neck", "parent": "chest", "pivot": [262.0, 229.3], "z": 9, "image": "sori_part_neck.png" },
{ "name": "head", "parent": "neck", "pivot": [259.8, 209.3], "z": 10, "image": "sori_part_head.png" },
{ "name": "upperarm_r", "parent": "chest", "pivot": [174.2, 287.2], "z": 5, "image": "sori_part_upperarm_r.png" },
{ "name": "forearm_r", "parent": "upperarm_r", "pivot": [137.3, 358.0], "z": 5, "image": "sori_part_forearm_r.png" },
{ "name": "hand_r", "parent": "forearm_r", "pivot": [134.2, 400.8], "z": 5, "image": "sori_part_hand_r.png" },
{ "name": "upperarm_l", "parent": "chest", "pivot": [346.0, 286.5], "z": 12, "image": "sori_part_upperarm_l.png" },
{ "name": "forearm_l", "parent": "upperarm_l", "pivot": [382.9, 357.6], "z": 12, "image": "sori_part_forearm_l.png" },
{ "name": "hand_l", "parent": "forearm_l", "pivot": [389.6, 400.6], "z": 13, "image": "sori_part_hand_l.png" },
{ "name": "thigh_r", "parent": "pelvis", "pivot": [226.3, 455.0], "z": 4, "image": "sori_part_thigh_r.png" },
{ "name": "shin_r", "parent": "thigh_r", "pivot": [233.3, 609.1], "z": 3, "image": "sori_part_shin_r.png" },
{ "name": "foot_r", "parent": "shin_r", "pivot": [236.5, 729.4], "z": 2, "image": "sori_part_foot_r.png" },
{ "name": "thigh_l", "parent": "pelvis", "pivot": [294.1, 455.0], "z": 4, "image": "sori_part_thigh_l.png" },
{ "name": "shin_l", "parent": "thigh_l", "pivot": [286.9, 609.1], "z": 3, "image": "sori_part_shin_l.png" },
{ "name": "foot_l", "parent": "shin_l", "pivot": [283.9, 729.5], "z": 2, "image": "sori_part_foot_l.png" }
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 639 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 639 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 639 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 639 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 639 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 853 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 774 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

@@ -0,0 +1,172 @@
{
"name": "LeeSoriDance",
"status": "leesori_solo3_dance_pose_parts_2026_07_04",
"canvas": {
"width": 773,
"height": 1448
},
"imageBase": "./Images/",
"note": "Solo Dance 3 inspired pose-part rig. Parts are broad overlapping masks for the CSS Live2DHost dance loop.",
"bones": [
{
"name": "base",
"parent": null,
"pivot": [
386.5,
839.8399999999999
],
"z": 0,
"image": "solo3_base.png"
},
{
"name": "pelvis",
"parent": "base",
"pivot": [
386.5,
695.04
],
"z": 2,
"image": "solo3_pelvis.png"
},
{
"name": "chest",
"parent": "base",
"pivot": [
386.5,
448.88
],
"z": 4,
"image": "solo3_chest.png"
},
{
"name": "upperarm_l",
"parent": "chest",
"pivot": [
231.89999999999998,
390.96000000000004
],
"z": 5,
"image": "solo3_upperarm_l.png"
},
{
"name": "forearm_l",
"parent": "upperarm_l",
"pivot": [
139.14,
622.64
],
"z": 6,
"image": "solo3_forearm_l.png"
},
{
"name": "hand_l",
"parent": "forearm_l",
"pivot": [
77.30000000000001,
868.8
],
"z": 7,
"image": "solo3_hand_l.png"
},
{
"name": "upperarm_r",
"parent": "chest",
"pivot": [
541.0999999999999,
390.96000000000004
],
"z": 5,
"image": "solo3_upperarm_r.png"
},
{
"name": "forearm_r",
"parent": "upperarm_r",
"pivot": [
633.86,
622.64
],
"z": 6,
"image": "solo3_forearm_r.png"
},
{
"name": "hand_r",
"parent": "forearm_r",
"pivot": [
695.7,
868.8
],
"z": 7,
"image": "solo3_hand_r.png"
},
{
"name": "thigh_l",
"parent": "pelvis",
"pivot": [
309.20000000000005,
810.8800000000001
],
"z": 2,
"image": "solo3_thigh_l.png"
},
{
"name": "shin_l",
"parent": "thigh_l",
"pivot": [
301.47,
1071.52
],
"z": 2,
"image": "solo3_shin_l.png"
},
{
"name": "foot_l",
"parent": "shin_l",
"pivot": [
301.47,
1332.16
],
"z": 2,
"image": "solo3_foot_l.png"
},
{
"name": "thigh_r",
"parent": "pelvis",
"pivot": [
463.79999999999995,
810.8800000000001
],
"z": 2,
"image": "solo3_thigh_r.png"
},
{
"name": "shin_r",
"parent": "thigh_r",
"pivot": [
471.53,
1071.52
],
"z": 2,
"image": "solo3_shin_r.png"
},
{
"name": "foot_r",
"parent": "shin_r",
"pivot": [
471.53,
1332.16
],
"z": 2,
"image": "solo3_foot_r.png"
},
{
"name": "head",
"parent": "chest",
"pivot": [
386.5,
231.68
],
"z": 10,
"image": "solo3_head.png"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 672 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

@@ -0,0 +1,92 @@
{
"name": "LeeSoriExtended",
"status": "extended_full_body_overlap_puppet_v1",
"canvas": {
"width": 929,
"height": 1693
},
"imageBase": "./Images/",
"note": "Full-body overlap puppet from AI-extended LeeSori source to avoid wrist/lower-body clipping.",
"bones": [
{
"name": "base",
"parent": null,
"pivot": [
464.5,
981.9
],
"z": 0,
"image": "leesori_ext_base.png"
},
{
"name": "legs",
"parent": "base",
"pivot": [
464.5,
778.8
],
"z": 1,
"image": "leesori_ext_legs.png"
},
{
"name": "chest",
"parent": "base",
"pivot": [
464.5,
575.6
],
"z": 3,
"image": "leesori_ext_chest.png"
},
{
"name": "upperarm_l",
"parent": "chest",
"pivot": [
288.0,
474.0
],
"z": 4,
"image": "leesori_ext_arm_l.png"
},
{
"name": "upperarm_r",
"parent": "chest",
"pivot": [
641.0,
524.8
],
"z": 4,
"image": "leesori_ext_arm_r.png"
},
{
"name": "hand_l",
"parent": "upperarm_l",
"pivot": [
362.3,
457.1
],
"z": 8,
"image": "leesori_ext_hand_l.png"
},
{
"name": "hand_r",
"parent": "upperarm_r",
"pivot": [
706.0,
880.4
],
"z": 8,
"image": "leesori_ext_hand_r.png"
},
{
"name": "head",
"parent": "chest",
"pivot": [
464.5,
389.4
],
"z": 10,
"image": "leesori_ext_head.png"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -0,0 +1,22 @@
{
"name": "LeeSoriRegenerated",
"status": "leesori_regenerated_idle_armscross_2026_07_04",
"canvas": {
"width": 1024,
"height": 1536
},
"imageBase": "./Images/",
"note": "Single-master LeeSori idle arms-cross waiting pose for DansoriEQ right-side host. Framed head-to-upper-thigh with subtle base idle animation.",
"bones": [
{
"name": "base",
"parent": null,
"pivot": [
512,
610
],
"z": 0,
"image": "leesori_regenerated_master.png"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

@@ -0,0 +1,82 @@
{
"name": "LeeSoriUpper",
"status": "approved_portrait_ratio_overlap_puppet_v2",
"canvas": {
"width": 1086,
"height": 1448
},
"imageBase": "./Images/",
"note": "Overlap-based upper-body puppet sliced from approved clean LeeSori portrait. Base layer preserves proportions; overlays provide part motion without visible seams.",
"bones": [
{
"name": "base",
"parent": null,
"pivot": [
545,
980
],
"z": 0,
"image": "sori_upper_base.png"
},
{
"name": "chest",
"parent": "base",
"pivot": [
545,
650
],
"z": 2,
"image": "sori_upper_chest_overlay.png"
},
{
"name": "upperarm_l",
"parent": "chest",
"pivot": [
360,
585
],
"z": 4,
"image": "sori_upper_arm_l.png"
},
{
"name": "upperarm_r",
"parent": "chest",
"pivot": [
765,
585
],
"z": 4,
"image": "sori_upper_arm_r.png"
},
{
"name": "hand_l",
"parent": "upperarm_l",
"pivot": [
430,
610
],
"z": 8,
"image": "sori_upper_hand_l.png"
},
{
"name": "hand_r",
"parent": "upperarm_r",
"pivot": [
825,
1320
],
"z": 8,
"image": "sori_upper_hand_r.png"
},
{
"name": "head",
"parent": "chest",
"pivot": [
548,
430
],
"z": 10,
"image": "sori_upper_head.png"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Some files were not shown because too many files have changed in this diff Show More