多语言
这一页讲 Covel 怎样处理多种语言:界面、插件、世界包和发给模型的提示词各自用哪种语言,文字写在哪个文件里,缺了译文时显示什么。给应用加一种语言、给插件或世界包做翻译的人需要它。
先记住一个类型。Covel 里给人看的多语言字段统一用 I18nText:
type I18nText = string | Record<string, string>;它要么是一个字符串,要么是“语言 → 文字”的表,例如 { "en": "Codex", "zh": "知识图鉴" }。
语言代码(locale)的规则:
- 可以用任何能被
Intl.getCanonicalLocales()规范化、并且适合做文件名的 Unicode 语言标识,例如ru、ru-RU、sr-Latn、zh-Hant-TW和en-u-ca-gregory。 - 私有的或历史遗留的 BCP 47 标签(例如
x-elvish、i-klingon)不能用于保存的数据和按语言命名的文件。 - 给人看的自然语言文字应该提供目标语言和 English 两份,English 用作回退。中文不是必需的。
- 稳定的 ID、路径、URL、图标名和状态值不翻译。
内置语言与扩展边界
仓库随包发布三种界面语言:zh-CN、en-US 和 ru-RU。
界面语言是自动发现的。 Web 扫描 apps/web/src/i18n/locales/*.json,文件名就是规范的语言代码。设置的校验和选项、浏览器语言检测、语言切换器、i18next 的资源,全都从扫描结果得出。所以新增一份完整的界面词典(下文叫 catalog)不需要改 TypeScript 注册表或组件。
@covel/shared的LocaleDefinition/defineLocaleRegistry()仍然提供框架的默认值、回退语言和宿主组合的入口。- 对不是内置的 catalog,Web 用
Intl.DisplayNames自动生成它在各种界面语言下的名字,用主语言的大写形式作短标签,并用 English 作回退。 - catalog 是动态加载的:只加载当前语言和它的回退语言,不会把所有词典塞进入口包。
自动发现发生在构建时。 把 JSON 放进仓库或发行包,再重新构建就行。
- 运行时安装的社区插件不会在浏览器或 Electron 主进程里执行代码,所以它不能覆盖已经构建好的核心界面 catalog。
- 插件和世界的内容可以使用同一套安全、可规范化的语言标识。
- 桌面端的 Web 界面会用上新的 catalog。没有内置这种语言的 Electron 原生菜单和启动错误会回到 English。
界面 catalog 和发给模型的提示词是两套东西。 新增一个 JSON 只翻译 Web 界面,不会把英文或中文的提示词机器翻译成目标语言。提示词按下面的规则取:
- 框架的提示词。 文件是
prompts/server/<name>.<locale>.md,或者同一种文字的主语言短名文件,例如generate-world.ru-RU.md/generate-world.ru.md。找不到时用 English 模板,再找不到用不带语言的主文件。English 模板仍然会明确要求模型用当前会话的语言输出。 - 插件的提示词是指令,不是玩家看到的内容。 主文件
PLUGIN.md/RUNTIME.md的正文必须是 English。只有简体中文可以有另一份:PLUGIN.zh.md/RUNTIME.zh.md(也可以用zh-CN、zh-Hans等更精确的文件名)。- 简体中文会话(
zh、zh-CN、zh-Hans)读中文那份。 - 其他语言一律读 English 主文件,包括
ru-RU和繁体中文(zh-Hant/zh-TW)。模型仍然按会话的语言输出玩家看到的文字。 - 繁体会话不读简体指令,因为简体指令会把输出带向简体字。
PLUGIN.ru.md这样的文件不会被读取:加载器记一条警告,pnpm check:prompts报错。
- 简体中文会话(
- 中文那份只需要写正文和
contributes.prompt的content。 frontmatter 可以是空的(---后面紧跟---)。省略的字段从主文件继承;结构上的契约始终以主文件为准。清单里的displayName/description/label是玩家看到的标签:主文件写 English,译文放在locales/<locale>.yaml,不放进提示词的中文文件。 - 中文那份是英文的翻译。 两份必须提到同一组工具名、注入块标签和
runtime-inputs.<name>。plugins/prompt-variants.lock.json记录每一对的内容指纹:只改英文不改中文,pnpm check:prompts会失败。两边都改完之后运行pnpm prompts:lock重新记录。 - 内置插件必须中英两份。 凡是模型会读的提示词(agent runtime 的正文、
contributes.prompt的固定段)都要有中文那份,pnpm check:prompts强制这一点。function runtime 的正文只是给作者看的说明,只写 English。社区插件只需要 English。 - 插件代码加载的模板(
@covel/plugin-handlers-utils/prompts的createPromptLoader(root))也是指令,规则相同:<name>.md是 English,<name>.zh.md是简体中文那份。调用时传会话的 locale,加载器按instructionLocaleFor()选。其他语言的文件(如<name>.ru.md)不会被读取,pnpm check:prompts报错。 插件prompts/下已有的 English/简体中文模板对也参加同一个指纹锁,包括子目录;没有译本的独立模板不因此强制新增译本。 独立模板直接读取完整正文,不解析清单 frontmatter;PLUGIN.md和RUNTIME.md仍按原清单规则读取。 templates/里的插件模板遵守同样的规则(English 正文,不允许其他语言的提示词文件),但不带中文那份。用pnpm create-plugin生成的插件从 English 开始,标签的中文写在locales/zh.yaml。- 框架自己拼进提示词的指令句(前置段里的运行框架、输出语言规则和完成约定,纠错提示,开场的 user 消息)遵守同样的规则。整段前置段只用一种语言,由
@covel/shared的instructionLocaleFor()决定用中文还是 English。代码拼进提示词的其他固定文字(数据块的标题和说明、截断与裁剪标记、重试提示)也是指令,规则见代码拼进提示词的文字。 COVEL_INSTRUCTION_LOCALE=en|zh强制所有会话用同一种指令语言。它用来对比两种语言的效果,或者在某个模型明显更适合其中一种时固定下来。它不改变会话的内容语言。- 世界的叙事正文用
WORLD.<locale>.md,<locale>是世界这个语言版本的完整写法(例如WORLD.ru-RU.md),找不到时用世界的主文件WORLD.md。 - 高级部署可以用
COVEL_PROMPTS_DIR指向一套自己维护的框架提示词目录。它替换的是整个提示词根目录,所以必须保留应用实际用到的每个主文件。
运行时安装的插件可以带着、也可以修改它自己的提示词,但不能覆盖框架的核心提示词。要覆盖核心提示词,只有两条路:在源码或发行包里加入文件,或者由部署者明确配置 COVEL_PROMPTS_DIR。
代码拼进提示词的文字
除了提示词正文,框架和插件的代码还会往提示词里写一些固定的文字:一个数据块前后的说明句、一个标题、一条纠错或重试提示。
问题在这里:简体中文会话的正文是中文,如果这些文字是 English,同一段提示词就中途换了语言。小模型最容易在这种地方把输出写成 English。
所以每一处按“它是什么”来处理:
| 这段文字是 | 怎么处理 |
|---|---|
| 指令,或模型必须读懂的一句话(说明、标题、提示、标记里的句子) | 有 English 和简体中文两份,保留相同规则、约束和引用,表达符合各语言习惯;按会话的指令语言选一份 |
机器标记:XML 标签名、[RUNTIME] / [retry N] 这类方括号标签、枚举值、id | 不翻译 |
| 提示词正文按字面引用的文字 | 不翻译;它是两种语言的正文共同引用的标记 |
| 工具定义,以及工具循环里返回给模型的固定协议消息、拒绝原因和参数校验错误 | English。它们引用的字段名和枚举值来自 English 的工具定义,每种会话相同 |
工具结果的字段名(summary、data、staged、error、code、message、hint)和错误码不翻译;data 中的故事内容、角色名和玩家可见文字仍使用会话内容语言。完整格式见统一工具结果。
框架代码用 instructionLocaleFor(locale) 选语言。现有的固定文字:
packages/context:- 世界规则的标题
[World Rule: …]/[世界规则:…]; <available-events>末尾的事件发射说明;- 历史摘要块后面的说明句;
- 最近图片消息的开头说明句,以及每张图前的
图片 N/Picture N标签; - 被裁掉的历史消息的占位标记;
- 自有数据块为空时的
(none)/(无)和条数说明; <runtime-inputs>里代替本回合正文的那句“指向对话”的话;characters.npcs里没有列出档案的角色名单。
- 世界规则的标题
packages/ai-provider:文本和对象请求的 JSON Schema 指令,以及 Anthropic 历史开头的占位句。agent 和函数 runtime 每次调用都用会话的instructionLocaleFor结果设置instructionLocale;COVEL_INSTRUCTION_LOCALE仍可覆盖它。packages/shared:维度投影里“有几个维度未列出”的一行。packages/runtime:- 草稿被插件审查拒绝时的说明句;
- 重试提示;
- 工具结果和压缩历史被截断时的标记;
output.schemaruntime 的“只返回符合 JSON Schema 的 JSON”规则。这一条由模型适配器加进 system prompt,所以每次模型请求都带着会话的locale。
packages/plugin-handlers-utils:- 叙事审查(
createNarrativeReview)加进请求的人称要求和三条纠错句; characterSheetSegments的未列出档案名单。它没有会话上下文,由调用方传入locale: ctx.locale。
- 叙事审查(
插件代码用 SDK 的 pickLocaleText(ctx.locale, zh, en)。它按 instructionLocaleFor(ctx.locale) 选:结果是 zh 时用中文那句,否则用 English。
hook 的上下文带 ctx.locale(会话的内容语言)。往一次请求里加文字的 hook 用 instructionLocaleFor(ctx.locale) 选语言,结果是 zh 时加中文。char-creator 的角色读取限制就是这样做的,见 Hooks。
内置插件里这样处理的有:
memory的提取提示词和“相关旧事”块的标题;npc-graph的“已知 NPC 关系”标题;history-compaction的模板、默认摘要分节、摘要请求和语言规则(语言规则仍要求用会话的语言写摘要);world-init两个维度块的说明句和“已截断”标题;story-events交给叙事的事件说明;soundtrack的音乐情绪说明;tabletop-rules的检定回执说明;scene-stage的在场角色块;branch-reply重新生成候选的提示词。
有两段文字保持 English,因为正文按字面引用了它们:
story-events在没有事件的回合给出的No hidden story event this turn.;tabletop-rules回执开头的Settled tabletop check。
narrator 和 chat-mode-narrator 两种语言的正文都用这两段文字来判断输入。改动它们,就等于改动 story-event-cue@1 和 tabletop-check@1 的输出。
NOTE
COVEL_INSTRUCTION_LOCALE 对插件代码写的指令同样生效:pickLocaleText、instructionLocaleFor 和插件的模板加载器都读它。
新增一段由代码写进提示词的文字时,先判断它属于上表的哪一类。框架源码里的中文行由 pnpm check:i18n 逐文件计数(scripts/check-plugin-i18n.mjs 的 FRAMEWORK_CHINESE_LINES)。加了中文那份,要同时改这张表。
语言分级
| 级别 | 语言 | 有什么 |
|---|---|---|
native | English、简体中文 | 界面、内置插件的标签与文字、内置世界的语言版本,以及模型读的指令。 |
extended | 其他语言 | 界面,以及插件、世界自己提供的那部分文字。指令是 English。 |
extended语言的会话让模型读 English 指令、用这种语言写输出。故事质量取决于模型。语言选择器把这些语言标为“实验性”。- 级别由
@covel/shared的localeTier()给出。它只看这种语言有没有自己的指令集,不是一张名单。 - 繁体中文属于
extended:它读 English 指令,不读简体中文指令。
所以新增一种语言只需要一份界面 catalog,加上内容的语言版本。指令集不会变多。
会话语言在创建时锁定
一个会话有三种语言,各自只管一件事:
| 语言 | 何时确定 | 决定什么 |
|---|---|---|
| 界面语言 | 每次渲染,跟随玩家当前的设置 | 按钮、面板标题、插件名称等标签 |
| 内容语言 | 创建会话时(session.locale) | 世界内容、模型写出的正文和数据、会话状态里的文本 |
| 指令语言 | 由内容语言推导,见上一节的规则 | 框架指令句与插件提示词用 English 还是简体中文 |
内容语言必须是世界实际有的语言版本。 创建会话时,客户端传入玩家的界面语言:
- 世界有这种语言的版本(根目录有
world.<locale>.yaml或WORLD.<locale>.md),会话就用它。 - 没有,会话用世界自己的语言(
defaultLocale)。界面仍然是玩家的语言,客户端在进入之前提示。
这样不会出现“设定是一种语言、指令是第二种、却要求模型用第三种输出”的组合,小模型最先在这种组合上出错。想用自己的语言玩一个没有这种版本的世界,先用 pnpm i18n translate 给它补一个版本。
准备页的世界名、简介和世界介绍卡按将要游玩的版本显示,与开局时选定内容语言的规则相同。 会话页的世界名、简介和窗口标题按 session.locale 选择版本;切换界面语言不会改变它们。Web 组件通过 worldDisplayText(world, locale) 统一读取版本文字。
内容语言创建之后不再改变。
- 世界包里的多语言内容在导入会话时就解析成这一种语言,会话状态里只存普通字符串。
POST /api/actions不接受locale。每个回合、手动 runtime 和后台任务都用session.locale。- 玩家在游玩中切换界面语言,只会改变标签,故事仍然用原来的语言继续写。
- 想用另一种语言游玩,需要新建会话。
AI 新建世界只生成一种语言。 它只生成请求的内容语言的一份正文,defaultLocale 是规范化后的请求语言,包里没有语言文件。要有其他语言版本,必须另行生成对应的译本。自然语言值使用这份内容语言;YAML/JSON key、schema 关键字、契约 ID、枚举/常量机器值与分隔标记保持原样。修订也一样:修订结果只有世界自己的语言,其他语言版本随整包替换一起移除,需要重新翻译。见 API 的 revise-world。
统一解析规则
框架和 Web 界面显示文字时,都必须用 @covel/shared 的 resolveI18nText(value, locale) 来解析。不得在组件里另写 startsWith("en")、split("-") 或 Object.values()[0] 这样的语言判断。
解析顺序:
- 当前 locale 精确匹配。忽略大小写,
_和-通用,例如en_US可以匹配en-US。 - 当前主语言的短键,再试同一种语言、同一种文字的其他地区键,例如
en-US匹配en。文字不同的不互相回退,例如zh-Hant不匹配zh/zh-CN。 - 按语言注册表里声明的
fallbackLocales依次回退。内置注册表最后的回退是 English。 - 对象里第一个可用的字符串。
没有传 locale 时,直接取对象里第一个可用的字符串。这是为了让服务端和旧数据的结果是确定的。Web 组件应该总是传当前的 i18n.language。
相关的几个函数:
localeLanguage(locale)只负责取出主语言。- 找资源文件的候选名必须用
localeLookupCandidates()。 - 判断两个 locale 是否兼容必须用
localesShareLanguageAndScript()或注册表的match()。不能只凭主语言相同,就把不同文字的语言当成一种。
嵌套数据。
- 世界维度的值不在读取时翻译。
x-i18n节点在导入会话时就按会话的内容语言解析成普通字符串(resolveWorldDimensionsLocale()),会话状态里没有“语言 → 文字”的表。 - 其他仍然带着这种表的嵌套数据(例如面板标签)统一用
resolveI18nDeep(value, locale)解析。它只负责递归遍历数组和普通对象,保持输入自己的数据键,不把__proto__等 JSON 键当成 JavaScript 的原型操作。遇到“语言 → 文字”的表时,它仍然交给resolveI18nText,所以规则和界面完全相同:精确匹配、同语言匹配、英文回退。
Web 边界
新增界面文字放进 catalog,通过 t() 显示。pnpm check 按文件检查 JSX 中的硬编码文字,已有数量只能减少;减少后必须下调基线。检测范围和更新命令见检查命令。
内置主题的名称、说明和分组标签属于 Web 界面文案,放在 appearance.builtinThemes.<id> 下,新增语言时和其他 catalog 条目一起翻译。自定义主题仍由主题包的 I18nText 提供文案。
主题校验错误携带稳定的 code 和插值参数;界面用 settings.themeImportErrors.<code> 显示原因,不直接显示解析器异常或 Zod JSON。未知导入错误使用本地化的通用提示。
API 和插件 JSON 是不可信的输入。Web 界面用 apps/web/src/lib/i18n-text.ts 的 resolveDisplayText 来显示它们:先丢掉不是字符串的字段,再调用共享的解析函数。这样嵌套对象不会被当成 React child 渲染出来。
已有的 resolveI18n、text 和 textValue 只是兼容入口,它们也必须走这同一条路。
应用的完整句子、单位和标点都放在 catalog 中,用插值嵌入数字和名称。计数影响名词时,English 提供 _one / _other,俄语提供 _one / _few / _many / _other;也可以改写为不随计数变化的标签。调用时传数字 count,不在组件内选择复数。数字、价格和日期格式显式使用当前 i18n.language,不跟随操作系统默认语言。
新增一种语言
下面以日语 ja-JP 为例。按语言命名的文件可以用完整的 ja-JP,也可以用同一种文字的短名 ja。
- 只想翻译一个世界包:不需要复制应用的 catalog,直接看第 3 步。
- 想让玩家能直接选择
ja-JP:随 Web 或发行包加入一份完整的 JSON catalog。
1. 注册应用语言
生成 catalog。 运行
pnpm i18n:add ja-JP生成完整的模板。也可以手动把apps/web/src/i18n/locales/en-US.json复制成apps/web/src/i18n/locales/ja-JP.json。- 脚手架会按目标语言的
Intl.PluralRules,自动补齐 English 里标记为复数的条目所需的各种复数形式。 - 文件名必须是安全的、规范的 BCP 47 locale,并且不能和内置的代码或别名重复(如
zh、zh-Hans、en、ru)。 - 不需要改
i18n/index.ts、设置 schema 或语言切换器。
- 脚手架会按目标语言的
只翻译 value。 所有基础 key、JSON 类型、数组长度和
{{ interpolation }}占位符保持不变。任何含{{count}}的基础文案都可以加上目标语言需要的 i18next 复数 key,例如俄语的_few/_many。脚手架生成的复数形式只是 English 的占位值,要一起翻译。运行
pnpm check:i18n。 检查器会校验:- 文件名,以及它有没有和内置别名冲突;
- 和
en-US的基础结构是否一致; - English 里标记为复数的条目,在目标语言里各种复数形式是否齐全;
- 所有复数变体的插值变量;
- 代码实际用到的 key;
- 代码里
t("key", "English")/defaultValue写的回退文字,是否和en-UScatalog 里这个 key 的文字逐字相同。改 English 文案时两处要一起改(apps/web/scripts/check-fallback-text.mjs)。
测试并构建。 运行
pnpm --filter @covel/web test和pnpm --filter @covel/web build。构建之后,ja-JP自动出现在设置、首页顶栏和新手引导里;浏览器语言是ja/ja-JP时自动匹配。可选的其他翻译。
- 框架提示词:
<name>.ja-JP.md/<name>.ja.md。 - 世界正文:
WORLD.ja-JP.md。世界的语言文件用这个版本完整的 locale 命名,世界有ja-JP版本时不接受只写语言的WORLD.ja.md。 - Electron 原生菜单的翻译。
这些缺了都按前面的规则回退,不影响 Web 界面 catalog 生效。插件提示词不需要日语版本:日语会话读 English 正文,并按会话的语言输出日语。
- 框架提示词:
暂时翻译不了的框架内容必须明确回到 English。不能回到中文,也不能为此改动稳定的字段、枚举和业务契约。
2. 本地化插件
插件里的文本分成四类:
| 文本 | 谁读 | 写在哪 | 支持的语言 |
|---|---|---|---|
标签:displayName、description、命令和设置的 label / description、title、summary、作者信息的 about、链接 label 和动作输入框的 placeholder | 玩家,按界面语言 | 主清单写 English;译文在 locales/<locale>.yaml | 任意 |
界面文字:UI spec(ui/*.json)里的面板名、按钮、提示,以及仅用于界面显示的徽标 | 玩家,按界面语言 | spec 和代码写 English;译文在 locales/<locale>.yaml 的 messages | 任意 |
| 会话文字:handler、工具写入状态或模型上下文的提示和自然语言值 | 玩家和模型,按会话内容语言 | 代码写 English,经 translate(ctx, ...) 解析为一种语言 | 任意 |
指令:提示词正文、contributes.prompt 固定段 | 模型 | 主文件写 English;简体中文在 PLUGIN.zh.md / RUNTIME.zh.md | 只有 English 和简体中文 |
所以给插件加日语,只需要一个文件:locales/ja.yaml。
plugins/dice-check/
├── PLUGIN.md # English:契约、标签、提示词
├── PLUGIN.zh.md # 简体中文提示词(可选;内置插件的 agent 必须有)
├── locales/
│ ├── zh.yaml # 中文标签
│ └── ja.yaml # 日语标签
└── runtimes/check/
├── RUNTIME.md
└── RUNTIME.zh.md这棵目录树只是示意各种文件放在哪。*.zh.md 只在模型会读这份正文时才需要。
locales/ja.yaml 按清单文件分节。每节只写译文,key 和主清单相同:
PLUGIN.md:
displayName: ダイス判定
description: 危険を伴う行動を判定します。
contributes:
commands:
- name: roll # 列表按 id / name / key 对齐
description: ダイスを振る。
runtimes/check/RUNTIME.md:
description: 危険な行動を一つ判定します。
messages: # UI spec 的文字:English 原文 → 译文
Checks: 判定
No checks yet.: まだ判定はありません。结果是:
- 加载器把这些译文编译进清单和 UI spec,界面按玩家当前的语言显示。没翻译的标签显示 English。
- 日语会话读 English 提示词,并用日语输出。不需要、也不能提供日语提示词。
标签文件碰不到契约。 它只能翻译上表列出的标签字段。写了 runtime.schedule、工具列表、提示词内容,或主清单里没有的条目,这一条会被忽略并告警,pnpm validate:plugin 把它报为错误。
主清单和 UI spec 里不写内联的多语言表(displayName: { zh: …, en: … })。校验同样会报错。
代码里的文字怎样选语言。 它按会话的内容语言逐条解析,规则和界面标签相同:精确 locale → 同一种语言、同一种文字 → English 原文。精确 locale 的词典不完整时,缺的条目仍然会去找同语言的译文;不会因为有一份不完整的词典,就丢掉其他可用的译文。
代码里的文字怎样写。 handler、guard、工具、RPC 命令、扩展点处理函数写出的文字,同样只写 English,译文也放在 messages 里。宿主把译文作为 ctx.messages 交给代码。@covel/plugin-handlers-utils 提供两个读取它的函数:
import { labelText, translate } from "@covel/plugin-handlers-utils";
// 会话语言的一份文字:给玩家的提示、放进提示词的标题、会被注入提示词的数据
translate(ctx, "World time: {display}", { display: clock.display });
// 插件带的每种语言各一份:只由界面绘制的徽标、状态名;界面按玩家的界面语言挑
labelText(ctx, "Critical success"); // { en: "Critical success", zh: "大成功" }- English 必须是常量字符串。 会变的部分用
{name}占位符加参数传入。校验器靠读源码来找出“代码里已经没有的原文”;运行时拼出来的字符串它读不到,会直接报错。 - 会被注入提示词的数据(
io.selfData)用translate,不要用labelText。 后者含所有语言,模型会全部读到。 - 没有译文时返回 English 原文。 上下文里没有
messages(旧宿主、测试里手写的上下文)时也一样。两个函数都是这样。 - 界面和代码用了同一句 English、译法却不同时,给界面那条加属性名前缀(
label|Completed: 已完成),不带前缀的那条归代码(Completed: 完成)。 pickLocaleText(locale, zh, en)只用于代码写给模型的指令(它按指令语言选,见上),例如骰子插件注入叙事提示词的判定规则,或一个提示段末尾的说明句(见代码拼进提示词的文字)。指令和提示词正文一样,只有 English 和简体中文两种。- 测试里用
@covel/plugin-test-utils的loadPluginMessages(pluginRoot, locale)取得宿主会传入的ctx.messages。
原文改了会怎样。 messages 以 English 原文为 key。原文改了,旧译文就不再匹配:界面回到 English,pnpm validate:plugin 把这条译文报为错误。同一句原文需要两种译法时,在 key 前加属性名,如 shortLabel|Codex。规则详见 UI 面板 · 插件 UI 文本规范。
3. 本地化世界包
主文件只写一种语言,就是 world.yaml 的 defaultLocale:
defaultLocale: en-US
name: My World
summary: An example world.再在每个要翻译的文件旁边加一份语言文件,只写译文。根目录有了 world.ja-JP.yaml 或 WORLD.ja-JP.md,世界就有了 ja-JP 版本,不需要在别处登记:
worlds/my-world/
├── world.yaml
├── world.ja-JP.yaml # name / summary / 属性名等标签的日语
├── WORLD.md
├── WORLD.ja-JP.md # 正文整份替换
├── characters/
│ ├── characters.json
│ └── characters.ja-JP.json # 按 id 对齐,只写翻译了的字段
└── data/
├── dimensions.yaml
├── dimensions.ja-JP.yaml
└── rules/
├── core.yaml
└── core.ja-JP.yaml# world.ja-JP.yaml
name: 私の世界
summary: サンプル世界です。合并规则:
- source 只指向主文件,例如
characters/characters.json;语言文件不是单独的 source。 - 创建
ja-JP会话时,导入器把characters.ja-JP.json的译文合并到主文件上。 - 没翻译的字段沿用主文件。结构、id 和数值始终来自主文件。所以可以一点一点地翻译,也不会因为复制整份文件而和主文件走样。
- 由对象组成的嵌套列表,可以在 source 里用
localeArrayKeys声明稳定的键(如label)。译文保留这个键之后,就不受插入或重新排序影响。 - 媒体哈希、路径等结构字段应该只写在主文件里。
完整的合并规则见 World Data。
插件没有某种语言时
缺一种语言不会阻止安装或启用。各层分别回退:
| 层 | 插件没有这种语言时 |
|---|---|
| 标签与界面文字 | 同语言的其他地区,然后是包外翻译(见下),最后是 English。 |
| 代码写进状态和上下文的文字 | 同上。 |
| 指令(提示词正文) | 用插件有的那份:简体中文会话读 *.zh.md,其他都读 English。 |
| 游玩中模型生成的内容 | 不受影响,由框架的输出语言指令决定。 |
插件有哪些语言由文件决定,不需要声明:
- 多一个
locales/<locale>.yaml,就多一种文字语言。 - 清单旁边有
*.zh.md,就多了中文指令。
这份摘要出现在三个地方:
pnpm validate:plugin <dir>的输出,例如:textlanguages: text en, zh; instructions en, zh插件列表接口的
languages字段。会话准备页据此给没有当前界面语言文字的插件显示“未翻译”。POST /api/sessions的响应:pluginsWithoutLocale列出启用了、但没有会话语言文字的插件。会话照常创建。
包外的翻译
译文也可以不放在插件包里。翻译目录(COVEL_USER_TRANSLATIONS_DIR,默认 $COVEL_HOME/translations)里的 plugins/<pluginId>/<locale>.yaml,格式和插件自己的 locales/<locale>.yaml 完全相同:
~/.covel/translations/
└── plugins/
└── dice-check/
└── ja.yaml两种来源用同一个目录:
- 翻译包:别人为若干插件做的译文,按上面的结构放进翻译目录即可。
- 本机机器翻译:
pnpm i18n translate <plugin-dir> --locale ja --to ~/.covel/translations。译文写进翻译目录,而不是插件包。
规则:
- 优先级:作者随插件发布的译文 → 翻译目录 → English。翻译目录只补作者没翻译的条目;同一条两边都有时用作者的。
- 只放标签和
messages,不放指令。 没有人审过的指令译文,等于没有人审过的行为改动。所以*.zh.md只认插件包里的那一份。 - 什么时候过期。
messages以 English 原文为 key,插件更新后原文变了,旧译文自然不再匹配。标签按位置对应,而翻译目录里没有lock.json:插件改了某个标签的原文后,旧译文会继续显示。重新运行translate --to或手工更新即可。 - 改动后要重启。 服务端在启动时读取翻译目录。
WARNING
只安装你信任的翻译包。翻译目录里的文字和世界包的文字是同一类输入:标签会显示给玩家,messages 里的代码文字会进入模型的上下文。
4. 翻译工具
插件翻译工具和加载器共用纯 YAML 清单读取规则,解析前拒绝 ---js、---javascript 等引擎指令;不会通过清单执行代码。runtime 目录遵循插件包布局。
pnpm i18n 对一个插件目录(有 PLUGIN.md)或世界目录(有 world.yaml)工作:
pnpm i18n status plugins/dice-check # 每种语言翻译了多少
pnpm i18n status worlds/emberback --locale zh-CN --missing # 逐条列出还缺什么
pnpm i18n extract plugins/dice-check --locale ja # 打印一份待填写的 YAML
pnpm i18n translate worlds/emberback --locale zh-CN # 用配置的模型补上缺的部分
pnpm i18n lock plugins/dice-check # 确认标签译文与现在的 English 对应
pnpm i18n translate plugins/dice-check --locale ja --to ~/.covel/translations # 译文写到包外第一条命令的输出是这样的:
plugins/dice-check [zh] labels 6/6 messages 14/14对世界目录,status 必须带 --locale <tag>。
| 命令 | 做什么 |
|---|---|
status | 统计每种语言的覆盖情况。插件分两项:标签(清单里的名称、说明)和 messages(界面与代码文字);世界按文件列出。--missing 逐条列出缺的和过期的。 |
extract | 把插件在某种语言里缺的条目打印成 YAML,值是 TODO: <English>,填好后合并进 locales/<locale>.yaml。 |
translate | 请模型翻译缺失和过期的条目,写入语言文件。它和服务端一样读取 llm.toml 与环境里的 key;--slot 指定槽位,--dry-run 只列出要翻译的内容。每条译文都会检查占位符是否原样保留,不合格的重问一次后报告。 |
lock | 把插件每条标签译文“对应的 English 原文”记进 locales/lock.json。 |
过期怎么判断。
messages以 English 原文为 key。原文一改,旧译文就不再匹配,自然显示为“缺失”,不需要另外记录。- 清单标签按位置对应(
displayName、contributes.commands[name=bag].description)。English 改了,旧译文仍然“在位”。所以用locales/lock.json记下每条译文是照哪句 English 翻的;status和pnpm check:i18n用它找出过期的译文。 - 人工改完译文后运行
pnpm i18n lock。translate写入之后会自动更新它。
世界包里哪些算文字。 世界数据没有一份 schema 来标注“哪些字符串是文字”,工具按样子判断:
- 中日韩文写成的世界里:含这种文字的字符串算文字。
- 其他语言写成的世界里:不在标识符字段(
id、type、kind……)下,并且含空格或以大写字母开头的字符串算文字。 - 触发词列表
keys总是算文字,包括[echo, signal]这样全部小写的单词。 - 其他纯文本列表(例如别名
aliases)只要有一项是文字,就整列翻译。
这是给译者的参考,不是门禁。写出来的语言文件由 pnpm validate:world 校验:译文无处安放、改动了不是文本的值,都会报告。
规则和 lorebook 条目只翻译正文而漏掉触发词时,校验会给出 locale-trigger-keys 提醒。在对应语言文件的条目里补上 keys,用玩家在这种语言里会输入的词。再次翻译同一文件的其他文字时,已有的触发词列表保持原样,即使词数与原文不同。
world.yaml 里的作者名(author.name)和许可证(license)是例外。它们在任何语言下都按原样显示,不算待翻译的文本。在语言文件里给它们写译文,清单校验会失败。
维度文件也是例外,它的 schema 说明了哪些是文字:
- 维度的
name、description、updateRule; - 节点的
title和x-enumLabels; - schema 节点标了
x-i18n: true的值。
IMPORTANT
没标 x-i18n 的值不会被本地化,在任何语言的会话里都按原文出现。status 会报告“有多少个值读起来是文字却不能翻译”。要让地理、势力、历史这类叙述性的初始值能翻译,先给对应的自然语言字符串叶节点加上 x-i18n: true。单语言创建也应标记,标记本身不会生成多语言值。稳定 ID、枚举/常量机器值、代码、数值和布尔值不标记。
同一个名字只有一种译法。
translate对同一批普通文本中的相同原文只翻译一次;含隐藏边界的正文按片段单独处理。- 翻译世界时先译名字(
name、title、aliases等字段里的短文本),再把译名作为术语表带进后面的每一批。 - 这种语言已有的译文同样作为术语沿用。所以补翻一个不完整的版本时,不会改掉已有的名字。
隐藏正文的翻译。 标记由程序保留,可见与隐藏片段分别请求,不把隐藏正文交给共享术语表抽取。片段仍接受占位符校验,模型插入边界标记会被拒绝;片段最终失败时,不写入这份正文的部分译文。详见 World Data 的正文边界。这不自动保证译文的事实、指代、数量或隐藏信息的语义正确性。
在应用里翻译世界。 世界没有玩家界面语言的版本时,世界详情页提供“翻译成〈语言〉”。确认后,服务端执行和 pnpm i18n translate 相同的流程(POST /api/worlds/:id/translate),把语言文件写进世界包,世界就有了这个语言的版本。它只对用户世界目录里的世界包可用,并且先让玩家确认,因为要花模型调用。
模型译文是草稿。 人名地名、世界观术语和语气需要人看一遍。内置插件的中文由 pnpm check:i18n 强制齐全且不过期;世界包没有这条强制。
5. 校验
pnpm check:i18n
pnpm --filter @covel/shared test -- i18n
pnpm --filter @covel/plugin-world-init test
pnpm --filter @covel/web test -- i18n-text execution-timeline还应该实际切换到新的语言,检查这几处:设置页、世界选择页、会话创建、插件面板、Agent 的提示词和 WorldData 的导入结果。
pnpm check:i18n 负责发现它扫描范围内的这几类问题:没有包起来的中日韩文案、缺少 English 回退的 I18nText、任何一份 Web catalog 缺 key。它不验证翻译质量。
插件示例
主清单只写 English:
# PLUGIN.md
displayName: Dice Check
description: Resolves risky actions with dice checks.中文写在语言文件里:
# locales/zh.yaml
PLUGIN.md:
displayName: 骰子判定
description: 为有失败风险的行动提供骰子判定。id 是稳定的插件 ID。插件列表、设置导航和执行时间线都显示按当前语言解析后的 displayName,只有它缺失时才显示 id。