多语言:给人看的文字
目标读者:写插件、契约包或语言包的开发者。本文写做法;用到的函数的签名与消息格式见 schema-i18n,服务与出口见 api-i18n,默认实现的规则见 plugin-i18n。
给人看的文字按读者的语言显示:插件交出文字对象(取自自己原文字典的一个键),出口按读者渲染。插件不判断读者是谁、用什么语言,也不自己取语言来源。
哪些要翻、哪些不翻
要翻的是给人看的文字:
- 声明里的显示名、配置项的 label 与说明、指令的说明、页面的标题与组件、动作的 label 与确认句、工具分组的称呼与概述;
- 运行中产出的句子:指令的回复、动作的回执、提示与诊断结论、宿主通知给人看的那句;
- 给人看的报错:激活失败的原因、指令与动作的失败、配置校验的问题。
不翻、照原文写的:
- 给模型的:工具说明、参数说明与工具结果,提示词与注入的内容,宿主通知写给模型的正文;
- 日志与诊断记录;
- 协议占位与标识:存档里的占位、配置键、命令名、文件名,指令的
usage与示例(用户照着敲的写法); - 用户数据与模型产出:用户写的话、会话标题、摘要、档案、记忆;
- 配置项的默认值:写进配置文件的是数据。
句子里的技术名词、配置键、命令与代码片段原样,不按语言改写。
字典的布局
src/texts.ts 原文字典:defineTexts({ ns: <包名>, language: <原文语言>, source, locales })
src/locales/<语言>.ts 一种语言的译文:defineLocale<Source>()({ … })ns写包名。插件前端的字典由宿主按插件名取,ns与包名不同时frame.t一律回键名。- 字典要由服务端入口导入:
src/index.ts里import { t } from './texts.js'。字典在服务进程里登记进目录,宿主才取得到;插件前端的frame.t也靠它(见 api-webui 的「助手」)。 - 原文语言写自己写原文的语言;第一方的原文是
zh-CN,带en译文,推荐的语言标签与第一方一致(en、zh-CN)。 - 单份字典控制在几百键以内:
defineLocale逐条核参数,上千键的文件在编辑器里诊断会慢。大的按区域拆成几份同一ns的defineTexts,目录按键合并。
声明里用 t()
收文字的字段写 t('键'),不写字面量:
import { t } from './texts.js';
export default definePlugin({
name: '@your-scope/plugin-x',
displayName: t('displayName'),
configSchema: defineConfig({
greeting: { type: 'string', label: t('greetingLabel'), description: t('greetingDescription'), default: '<问候语>' },
}),
// …
});契约里写成 string | TextRef 或 unknown 的展示字段都收文字对象:schema-config 的 label、description 与可选项的 label,指令与参数的说明,页面声明里给人看的字段,提示的 title 与 detail,诊断结论,provide 的 label 等。写普通字符串也可以,那就是不翻译的原文。
插件的显示名与 provide 的 label 在 core 里是展示值:core 0.21 起才收 unknown,0.20 只收字符串。用文字对象时,core 的 peer 下限写 >=0.21.0 <1.0.0。
运行中的句子
- 一句一个键,写整句,参数放变化的部分:
t('saved', { count }),不拼「已保存」+ 数字 + 「项」。 - 条件片段拆成独立的句子各占一个键,不按条件拼前后半句:别的语言里语序不同。
- 数量用
{n, plural, …}或{n, number},按读者的语言写复数与千分位;时间用date、time、duration,大小用size。 - 罗列用
{items, list}(或list, or),多行用{items, lines}或textLines,并列的几条原因用textClauses。 - 参数里可以放别的文字对象,它与外层按同一条语言链渲染。
- 参数要放进代码段时用
{x, code},不在模板里写`{x}`;字面的花括号与撇号按引号规则写。
写法与边界见 schema-i18n 的「消息格式」。
报错
- 给人看的报错抛
new TextError(t('…')):message是原文,日志照旧可读,出口按读者语言显示;HTTP 出口的状态码写在status,下层的错误写在cause。 - 已有的错误类(按
name判定的,如 schema-config 的ConfigError)用withText(err, t('…'))挂上文字对象,类与name不变。 - 接住的报错要写进句子时用
textOf(err)作参数:有文字对象取它,否则取message。不要取.message拼句。 - 判断一个错误有没有带文字对象用
isTextError,不用instanceof:进程里装了两份 schema-i18n 时,两份的类互不认得。
一份内容两个读者
同一件事既要告诉模型、又要给人看时,拆成两样:
- 消息与通知:
content写给模型(原文,可以带处理办法与工具用法),text写给人看的那句文字对象。schema-message 的OutgoingMessage.text、IncomingMessage.text、宿主通知的hostNotice.text、存档的ArchiveNoticeOptions.text都是这样分的。 - 共用的回执:产出文字对象,给人看的一路交给出口,工具一路
toSource后再交给模型。
交给模型的文字对象一律取 toSource,工具结果与注入的内容都是。
落盘的文字对象
文字对象是纯数据,可以原样落盘;旧记录里的字符串照旧当原文显示。
- 落盘前给参数封顶:
truncateParams(text, 200),参数里可能带着上游整段报错。第一方一律截到 200 字;返回值可能退成字符串,存它的字段本来就收string | TextRef。 - 读回时校验要认
isTextRef:形状对的文字对象与字符串都收下。 - 落盘的记录再交给模型前取
toSource。
出口
把文字交给人看的地方(发到平台、写进页面响应)用 api-i18n 的 localizeFor 按读者渲染,日志与交给模型的用 toSource。多数插件不是出口:把文字对象交给收它的字段就够了。
自检与键的约定
单测里一行:
import { checkTexts } from '@aalis/schema-i18n';
import { t } from '../src/texts.js';
expect(checkTexts(t)).toEqual([]);它查原文与译文能不能解析、参数对不对得上、引号与反引号的写法、各语言缺不缺键。
键一旦落盘就是约定:读回的文字对象按当时字典里这个键渲染。意思或参数变了换新键,删掉的键不复用;改了原文的措辞要同步改译文,语言包里对应的条目也要重译(见下一节)。
写语言包
语言包给别人的字典补它们没有的语言,是一个普通插件,经贡献点 i18n:pack 交条目:
import { contributions } from '@aalis/api-contributions';
import type {} from '@aalis/api-i18n'; // 带上 i18n:pack 的类型
import { fingerprint } from '@aalis/schema-i18n';
// uses: { contributions }
contributions.use.contribute('i18n:pack', {
id: 'ja',
language: 'ja',
entries: {
'@your-scope/plugin-x': { title: { text: 'チェックポイントを戻す', from: fingerprint('回滚检查点') } },
},
});- 一包一种语言,提供哪种语言以贡献里的
language为准。 from是翻译时原文模板的指纹(fingerprint算出)。只补缺、指纹不符与几个包给同一条时的取舍见 plugin-i18n 的「渲染与语言包」。- 罗列的分隔符也要补:
{x, list}用@aalis/schema-i18n字典的listSeparator键,只带 zh-CN 与 en。日文要用「、」就在entries里加'@aalis/schema-i18n': { listSeparator: { text: '、', from: fingerprint('、') } }。 - 骨架从 WebUI 的「翻译」页导出(plugin-i18n 的「翻译页」):选中一个字典与语言,「待译骨架:语言包的 entries」一栏只列字典没有自带译文的键,缺的与过时的先填了原文,
from已算好。
命名与关键词:
- 包名:官方
@aalis/plugin-lang-<小写标签>,第三方aalis-plugin-lang-<标签>或@scope/aalis-plugin-lang-<标签>。 keywords带三个:aalis-plugin(加载器只认它)、aalis-language-pack(标明这是语言包)、aalis-lang-<小写规范标签>(按语言找,写这个包覆盖的最泛的一级,如ja、zh-tw)。- 找语言包只看关键词、不解析包名:先按完整标签找,再按基础语言(
ja-JP先找aalis-lang-ja-jp,再找aalis-lang-ja)。npm create aalis按这个顺序找,plugin-i18n 的诊断也写这两个关键词。
读别的插件交来的文字
别的插件与契约交来的展示字段可能是文字对象。读法四条:
- 给人看的:交给收文字对象的字段(消息的
text、页面声明、提示),或自己是出口时用localizeFor按读者渲染。 - 给模型与写日志的:
toSource。 - 只判有没有的:照真假判,文字对象是真值。
- 不要拼接,也不要
String()或模板串插值:文字对象拼进字符串只会得到[object Object]。
契约里写成 string | TextRef 或 unknown 的展示字段都照此读,具体字段看各包的契约页。下表只列类型看不出读法的:
| 来源 | 字段 | 读法 |
|---|---|---|
| api-authority | AuthorityService.authorize 的返回 | string | TextRef | null:null 放行,其余是拒绝原因(authority) |
| schema-message | OutboundDeliveryResult.error | string | TextRef;OneBot 的投递失败原因是文字对象,交给模型取 toSource |
| api-commands | Command 的说明(含位置参数、选项与取值的说明) | string | TextRef:给人看用 localizeFor,给模型或日志用 toSource。CommandService.execute 回的是字符串,已按 ctx 的会话渲染 |
| util-cron、api-cron-engine | ValidateResult | 失败分支是 { ok: false; text },没有 reason;要抛给人看时,文字对象用 TextError |
| api-persona | getPersonaName | 卡没写名字时回空串:点名检测跳过空串,要显示名字的一方自己兜底(persona) |
| api-session-protocol | tool-call 条目 | 生成了却没执行的带 notExecuted、没有 result,客户端据此写出没有执行(api-session-protocol) |
| 存档 | 元数据 Text | 实现存档的一方要把 IncomingMessage.text 记进元数据 Text;读历史的一方要认出带 Text 的 user 角色消息是平台事件合成的入站,画成通知,不是谁的发言(message-archive) |
| plugin-scheduler | SchedulerService.getJobs() 的 lastResult | string | TextRef,还没执行过时是空串 |
| api-doctor | UnmetRoot.error(经 unmetDependencies 交出) | string | TextRef(doctor) |
| api-webui | themeContrastIssues 返回的 ContrastIssue.pair | 文字对象,不是字符串 |
| schema-config | SchemaIssue.message | 仍是字符串(原文);给人看取 text |
| 会话协议与 WebUI REST | 交给前端的字段 | 已渲染好的字符串。按类型读线上形状用 Localized<…> 与 api-webui 的 WebuiRestResponse<K> |
| 第一方插件 | 抛出的给人看的报错 | TextError:message 是原文,取 .message 或 textOf;String(err) 的前缀是 TextError: |
脚手架生成的样子
create-aalis-plugin 生成的插件已按本文写好:src/texts.ts 与另一种语言的 src/locales/<语言>.ts,声明里用 t(),给模型的保持字符串并注明,npm test 跑字典自检,插件前端经 frame.t 显示。见 脚手架上手指南 的「生成的 src/index.ts 形状」与「页面用自带前端」两节。