Skip to content

Commit ed401eb

Browse files
committed
🚧 尝试实现增量翻译,目前输出的patch效果不佳
1 parent 1368a98 commit ed401eb

File tree

3 files changed

+197
-19
lines changed

3 files changed

+197
-19
lines changed

docs/developer/dwebapp/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ plaoc 遵循 web 规范,提供一系列的 Api/WebComponent,来满足应用
1515
基于 DwebBrowser 已经实现了 Android、IOS、MacOS、Windows、Linux 这些主流平台的支持。
1616
那么也就意味着您的 Web 应用,可以背靠 plaoc 直接实现多端发布。
1717

18-
## 开发 Dweb app 流程
18+
## 开发 Dwebapp 流程
1919

2020
您首选需要在您的应用根目录下创建 `manifest.json` 文件,您可以认为`manifest.json`等同于 `PWA``manifest.json`
2121
它主要声明了一些应用的参数和在用户安装的时候做一些展示。

docs/intro.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ dweb-browser 是一个遵循 Dweb 标准构建起来的浏览器平台,并将
2424

2525
## 什么是 dweb?
2626

27-
Dweb 是一种去中心化的 Web 共识标准。它直接体现在您的域名上,如: `example.dweb`
27+
Dweb 是一种去中心化的 Web 共识标准。
28+
29+
它直接体现在您的域名上,如: `example.dweb`
2830
然而正是因为 dweb 这个跟域名并不在互联网上真实存在,也就意味着它不归属于任何组织,也就是说,如何解释`example.dweb`这个域名,完全由您自己(的设备)执行决策。
2931
dweb 共识标准由几个部分联合组成:
3032

scripts/i18n.ts

Lines changed: 193 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import OpenAI from "openai";
2-
import { str_trim_indent } from "@gaubee/util";
2+
import { func_remember, str_trim_indent } from "@gaubee/util";
33
import { z } from "zod";
44
import { parseArgs } from "@std/cli/parse-args";
55
import { import_meta_ponyfill } from "import-meta-ponyfill";
66
import fs from "node:fs";
77
import path from "node:path";
88
import { config } from "dotenv";
9+
import { execSync } from "node:child_process";
910

1011
const 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
/**定义一个源文件 */
1618
const 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的内容 */
2633
const 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
};
5965
async 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 (/unknown revision/.test(error.message)) {
362+
throw new Error(`Invalid commit hash: ${commit}`);
363+
}
364+
if (/bad revision/.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+
207376
if (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

Comments
 (0)