1+ using System . Globalization ;
2+ using System . Text ;
3+ using System . Text . Json ;
4+ using CsvHelper ;
5+ using CsvHelper . Configuration ;
6+ using GitHub . Copilot . SDK ;
7+ using Microsoft . Extensions . Options ;
8+ using ValueTaskSupplement ;
9+ using WindowTranslator . Modules ;
10+
11+ namespace WindowTranslator . Plugin . GitHubCopilotPlugin ;
12+
13+ public class GitHubCopilotTranslator : ITranslateModule , IAsyncDisposable
14+ {
15+ private static readonly JsonSerializerOptions jsonOptions = new ( JsonSerializerDefaults . Web )
16+ {
17+ AllowTrailingCommas = true ,
18+ } ;
19+
20+ private readonly string preSystem ;
21+ private readonly string ? userContext ;
22+ private readonly string postSystem ;
23+ private readonly string model ;
24+ private readonly IDictionary < string , string > glossary = new Dictionary < string , string > ( ) ;
25+ private readonly CopilotClient client ;
26+ private readonly AsyncLazy < CopilotSession > session ;
27+ private IReadOnlyList < string > common = [ ] ;
28+ private string ? context ;
29+ private volatile bool sessionStarted ;
30+
31+ public string Name => $ "{ nameof ( GitHubCopilotTranslator ) } : { this . model } ";
32+
33+ public GitHubCopilotTranslator ( IOptionsSnapshot < LanguageOptions > langOptions , IOptionsSnapshot < GitHubCopilotOptions > options )
34+ {
35+ var srcLang = CultureInfo . GetCultureInfo ( langOptions . Value . Source ) . DisplayName ;
36+ var targetLang = CultureInfo . GetCultureInfo ( langOptions . Value . Target ) . DisplayName ;
37+ this . model = options . Value . Model ;
38+ this . userContext = options . Value . TranslateContext ;
39+
40+ this . preSystem = $$ """
41+ あなたは{{ srcLang }} から{{ targetLang }} へ翻訳するの専門家です。
42+ 入力テキストは{{ srcLang }} のテキストであり、翻訳が必要です。
43+ 渡されたテキストを{{ targetLang }} へ翻訳して出力してください。
44+ """ ;
45+ this . postSystem = """
46+ 入力テキストは以下のJsonフォーマットになっています。
47+ 各textの内容はペアとなるcontextの文脈を考慮して翻訳してください。
48+ contextに一人称が指定されている場合は、漢字、ひらがな、カタカナの表記を変更せずに一人称をそのまま使ってください。
49+ 翻訳対象のテキストが判別できない場合は、翻訳を行わずにそのままの表記を利用してください。
50+ <入力テキストのJsonフォーマット>
51+ [{"text":"翻訳対象のテキスト1", "context": "翻訳対象のテキスト1の文脈"}, {"text":"翻訳対象のテキスト2", "context": "翻訳対象のテキスト2の文脈"}]
52+ </入力テキストのJsonフォーマット>
53+
54+ 出力は以下の文字列型の配列を持ったJsonフォーマットです。
55+ 入力されたテキストの順序を維持して翻訳したテキストを出力してください。
56+ <出力テキストのJsonフォーマット>
57+ {"translated": ["翻訳したテキスト1", "翻訳したテキスト2"]}
58+ </出力テキストのJsonフォーマット>
59+ """ ;
60+
61+ this . client = new CopilotClient ( new ( ) { CliPath = Utility . GetBundledCliPath ( ) } ) ;
62+
63+ this . session = new ( this . CreateSessionAsync ) ;
64+
65+ if ( File . Exists ( options . Value . GlossaryPath ) )
66+ {
67+ using var reader = new StreamReader ( options . Value . GlossaryPath ) ;
68+ using var csv = new CsvReader ( reader , new CsvConfiguration ( CultureInfo . InvariantCulture ) { HasHeaderRecord = false } ) ;
69+ foreach ( var ( src , dst ) in csv . GetRecords < Glossary > ( ) )
70+ {
71+ this . glossary [ src ] = dst ;
72+ }
73+ }
74+ }
75+
76+ private record Glossary ( string Source , string Target ) ;
77+
78+ private record Response ( string [ ] Translated ) ;
79+
80+ private async ValueTask < CopilotSession > CreateSessionAsync ( )
81+ {
82+ var system = string . Join ( Environment . NewLine , [ this . preSystem , this . context , this . userContext , this . postSystem ] ) ;
83+ var s = await this . client . CreateSessionAsync ( new ( )
84+ {
85+ Model = this . model ,
86+ SystemMessage = new ( )
87+ {
88+ Mode = SystemMessageMode . Replace ,
89+ Content = system ,
90+ } ,
91+ OnPermissionRequest = static ( _ , _ ) => Task . FromResult ( new PermissionRequestResult ( ) { Kind = PermissionRequestResultKind . NoResult } ) ,
92+ ReasoningEffort = "low" ,
93+ } ) . ConfigureAwait ( false ) ;
94+ this . sessionStarted = true ;
95+ return s ;
96+ }
97+
98+ public async ValueTask < string [ ] > TranslateAsync ( TextInfo [ ] srcTexts )
99+ {
100+ var glossary = this . glossary . Where ( kv => srcTexts . Any ( s => s . SourceText . Contains ( kv . Key ) ) ) . ToArray ( ) ;
101+ var common = this . common . Where ( c => srcTexts . Any ( s => s . SourceText . Contains ( c ) ) ) . ToArray ( ) ;
102+ var sb = new StringBuilder ( ) ;
103+ if ( glossary . Length > 0 )
104+ {
105+ sb . AppendLine ( $ """
106+ 翻訳する際に以下の用語集を参照して、一貫した翻訳を行ってください。
107+ <用語集>
108+ { string . Join ( Environment . NewLine , glossary . Select ( kv => $ "<用語>{ kv . Key } </用語><翻訳>{ kv . Value } </翻訳>") ) }
109+ </用語集>
110+
111+ """ ) ;
112+ }
113+ if ( common . Length > 0 )
114+ {
115+ sb . AppendLine ( $ """
116+ 翻訳するテキストに以下の共通の用語が含まれている場合は、その用語のみは必ず翻訳せずにそのままの表記を利用してください。
117+ <共通の用語>
118+ { string . Join ( Environment . NewLine , common ) }
119+ </共通の用語>
120+
121+ """ ) ;
122+ }
123+
124+ var jsonData = JsonSerializer . Serialize ( srcTexts . Select ( s => new { text = s . SourceText , context = s . Context } ) . ToArray ( ) , jsonOptions ) ;
125+ var content = sb . Length > 0 ? sb . Append ( jsonData ) . ToString ( ) : jsonData ;
126+
127+ var session = await this . session . AsValueTask ( ) . ConfigureAwait ( false ) ;
128+ var response = await session . SendAndWaitAsync ( content ) . ConfigureAwait ( false ) ;
129+ var json = response ? . Data ? . Content ? . Trim ( ) ?? string . Empty ;
130+ var res = JsonSerializer . Deserialize < Response > ( json , jsonOptions ) ;
131+ return res ? . Translated ?? [ ] ;
132+ }
133+
134+ public ValueTask RegisterGlossaryAsync ( IReadOnlyDictionary < string , string > glossary )
135+ {
136+ this . common = glossary . Where ( kv => kv . Key == kv . Value ) . Select ( kv => kv . Key . ReplaceLineEndings ( string . Empty ) ) . ToArray ( ) ;
137+ foreach ( var ( key , value ) in glossary . Where ( kv => kv . Key != kv . Value ) )
138+ {
139+ this . glossary . TryAdd ( key . ReplaceLineEndings ( string . Empty ) , value . ReplaceLineEndings ( string . Empty ) ) ;
140+ }
141+ return default ;
142+ }
143+
144+ public void RegisterContext ( string context )
145+ => this . context = $ """
146+ 翻訳するテキストは全体を通して、以下の背景や文脈があるものして翻訳してください。
147+ <背景>
148+ { context }
149+ </背景>
150+
151+ """ ;
152+
153+ public async ValueTask DisposeAsync ( )
154+ {
155+ if ( this . sessionStarted )
156+ {
157+ await ( await this . session . AsValueTask ( ) . ConfigureAwait ( false ) ) . DisposeAsync ( ) . ConfigureAwait ( false ) ;
158+ }
159+ await this . client . DisposeAsync ( ) . ConfigureAwait ( false ) ;
160+ GC . SuppressFinalize ( this ) ;
161+ }
162+ }
163+
164+ file static class CopilotClientExtensions
165+ {
166+ public static async Task < AssistantMessageEvent ? > SendAndWaitAsync ( this CopilotSession session , string prompt )
167+ {
168+ var effectiveTimeout = TimeSpan . FromSeconds ( 60 ) ;
169+ var tcs = new TaskCompletionSource < AssistantMessageEvent ? > ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
170+ AssistantMessageEvent ? lastAssistantMessage = null ;
171+
172+ using var subscription = session . On ( evt =>
173+ {
174+ switch ( evt )
175+ {
176+ case AssistantMessageEvent assistantMessage :
177+ lastAssistantMessage = assistantMessage ;
178+ break ;
179+
180+ case SessionIdleEvent :
181+ tcs . TrySetResult ( lastAssistantMessage ) ;
182+ break ;
183+
184+ case SessionErrorEvent { Data : var err } :
185+ tcs . TrySetException ( new AppUserException ( $ """
186+ ## { err . ErrorType } : { err . StatusCode }
187+
188+ { err . Message }
189+ ({ err . Url } )
190+
191+ ```
192+ { err . Stack }
193+ ```
194+ """ ) ) ;
195+ break ;
196+ }
197+ } ) ;
198+
199+ await session . SendAsync ( new ( ) { Prompt = prompt } ) ;
200+
201+ using var cts = new CancellationTokenSource ( ) ;
202+ cts . CancelAfter ( effectiveTimeout ) ;
203+
204+ using var registration = cts . Token . Register ( ( ) => tcs . TrySetException ( new TimeoutException ( $ "SendAndWaitAsync timed out after { effectiveTimeout } ") ) ) ;
205+ return await tcs . Task ;
206+ }
207+ }
0 commit comments