11import OpenAI from "openai" ;
2- import { str_trim_indent } from "@gaubee/util" ;
2+ import { func_remember , str_trim_indent } from "@gaubee/util" ;
33import { z } from "zod" ;
44import { parseArgs } from "@std/cli/parse-args" ;
55import { import_meta_ponyfill } from "import-meta-ponyfill" ;
66import fs from "node:fs" ;
77import path from "node:path" ;
88import { config } from "dotenv" ;
9+ import { execSync } from "node:child_process" ;
910
1011const zTranslateOptions = z . object ( {
1112 file : z . string ( ) . array ( ) . nonempty ( ) ,
1213 language : z . string ( ) . array ( ) . nonempty ( ) ,
1314 mode : z . enum ( [ "full" , "increment" ] ) ,
15+ commit : z . string ( ) . optional ( ) ,
1416} ) ;
1517/**定义一个源文件 */
1618const zFile = z . object ( {
@@ -22,6 +24,11 @@ const zFileWithContent = zFile.merge(
2224 content : z . string ( ) ,
2325 } )
2426) ;
27+ const zFileWithContentAndDiff = zFileWithContent . merge (
28+ z . object ( {
29+ diff : z . string ( ) ,
30+ } )
31+ ) ;
2532/** 差异信息,一种基于git-diff的内容 */
2633const zGitDiff = z . string ( ) ;
2734/** 补丁信息,一种基于git-patch的内容 */
@@ -47,13 +54,12 @@ const zModes = {
4754 input : z . object ( {
4855 mode : z . enum ( [ "increment" ] ) ,
4956 /** 输入的文件内容 */
50- files : zFileWithContent . array ( ) ,
51- diffs : zGitDiff ,
57+ changes : zFileWithContentAndDiff . array ( ) ,
5258 /** 输出的文件路径 */
53- outputs : zFile . array ( ) ,
59+ files : zFileWithContent . array ( ) ,
5460 } ) ,
55- /** 返回参数:翻译好的文件内容 */
56- output : z . object ( { patch : zGitPatch } ) ,
61+ /** 返回参数:翻译内容的更新补丁 */
62+ output : z . object ( { files : zFileWithContent . array ( ) } ) ,
5763 } ,
5864} ;
5965async function translate ( args : z . TypeOf < typeof zTranslateOptions > ) {
@@ -91,15 +97,68 @@ async function translate(args: z.TypeOf<typeof zTranslateOptions>) {
9197 files : files ,
9298 outputs : outputs ,
9399 } ;
100+ /// 发送给AI,获得输出
94101 const output = zModes . full . output . parse (
95102 await getOpenaiOutput ( JSON . stringify ( input ) )
96103 ) ;
104+ /// 处理输出
97105 output . outputs . forEach ( ( file ) => {
98106 fs . mkdirSync ( path . dirname ( file . filepath ) , { recursive : true } ) ;
99107 fs . writeFileSync ( file . filepath , file . content ) ;
100108 } ) ;
101109 } else {
102- // TODO
110+ type InputType = z . TypeOf < typeof zModes . increment . input > ;
111+ const files : InputType [ "files" ] = [ ] ;
112+ const changes : InputType [ "changes" ] = [ ] ;
113+ if ( args . file . length !== args . language . length ) {
114+ throw new Error ( "file and language must be same length" ) ;
115+ }
116+ for ( let [ index , filepath ] of args . file . entries ( ) ) {
117+ filepath = path . resolve ( cwd , filepath ) ;
118+ let isFiles = true ;
119+ let content = "" ;
120+ if ( fs . existsSync ( filepath ) ) {
121+ content = fs . readFileSync ( filepath , "utf-8" ) ;
122+ if ( content . trim ( ) !== "" ) {
123+ const diff = await generateFilePatch ( {
124+ filepath,
125+ commit : args . commit ,
126+ } ) ;
127+ if ( diff !== "" ) {
128+ isFiles = false ;
129+ changes . push ( {
130+ filepath : filepath ,
131+ language : args . language [ index ] ,
132+ diff : diff ,
133+ content : content ,
134+ } ) ;
135+ }
136+ }
137+ }
138+ if ( isFiles ) {
139+ files . push ( {
140+ filepath : filepath ,
141+ language : args . language [ index ] ,
142+ content : content ,
143+ } ) ;
144+ }
145+ }
146+ const input : InputType = {
147+ mode : args . mode ,
148+ changes : changes ,
149+ files : files ,
150+ } ;
151+ // console.log("input", input);
152+
153+ /// 发送给AI,获得输出
154+ const output = zModes . increment . output . parse (
155+ await getOpenaiOutput ( JSON . stringify ( input ) )
156+ ) ;
157+ /// 处理输出
158+ output . files . forEach ( ( file ) => {
159+ fs . mkdirSync ( path . dirname ( file . filepath ) , { recursive : true } ) ;
160+ fs . writeFileSync ( file . filepath , file . content ) ;
161+ } ) ;
103162 }
104163}
105164
@@ -113,7 +172,7 @@ const getOpenaiOutput = async (user_content: string) => {
113172 . parse ( process . env . OPENAI_API_KEY ) ,
114173 } ) ;
115174
116- const completion = await openai . chat . completions . create ( {
175+ const stream = await openai . chat . completions . create ( {
117176 messages : [
118177 {
119178 role : "system" ,
@@ -128,9 +187,7 @@ const getOpenaiOutput = async (user_content: string) => {
128187 {
129188 role : "system" ,
130189 content : str_trim_indent ( `
131- # Translation Protocol v1.2
132-
133- ## 输入输出规范
190+ ## 全量翻译的输入输出规范
134191 输入格式(JSON Schema):
135192 \`\`\`json
136193 {
@@ -152,13 +209,46 @@ const getOpenaiOutput = async (user_content: string) => {
152209 {
153210 "outputs": [
154211 {
155- "filepath": "docs/en/guide.md ",
156- "language": "en ",
212+ "filepath": "<目标路径> ",
213+ "language": "<目标语言> ",
157214 "content": "<翻译后的完整内容>"
158215 }
159216 ]
160217 }
161218 \`\`\`
219+
220+ ## 增量翻译的输入输出规范
221+ 输入格式(JSON Schema):
222+ \`\`\`json
223+ {
224+ "mode": "full",
225+ // 这些是有过变更的文件内容
226+ "changes": [{
227+ "filepath": "<输入路径>",
228+ "language": "<输入语言>",
229+ "content": "<输入文件内容>",
230+ "diff": "<输入文件的变更信息>"
231+ }],
232+ // 这些是没有变更的文件内容
233+ "files": [{
234+ "filepath": "<目标路径>",
235+ "language": "<目标语言>",
236+ "content": "<目标文件内容(可能是空的;也有可能是上一次翻译的内容;也可能是翻译到中途断掉的)>"
237+ }]
238+ }
239+ \`\`\`
240+ 输出格式(JSON Schema):
241+ \`\`\`json
242+ {
243+ "files": [
244+ {
245+ "filepath": "<目标路径> or <目标路径.patch>",
246+ "language": "en",
247+ "content": "<翻译后的完整内容> or <git apply 格式的内容>"
248+ }
249+ ]
250+ }
251+ \`\`\`
162252
163253 ## 翻译规则
164254 1. 结构保留:
@@ -182,7 +272,23 @@ const getOpenaiOutput = async (user_content: string) => {
182272 4. 混合内容处理:
183273 [中文](原文) → [英文](翻译) // 保留注释
184274 "key": "值" // 保留行尾注释
185-
275+
276+ 5. 对于增量翻译模式,需要理解参数和返回值的含义:
277+ - 首先是changes参数,它是指某一个文件的变更记录,使用\`git diff\`获得的差异信息
278+ - 然后是files参数,是指其它没有变更的文件。
279+ - 最后关于返回值files,会有两种格式:
280+ - 第一种是翻译完成的目标文件,包含了全量的内容;
281+ - 第二种是 patch 文件后缀,它是可以通过\`git apply\`作用到目标文件的补丁文件,因为如果变更不多,只是几行,那么返回第一种全量内容就没太大意义,所以使用patch格式,可以获得更快的输出结果;
282+ -
283+ - 关于changes参数的变更内容,主要关注这两种可能:
284+ - 第一种是只修改了一种语言,这种情况下,输出的files和输入的files是一一对应的。
285+ - 第二种是同时修改了多种语言,这就意味着,变更者是知道多语言的,只不过根据顺序,排在前面的文件,作为源文件的优先级更高。这时候要注意的是,有可能changes[0]改动了第二行,然后changes[1]改动了第10行,那么最终输出的files中,也应该包含这两个changes文件,因为需要将changes[0]改动的第3行翻译给changes[1],同时也要讲changes[1]改动的第10行翻译给changes[0]。
286+ -
287+ - 关于files参数的内容,要关注这有三种可能:
288+ - 第一种是,它是一个空文件,那么此时要参考changes[0]的完整内容,做完整的翻译
289+ - 第二种是,它是一个翻译到一半就断开的内容,可能是人为的翻译不完整,也有可能是上一次翻译输出不完整,不论如何,仍然是优先参考changes[0]的完整内容,然后参照现有的内容,对剩余没有翻译的部分做补全,通常会返回补丁,但是如果补丁的内容长度已经接近甚至超过翻译后的原文,这时候直接返回原文即可。
290+ - 第三种是,它是一个完整的翻译后的内容,这时候主要参考changes[0]的diff内容,来返回补丁内容。
291+
186292 ## 质量要求
187293 • 术语一致性:保持相同上下文术语统一
188294 • 格式对齐:译文长度与原文段落结构匹配
@@ -199,14 +305,77 @@ const getOpenaiOutput = async (user_content: string) => {
199305 response_format : {
200306 type : "json_object" ,
201307 } ,
308+ stream : true ,
202309 } ) ;
203- console . log ( completion . choices [ 0 ] . message . content ) ;
204- return JSON . parse ( completion . choices [ 0 ] . message . content ! ) ;
310+ let res = "" ;
311+ for await ( const chunk of stream ) {
312+ const part = chunk . choices [ 0 ] . delta . content ?? "" ;
313+ res += part ;
314+ process . stdout . write ( part ) ;
315+ }
316+ return JSON . parse ( res ) ;
205317} ;
206318
319+ interface GeneratePatchOptions {
320+ /** 目标文件路径(绝对或相对路径) */
321+ filepath : string ;
322+ /** 目标提交 hash 或引用(默认 HEAD) */
323+ commit ?: string ;
324+ /** git 仓库目录 */
325+ gitRoot ?: string ;
326+ }
327+
328+ const gitRootDir = func_remember ( ( ) =>
329+ execSync ( "git rev-parse --show-toplevel" , { encoding : "utf-8" } ) . trim ( )
330+ ) ;
331+ export async function generateFilePatch (
332+ options : GeneratePatchOptions
333+ ) : Promise < string > {
334+ const { filepath, commit = "HEAD" } = options ;
335+
336+ // 获取 Git 仓库根目录
337+ const rootDir = options . gitRoot ?? gitRootDir ( ) ; // execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim()
338+
339+ // 解析绝对路径并验证文件存在
340+ const absolutePath = path . resolve ( process . cwd ( ) , filepath ) ;
341+ if ( ! fs . existsSync ( absolutePath ) ) {
342+ throw new Error ( `File not found: ${ absolutePath } ` ) ;
343+ }
344+
345+ // 转换为仓库相对路径
346+ const relativePath = path . relative ( rootDir , absolutePath ) ;
347+
348+ try {
349+ // 生成补丁命令
350+ const cmd = `git diff ${ commit } -- ${ JSON . stringify ( relativePath ) } ` ;
351+
352+ // 执行并返回补丁内容
353+ return execSync ( cmd , {
354+ cwd : rootDir ,
355+ encoding : "utf-8" ,
356+ stdio : [ "ignore" , "pipe" , "ignore" ] , // 忽略 stderr
357+ } ) . trim ( ) ;
358+ } catch ( error ) {
359+ // 处理常见错误
360+ if ( error instanceof Error ) {
361+ if ( / u n k n o w n r e v i s i o n / . test ( error . message ) ) {
362+ throw new Error ( `Invalid commit hash: ${ commit } ` ) ;
363+ }
364+ if ( / b a d r e v i s i o n / . test ( error . message ) ) {
365+ throw new Error ( `Cannot find parent commit for: ${ commit } ` ) ;
366+ }
367+ }
368+ throw new Error (
369+ `Failed to generate patch: ${
370+ error instanceof Error ? error . message : error
371+ } `
372+ ) ;
373+ }
374+ }
375+
207376if ( import_meta_ponyfill ( import . meta) . main ) {
208377 const args = parseArgs ( process . argv . slice ( 2 ) , {
209- string : [ "mode" ] ,
378+ string : [ "mode" , "commit" ] ,
210379 collect : [ "file" , "language" , "env-file" ] ,
211380 alias : {
212381 m : "mode" ,
@@ -216,6 +385,7 @@ if (import_meta_ponyfill(import.meta).main) {
216385 default : {
217386 mode : "full" ,
218387 "env-file" : [ ".env.local" ] ,
388+ commit : "HEAD~1" ,
219389 } ,
220390 } ) ;
221391
@@ -225,3 +395,9 @@ if (import_meta_ponyfill(import.meta).main) {
225395
226396 translate ( zTranslateOptions . parse ( args ) ) ;
227397}
398+
399+ /**
400+ * eg:
401+ * `node .\scripts\i18n.ts -f .\docs\intro.md -f .\docs\en\intro.md -l zh-Hans -l en`
402+ * `node .\scripts\i18n.ts --mode increment -f .\docs\intro.md -f .\docs\en\intro.md -l zh-Hans -l en`
403+ */
0 commit comments