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
31 changes: 31 additions & 0 deletions Plugins/WindowTranslator.Plugin.Sarashina3Plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# WindowTranslator Sarashina3 Translator Plugin

## ja

[WindowTranslator](https://github.com/Freeesia/WindowTranslator)で、Sarashina3のTranslate APIを利用する翻訳プラグインです。

- `POST https://api.sarashina.ai/v1/translate` を使った翻訳
- 翻訳元・翻訳先が日本語と英語の場合は専用のTranslate APIを使用
- それ以外の言語の組み合わせでは、Chat Completions API (`sarashina3-mini`) にフォールバック
- Sarashina APIキーをBearer認証で送信
- 翻訳にはインターネット接続が必要です。翻訳対象のテキストはSarashina APIに送信されます。
- レート制限(Translate APIとChat Completions APIの`sarashina3-mini`は1分あたり6リクエスト)に達した場合は、`Retry-After`に従って待機してから再試行します。そのため、テキストが多いと翻訳に時間がかかります。
- Sarashinaサービス自体が無償ベータ版として提供されています。提供内容や提供条件、レート制限は変更または終了される可能性があります。
- 無償プランでは、送信したテキストがモデルの学習などに利用される場合があります。個人情報や機密情報を含むウィンドウでは使用しないでください。利用には[Sarashinaサービス利用規約](https://sarashina.ai/terms)への同意が必要です。個人情報の取り扱いは[SB Intuitionsの個人情報保護方針](https://www.sbintuitions.co.jp/privacy-policy/)を参照してください。

Sarashina APIの利用登録とAPIキーの取得については、[Sarashina](https://sarashina.ai/)を確認してください。API仕様は[公式Translate APIリファレンス](https://docs.sarashina.ai/api-reference/translate)を参照してください。

## en

A [WindowTranslator](https://github.com/Freeesia/WindowTranslator) translation plugin that uses the Sarashina3 Translate API.

- Uses `POST https://api.sarashina.ai/v1/translate`
- Uses the dedicated Translate API when both languages are Japanese/English
- Falls back to the Chat Completions API (`sarashina3-mini`) for any other language pair
- Sends the Sarashina API key as a bearer token in the Authorization header
- An internet connection is required. Text to translate is sent to the Sarashina API.
- When the rate limit is reached (6 requests per minute for the Translate API and for `sarashina3-mini` Chat Completions), the plugin waits for the `Retry-After` period and retries. Translating many texts can therefore be slow.
- The Sarashina service itself is currently offered as a free beta. Features, terms, and rate limits may change or the service may end.
- On the free plan, the text you send may be used for model training and similar purposes. Do not use it with windows that contain personal or confidential information. Use of the service requires agreeing to the [Sarashina Terms of Service](https://sarashina.ai/terms). For how personal data is handled, see the [SB Intuitions Privacy Policy](https://www.sbintuitions.co.jp/privacy-policy/).

See [Sarashina](https://sarashina.ai/) to register and obtain an API key. Refer to the [official Translate API reference](https://docs.sarashina.ai/api-reference/translate) for API details.
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
using WindowTranslator.Modules;
using DisplayNameAttribute = System.ComponentModel.DisplayNameAttribute;

namespace WindowTranslator.Plugin.Sarashina3Plugin;

[DisplayName("Sarashina3")]
public sealed class Sarashina3Options : IPluginParam
{
[DataType(DataType.Password)]
public string? ApiKey { get; set; }
}

public sealed class Sarashina3Validator : ITargetSettingsValidator
{
public ValueTask<ValidateResult> Validate(TargetSettings settings)
{
if (settings.SelectedPlugins.GetValueOrDefault(nameof(ITranslateModule)) != nameof(Sarashina3Translator))
{
return ValueTask.FromResult(ValidateResult.Valid);
}

var options = settings.PluginParams.GetValueOrDefault(nameof(Sarashina3Options)) as Sarashina3Options;
if (string.IsNullOrWhiteSpace(options?.ApiKey))
{
return ValueTask.FromResult(ValidateResult.Invalid("Sarashina3", "Sarashina APIキーを設定してください。"));
}

return ValueTask.FromResult(ValidateResult.Valid);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using Microsoft.Extensions.Options;
using OpenAI;
using OpenAI.Chat;
using WindowTranslator.Modules;

namespace WindowTranslator.Plugin.Sarashina3Plugin;

public sealed class Sarashina3Translator : ITranslateModule
{
internal const string Endpoint = "https://api.sarashina.ai/v1/translate";
internal const int MaxTextLength = 5000;
private const int MaxParseAttempts = 3;
private const int MaxRateLimitAttempts = 5;
private static readonly JsonSerializerOptions jsonOptions = new(JsonSerializerDefaults.Web)
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
AllowTrailingCommas = true,
};
private static readonly ChatCompletionOptions structuredOutputOptions = new()
{
ResponseFormat = ChatResponseFormat.CreateJsonSchemaFormat(
"translated_array",
BinaryData.FromString("""
{
"type": "object",
"properties": {
"translated": {
"type": "array",
"items": { "type": "string" }
}
},
"required": ["translated"],
"additionalProperties": false
}
"""),
"翻訳後のテキストの配列",
true),
};
internal const string ChatModel = "sarashina3-mini";
internal const string ChatEndpoint = "https://api.sarashina.ai/v1";
// 429の待機時間を含めるため、既定の100秒より長くする
private static readonly TimeSpan requestTimeout = TimeSpan.FromMinutes(10);
private static readonly HttpClient httpClient = new() { Timeout = requestTimeout };

private readonly string apiKey;
private readonly string sourceLanguage;
private readonly string targetLanguage;
private readonly bool useTranslateApi;
private readonly HttpClient client;
private readonly ChatClient chatClient;
private readonly string system;

internal Func<TimeSpan, Task> Delay { get; set; } = Task.Delay;

public Sarashina3Translator(IOptionsSnapshot<Sarashina3Options> options, IOptionsSnapshot<LanguageOptions> languages)
: this(options.Value, languages.Value, httpClient)
{
}

internal Sarashina3Translator(Sarashina3Options options, LanguageOptions languages, HttpClient client)
{
if (string.IsNullOrWhiteSpace(options.ApiKey))
{
throw new AppUserException("Sarashina APIキーを設定してください。");
}

this.sourceLanguage = CultureInfo.GetCultureInfo(languages.Source).TwoLetterISOLanguageName;
this.targetLanguage = CultureInfo.GetCultureInfo(languages.Target).TwoLetterISOLanguageName;
this.useTranslateApi = IsTranslateApiLanguage(this.sourceLanguage) && IsTranslateApiLanguage(this.targetLanguage);
this.apiKey = options.ApiKey.Trim();
this.client = client;
this.chatClient = new ChatClient(ChatModel, new ApiKeyCredential(this.apiKey), new OpenAIClientOptions
{
Endpoint = new Uri(ChatEndpoint),
Transport = new HttpClientPipelineTransport(client),
NetworkTimeout = requestTimeout,
});
var source = CultureInfo.GetCultureInfo(languages.Source).DisplayName;
var target = CultureInfo.GetCultureInfo(languages.Target).DisplayName;
this.system = $$"""
あなたは{{source}}から{{target}}への翻訳の専門家です。
入力の各 text を、対となる context の文脈を考慮して{{target}}に翻訳してください。
入力 JSON は [{"text":"翻訳対象", "context":"文脈"}] の形式です。
翻訳できない文字列は元の表記を維持してください。
出力は必ず {"translated":["翻訳結果"]} 形式の JSON オブジェクトのみとし、説明や Markdown は付けないでください。
translated は入力と同じ個数・順序の文字列配列にしてください。
""";
}

// 専用のTranslate APIは日本語と英語の相互翻訳のみ対応している
private static bool IsTranslateApiLanguage(string languageCode)
=> languageCode is "ja" or "en";

public async ValueTask<string[]> TranslateAsync(TextInfo[] srcTexts)
{
if (this.sourceLanguage == this.targetLanguage)
{
return [.. srcTexts.Select(t => t.SourceText)];
}

if (!this.useTranslateApi)
{
return srcTexts.Length == 0 ? [] : await this.TranslateWithChatAsync(srcTexts).ConfigureAwait(false);
}

var translated = new string[srcTexts.Length];
for (var i = 0; i < srcTexts.Length; i++)
{
var builder = new StringBuilder();
foreach (var chunk in SplitText(srcTexts[i].SourceText))
{
builder.Append(await this.TranslateChunkAsync(chunk).ConfigureAwait(false));
}

translated[i] = builder.ToString();
}

return translated;
}

private async ValueTask<string[]> TranslateWithChatAsync(TextInfo[] srcTexts)
{
var input = JsonSerializer.Serialize(
srcTexts.Select(t => new { text = t.SourceText, context = t.Context }),
jsonOptions);
ChatMessage[] messages = [new SystemChatMessage(this.system), new UserChatMessage(input)];
var parseFailures = 0;
var rateLimits = 0;
while (true)
{
ChatCompletion completion;
try
{
completion = await this.chatClient.CompleteChatAsync(messages, structuredOutputOptions).ConfigureAwait(false);
}
catch (ClientResultException e) when (e.Status == 429 && ++rateLimits < MaxRateLimitAttempts)
{
double? seconds = e.GetRawResponse()?.Headers.TryGetValue("Retry-After", out var value) == true
&& double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)
? parsed
: null;
await this.Delay(GetRetryDelay(seconds)).ConfigureAwait(false);
continue;
}

try
{
return ParseTranslation(string.Concat(completion.Content.Select(c => c.Text)), srcTexts.Length);
}
catch (JsonException) when (++parseFailures < MaxParseAttempts)
{
}
}
}

// レート制限はカウント期間(60秒)ごとにリセットされるため、Retry-Afterの秒数だけ待つ
private static TimeSpan GetRetryDelay(double? retryAfterSeconds)
=> TimeSpan.FromSeconds(Math.Clamp(retryAfterSeconds ?? 60, 1, 120));

// 構造化出力でもスキーマ準拠は保証されないため、件数も検証する
internal static string[] ParseTranslation(string text, int count)
{
text = text.Trim();
if (text.StartsWith("```", StringComparison.Ordinal) && text.EndsWith("```", StringComparison.Ordinal))
{
var newline = text.IndexOf('\n');
if (newline >= 0)
{
text = text[(newline + 1)..^3].Trim();
}
}

var result = JsonSerializer.Deserialize<ChatResponse>(text, jsonOptions)?.Translated;
if (result is null || result.Length != count || result.Any(t => t is null))
{
throw new JsonException("Translation must contain one string per input.");
}

return result;
}

private async ValueTask<string> TranslateChunkAsync(string text)
{
HttpResponseMessage response;
for (var attempt = 1; ; attempt++)
{
using var request = new HttpRequestMessage(HttpMethod.Post, Endpoint);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", this.apiKey);
request.Content = JsonContent.Create(new TranslateRequest(text, this.sourceLanguage, this.targetLanguage));

response = await this.client.SendAsync(request).ConfigureAwait(false);
if (response.StatusCode != HttpStatusCode.TooManyRequests || attempt >= MaxRateLimitAttempts)
{
break;
}

var wait = GetRetryDelay(response.Headers.RetryAfter?.Delta?.TotalSeconds);
response.Dispose();
await this.Delay(wait).ConfigureAwait(false);
}

using var _ = response;
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException(
$"Sarashina Translate API returned HTTP {(int)response.StatusCode}.",
null,
response.StatusCode);
}

await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
if (!document.RootElement.TryGetProperty("translated_text", out var translatedText)
|| translatedText.ValueKind != JsonValueKind.String)
{
throw new InvalidDataException("Sarashina Translate API response did not contain translated_text.");
}

return translatedText.GetString()!;
}

// 上限はUnicodeコードポイント単位のため、サロゲートペアを分断しないよう分割する
internal static IEnumerable<string> SplitText(string text)
{
var start = 0;
while (start < text.Length)
{
var end = start;
var count = 0;
while (end < text.Length && count < MaxTextLength)
{
end += char.IsHighSurrogate(text[end]) && end + 1 < text.Length && char.IsLowSurrogate(text[end + 1]) ? 2 : 1;
count++;
}

yield return text[start..end];
start = end;
}
}

private sealed record ChatResponse(string[] Translated);
private sealed record TranslateRequest(string text, string source_lang, string target_lang);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Title>Sarashina3 Translator Plugin</Title>
<Description>Translation for WindowTranslator using the Sarashina3 translate API.</Description>
<IsPackable>true</IsPackable>
<PackageTags>$(PackageTags);translate</PackageTags>
<IsPublishable>false</IsPublishable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OpenAI" />
<InternalsVisibleTo Include="WindowTranslator.Tests" />
</ItemGroup>
</Project>
Loading