Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ namespace WindowTranslator.Plugin.GitHubCopilotPlugin;

public class GitHubCopilotOptions : IPluginParam
{
[EditableItemsSource(nameof(ModelCandidates))]
[LocalizedDescription(typeof(Resources), $"{nameof(Model)}_Desc")]
public string Model { get; set; } = "gpt-5-mini";

[System.ComponentModel.Browsable(false)]
public IReadOnlyList<string> ModelCandidates { get; set; } = [];

[Height(120)]
[DataType(DataType.MultilineText)]
public string? TranslateContext { get; set; }
Expand Down
4 changes: 4 additions & 0 deletions Plugins/WindowTranslator.Plugin.LLMPlugin/LLMOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,12 @@ public class LLMOptions : IPluginParam

public bool WaitCorrect { get; set; }

[EditableItemsSource(nameof(ModelCandidates))]
public string? Model { get; set; } = "gpt-4o-mini";

[System.ComponentModel.Browsable(false)]
public IReadOnlyList<string> ModelCandidates { get; set; } = [];

[DataType(DataType.Password)]
public string? ApiKey { get; set; }

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace WindowTranslator.ComponentModel;

/// <summary>
/// プロパティを編集可能なComboBoxとして表示し、候補一覧をバインドするプロパティ名を指定する属性です。
/// </summary>
/// <param name="itemsSourcePropertyName">候補一覧を提供するプロパティの名前</param>
[AttributeUsage(AttributeTargets.Property)]
public class EditableItemsSourceAttribute(string itemsSourcePropertyName) : Attribute
{
/// <summary>
/// 候補一覧を提供するプロパティの名前を取得します。
/// </summary>
public string ItemsSourcePropertyName { get; } = itemsSourcePropertyName;
}
46 changes: 44 additions & 2 deletions WindowTranslator/Modules/Settings/AllSettingsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ sealed partial class AllSettingsViewModel : ObservableObject, IDisposable
private readonly IContentDialogService dialogService;
private readonly IPresentationService presentationService;
private readonly IAutoTargetStore autoTargetStore;
private readonly IModelHistoryStore modelHistoryStore;
private readonly IEnumerable<ITargetSettingsValidator> validators;
private readonly IMainWindowModule mainWindowModule;
private readonly ILogger<AllSettingsViewModel> logger;
Expand Down Expand Up @@ -112,6 +113,7 @@ public AllSettingsViewModel(
[Inject] IContentDialogService dialogService,
[Inject] IPresentationService presentationService,
[Inject] IAutoTargetStore autoTargetStore,
[Inject] IModelHistoryStore modelHistoryStore,
[Inject] IConfiguration config,
[Inject] IEnumerable<ITargetSettingsValidator> validators,
[Inject] IMainWindowModule mainWindowModule,
Expand All @@ -134,12 +136,12 @@ public AllSettingsViewModel(

this.Targets = [.. options.Value.Targets
.DefaultIfEmpty(new KeyValuePair<string, TargetSettings>(string.Empty, new()))
.Select(t => new TargetSettingsViewModel(t.Key, sp, t.Value, ocrModules, translateModules, cacheModules))];
.Select(t => new TargetSettingsViewModel(t.Key, sp, modelHistoryStore, t.Value, ocrModules, translateModules, cacheModules))];

this.ApplyMode = applyMode ?? !string.IsNullOrEmpty(target) && options.Value.Targets.ContainsKey(target);
if (this.Targets.FirstOrDefault(t => t.Name == target) is not { } selected)
{
selected = new(target, sp, options.Value.Targets.TryGetValue(string.Empty, out var d) ? d : new(), ocrModules, translateModules, cacheModules);
selected = new(target, sp, modelHistoryStore, options.Value.Targets.TryGetValue(string.Empty, out var d) ? d : new(), ocrModules, translateModules, cacheModules);
this.Targets.Add(selected);
}
if (!string.IsNullOrEmpty(target))
Expand All @@ -153,6 +155,7 @@ public AllSettingsViewModel(
this.dialogService = dialogService;
this.presentationService = presentationService;
this.autoTargetStore = autoTargetStore;
this.modelHistoryStore = modelHistoryStore;
this.validators = validators;
this.mainWindowModule = mainWindowModule;
this.logger = logger;
Expand Down Expand Up @@ -301,6 +304,14 @@ public async Task SaveAsync(object window)
this.autoTargetStore.AutoTargets.Clear();
this.autoTargetStore.AutoTargets.UnionWith(this.AutoTargets);
this.autoTargetStore.Save();

// 編集可能ComboBoxの履歴保存
foreach (var (param, propertyName, value) in GetEditableItemsSourceValues(this.Targets))
{
this.modelHistoryStore.AddHistory($"{param.GetType().Name}.{propertyName}", value);
}
this.modelHistoryStore.Save();

this.rootConfig?.Reload();
if (this.ApplyMode)
{
Expand Down Expand Up @@ -329,6 +340,24 @@ private DisposeAction EnterBusy()
return new DisposeAction(() => this.IsBusy = false);
}

private static IEnumerable<(IPluginParam Param, string PropertyName, string Value)> GetEditableItemsSourceValues(IEnumerable<TargetSettingsViewModel> targets)
{
foreach (var target in targets)
{
foreach (var param in target.Params)
{
foreach (System.ComponentModel.PropertyDescriptor pd in System.ComponentModel.TypeDescriptor.GetProperties(param))
{
var attr = pd.Attributes.OfType<EditableItemsSourceAttribute>().FirstOrDefault();
if (attr != null && pd.GetValue(param) is string value && !string.IsNullOrWhiteSpace(value))
{
yield return (param, pd.Name, value);
}
}
}
}
}

public void Dispose()
{
this.updateChecker.UpdateAvailable -= UpdateChecker_UpdateAvailable;
Expand All @@ -348,6 +377,7 @@ public record ModuleItem(string Name, string DisplayName, bool IsDefault);
public partial class TargetSettingsViewModel(
string name,
IServiceProvider sp,
IModelHistoryStore modelHistoryStore,
TargetSettings settings,
IReadOnlyList<ModuleItem> ocrModules,
IReadOnlyList<ModuleItem> translateModules,
Expand Down Expand Up @@ -477,6 +507,18 @@ public partial class TargetSettingsViewModel(
{
configureMethod.Invoke(configure, [name, p]);
}

// EditableItemsSourceAttributeが付与されたプロパティに履歴を設定
foreach (System.ComponentModel.PropertyDescriptor pd in System.ComponentModel.TypeDescriptor.GetProperties(p))
{
var attr = pd.Attributes.OfType<EditableItemsSourceAttribute>().FirstOrDefault();
if (attr != null)
{
var sourceProp = System.ComponentModel.TypeDescriptor.GetProperties(p)[attr.ItemsSourcePropertyName];
sourceProp?.SetValue(p, modelHistoryStore.GetHistory($"{p.GetType().Name}.{pd.Name}"));
}
}

return p;
}).ToArray();
}
14 changes: 14 additions & 0 deletions WindowTranslator/Modules/Settings/SettingsPropertyGridFactory.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using PropertyTools.DataAnnotations;
using PropertyTools.Wpf;
using System.Collections;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Reflection;
Expand Down Expand Up @@ -42,6 +43,19 @@ public override FrameworkElement CreateControl(PropertyItem property, PropertyCo
fe.SetBinding(TextBox.TextProperty, property.CreateBinding());
}

// EditableItemsSourceAttributeが指定されている場合、編集可能ComboBoxを生成
if (fe == null && property is IEditableItemsPropertyItem editableItem && editableItem.EditableCandidates != null)
{
var comboBox = new ComboBox
{
IsEditable = true,
IsTextSearchEnabled = true,
ItemsSource = editableItem.EditableCandidates,
};
comboBox.SetBinding(ComboBox.TextProperty, property.CreateBinding(UpdateSourceTrigger.PropertyChanged));
fe = comboBox;
}

fe ??= base.CreateControl(property, options);

if (property.Descriptor.Attributes.Matches(enableAttribute))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
using PropertyTools.Wpf;
using System.Collections;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel;
using System.Windows.Data;
using System.Globalization;
using System.Windows.Input;
using PropertyTools.DataAnnotations;
using WindowTranslator.ComponentModel;

namespace WindowTranslator.Modules.Settings;

internal interface IEditableItemsPropertyItem
{
IEnumerable? EditableCandidates { get; set; }
}

internal class SettingsPropertyGridOperator : PropertyGridOperator
{
public SettingsPropertyGridOperator()
Expand Down Expand Up @@ -86,17 +94,27 @@ protected override void SetAttribute(Attribute attribute, PropertyItem pi, objec
{
pi.SortIndex = order;
}
if (attribute is EditableItemsSourceAttribute editableAttr && pi is IEditableItemsPropertyItem editableItem)
{
var sourceProp = TypeDescriptor.GetProperties(instance)[editableAttr.ItemsSourcePropertyName];
if (sourceProp?.GetValue(instance) is IEnumerable candidates)
{
editableItem.EditableCandidates = candidates;
}
}
base.SetAttribute(attribute, pi, instance);
}

protected override PropertyItem CreateCore(PropertyDescriptor pd, PropertyDescriptorCollection propertyDescriptors)
=> new ParentablePropertyItem(pd, propertyDescriptors);

private class ParentablePropertyItem(PropertyDescriptor propertyDescriptor, PropertyDescriptorCollection propertyDescriptors)
: PropertyItem(propertyDescriptor, propertyDescriptors)
: PropertyItem(propertyDescriptor, propertyDescriptors), IEditableItemsPropertyItem
{
private readonly Stack<string> parents = new();

public IEnumerable? EditableCandidates { get; set; }

public void AddParent(string parent)
=> parents.Push(parent);

Expand Down
1 change: 1 addition & 0 deletions WindowTranslator/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@

builder.Services.AddSingleton<IMainWindowModule, MainWindowModule>();
builder.Services.AddSingleton<IAutoTargetStore, AutoTargetStore>();
builder.Services.AddSingleton<IModelHistoryStore, ModelHistoryStore>();
builder.Services.AddHostedService<WindowMonitor>();
if (builder.Configuration.GetValue<bool>("IgnoreUpdate"))
{
Expand Down
96 changes: 96 additions & 0 deletions WindowTranslator/Stores/ModelHistoryStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using System.IO;
using System.Text.Json;

namespace WindowTranslator.Stores;

/// <summary>
/// モデル名の利用履歴を管理するストアのインターフェースです。
/// </summary>
public interface IModelHistoryStore
{
/// <summary>
/// 指定キーの履歴を取得します。
/// </summary>
IReadOnlyList<string> GetHistory(string key);

/// <summary>
/// 指定キーに値を追加します。既存の値がある場合は先頭に移動します。
/// </summary>
void AddHistory(string key, string value);

/// <summary>
/// 履歴をファイルに保存します。
/// </summary>
void Save();
}

/// <inheritdoc cref="IModelHistoryStore"/>
public class ModelHistoryStore : IModelHistoryStore
{
private static readonly string historyPath = Path.Combine(PathUtility.UserDir, "model-history.json");
private const int MaxHistoryCount = 10;

private readonly Dictionary<string, List<string>> history;

/// <summary>
/// <see cref="ModelHistoryStore"/> の新しいインスタンスを初期化します。
/// </summary>
public ModelHistoryStore()
{
if (File.Exists(historyPath))
{
try
{
using var fs = File.OpenRead(historyPath);
this.history = JsonSerializer.Deserialize<Dictionary<string, List<string>>>(fs) ?? [];
}
catch (JsonException)
{
this.history = [];
}
catch (IOException)
{
this.history = [];
}
}
else
{
this.history = [];
}
}

/// <inheritdoc/>
public IReadOnlyList<string> GetHistory(string key)
=> this.history.TryGetValue(key, out var list) ? list.AsReadOnly() : [];

/// <inheritdoc/>
public void AddHistory(string key, string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}

if (!this.history.TryGetValue(key, out var list))
{
list = [];
this.history[key] = list;
}

list.Remove(value);
list.Insert(0, value);

if (list.Count > MaxHistoryCount)
{
list.RemoveRange(MaxHistoryCount, list.Count - MaxHistoryCount);
}
}

/// <inheritdoc/>
public void Save()
{
Directory.CreateDirectory(PathUtility.UserDir);
using var fs = File.Create(historyPath);
JsonSerializer.Serialize(fs, this.history);
}
}