Updates
This commit is contained in:
@@ -16,7 +16,7 @@ public class AiAssistantService
|
|||||||
{
|
{
|
||||||
private readonly string _apiKey;
|
private readonly string _apiKey;
|
||||||
private readonly string _model;
|
private readonly string _model;
|
||||||
private static readonly HttpClient _http = new();
|
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(90) };
|
||||||
|
|
||||||
private const string ApiUrl = "https://openrouter.ai/api/v1/chat/completions";
|
private const string ApiUrl = "https://openrouter.ai/api/v1/chat/completions";
|
||||||
|
|
||||||
@@ -134,6 +134,60 @@ public class AiAssistantService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes an existing AI-generated listing and rewrites title, description and price
|
||||||
|
/// to incorporate user corrections (e.g. "earrings are white gold, not silver").
|
||||||
|
/// Returns the updated fields; other PhotoAnalysisResult fields are unchanged.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<(string Title, string Description, decimal Price, string PriceReasoning)>
|
||||||
|
RefineWithCorrectionsAsync(string title, string description, decimal price, string corrections)
|
||||||
|
{
|
||||||
|
var priceContext = price > 0
|
||||||
|
? $"Current price: £{price:F2}\n\n"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
var prompt =
|
||||||
|
"You are an expert eBay UK seller. I have a listing with incorrect details that need fixing.\n\n" +
|
||||||
|
$"Current title: {title}\n" +
|
||||||
|
$"Current description:\n{description}\n\n" +
|
||||||
|
priceContext +
|
||||||
|
$"CORRECTIONS FROM SELLER: {corrections}\n\n" +
|
||||||
|
"Rewrite the listing incorporating these corrections. " +
|
||||||
|
"Return ONLY valid JSON — no markdown, no explanation:\n" +
|
||||||
|
"{\n" +
|
||||||
|
" \"title\": \"corrected eBay UK title, max 80 chars, keyword-rich\",\n" +
|
||||||
|
" \"description\": \"corrected full plain-text eBay UK description\",\n" +
|
||||||
|
" \"price_suggested\": 29.99,\n" +
|
||||||
|
" \"price_reasoning\": \"one sentence why this price\"\n" +
|
||||||
|
"}\n\n" +
|
||||||
|
"price_suggested MUST be a realistic GBP amount greater than 0. Never return 0.";
|
||||||
|
|
||||||
|
var json = await CallAsync(prompt);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
JObject obj;
|
||||||
|
try { obj = JObject.Parse(json.Trim()); }
|
||||||
|
catch (JsonReaderException)
|
||||||
|
{
|
||||||
|
var m = System.Text.RegularExpressions.Regex.Match(json, @"```(?:json)?\s*(\{[\s\S]*?\})\s*```");
|
||||||
|
obj = JObject.Parse(m.Success ? m.Groups[1].Value : json.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsedPrice = obj["price_suggested"]?.Value<decimal>() ?? 0;
|
||||||
|
return (
|
||||||
|
obj["title"]?.ToString() ?? title,
|
||||||
|
obj["description"]?.ToString() ?? description,
|
||||||
|
parsedPrice > 0 ? parsedPrice : price, // treat 0 as "not provided", keep original
|
||||||
|
obj["price_reasoning"]?.ToString() ?? ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Could not parse AI refinement response: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Convenience wrapper — analyses a single photo.</summary>
|
/// <summary>Convenience wrapper — analyses a single photo.</summary>
|
||||||
public Task<PhotoAnalysisResult> AnalyseItemFromPhotoAsync(string imagePath)
|
public Task<PhotoAnalysisResult> AnalyseItemFromPhotoAsync(string imagePath)
|
||||||
=> AnalyseItemFromPhotosAsync(new[] { imagePath });
|
=> AnalyseItemFromPhotosAsync(new[] { imagePath });
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ public class EbayAuthService
|
|||||||
private readonly string _tokenPath;
|
private readonly string _tokenPath;
|
||||||
private EbayToken? _token;
|
private EbayToken? _token;
|
||||||
|
|
||||||
|
// App-level (client credentials) token — no user login needed, used for Browse API
|
||||||
|
private string? _appToken;
|
||||||
|
private DateTime _appTokenExpiry = DateTime.MinValue;
|
||||||
|
private readonly SemaphoreSlim _appTokenLock = new(1, 1); // prevents concurrent token fetches
|
||||||
|
|
||||||
|
// Shared HttpClient for all auth/token calls — avoids socket exhaustion from per-call `new HttpClient()`
|
||||||
|
private static readonly HttpClient _http = new();
|
||||||
|
|
||||||
private static readonly string[] Scopes =
|
private static readonly string[] Scopes =
|
||||||
[
|
[
|
||||||
"https://api.ebay.com/oauth/api_scope",
|
"https://api.ebay.com/oauth/api_scope",
|
||||||
@@ -110,19 +118,19 @@ public class EbayAuthService
|
|||||||
? "https://api.sandbox.ebay.com/identity/v1/oauth2/token"
|
? "https://api.sandbox.ebay.com/identity/v1/oauth2/token"
|
||||||
: "https://api.ebay.com/identity/v1/oauth2/token";
|
: "https://api.ebay.com/identity/v1/oauth2/token";
|
||||||
|
|
||||||
using var http = new HttpClient();
|
|
||||||
var credentials = Convert.ToBase64String(
|
var credentials = Convert.ToBase64String(
|
||||||
Encoding.UTF8.GetBytes($"{_settings.ClientId}:{_settings.ClientSecret}"));
|
Encoding.UTF8.GetBytes($"{_settings.ClientId}:{_settings.ClientSecret}"));
|
||||||
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
|
||||||
|
|
||||||
var body = new FormUrlEncodedContent(new Dictionary<string, string>
|
using var codeRequest = new HttpRequestMessage(HttpMethod.Post, tokenUrl);
|
||||||
|
codeRequest.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
||||||
|
codeRequest.Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
["grant_type"] = "authorization_code",
|
["grant_type"] = "authorization_code",
|
||||||
["code"] = code,
|
["code"] = code,
|
||||||
["redirect_uri"] = _settings.RuName
|
["redirect_uri"] = _settings.RuName
|
||||||
});
|
});
|
||||||
|
|
||||||
var response = await http.PostAsync(tokenUrl, body);
|
var response = await _http.SendAsync(codeRequest);
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
@@ -148,19 +156,19 @@ public class EbayAuthService
|
|||||||
? "https://api.sandbox.ebay.com/identity/v1/oauth2/token"
|
? "https://api.sandbox.ebay.com/identity/v1/oauth2/token"
|
||||||
: "https://api.ebay.com/identity/v1/oauth2/token";
|
: "https://api.ebay.com/identity/v1/oauth2/token";
|
||||||
|
|
||||||
using var http = new HttpClient();
|
|
||||||
var credentials = Convert.ToBase64String(
|
var credentials = Convert.ToBase64String(
|
||||||
Encoding.UTF8.GetBytes($"{_settings.ClientId}:{_settings.ClientSecret}"));
|
Encoding.UTF8.GetBytes($"{_settings.ClientId}:{_settings.ClientSecret}"));
|
||||||
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
|
||||||
|
|
||||||
var body = new FormUrlEncodedContent(new Dictionary<string, string>
|
using var refreshRequest = new HttpRequestMessage(HttpMethod.Post, tokenUrl);
|
||||||
|
refreshRequest.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
||||||
|
refreshRequest.Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
["grant_type"] = "refresh_token",
|
["grant_type"] = "refresh_token",
|
||||||
["refresh_token"] = _token!.RefreshToken,
|
["refresh_token"] = _token!.RefreshToken,
|
||||||
["scope"] = string.Join(" ", Scopes)
|
["scope"] = string.Join(" ", Scopes)
|
||||||
});
|
});
|
||||||
|
|
||||||
var response = await http.PostAsync(tokenUrl, body);
|
var response = await _http.SendAsync(refreshRequest);
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
@@ -176,12 +184,13 @@ public class EbayAuthService
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var http = new HttpClient();
|
|
||||||
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
|
||||||
var url = _settings.Sandbox
|
var url = _settings.Sandbox
|
||||||
? "https://apiz.sandbox.ebay.com/commerce/identity/v1/user/"
|
? "https://apiz.sandbox.ebay.com/commerce/identity/v1/user/"
|
||||||
: "https://apiz.ebay.com/commerce/identity/v1/user/";
|
: "https://apiz.ebay.com/commerce/identity/v1/user/";
|
||||||
var json = await http.GetStringAsync(url);
|
using var idRequest = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
idRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||||
|
var idResponse = await _http.SendAsync(idRequest);
|
||||||
|
var json = await idResponse.Content.ReadAsStringAsync();
|
||||||
var obj = JObject.Parse(json);
|
var obj = JObject.Parse(json);
|
||||||
return obj["username"]?.ToString() ?? "Unknown";
|
return obj["username"]?.ToString() ?? "Unknown";
|
||||||
}
|
}
|
||||||
@@ -191,6 +200,60 @@ public class EbayAuthService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns an application-level OAuth token (client credentials grant).
|
||||||
|
/// Does NOT require the user to be logged in — uses Client ID + Secret only.
|
||||||
|
/// Cached for the token lifetime minus a 5-minute safety margin.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string> GetAppTokenAsync()
|
||||||
|
{
|
||||||
|
// Fast path — no lock needed for a read when token is valid
|
||||||
|
if (_appToken != null && DateTime.UtcNow < _appTokenExpiry)
|
||||||
|
return _appToken;
|
||||||
|
|
||||||
|
await _appTokenLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Re-check inside lock (another thread may have refreshed while we waited)
|
||||||
|
if (_appToken != null && DateTime.UtcNow < _appTokenExpiry)
|
||||||
|
return _appToken;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(_settings.ClientId) || string.IsNullOrEmpty(_settings.ClientSecret))
|
||||||
|
throw new InvalidOperationException("eBay Client ID / Secret not configured in appsettings.json.");
|
||||||
|
|
||||||
|
var tokenUrl = _settings.Sandbox
|
||||||
|
? "https://api.sandbox.ebay.com/identity/v1/oauth2/token"
|
||||||
|
: "https://api.ebay.com/identity/v1/oauth2/token";
|
||||||
|
|
||||||
|
var credentials = Convert.ToBase64String(
|
||||||
|
Encoding.UTF8.GetBytes($"{_settings.ClientId}:{_settings.ClientSecret}"));
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, tokenUrl);
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
||||||
|
request.Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["grant_type"] = "client_credentials",
|
||||||
|
["scope"] = "https://api.ebay.com/oauth/api_scope"
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await _http.SendAsync(request);
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
throw new HttpRequestException($"App token request failed ({(int)response.StatusCode})");
|
||||||
|
|
||||||
|
var obj = JObject.Parse(json);
|
||||||
|
_appToken = obj["access_token"]!.ToString();
|
||||||
|
var expiresIn = obj["expires_in"]?.Value<int>() ?? 7200;
|
||||||
|
_appTokenExpiry = DateTime.UtcNow.AddSeconds(expiresIn - 300); // 5-min buffer
|
||||||
|
return _appToken;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_appTokenLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Disconnect()
|
public void Disconnect()
|
||||||
{
|
{
|
||||||
_token = null;
|
_token = null;
|
||||||
|
|||||||
97
EbayListingTool/Services/EbayPriceResearchService.cs
Normal file
97
EbayListingTool/Services/EbayPriceResearchService.cs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace EbayListingTool.Services;
|
||||||
|
|
||||||
|
public class LivePriceResult
|
||||||
|
{
|
||||||
|
public List<decimal> Prices { get; init; } = new();
|
||||||
|
public int Count => Prices.Count;
|
||||||
|
public decimal Min => Prices.Count > 0 ? Prices.Min() : 0;
|
||||||
|
public decimal Max => Prices.Count > 0 ? Prices.Max() : 0;
|
||||||
|
public decimal Median => Prices.Count > 0 ? Percentile(50) : 0;
|
||||||
|
|
||||||
|
// Require ≥5 samples before offering a suggestion; 40th pct = competitive but not cheapest
|
||||||
|
public decimal Suggested => Prices.Count >= MinSampleSize ? Percentile(40) : 0;
|
||||||
|
public bool HasSuggestion => Prices.Count >= MinSampleSize;
|
||||||
|
|
||||||
|
internal const int MinSampleSize = 5;
|
||||||
|
|
||||||
|
private decimal Percentile(int pct)
|
||||||
|
{
|
||||||
|
var sorted = Prices.OrderBy(p => p).ToList();
|
||||||
|
int idx = (int)Math.Round((pct / 100.0) * (sorted.Count - 1));
|
||||||
|
return sorted[Math.Clamp(idx, 0, sorted.Count - 1)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Uses the eBay Browse API (app-level token, no user login required) to fetch
|
||||||
|
/// current Buy It Now prices for similar items on eBay UK.
|
||||||
|
/// </summary>
|
||||||
|
public class EbayPriceResearchService
|
||||||
|
{
|
||||||
|
private readonly EbayAuthService _auth;
|
||||||
|
|
||||||
|
// 15-second timeout — price lookup is non-critical; don't leave spinner hanging
|
||||||
|
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(15) };
|
||||||
|
|
||||||
|
public EbayPriceResearchService(EbayAuthService auth)
|
||||||
|
{
|
||||||
|
_auth = auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LivePriceResult> GetLivePricesAsync(string query, int limit = 30)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
|
return new LivePriceResult();
|
||||||
|
|
||||||
|
var token = await _auth.GetAppTokenAsync();
|
||||||
|
var baseUrl = _auth.BaseUrl;
|
||||||
|
|
||||||
|
// Omit sort= to use eBay BEST_MATCH — price-sorted results are biased toward
|
||||||
|
// the cheapest (broken/spam) listings; BEST_MATCH gives a more representative sample
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get,
|
||||||
|
$"{baseUrl}/buy/browse/v1/item_summary/search" +
|
||||||
|
$"?q={Uri.EscapeDataString(query)}" +
|
||||||
|
$"&filter=buyingOptions%3A%7BFIXED_PRICE%7D" +
|
||||||
|
$"&limit={limit}");
|
||||||
|
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
request.Headers.Add("X-EBAY-C-MARKETPLACE-ID", "EBAY_GB");
|
||||||
|
|
||||||
|
var response = await _http.SendAsync(request);
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
throw new HttpRequestException($"eBay Browse API error ({(int)response.StatusCode})");
|
||||||
|
|
||||||
|
var obj = JObject.Parse(json);
|
||||||
|
var items = obj["itemSummaries"] as JArray ?? new JArray();
|
||||||
|
var prices = new List<decimal>();
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
var priceVal = item["price"]?["value"]?.ToString();
|
||||||
|
var currency = item["price"]?["currency"]?.ToString();
|
||||||
|
if (currency == "GBP" && decimal.TryParse(priceVal,
|
||||||
|
System.Globalization.NumberStyles.Any,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
out var p) && p > 0)
|
||||||
|
{
|
||||||
|
prices.Add(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim top and bottom 10% to remove outliers (spammers, broken items, typos).
|
||||||
|
// Only trim when there are enough data points to make it worthwhile.
|
||||||
|
if (prices.Count >= 10)
|
||||||
|
{
|
||||||
|
prices.Sort();
|
||||||
|
int trim = (int)Math.Floor(prices.Count * 0.10);
|
||||||
|
prices = prices.Skip(trim).Take(prices.Count - 2 * trim).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LivePriceResult { Prices = prices };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -198,7 +198,7 @@
|
|||||||
Background="{DynamicResource MahApps.Brushes.Gray8}"
|
Background="{DynamicResource MahApps.Brushes.Gray8}"
|
||||||
VerticalAlignment="Center">
|
VerticalAlignment="Center">
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
<iconPacks:PackIconMaterial Kind="TableRowsPlusAfter" Width="12" Height="12"
|
<iconPacks:PackIconMaterial Kind="TablePlus" Width="12" Height="12"
|
||||||
Margin="0,0,5,0" VerticalAlignment="Center"
|
Margin="0,0,5,0" VerticalAlignment="Center"
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray4}"/>
|
Foreground="{DynamicResource MahApps.Brushes.Gray4}"/>
|
||||||
<TextBlock x:Name="RowCountLabel" VerticalAlignment="Center"
|
<TextBlock x:Name="RowCountLabel" VerticalAlignment="Center"
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
||||||
Height="28" Padding="8,0">
|
Height="28" Padding="8,0">
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
<iconPacks:PackIconMaterial Kind="LinkOff" Width="12" Height="12"
|
<iconPacks:PackIconMaterial Kind="LinkVariantOff" Width="12" Height="12"
|
||||||
Margin="0,0,4,0" VerticalAlignment="Center"/>
|
Margin="0,0,4,0" VerticalAlignment="Center"/>
|
||||||
<TextBlock Text="Disconnect" VerticalAlignment="Center" FontSize="12"/>
|
<TextBlock Text="Disconnect" VerticalAlignment="Center" FontSize="12"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -278,7 +278,7 @@
|
|||||||
<ColumnDefinition Width="Auto"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<StackPanel Grid.Column="0" Orientation="Horizontal">
|
<StackPanel Grid.Column="0" Orientation="Horizontal">
|
||||||
<iconPacks:PackIconMaterial Kind="InformationOutline"
|
<iconPacks:PackIconMaterial Kind="AlertCircleOutline"
|
||||||
Width="12" Height="12" Margin="0,0,5,0"
|
Width="12" Height="12" Margin="0,0,5,0"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
|
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public partial class MainWindow : MetroWindow
|
|||||||
private readonly AiAssistantService _aiService;
|
private readonly AiAssistantService _aiService;
|
||||||
private readonly BulkImportService _bulkService;
|
private readonly BulkImportService _bulkService;
|
||||||
private readonly SavedListingsService _savedService;
|
private readonly SavedListingsService _savedService;
|
||||||
|
private readonly EbayPriceResearchService _priceService;
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
@@ -26,9 +27,10 @@ public partial class MainWindow : MetroWindow
|
|||||||
_aiService = new AiAssistantService(config);
|
_aiService = new AiAssistantService(config);
|
||||||
_bulkService = new BulkImportService();
|
_bulkService = new BulkImportService();
|
||||||
_savedService = new SavedListingsService();
|
_savedService = new SavedListingsService();
|
||||||
|
_priceService = new EbayPriceResearchService(_auth);
|
||||||
|
|
||||||
// Photo Analysis tab — no eBay needed
|
// Photo Analysis tab — no eBay needed
|
||||||
PhotoView.Initialise(_aiService, _savedService);
|
PhotoView.Initialise(_aiService, _savedService, _priceService);
|
||||||
PhotoView.UseDetailsRequested += OnUseDetailsRequested;
|
PhotoView.UseDetailsRequested += OnUseDetailsRequested;
|
||||||
|
|
||||||
// Saved Listings tab
|
// Saved Listings tab
|
||||||
|
|||||||
@@ -490,6 +490,18 @@
|
|||||||
StringFormat="F2" Interval="0.5"/>
|
StringFormat="F2" Interval="0.5"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Live eBay price status -->
|
||||||
|
<StackPanel x:Name="LivePriceRow" Orientation="Horizontal"
|
||||||
|
Margin="0,6,0,0" Visibility="Collapsed">
|
||||||
|
<mah:ProgressRing x:Name="LivePriceSpinner"
|
||||||
|
Width="11" Height="11" Margin="0,0,6,0"/>
|
||||||
|
<TextBlock x:Name="LivePriceStatus"
|
||||||
|
FontSize="10"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
</StackPanel>
|
||||||
<!-- Price reasoning -->
|
<!-- Price reasoning -->
|
||||||
<TextBlock x:Name="PriceReasoningText"
|
<TextBlock x:Name="PriceReasoningText"
|
||||||
FontSize="11" FontStyle="Italic"
|
FontSize="11" FontStyle="Italic"
|
||||||
@@ -554,6 +566,47 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Corrections for AI refinement -->
|
||||||
|
<Border BorderThickness="1" CornerRadius="8" Padding="14,10"
|
||||||
|
Margin="0,0,0,10"
|
||||||
|
BorderBrush="{DynamicResource MahApps.Brushes.Gray7}"
|
||||||
|
Background="{DynamicResource MahApps.Brushes.Gray9}">
|
||||||
|
<StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
|
||||||
|
<iconPacks:PackIconMaterial Kind="Pencil" Width="12" Height="12"
|
||||||
|
Margin="0,0,6,0" VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
|
||||||
|
<TextBlock Text="CORRECTIONS" FontSize="10" FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Accent}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBox x:Name="CorrectionsBox"
|
||||||
|
AcceptsReturn="False"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Height="52"
|
||||||
|
VerticalScrollBarVisibility="Auto"
|
||||||
|
Style="{DynamicResource MahApps.Styles.TextBox}"
|
||||||
|
FontSize="12"
|
||||||
|
mah:TextBoxHelper.Watermark="e.g. earrings are white gold with diamonds, not silver and zirconium"
|
||||||
|
Margin="0,0,0,8"/>
|
||||||
|
<Button x:Name="RefineBtn"
|
||||||
|
Click="Refine_Click"
|
||||||
|
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
||||||
|
Height="32" Padding="12,0" HorizontalAlignment="Left">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<iconPacks:PackIconMaterial x:Name="RefineIcon" Kind="AutoFix" Width="13" Height="13"
|
||||||
|
Margin="0,0,6,0" VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
|
||||||
|
<mah:ProgressRing x:Name="RefineSpinner" Width="13" Height="13"
|
||||||
|
Margin="0,0,6,0" Visibility="Collapsed"/>
|
||||||
|
<TextBlock x:Name="RefineBtnText" Text="Refine with AI"
|
||||||
|
VerticalAlignment="Center" FontSize="12"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- Actions + toast overlay -->
|
<!-- Actions + toast overlay -->
|
||||||
<Grid Margin="0,4,0,16" ClipToBounds="False">
|
<Grid Margin="0,4,0,16" ClipToBounds="False">
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ namespace EbayListingTool.Views;
|
|||||||
|
|
||||||
public partial class PhotoAnalysisView : UserControl
|
public partial class PhotoAnalysisView : UserControl
|
||||||
{
|
{
|
||||||
private AiAssistantService? _aiService;
|
private AiAssistantService? _aiService;
|
||||||
private SavedListingsService? _savedService;
|
private SavedListingsService? _savedService;
|
||||||
|
private EbayPriceResearchService? _priceService;
|
||||||
private List<string> _currentImagePaths = new();
|
private List<string> _currentImagePaths = new();
|
||||||
private PhotoAnalysisResult? _lastResult;
|
private PhotoAnalysisResult? _lastResult;
|
||||||
private int _activePhotoIndex = 0;
|
private int _activePhotoIndex = 0;
|
||||||
@@ -50,10 +51,12 @@ public partial class PhotoAnalysisView : UserControl
|
|||||||
PhotoPreviewContainer.SizeChanged += PhotoPreviewContainer_SizeChanged;
|
PhotoPreviewContainer.SizeChanged += PhotoPreviewContainer_SizeChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Initialise(AiAssistantService aiService, SavedListingsService savedService)
|
public void Initialise(AiAssistantService aiService, SavedListingsService savedService,
|
||||||
|
EbayPriceResearchService priceService)
|
||||||
{
|
{
|
||||||
_aiService = aiService;
|
_aiService = aiService;
|
||||||
_savedService = savedService;
|
_savedService = savedService;
|
||||||
|
_priceService = priceService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Photo clip geometry sync ----
|
// ---- Photo clip geometry sync ----
|
||||||
@@ -204,6 +207,8 @@ public partial class PhotoAnalysisView : UserControl
|
|||||||
var result = await _aiService.AnalyseItemFromPhotosAsync(_currentImagePaths);
|
var result = await _aiService.AnalyseItemFromPhotosAsync(_currentImagePaths);
|
||||||
_lastResult = result;
|
_lastResult = result;
|
||||||
ShowResults(result);
|
ShowResults(result);
|
||||||
|
// Fire live price lookup in background — updates price display when ready
|
||||||
|
_ = UpdateLivePricesAsync(result.Title);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -220,6 +225,64 @@ public partial class PhotoAnalysisView : UserControl
|
|||||||
private void ReAnalyse_Click(object sender, RoutedEventArgs e)
|
private void ReAnalyse_Click(object sender, RoutedEventArgs e)
|
||||||
=> Analyse_Click(sender, e);
|
=> Analyse_Click(sender, e);
|
||||||
|
|
||||||
|
private async void Refine_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_aiService == null || _lastResult == null) return;
|
||||||
|
|
||||||
|
var corrections = CorrectionsBox.Text.Trim();
|
||||||
|
if (string.IsNullOrEmpty(corrections))
|
||||||
|
{
|
||||||
|
CorrectionsBox.Focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var title = TitleBox.Text;
|
||||||
|
var description = DescriptionBox.Text;
|
||||||
|
var price = (decimal)(PriceOverride.Value ?? (double)_lastResult.PriceSuggested);
|
||||||
|
|
||||||
|
SetRefining(true);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (newTitle, newDesc, newPrice, newReasoning) =
|
||||||
|
await _aiService.RefineWithCorrectionsAsync(title, description, price, corrections);
|
||||||
|
|
||||||
|
TitleBox.Text = newTitle;
|
||||||
|
DescriptionBox.Text = newDesc;
|
||||||
|
PriceOverride.Value = (double)Math.Round(newPrice, 2); // Issue 6
|
||||||
|
PriceSuggestedText.Text = newPrice > 0 ? $"£{newPrice:F2}" : "—";
|
||||||
|
|
||||||
|
_lastResult.Title = newTitle;
|
||||||
|
_lastResult.Description = newDesc;
|
||||||
|
_lastResult.PriceSuggested = newPrice;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(newReasoning))
|
||||||
|
{
|
||||||
|
PriceReasoningText.Text = newReasoning;
|
||||||
|
PriceReasoningText.Visibility = Visibility.Visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the corrections box now they're applied
|
||||||
|
CorrectionsBox.Text = "";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show($"Refinement failed:\n\n{ex.Message}", "AI Error",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SetRefining(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetRefining(bool busy)
|
||||||
|
{
|
||||||
|
RefineBtn.IsEnabled = !busy;
|
||||||
|
RefineIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible;
|
||||||
|
RefineSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
RefineBtnText.Text = busy ? "Refining…" : "Refine with AI";
|
||||||
|
}
|
||||||
|
|
||||||
private void ShowResults(PhotoAnalysisResult r)
|
private void ShowResults(PhotoAnalysisResult r)
|
||||||
{
|
{
|
||||||
IdlePanel.Visibility = Visibility.Collapsed;
|
IdlePanel.Visibility = Visibility.Collapsed;
|
||||||
@@ -275,7 +338,7 @@ public partial class PhotoAnalysisView : UserControl
|
|||||||
PriceRangeBar.Visibility = Visibility.Collapsed;
|
PriceRangeBar.Visibility = Visibility.Collapsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
PriceOverride.Value = (double)r.PriceSuggested;
|
PriceOverride.Value = (double)Math.Round(r.PriceSuggested, 2); // Issue 6
|
||||||
|
|
||||||
// Price reasoning
|
// Price reasoning
|
||||||
PriceReasoningText.Text = r.PriceReasoning;
|
PriceReasoningText.Text = r.PriceReasoning;
|
||||||
@@ -286,11 +349,69 @@ public partial class PhotoAnalysisView : UserControl
|
|||||||
TitleBox.Text = r.Title;
|
TitleBox.Text = r.Title;
|
||||||
DescriptionBox.Text = r.Description;
|
DescriptionBox.Text = r.Description;
|
||||||
|
|
||||||
|
// Reset live price row until lookup completes
|
||||||
|
LivePriceRow.Visibility = Visibility.Collapsed;
|
||||||
|
|
||||||
// Animate results in
|
// Animate results in
|
||||||
var sb = (Storyboard)FindResource("ResultsReveal");
|
var sb = (Storyboard)FindResource("ResultsReveal");
|
||||||
sb.Begin(this);
|
sb.Begin(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task UpdateLivePricesAsync(string query)
|
||||||
|
{
|
||||||
|
if (_priceService == null) return;
|
||||||
|
|
||||||
|
// Issue 7: guard against off-thread callers (fire-and-forget may lose sync context)
|
||||||
|
if (!Dispatcher.CheckAccess())
|
||||||
|
{
|
||||||
|
await Dispatcher.InvokeAsync(() => UpdateLivePricesAsync(query)).Task.Unwrap();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Issue 1: spinner-show inside try so a disposed control doesn't crash the caller
|
||||||
|
LivePriceRow.Visibility = Visibility.Visible;
|
||||||
|
LivePriceSpinner.Visibility = Visibility.Visible;
|
||||||
|
LivePriceStatus.Text = "Checking live eBay UK prices…";
|
||||||
|
|
||||||
|
var live = await _priceService.GetLivePricesAsync(query);
|
||||||
|
|
||||||
|
if (live.Count == 0)
|
||||||
|
{
|
||||||
|
LivePriceStatus.Text = "No matching live listings found.";
|
||||||
|
LivePriceSpinner.Visibility = Visibility.Collapsed;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update range bar with real data
|
||||||
|
PriceMinText.Text = $"£{live.Min:F2}";
|
||||||
|
PriceMaxText.Text = $"£{live.Max:F2}";
|
||||||
|
PriceRangeBar.Visibility = Visibility.Visible;
|
||||||
|
|
||||||
|
// Update suggested price to 40th percentile (competitive but not cheapest)
|
||||||
|
var suggested = live.Suggested;
|
||||||
|
PriceSuggestedText.Text = $"£{suggested:F2}";
|
||||||
|
PriceOverride.Value = (double)Math.Round(suggested, 2); // Issue 6: avoid decimal→double drift
|
||||||
|
if (_lastResult != null) _lastResult.PriceSuggested = suggested;
|
||||||
|
|
||||||
|
// Update status label
|
||||||
|
LivePriceSpinner.Visibility = Visibility.Collapsed;
|
||||||
|
LivePriceStatus.Text =
|
||||||
|
$"Based on {live.Count} live eBay UK listing{(live.Count == 1 ? "" : "s")} " +
|
||||||
|
$"(range £{live.Min:F2} – £{live.Max:F2})";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
LivePriceSpinner.Visibility = Visibility.Collapsed;
|
||||||
|
LivePriceStatus.Text = $"Live price lookup unavailable: {ex.Message}";
|
||||||
|
}
|
||||||
|
catch { /* control may be unloaded by the time catch runs */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void TitleBox_TextChanged(object sender, TextChangedEventArgs e)
|
private void TitleBox_TextChanged(object sender, TextChangedEventArgs e)
|
||||||
{
|
{
|
||||||
var len = TitleBox.Text.Length;
|
var len = TitleBox.Text.Length;
|
||||||
@@ -492,12 +613,13 @@ public partial class PhotoAnalysisView : UserControl
|
|||||||
|
|
||||||
// ---- Save toast ----
|
// ---- Save toast ----
|
||||||
|
|
||||||
private bool _toastAnimating = false;
|
|
||||||
|
|
||||||
private void ShowSaveToast()
|
private void ShowSaveToast()
|
||||||
{
|
{
|
||||||
if (_toastAnimating) return;
|
// Issue 8: always restart — stop any in-progress hold timer and cancel the running
|
||||||
_toastAnimating = true;
|
// animation so the flag can never get permanently stuck and rapid saves feel responsive.
|
||||||
|
_holdTimer?.Stop();
|
||||||
|
_holdTimer = null;
|
||||||
|
ToastTranslate.BeginAnimation(TranslateTransform.YProperty, null); // cancel current animation
|
||||||
|
|
||||||
SaveToast.Visibility = Visibility.Visible;
|
SaveToast.Visibility = Visibility.Visible;
|
||||||
|
|
||||||
@@ -521,7 +643,6 @@ public partial class PhotoAnalysisView : UserControl
|
|||||||
slideOut.Completed += (_, _) =>
|
slideOut.Completed += (_, _) =>
|
||||||
{
|
{
|
||||||
SaveToast.Visibility = Visibility.Collapsed;
|
SaveToast.Visibility = Visibility.Collapsed;
|
||||||
_toastAnimating = false;
|
|
||||||
};
|
};
|
||||||
ToastTranslate.BeginAnimation(TranslateTransform.YProperty, slideOut);
|
ToastTranslate.BeginAnimation(TranslateTransform.YProperty, slideOut);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user