feat(editor): add live glossary editing
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
|
||||
本工具服务于中日双语 EPUB 的网页审校:长篇连续阅读、逐段修改中文译文、标记翻译问题、导出反馈与修订 EPUB。翻译质量规则仍由项目根目录的 `AGENTS.md`、`rules/translation_rules.md`、当前系列的 `series-style.md` 与术语表共同约束。
|
||||
|
||||
审校器不负责整卷首译流程,也不直接改正式术语表。用户在网页中留下的编辑、问题备注、长期规则建议,需要由 Codex 后续读取反馈文件后再沉淀到系列风格或候选术语中。
|
||||
审校器不负责整卷首译流程。用户在网页中留下的编辑、问题备注、长期规则建议,需要由 Codex 后续读取反馈文件后再沉淀到系列风格或候选术语中。网页术语表编辑是用户显式操作正式术语表的入口,保存时会直接写入配置路径指向的 JSON 文件。
|
||||
|
||||
## 数据模型与会话目录
|
||||
|
||||
@@ -34,6 +34,7 @@ epub_review_sessions/
|
||||
- `state.json.edits` 保存用户修改与标记,字段缺省时必须能回退到原始译文。
|
||||
- `session.json` 与 `app_state.json` 只保存会话定位信息。
|
||||
- `gpt_config.json` 只属于本地运行环境,不写入 EPUB、不写入导出文件、不通过 API 回传密钥。
|
||||
- `gpt_config.json` 可保存本地提示词设置与术语表路径,但仍不得写入 EPUB 或导出文件。
|
||||
|
||||
## 目录与插图
|
||||
|
||||
@@ -91,8 +92,13 @@ GPT 能力是可选增强,不应影响离线审校。
|
||||
安全与配置:
|
||||
|
||||
- 支持 OpenAI 兼容的 `base_url`、`model`、`api_key`。
|
||||
- 支持单独设置 `translation_prompt`、`format_prompt`、`character_prompt`;缺省时必须回退到内置预设。
|
||||
- API Key 优先读取 `gpt_config.json`,也支持环境变量 `OPENAI_API_KEY`。
|
||||
- `/api/gpt/config` 不得返回 API Key。
|
||||
- `/api/gpt/config` 可以返回提示词与预设,但不得返回密钥。
|
||||
- `/api/glossary` 读取和保存配置路径指向的 `mingcibiao.json`;默认路径是项目根目录下当前 TRPG 系列的正式术语表。
|
||||
- 术语表保存应保持 JSON 对象结构:key 为源语或匹配词,value 为译名,可继续使用 `#` 作为备注分隔。
|
||||
- 重翻时必须实时读取术语表,按目标段落日文、HTML 与当前中文命中相关条目,不得只在提示词里泛泛要求“遵守术语表”。
|
||||
- 未配置 Key 时,重翻接口应返回可读错误,不影响编辑保存。
|
||||
- 重翻请求不得临时覆盖 `base_url` 或 `model` 后继续复用已保存密钥;要更换端点或模型必须先保存配置,避免把密钥发往未确认端点。
|
||||
|
||||
@@ -100,9 +106,10 @@ GPT 能力是可选增强,不应影响离线审校。
|
||||
|
||||
1. 用户点击阅读段落,打开右侧面板。
|
||||
2. 用户可填写额外重翻要求。
|
||||
3. `/api/row/<row_id>/retranslate` 使用当前日文、当前中文、术语提示生成候选。
|
||||
3. `/api/row/<row_id>/retranslate` 使用提示词、目标语句、当前中文、实时命中的术语表条目、同文件前后一段少量上下文、角色口吻提示生成候选。
|
||||
4. 候选只显示在面板里,不自动覆盖。
|
||||
5. 用户点击“应用重翻”后写入编辑框,再点击保存才进入审校记录。
|
||||
6. 候选事件写入 jsonl,便于后续根据精修反馈优化提示词;当前版本不自动改写提示词。
|
||||
|
||||
Prompt 硬规则:
|
||||
|
||||
@@ -111,6 +118,7 @@ Prompt 硬规则:
|
||||
- 不合并、不拆分段落。
|
||||
- 有意义 ruby 必须保留为真实 `<ruby><rb>...</rb><rt>...</rt></ruby>`,不要改成括号。
|
||||
- Falchion / ファルシオン 固定译为“大砍刀”,不要译成“偃月刀”。
|
||||
- 标点统一为简中出版格式:对话引号用「」,省略号用……,破折号用——,问号用?,感叹号用!,问叹连用用?!/!?。
|
||||
|
||||
## 版本规则
|
||||
|
||||
@@ -127,6 +135,7 @@ Prompt 硬规则:
|
||||
- `version.py`
|
||||
- `static/index.html` 顶部版本徽标 fallback
|
||||
- `README.md` 当前版本与版本说明
|
||||
- 必要时同步 `MAINTENANCE.md` 中的维护口径
|
||||
|
||||
## 回归清单
|
||||
|
||||
@@ -145,3 +154,5 @@ Prompt 硬规则:
|
||||
- 阅读段落下方没有编号和“快速编辑/快速标记”
|
||||
- 未配置 API Key 时重翻返回清晰错误
|
||||
- 配置保存后 `/api/gpt/config` 不包含密钥字段
|
||||
- `/api/glossary` 能读取术语表;新增、修改、删除条目后保存,再读取内容一致
|
||||
- 重翻候选事件包含 `glossary_path` 与 `glossary_matches`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 双语 EPUB 审校编辑器
|
||||
|
||||
当前版本:`0.5.0`
|
||||
当前版本:`0.7.0`
|
||||
|
||||
这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
|
||||
`0.5.0` 改进长篇目录与检索:目录章节标题不再用正文首段猜测,章节内插图作为次级条目挂在所属章节下,阅读时可按原 spine 顺序穿插显示;点击目录不会自动收起侧栏,桌面端正文会为常驻侧栏让位;检索新增“当前范围 / 全书全局”切换。
|
||||
|
||||
`0.6.0` 新增 GPT 提示词设置:可在网页中单独配置“翻译内容提示词”“格式与标点提示词”“角色状态与口吻提示词”,并提供默认预设;单段重翻只携带目标语句、当前译文、相关名词表、少量前后文和角色口吻提示,候选输出会进行中文标点规整与术语 ruby 括号形式修正。
|
||||
|
||||
`0.7.0` 新增实时术语表能力:GPT 重翻会从配置的 `mingcibiao.json` 实时读取命中术语并写入提示词;网页中可查看当前术语表路径、搜索术语,新增、修改、删除条目并保存,保存后的术语会立即用于下一次重翻。
|
||||
|
||||
## 独立安装
|
||||
|
||||
这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub_review_editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖:
|
||||
@@ -88,7 +92,8 @@ python .\server.py --host 0.0.0.0 --port 5177 --review-root .\epub_review_sessio
|
||||
- 检索支持“当前范围”和“全书全局”两种范围,适合在单章精查和整本书排查术语时切换。
|
||||
- 在阅读模式中点击段落即可打开右侧精修与重翻面板,直接编辑中文译文、标记问题或调用 GPT 重翻;打开/关闭面板时会尽量保持原来的阅读位置。
|
||||
- 精修模式保留原有逐段编辑界面,用于较细的逐段复审。
|
||||
- 可在网页中配置 OpenAI 兼容的 `base_url`、`model` 与 API Key,对当前段落生成重翻候选;候选不会自动覆盖正文,需要点击“应用重翻”并保存。
|
||||
- 可在网页中配置 OpenAI 兼容的 `base_url`、`model`、API Key 与重翻提示词,对当前段落生成重翻候选;候选不会自动覆盖正文,需要点击“应用重翻”并保存。
|
||||
- 可在“提示词与 API”里配置并读取正式术语表,实时搜索、增删改术语并保存;单段重翻会按当前段落命中术语表条目,而不是只在提示词里泛泛要求遵守术语。
|
||||
- 直接编辑中文译文。
|
||||
- 标记问题段落或选中文本。
|
||||
- 记录问题类型、严重程度、标签、备注和可沉淀的长期规则。
|
||||
@@ -127,12 +132,13 @@ epub_review_sessions\<epub-name>_<hash>\
|
||||
- 只想记录问题时:勾选“标记为翻译问题”,写备注,保存本段。
|
||||
- 想直接修正译文时:编辑中文译文后保存本段。
|
||||
- 想让 Codex 学习你的偏好时:把“可沉淀为长期规则”写具体,例如“Falchion 固定译为大砍刀,不要译成偃月刀”。
|
||||
- 想单段重翻时:点击段落打开右侧面板,先在“API 设置”中保存 GPT 配置,再点“重翻本段”;满意后点“应用重翻”,最后保存本段记录。
|
||||
- 想单段重翻时:点击段落打开右侧面板,先在“提示词与 API”中保存 GPT 配置;提示词分为翻译内容、格式标点、角色口吻三块,可恢复预设。术语表路径默认指向本项目的 `TRPG玩家在异世界打造最强角色/mingcibiao.json`,也可以改成其他 `mingcibiao.json`。再点“重翻本段”;满意后点“应用重翻”,最后保存本段记录。
|
||||
- 想调整术语时:展开“提示词与 API”,在术语表区域搜索、修改或新增条目,点击“保存术语表”;下一次重翻会重新读取已保存的最新术语。
|
||||
- 审校结束后点击“生成反馈”,再在聊天里告诉 Codex 读取 `feedback_for_codex.md` 和 `translation_feedback.jsonl`。
|
||||
|
||||
## 当前限制
|
||||
|
||||
- 当前解析规则针对本项目的“日文灰字 + 中文紧随其后”的双语 EPUB。
|
||||
- 目录用于审校导航;插图可以阅读和跳转,但当前版本不提供图片内容编辑。
|
||||
- GPT API Key 仅保存在 `--review-root` 下的本地 `gpt_config.json`,接口不会回传密钥;重翻时只能使用已保存的 Base URL 和模型,避免把密钥发往未确认端点。服务器部署时请保护好审校目录,不要把没有鉴权的服务暴露到公网。
|
||||
- GPT API Key、提示词与术语表路径仅保存在 `--review-root` 下的本地 `gpt_config.json`,接口不会回传密钥;重翻时只能使用已保存的 Base URL 和模型,避免把密钥发往未确认端点。术语表编辑会直接写入配置路径指向的 JSON 文件,服务器部署时请保护好审校目录和术语表,不要把没有鉴权的服务暴露到公网。
|
||||
- 浏览器内保存的是审校工作副本;要生成 EPUB 文件,需要点击“导出审校 EPUB”。
|
||||
|
||||
@@ -36,7 +36,7 @@ except ImportError:
|
||||
"MINOR": "新增向后兼容能力、API 字段或可选配置",
|
||||
"MAJOR": "破坏兼容的 API、配置或行为变更",
|
||||
}
|
||||
version = "0.4.1"
|
||||
version = "0.7.0"
|
||||
|
||||
|
||||
P_RE = re.compile(r"(<p\b[^>]*>)(.*?)(</p>)", re.S | re.I)
|
||||
@@ -58,6 +58,34 @@ STATE_VERSION = 1
|
||||
UPLOAD_DIR_NAME = "uploads"
|
||||
DEFAULT_GPT_BASE_URL = "https://api.openai.com/v1"
|
||||
DEFAULT_GPT_MODEL = "gpt-4o-mini"
|
||||
DEFAULT_TRANSLATION_PROMPT = (
|
||||
"你是日语轻小说到简体中文的审校重翻助手。目标是把当前目标语句译成自然、流畅、忠实的简体中文轻小说文风。"
|
||||
"意义优先,再调整为中文语序;不要贴着日语句序硬译,也不要补写原文没有的信息。"
|
||||
"术语、人名、地名、TRPG/系统词优先服从给定名词表;Falchion / ファルシオン固定译为“大砍刀”,绝对不要译成“偃月刀”。"
|
||||
)
|
||||
DEFAULT_FORMAT_PROMPT = (
|
||||
"只输出目标语句的新中文译文 HTML 片段,不要解释、不要 Markdown、不要代码围栏。"
|
||||
"不得合并或拆分段落。保留必要的 HTML 内联标签,尤其是有意义的 ruby,必须写成"
|
||||
"<ruby><rb>中文</rb><rt>读音/外语</rt></ruby>,不要改成括号。"
|
||||
"标点统一为简中出版格式:对话引号用「」;省略号用……;破折号用——;问号用?,感叹号用!,问叹连用用?!或!?;"
|
||||
"逗号、句号、顿号、冒号、分号用,。、:;。不要用英文半角标点替代中文标点。"
|
||||
)
|
||||
DEFAULT_CHARACTER_PROMPT = (
|
||||
"根据目标语句和少量前后文判断说话人、情绪和角色口吻。"
|
||||
"本系列以第一人称轻小说叙述为主,主角聪明、爱吐槽、有TRPG玩家思维;对话要符合身份差异,"
|
||||
"不要把所有角色写成同一种口吻,也不要过度网络化。"
|
||||
)
|
||||
GPT_PROMPT_DEFAULTS = {
|
||||
"translation_prompt": DEFAULT_TRANSLATION_PROMPT,
|
||||
"format_prompt": DEFAULT_FORMAT_PROMPT,
|
||||
"character_prompt": DEFAULT_CHARACTER_PROMPT,
|
||||
}
|
||||
GPT_PROMPT_MAX_CHARS = {
|
||||
"translation_prompt": 5000,
|
||||
"format_prompt": 5000,
|
||||
"character_prompt": 3000,
|
||||
}
|
||||
GLOSSARY_ENTRY_LIMIT = 80
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
@@ -262,11 +290,53 @@ def normalize_gpt_base_url(value: str) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def normalize_gpt_prompt(name: str, value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
text = GPT_PROMPT_DEFAULTS[name]
|
||||
return text[: GPT_PROMPT_MAX_CHARS[name]]
|
||||
|
||||
|
||||
def gpt_prompt_defaults() -> dict[str, str]:
|
||||
return dict(GPT_PROMPT_DEFAULTS)
|
||||
|
||||
|
||||
def project_root() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def default_glossary_path() -> Path:
|
||||
return project_root() / "TRPG玩家在异世界打造最强角色" / "mingcibiao.json"
|
||||
|
||||
|
||||
def normalize_glossary_path_value(value: str = "") -> str:
|
||||
raw = str(value or "").strip()
|
||||
path = Path(raw).expanduser() if raw else default_glossary_path()
|
||||
if not path.is_absolute():
|
||||
path = project_root() / path
|
||||
if path.suffix.lower() != ".json":
|
||||
raise ValueError("术语表路径必须指向 .json 文件")
|
||||
return str(path.resolve())
|
||||
|
||||
|
||||
def update_gpt_config_fields(review_root: Path, fields: dict[str, Any]) -> None:
|
||||
existing = read_json(gpt_config_path(review_root), {})
|
||||
if not isinstance(existing, dict):
|
||||
existing = {}
|
||||
existing.update(fields)
|
||||
existing["updated_at"] = now_iso()
|
||||
write_json(gpt_config_path(review_root), existing)
|
||||
|
||||
|
||||
def read_gpt_config(review_root: Path) -> dict[str, Any]:
|
||||
data = read_json(gpt_config_path(review_root), {})
|
||||
api_key = str(data.get("api_key") or os.environ.get("OPENAI_API_KEY") or "").strip()
|
||||
base_url = str(data.get("base_url") or os.environ.get("OPENAI_BASE_URL") or DEFAULT_GPT_BASE_URL).strip()
|
||||
model = str(data.get("model") or os.environ.get("OPENAI_MODEL") or DEFAULT_GPT_MODEL).strip()
|
||||
prompts = {
|
||||
name: normalize_gpt_prompt(name, data.get(name, default))
|
||||
for name, default in GPT_PROMPT_DEFAULTS.items()
|
||||
}
|
||||
return {
|
||||
"base_url": normalize_gpt_base_url(base_url),
|
||||
"model": model or DEFAULT_GPT_MODEL,
|
||||
@@ -274,6 +344,8 @@ def read_gpt_config(review_root: Path) -> dict[str, Any]:
|
||||
"configured": bool(api_key),
|
||||
"key_source": "config" if str(data.get("api_key") or "").strip() else ("env" if os.environ.get("OPENAI_API_KEY") else ""),
|
||||
"updated_at": data.get("updated_at", ""),
|
||||
"glossary_path": normalize_glossary_path_value(str(data.get("glossary_path") or "")),
|
||||
**prompts,
|
||||
}
|
||||
|
||||
|
||||
@@ -285,6 +357,11 @@ def public_gpt_config(review_root: Path) -> dict[str, Any]:
|
||||
"model": config["model"],
|
||||
"key_source": config["key_source"],
|
||||
"updated_at": config.get("updated_at", ""),
|
||||
"translation_prompt": config["translation_prompt"],
|
||||
"format_prompt": config["format_prompt"],
|
||||
"character_prompt": config["character_prompt"],
|
||||
"glossary_path": config["glossary_path"],
|
||||
"prompt_defaults": gpt_prompt_defaults(),
|
||||
}
|
||||
|
||||
|
||||
@@ -296,26 +373,118 @@ def save_gpt_config(review_root: Path, data: dict[str, Any]) -> dict[str, Any]:
|
||||
existing = read_json(gpt_config_path(review_root), {})
|
||||
if keep_existing and not api_key:
|
||||
api_key = str(existing.get("api_key") or "").strip()
|
||||
prompts = {
|
||||
name: normalize_gpt_prompt(
|
||||
name,
|
||||
data.get(name) if name in data else existing.get(name, default),
|
||||
)
|
||||
for name, default in GPT_PROMPT_DEFAULTS.items()
|
||||
}
|
||||
record = {
|
||||
"base_url": base_url,
|
||||
"model": model,
|
||||
"api_key": api_key,
|
||||
"glossary_path": normalize_glossary_path_value(str(data.get("glossary_path") or existing.get("glossary_path") or "")),
|
||||
**prompts,
|
||||
"updated_at": now_iso(),
|
||||
}
|
||||
write_json(gpt_config_path(review_root), record)
|
||||
return public_gpt_config(review_root)
|
||||
|
||||
|
||||
def compact_glossary_for_prompt(row: dict[str, Any], limit: int = 36) -> list[str]:
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
glossary_path = project_root / "TRPG玩家在异世界打造最强角色" / "mingcibiao.json"
|
||||
if not glossary_path.exists():
|
||||
return ["Falchion/ファルシオン:大砍刀,不要译成偃月刀。"]
|
||||
def glossary_path(review_root: Path) -> Path:
|
||||
data = read_json(gpt_config_path(review_root), {})
|
||||
configured = str(data.get("glossary_path") or "").strip()
|
||||
return Path(normalize_glossary_path_value(configured))
|
||||
|
||||
|
||||
def read_glossary(review_root: Path) -> dict[str, str]:
|
||||
path = glossary_path(review_root)
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
glossary = read_json(glossary_path, {})
|
||||
except Exception:
|
||||
glossary = {}
|
||||
source = f"{row.get('jp_text', '')}\n{row.get('jp_html', '')}"
|
||||
data = read_json(path, {})
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"术语表 JSON 解析失败:{exc}") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("术语表必须是 JSON 对象")
|
||||
glossary: dict[str, str] = {}
|
||||
for key, value in data.items():
|
||||
if isinstance(key, str) and isinstance(value, str):
|
||||
glossary[key] = value
|
||||
return glossary
|
||||
|
||||
|
||||
def public_glossary(review_root: Path) -> dict[str, Any]:
|
||||
path = glossary_path(review_root)
|
||||
glossary = read_glossary(review_root)
|
||||
entries = [
|
||||
{
|
||||
"source": key,
|
||||
"target": value,
|
||||
"clean_target": value.split("#", 1)[0].strip(),
|
||||
}
|
||||
for key, value in glossary.items()
|
||||
]
|
||||
return {
|
||||
"path": str(path),
|
||||
"exists": path.exists(),
|
||||
"count": len(entries),
|
||||
"entries": entries,
|
||||
"updated_at": dt.datetime.fromtimestamp(path.stat().st_mtime).astimezone().isoformat(timespec="seconds") if path.exists() else "",
|
||||
}
|
||||
|
||||
|
||||
def save_glossary(review_root: Path, entries: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
path = glossary_path(review_root)
|
||||
if path.exists() and not path.is_file():
|
||||
raise ValueError("术语表路径不是文件")
|
||||
glossary: dict[str, str] = {}
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
raise ValueError("术语条目必须是对象")
|
||||
source = str(entry.get("source") or "").strip()
|
||||
target = str(entry.get("target") or "").strip()
|
||||
if not source:
|
||||
continue
|
||||
if not target:
|
||||
raise ValueError(f"术语“{source}”缺少译名")
|
||||
if len(source) > 300:
|
||||
raise ValueError(f"术语“{source[:80]}...”过长")
|
||||
if len(target) > 1200:
|
||||
raise ValueError(f"术语“{source}”的译名/备注过长")
|
||||
if source in glossary:
|
||||
raise ValueError(f"术语“{source}”重复")
|
||||
glossary[source] = target
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_json(path, glossary)
|
||||
return public_glossary(review_root)
|
||||
|
||||
|
||||
def search_glossary_entries(glossary: dict[str, str], term: str, limit: int = GLOSSARY_ENTRY_LIMIT) -> list[str]:
|
||||
needle = term.strip().lower()
|
||||
if not needle:
|
||||
return []
|
||||
selected: list[str] = []
|
||||
for key, value in glossary.items():
|
||||
clean_value = value.split("#", 1)[0].strip()
|
||||
if needle in key.lower() or needle in value.lower():
|
||||
selected.append(f"{key} => {clean_value or value}")
|
||||
if len(selected) >= limit:
|
||||
break
|
||||
return selected
|
||||
|
||||
|
||||
def glossary_matches_for_prompt(review_root: Path, row: dict[str, Any], limit: int = GLOSSARY_ENTRY_LIMIT) -> list[str]:
|
||||
try:
|
||||
glossary = read_glossary(review_root)
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ValueError(f"读取术语表失败:{exc}") from exc
|
||||
if not glossary and not default_glossary_path().exists():
|
||||
return ["Falchion/ファルシオン:大砍刀,不要译成偃月刀。"]
|
||||
source = f"{row.get('jp_text', '')}\n{row.get('jp_html', '')}\n{strip_tags(row_translation_html(row))}\n{row_translation_html(row)}"
|
||||
selected: list[str] = []
|
||||
for key, value in glossary.items():
|
||||
if not isinstance(key, str) or not isinstance(value, str):
|
||||
@@ -323,7 +492,7 @@ def compact_glossary_for_prompt(row: dict[str, Any], limit: int = 36) -> list[st
|
||||
clean_value = value.split("#", 1)[0].strip()
|
||||
if not clean_value:
|
||||
continue
|
||||
if key and key in source:
|
||||
if (key and key in source) or (clean_value and clean_value in source):
|
||||
selected.append(f"{key} => {clean_value}")
|
||||
if len(selected) >= limit:
|
||||
break
|
||||
@@ -333,28 +502,144 @@ def compact_glossary_for_prompt(row: dict[str, Any], limit: int = 36) -> list[st
|
||||
return selected[:limit]
|
||||
|
||||
|
||||
def build_retranslate_messages(row: dict[str, Any], instruction: str = "") -> list[dict[str, str]]:
|
||||
glossary_lines = "\n".join(f"- {item}" for item in compact_glossary_for_prompt(row))
|
||||
system_prompt = (
|
||||
"你是日语轻小说到简体中文的审校重翻助手。请只输出当前这一段的中文译文 HTML 片段,"
|
||||
"不要解释,不要输出 Markdown。译文要自然流畅,符合简中轻小说文风;意义优先,不贴日语语序硬译。"
|
||||
"不得合并或拆分段落。保留必要的 HTML 内联标签,尤其是有意义的 ruby:"
|
||||
"必须使用 <ruby><rb>中文</rb><rt>读音/外语</rt></ruby>,不要改成括号。"
|
||||
"纯读音 ruby 可自然译入中文。术语优先服从给定术语表。"
|
||||
def truncate_prompt_text(value: Any, limit: int = 1600) -> str:
|
||||
text = compact_text(str(value or ""))
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def row_translation_html(row: dict[str, Any]) -> str:
|
||||
return str(row.get("current_html") or row.get("cn_html") or row.get("cn_text") or "")
|
||||
|
||||
|
||||
def row_source_html(row: dict[str, Any]) -> str:
|
||||
return str(row.get("jp_html") or row.get("jp_text") or "")
|
||||
|
||||
|
||||
def build_retranslate_context(row: dict[str, Any], rows: list[dict[str, Any]], radius: int = 1) -> list[str]:
|
||||
same_file_rows = [item for item in rows if item.get("file") == row.get("file")]
|
||||
index = next((idx for idx, item in enumerate(same_file_rows) if item.get("id") == row.get("id")), -1)
|
||||
if index < 0:
|
||||
return []
|
||||
start = max(0, index - radius)
|
||||
end = min(len(same_file_rows), index + radius + 1)
|
||||
context: list[str] = []
|
||||
for idx in range(start, end):
|
||||
item = same_file_rows[idx]
|
||||
if item.get("id") == row.get("id"):
|
||||
continue
|
||||
direction = "前文" if idx < index else "后文"
|
||||
context.append(
|
||||
f"{direction} {item.get('id', '')}: 日文:{truncate_prompt_text(item.get('jp_text'), 500)} "
|
||||
f"现译:{truncate_prompt_text(strip_tags(row_translation_html(item)), 500)}"
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
def normalize_candidate_punctuation(fragment: str) -> str:
|
||||
protected: list[str] = []
|
||||
|
||||
def protect(match: re.Match[str]) -> str:
|
||||
protected.append(match.group(0))
|
||||
return f"\uE000{len(protected) - 1}\uE001"
|
||||
|
||||
protected_pattern = (
|
||||
r"<[^>]+>|"
|
||||
r"&(?:#[0-9]+|#x[0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]+);|"
|
||||
r"https?://[^\s<>\"]+|"
|
||||
r"[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}|"
|
||||
r"\b\d+(?:[.,]\d+)+\b|"
|
||||
r"\b[A-Za-z0-9]+(?:\.[A-Za-z0-9]+)+\b"
|
||||
)
|
||||
text = re.sub(protected_pattern, protect, fragment)
|
||||
text = text.replace("“", "「").replace("”", "」")
|
||||
text = text.replace("『", "「").replace("』", "」")
|
||||
text = text.replace("...", "……").replace("…", "……")
|
||||
text = re.sub(r"(?:……)+", "……", text)
|
||||
text = re.sub(r"[-]{2,}", "——", text)
|
||||
text = text.translate(str.maketrans({
|
||||
",": ",",
|
||||
".": "。",
|
||||
":": ":",
|
||||
";": ";",
|
||||
}))
|
||||
text = text.replace("?", "?").replace("!", "!")
|
||||
text = re.sub(r"?+!+", "?!", text)
|
||||
text = re.sub(r"!+?+", "!?", text)
|
||||
text = re.sub(r"?{2,}", "?", text)
|
||||
text = re.sub(r"!{2,}", "!", text)
|
||||
|
||||
def restore(match: re.Match[str]) -> str:
|
||||
return protected[int(match.group(1))]
|
||||
|
||||
return re.sub(r"\uE000(\d+)\uE001", restore, text)
|
||||
|
||||
|
||||
def restore_glossary_ruby_candidates(fragment: str, glossary_lines: list[str]) -> str:
|
||||
pairs: list[tuple[str, str]] = []
|
||||
for line in glossary_lines:
|
||||
value = line.split("=>", 1)[1].strip() if "=>" in line else line
|
||||
match = re.fullmatch(r"(.+?)\(([^()<>]{1,80})\)", value)
|
||||
if match:
|
||||
rb, rt = match.group(1).strip(), match.group(2).strip()
|
||||
if rb and rt and len(rb) <= 80:
|
||||
pairs.append((rb, rt))
|
||||
if not pairs:
|
||||
return fragment
|
||||
protected: list[str] = []
|
||||
|
||||
def protect(match: re.Match[str]) -> str:
|
||||
protected.append(match.group(0))
|
||||
return f"\uE010{len(protected) - 1}\uE011"
|
||||
|
||||
text = re.sub(r"<ruby\b.*?</ruby>|<[^>]+>", protect, fragment, flags=re.S | re.I)
|
||||
for rb, rt in sorted(pairs, key=lambda item: len(item[0]), reverse=True):
|
||||
pattern = re.escape(f"{rb}({rt})")
|
||||
ruby = f"<ruby><rb>{html.escape(rb)}</rb><rt>{html.escape(rt)}</rt></ruby>"
|
||||
text = re.sub(pattern, ruby, text)
|
||||
|
||||
def restore(match: re.Match[str]) -> str:
|
||||
return protected[int(match.group(1))]
|
||||
|
||||
return re.sub(r"\uE010(\d+)\uE011", restore, text)
|
||||
|
||||
|
||||
def build_retranslate_messages(
|
||||
row: dict[str, Any],
|
||||
rows: list[dict[str, Any]],
|
||||
config: dict[str, Any],
|
||||
glossary_lines: list[str],
|
||||
instruction: str = "",
|
||||
) -> list[dict[str, str]]:
|
||||
glossary_text = "\n".join(f"- {item}" for item in glossary_lines)
|
||||
context_lines = build_retranslate_context(row, rows)
|
||||
system_prompt = "\n\n".join(
|
||||
[
|
||||
"你是日语轻小说到简体中文的审校重翻助手。重翻只处理一个目标语句/段落,保持上下文简洁。",
|
||||
"【翻译内容提示词】",
|
||||
str(config.get("translation_prompt") or DEFAULT_TRANSLATION_PROMPT).strip(),
|
||||
"【格式与标点提示词】",
|
||||
str(config.get("format_prompt") or DEFAULT_FORMAT_PROMPT).strip(),
|
||||
"【角色状态与口吻提示词】",
|
||||
str(config.get("character_prompt") or DEFAULT_CHARACTER_PROMPT).strip(),
|
||||
]
|
||||
)
|
||||
user_parts = [
|
||||
"请重翻以下单段。",
|
||||
"请根据以下信息重翻目标语句,只输出新的中文译文 HTML 片段。",
|
||||
"",
|
||||
"日文原文 HTML:",
|
||||
row.get("jp_html") or row.get("jp_text") or "",
|
||||
"【目标语句:日文原文 HTML】",
|
||||
row_source_html(row),
|
||||
"",
|
||||
"当前中文译文 HTML:",
|
||||
row.get("current_html") or row.get("cn_html") or row.get("cn_text") or "",
|
||||
"【当前中文译文 HTML】",
|
||||
row_translation_html(row),
|
||||
]
|
||||
if glossary_lines:
|
||||
user_parts.extend(["", "相关术语:", glossary_lines])
|
||||
if context_lines:
|
||||
user_parts.extend(["", "【少量上下文】", "\n".join(f"- {item}" for item in context_lines)])
|
||||
if glossary_text:
|
||||
user_parts.extend(["", "【名词表】", glossary_text])
|
||||
if instruction.strip():
|
||||
user_parts.extend(["", "本次额外要求:", instruction.strip()[:1200]])
|
||||
user_parts.extend(["", "【本次额外要求】", instruction.strip()[:1200]])
|
||||
return [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": "\n".join(user_parts)},
|
||||
@@ -1722,10 +2007,73 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
return jsonify(public_gpt_config(review_root))
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
if data.get("reset_prompts"):
|
||||
current = read_gpt_config(review_root)
|
||||
data = {
|
||||
**data,
|
||||
"base_url": data.get("base_url") or current["base_url"],
|
||||
"model": data.get("model") or current["model"],
|
||||
"keep_existing": data.get("keep_existing", True),
|
||||
**gpt_prompt_defaults(),
|
||||
}
|
||||
return jsonify(save_gpt_config(review_root, data))
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
@app.route("/api/glossary", methods=["GET", "POST"])
|
||||
def api_glossary():
|
||||
if request.method == "GET":
|
||||
try:
|
||||
return jsonify(public_glossary(review_root))
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
requested_path = str(data.get("path") or "").strip()
|
||||
if requested_path:
|
||||
update_gpt_config_fields(review_root, {"glossary_path": normalize_glossary_path_value(requested_path)})
|
||||
if "entries" not in data:
|
||||
return jsonify(public_glossary(review_root))
|
||||
entries = data.get("entries", [])
|
||||
if not isinstance(entries, list):
|
||||
return jsonify({"error": "entries 必须是数组"}), 400
|
||||
return jsonify(save_glossary(review_root, entries))
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
@app.route("/api/glossary/entry", methods=["POST", "DELETE"])
|
||||
def api_glossary_entry():
|
||||
data = request.get_json(silent=True) or {}
|
||||
source = str(data.get("source") or "").strip()
|
||||
if not source:
|
||||
return jsonify({"error": "缺少术语原文"}), 400
|
||||
try:
|
||||
glossary = read_glossary(review_root)
|
||||
if request.method == "DELETE":
|
||||
if source not in glossary:
|
||||
return jsonify({"error": "术语不存在"}), 404
|
||||
del glossary[source]
|
||||
else:
|
||||
target = str(data.get("target") or "").strip()
|
||||
if not target:
|
||||
return jsonify({"error": "缺少术语译名"}), 400
|
||||
old_source = str(data.get("old_source") or source).strip()
|
||||
if old_source and old_source != source:
|
||||
glossary.pop(old_source, None)
|
||||
glossary[source] = target
|
||||
return jsonify(save_glossary(review_root, [{"source": key, "target": value} for key, value in glossary.items()]))
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
@app.route("/api/glossary/search")
|
||||
def api_glossary_search():
|
||||
term = str(request.args.get("q") or "")
|
||||
try:
|
||||
matches = search_glossary_entries(read_glossary(review_root), term)
|
||||
return jsonify({"query": term, "count": len(matches), "matches": matches})
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
@app.route("/api/row/<row_id>/retranslate", methods=["POST"])
|
||||
def api_retranslate_row(row_id: str):
|
||||
session_root, _epub_path = active_session()
|
||||
@@ -1745,8 +2093,9 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
if requested_model and requested_model != config["model"]:
|
||||
return jsonify({"error": "重翻请求不能临时覆盖模型。请先保存 API 设置后再重翻。"}), 400
|
||||
instruction = str(data.get("instruction") or "")
|
||||
raw = call_openai_compatible_chat(config, build_retranslate_messages(row, instruction))
|
||||
candidate_html = sanitize_cn_html(raw)
|
||||
glossary_lines = glossary_matches_for_prompt(review_root, row)
|
||||
raw = call_openai_compatible_chat(config, build_retranslate_messages(row, rows, config, glossary_lines, instruction))
|
||||
candidate_html = sanitize_cn_html(restore_glossary_ruby_candidates(normalize_candidate_punctuation(raw), glossary_lines))
|
||||
if not candidate_html:
|
||||
return jsonify({"error": "GPT 返回内容为空或无法作为译文保存"}), 502
|
||||
candidate_text = strip_tags(candidate_html)
|
||||
@@ -1757,6 +2106,13 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
"file": row["file"],
|
||||
"model": config["model"],
|
||||
"base_url": config["base_url"],
|
||||
"prompt_version": version,
|
||||
"translation_prompt": config.get("translation_prompt", ""),
|
||||
"format_prompt": config.get("format_prompt", ""),
|
||||
"character_prompt": config.get("character_prompt", ""),
|
||||
"glossary_path": str(glossary_path(review_root)),
|
||||
"glossary_matches": glossary_lines,
|
||||
"instruction": instruction[:1200],
|
||||
"jp_text": row["jp_text"],
|
||||
"before_cn_text": strip_tags(row.get("current_html") or row.get("cn_html") or ""),
|
||||
"candidate_cn_text": candidate_text,
|
||||
@@ -1769,6 +2125,8 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
"candidate_html": candidate_html,
|
||||
"candidate_text": candidate_text,
|
||||
"model": config["model"],
|
||||
"glossary_path": str(glossary_path(review_root)),
|
||||
"glossary_matches": glossary_lines,
|
||||
"updated_at": record["ts"],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -16,6 +16,9 @@ let sidePanelMode = null;
|
||||
let searchScope = "current";
|
||||
let gptConfigLoaded = false;
|
||||
let gptCandidateHtml = "";
|
||||
let glossaryLoaded = false;
|
||||
let glossaryEntries = [];
|
||||
let glossaryDirty = false;
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
@@ -92,7 +95,18 @@ const dom = {
|
||||
gptBaseUrlInput: $("gptBaseUrlInput"),
|
||||
gptModelInput: $("gptModelInput"),
|
||||
gptApiKeyInput: $("gptApiKeyInput"),
|
||||
glossaryPathInput: $("glossaryPathInput"),
|
||||
gptTranslationPromptInput: $("gptTranslationPromptInput"),
|
||||
gptFormatPromptInput: $("gptFormatPromptInput"),
|
||||
gptCharacterPromptInput: $("gptCharacterPromptInput"),
|
||||
glossaryMeta: $("glossaryMeta"),
|
||||
glossarySearchInput: $("glossarySearchInput"),
|
||||
reloadGlossaryBtn: $("reloadGlossaryBtn"),
|
||||
addGlossaryEntryBtn: $("addGlossaryEntryBtn"),
|
||||
saveGlossaryBtn: $("saveGlossaryBtn"),
|
||||
glossaryList: $("glossaryList"),
|
||||
saveGptConfigBtn: $("saveGptConfigBtn"),
|
||||
resetGptPromptsBtn: $("resetGptPromptsBtn"),
|
||||
gptInstructionInput: $("gptInstructionInput"),
|
||||
retranslateBtn: $("retranslateBtn"),
|
||||
applyRetranslationBtn: $("applyRetranslationBtn"),
|
||||
@@ -251,10 +265,16 @@ function resetReviewState() {
|
||||
searchScope = "current";
|
||||
gptCandidateHtml = "";
|
||||
gptConfigLoaded = false;
|
||||
glossaryLoaded = false;
|
||||
glossaryEntries = [];
|
||||
glossaryDirty = false;
|
||||
dom.showTouchedBtn.classList.remove("primary");
|
||||
dom.searchScopeCurrentBtn.classList.add("active");
|
||||
dom.searchScopeBookBtn.classList.remove("active");
|
||||
dom.searchInput.value = "";
|
||||
if (dom.glossarySearchInput) dom.glossarySearchInput.value = "";
|
||||
if (dom.glossaryList) dom.glossaryList.innerHTML = "";
|
||||
if (dom.glossaryMeta) dom.glossaryMeta.textContent = "未读取术语表";
|
||||
dom.sidebar.hidden = true;
|
||||
dom.tocPanel.hidden = true;
|
||||
dom.searchPanel.hidden = true;
|
||||
@@ -1360,16 +1380,29 @@ function renderGptConfig(config) {
|
||||
dom.gptBaseUrlInput.value = config.base_url || "https://api.openai.com/v1";
|
||||
dom.gptModelInput.value = config.model || "gpt-4o-mini";
|
||||
dom.gptApiKeyInput.value = "";
|
||||
dom.glossaryPathInput.value = config.glossary_path || "";
|
||||
dom.gptTranslationPromptInput.value = config.translation_prompt || (config.prompt_defaults && config.prompt_defaults.translation_prompt) || "";
|
||||
dom.gptFormatPromptInput.value = config.format_prompt || (config.prompt_defaults && config.prompt_defaults.format_prompt) || "";
|
||||
dom.gptCharacterPromptInput.value = config.character_prompt || (config.prompt_defaults && config.prompt_defaults.character_prompt) || "";
|
||||
dom.resetGptPromptsBtn.dataset.translationPrompt = (config.prompt_defaults && config.prompt_defaults.translation_prompt) || "";
|
||||
dom.resetGptPromptsBtn.dataset.formatPrompt = (config.prompt_defaults && config.prompt_defaults.format_prompt) || "";
|
||||
dom.resetGptPromptsBtn.dataset.characterPrompt = (config.prompt_defaults && config.prompt_defaults.character_prompt) || "";
|
||||
const source = config.key_source === "env" ? "环境变量" : config.key_source === "config" ? "本地配置" : "未配置";
|
||||
dom.gptStatus.textContent = config.configured ? `已配置 · ${source} · ${config.model}` : "未配置 API Key,保存设置后可重翻。";
|
||||
}
|
||||
|
||||
async function loadGptConfig(force = false) {
|
||||
if (gptConfigLoaded && !force) return;
|
||||
if (gptConfigLoaded && !force) {
|
||||
if (!glossaryLoaded) loadGlossary(false).catch(() => {});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const config = await api("/api/gpt/config");
|
||||
renderGptConfig(config);
|
||||
gptConfigLoaded = true;
|
||||
loadGlossary(force).catch((error) => {
|
||||
dom.glossaryMeta.textContent = `读取术语表失败:${error.message}`;
|
||||
});
|
||||
} catch (error) {
|
||||
dom.gptStatus.textContent = `读取 GPT 配置失败:${error.message}`;
|
||||
}
|
||||
@@ -1383,17 +1416,149 @@ async function saveGptConfig() {
|
||||
base_url: dom.gptBaseUrlInput.value,
|
||||
model: dom.gptModelInput.value,
|
||||
api_key: dom.gptApiKeyInput.value,
|
||||
glossary_path: dom.glossaryPathInput.value,
|
||||
translation_prompt: dom.gptTranslationPromptInput.value,
|
||||
format_prompt: dom.gptFormatPromptInput.value,
|
||||
character_prompt: dom.gptCharacterPromptInput.value,
|
||||
keep_existing: true,
|
||||
}),
|
||||
});
|
||||
renderGptConfig(config);
|
||||
gptConfigLoaded = true;
|
||||
toast("GPT API 设置已保存。");
|
||||
glossaryLoaded = false;
|
||||
await loadGlossary(true);
|
||||
toast("GPT 提示词与 API 设置已保存。");
|
||||
} catch (error) {
|
||||
toast(`保存 GPT 设置失败:${error.message}`, 7000);
|
||||
}
|
||||
}
|
||||
|
||||
function glossaryFilteredEntries() {
|
||||
const q = (dom.glossarySearchInput.value || "").trim().toLowerCase();
|
||||
if (!q) return glossaryEntries.map((entry, index) => ({ entry, index }));
|
||||
return glossaryEntries
|
||||
.map((entry, index) => ({ entry, index }))
|
||||
.filter(({ entry }) => {
|
||||
return [entry.source, entry.target, entry.clean_target]
|
||||
.join("\n")
|
||||
.toLowerCase()
|
||||
.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
function updateGlossaryMeta(shownCount = null, matchCount = null) {
|
||||
const matches = shownCount === null || matchCount === null ? glossaryFilteredEntries() : null;
|
||||
const shown = shownCount === null ? Math.min(80, matches.length) : shownCount;
|
||||
const matched = matchCount === null ? matches.length : matchCount;
|
||||
const path = dom.glossaryPathInput.value || "未设置";
|
||||
const dirtyText = glossaryDirty ? " · 有未保存修改" : "";
|
||||
dom.glossaryMeta.textContent = `当前术语表:${path} · ${glossaryEntries.length} 条 · 显示 ${shown}/${matched}${dirtyText}`;
|
||||
}
|
||||
|
||||
function setGlossaryDirty(value) {
|
||||
glossaryDirty = value;
|
||||
dom.saveGlossaryBtn.disabled = !glossaryDirty;
|
||||
if (glossaryLoaded) updateGlossaryMeta();
|
||||
}
|
||||
|
||||
function renderGlossary(payload = null) {
|
||||
if (payload) {
|
||||
glossaryEntries = (payload.entries || []).map((entry) => ({
|
||||
source: entry.source || "",
|
||||
target: entry.target || "",
|
||||
clean_target: entry.clean_target || "",
|
||||
}));
|
||||
glossaryLoaded = true;
|
||||
setGlossaryDirty(false);
|
||||
if (payload.path) dom.glossaryPathInput.value = payload.path;
|
||||
}
|
||||
|
||||
const matches = glossaryFilteredEntries();
|
||||
const shown = matches.slice(0, 80);
|
||||
updateGlossaryMeta(shown.length, matches.length);
|
||||
dom.glossaryList.innerHTML = "";
|
||||
if (!glossaryLoaded) {
|
||||
dom.glossaryList.innerHTML = '<div class="glossaryEmpty">展开后会读取术语表。</div>';
|
||||
return;
|
||||
}
|
||||
if (!shown.length) {
|
||||
dom.glossaryList.innerHTML = '<div class="glossaryEmpty">没有匹配的术语。</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const frag = document.createDocumentFragment();
|
||||
shown.forEach(({ entry, index }) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "glossaryRow";
|
||||
row.dataset.index = String(index);
|
||||
row.innerHTML = `
|
||||
<input class="glossarySource" type="text" value="${escapeHtml(entry.source)}" aria-label="术语原文">
|
||||
<input class="glossaryTarget" type="text" value="${escapeHtml(entry.target)}" aria-label="术语译名">
|
||||
<button class="glossaryDelete" type="button" aria-label="删除术语">删除</button>
|
||||
`;
|
||||
row.querySelector(".glossarySource").addEventListener("input", (event) => {
|
||||
glossaryEntries[index].source = event.target.value;
|
||||
setGlossaryDirty(true);
|
||||
});
|
||||
row.querySelector(".glossaryTarget").addEventListener("input", (event) => {
|
||||
glossaryEntries[index].target = event.target.value;
|
||||
glossaryEntries[index].clean_target = event.target.value.split("#", 1)[0].trim();
|
||||
setGlossaryDirty(true);
|
||||
});
|
||||
row.querySelector(".glossaryDelete").addEventListener("click", () => {
|
||||
glossaryEntries.splice(index, 1);
|
||||
setGlossaryDirty(true);
|
||||
renderGlossary();
|
||||
});
|
||||
frag.appendChild(row);
|
||||
});
|
||||
dom.glossaryList.appendChild(frag);
|
||||
}
|
||||
|
||||
async function loadGlossary(force = false) {
|
||||
if (glossaryLoaded && !force) return;
|
||||
const payload = await api("/api/glossary");
|
||||
renderGlossary(payload);
|
||||
}
|
||||
|
||||
function addGlossaryEntry() {
|
||||
glossaryEntries.unshift({ source: "", target: "", clean_target: "" });
|
||||
dom.glossarySearchInput.value = "";
|
||||
glossaryLoaded = true;
|
||||
setGlossaryDirty(true);
|
||||
renderGlossary();
|
||||
requestAnimationFrame(() => dom.glossaryList.querySelector(".glossarySource")?.focus());
|
||||
}
|
||||
|
||||
async function saveGlossary() {
|
||||
try {
|
||||
const payload = await api("/api/glossary", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
path: dom.glossaryPathInput.value,
|
||||
entries: glossaryEntries,
|
||||
}),
|
||||
});
|
||||
renderGlossary(payload);
|
||||
toast("术语表已保存,下一次重翻会实时使用最新术语。");
|
||||
} catch (error) {
|
||||
toast(`保存术语表失败:${error.message}`, 9000);
|
||||
}
|
||||
}
|
||||
|
||||
function glossaryMatchedText(data) {
|
||||
const matches = data.glossary_matches || [];
|
||||
if (!matches.length) return "";
|
||||
return `<div class="gptCandidateMeta">术语命中:${escapeHtml(matches.slice(0, 10).join("; "))}${matches.length > 10 ? ` 等 ${matches.length} 条` : ""}</div>`;
|
||||
}
|
||||
|
||||
function resetGptPrompts() {
|
||||
dom.gptTranslationPromptInput.value = dom.resetGptPromptsBtn.dataset.translationPrompt || "";
|
||||
dom.gptFormatPromptInput.value = dom.resetGptPromptsBtn.dataset.formatPrompt || "";
|
||||
dom.gptCharacterPromptInput.value = dom.resetGptPromptsBtn.dataset.characterPrompt || "";
|
||||
toast("已恢复预设提示词,保存后生效。");
|
||||
}
|
||||
|
||||
async function retranslateCurrentRow() {
|
||||
if (!currentRow) return;
|
||||
if (quickDirty) {
|
||||
@@ -1413,7 +1578,7 @@ async function retranslateCurrentRow() {
|
||||
}),
|
||||
});
|
||||
gptCandidateHtml = data.candidate_html || "";
|
||||
dom.gptCandidate.innerHTML = gptCandidateHtml || escapeHtml(data.candidate_text || "");
|
||||
dom.gptCandidate.innerHTML = `${glossaryMatchedText(data)}${gptCandidateHtml || escapeHtml(data.candidate_text || "")}`;
|
||||
dom.applyRetranslationBtn.disabled = !gptCandidateHtml;
|
||||
toast("已生成重翻候选,可确认后应用。");
|
||||
} catch (error) {
|
||||
@@ -1610,9 +1775,35 @@ function initEvents() {
|
||||
dom.quickMarkSelectionBtn.addEventListener("click", markQuickSelection);
|
||||
dom.toggleGptConfigBtn.addEventListener("click", () => {
|
||||
dom.gptConfig.hidden = !dom.gptConfig.hidden;
|
||||
if (!dom.gptConfig.hidden) loadGptConfig(true).catch(() => {});
|
||||
if (!dom.gptConfig.hidden) {
|
||||
loadGptConfig(true).catch(() => {});
|
||||
}
|
||||
});
|
||||
dom.saveGptConfigBtn.addEventListener("click", saveGptConfig);
|
||||
dom.resetGptPromptsBtn.addEventListener("click", resetGptPrompts);
|
||||
dom.reloadGlossaryBtn.addEventListener("click", () => {
|
||||
if (glossaryDirty && !window.confirm("术语表有未保存修改,重新读取会丢失这些修改。继续吗?")) return;
|
||||
loadGlossary(true)
|
||||
.then(() => toast("术语表已重新读取。"))
|
||||
.catch((error) => toast(`读取术语表失败:${error.message}`, 9000));
|
||||
});
|
||||
dom.addGlossaryEntryBtn.addEventListener("click", addGlossaryEntry);
|
||||
dom.saveGlossaryBtn.addEventListener("click", saveGlossary);
|
||||
dom.glossarySearchInput.addEventListener("input", () => renderGlossary());
|
||||
dom.glossaryPathInput.addEventListener("change", async () => {
|
||||
if (glossaryDirty && !window.confirm("术语表有未保存修改,切换路径会丢失这些修改。继续吗?")) return;
|
||||
try {
|
||||
await api("/api/glossary", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path: dom.glossaryPathInput.value }),
|
||||
});
|
||||
glossaryLoaded = false;
|
||||
await loadGlossary(true);
|
||||
toast("已切换术语表路径。");
|
||||
} catch (error) {
|
||||
toast(`切换术语表失败:${error.message}`, 9000);
|
||||
}
|
||||
});
|
||||
dom.retranslateBtn.addEventListener("click", retranslateCurrentRow);
|
||||
dom.applyRetranslationBtn.addEventListener("click", applyRetranslation);
|
||||
dom.quickOpenFullBtn.addEventListener("click", async () => {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brandBlock">
|
||||
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.5.0</span></h1>
|
||||
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.7.0</span></h1>
|
||||
<p id="sessionMeta">正在载入……</p>
|
||||
</div>
|
||||
<div class="modeTabs" role="tablist" aria-label="审校模式">
|
||||
@@ -200,23 +200,59 @@
|
||||
<section class="gptBox" id="gptBox">
|
||||
<div class="sectionTitle">
|
||||
<h2>GPT 重翻</h2>
|
||||
<button id="toggleGptConfigBtn" type="button">API 设置</button>
|
||||
<button id="toggleGptConfigBtn" type="button">提示词与 API</button>
|
||||
</div>
|
||||
<div class="gptStatus" id="gptStatus">未读取配置</div>
|
||||
<div class="gptConfig" id="gptConfig" hidden>
|
||||
<label>
|
||||
Base URL
|
||||
<input id="gptBaseUrlInput" type="url" placeholder="https://api.openai.com/v1">
|
||||
</label>
|
||||
<label>
|
||||
Model
|
||||
<input id="gptModelInput" type="text" placeholder="gpt-4o-mini">
|
||||
</label>
|
||||
<label>
|
||||
API Key
|
||||
<input id="gptApiKeyInput" type="password" placeholder="留空则保留已有密钥">
|
||||
</label>
|
||||
<button id="saveGptConfigBtn" type="button">保存 API 设置</button>
|
||||
<div class="gptConfigGroup">
|
||||
<div class="gptConfigTitle">API</div>
|
||||
<label>
|
||||
Base URL
|
||||
<input id="gptBaseUrlInput" type="url" placeholder="https://api.openai.com/v1">
|
||||
</label>
|
||||
<label>
|
||||
Model
|
||||
<input id="gptModelInput" type="text" placeholder="gpt-4o-mini">
|
||||
</label>
|
||||
<label>
|
||||
API Key
|
||||
<input id="gptApiKeyInput" type="password" placeholder="留空则保留已有密钥">
|
||||
</label>
|
||||
<label>
|
||||
术语表路径
|
||||
<input id="glossaryPathInput" type="text" placeholder="mingcibiao.json 的完整路径或项目内相对路径">
|
||||
</label>
|
||||
</div>
|
||||
<div class="gptConfigGroup">
|
||||
<div class="gptConfigTitle">提示词</div>
|
||||
<label>
|
||||
翻译内容提示词
|
||||
<textarea id="gptTranslationPromptInput" rows="5" placeholder="设置忠实度、文风、术语优先级等内容要求。"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
格式与标点提示词
|
||||
<textarea id="gptFormatPromptInput" rows="5" placeholder="设置 HTML、ruby、标点替换与输出格式规则。"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
角色状态与口吻提示词
|
||||
<textarea id="gptCharacterPromptInput" rows="4" placeholder="设置角色状态、性格口吻、对话风格判断规则。"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="gptConfigGroup glossaryGroup">
|
||||
<div class="gptConfigTitle">术语表</div>
|
||||
<div class="glossaryMeta" id="glossaryMeta">未读取术语表</div>
|
||||
<div class="glossaryTools">
|
||||
<input id="glossarySearchInput" type="search" placeholder="搜索原文、译名或备注">
|
||||
<button id="reloadGlossaryBtn" type="button">重新读取</button>
|
||||
<button id="addGlossaryEntryBtn" type="button">新增术语</button>
|
||||
<button id="saveGlossaryBtn" type="button" disabled>保存术语表</button>
|
||||
</div>
|
||||
<div class="glossaryList" id="glossaryList"></div>
|
||||
</div>
|
||||
<div class="gptConfigActions">
|
||||
<button id="resetGptPromptsBtn" type="button">恢复预设提示词</button>
|
||||
<button id="saveGptConfigBtn" type="button">保存提示词与 API</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="quickField">
|
||||
重翻要求
|
||||
|
||||
@@ -989,7 +989,7 @@ textarea {
|
||||
|
||||
.gptConfig {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
@@ -997,6 +997,84 @@ textarea {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.gptConfigGroup {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #e3e9f0;
|
||||
}
|
||||
|
||||
.gptConfigGroup:first-child {
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.gptConfigTitle {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.gptConfigActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.gptConfig textarea {
|
||||
min-height: 112px;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.glossaryMeta {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.glossaryTools {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.glossaryList {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.glossaryRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr) auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.glossaryRow input {
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.glossaryDelete {
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.glossaryEmpty {
|
||||
padding: 10px;
|
||||
border: 1px dashed #cbd5df;
|
||||
border-radius: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.gptActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -1015,6 +1093,15 @@ textarea {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.gptCandidateMeta {
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #e3e9f0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
@@ -1120,3 +1207,10 @@ textarea {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.glossaryTools,
|
||||
.glossaryRow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version = "0.5.0"
|
||||
version = "0.7.0"
|
||||
|
||||
VERSION_RULES = {
|
||||
"PATCH": "修复 bug 或兼容性小修",
|
||||
|
||||
Reference in New Issue
Block a user