feat(i18n): add English support for host-side chat messages

Wrap all user-facing chat text in src/channels with a host-side t()
translator (Chinese is the identity default, so untranslated text is
always sent verbatim in Chinese). English is selected via the host
plugin config `language: en` or the DSH_IM_LANGUAGE environment
variable, and the settings UI keeps following the DSH web locale.

- add src/channels/shared/i18n.mjs with setImHostLanguage/t() and a
  Chinese-key -> English dictionary split by area (shared + channels)
- complete the English dictionary for every t() key in src/channels,
  including the feishu channel (welcome/help, repair flow, interactive
  cards, watch flow) and the shared command/approval/stream text
- wrap previously untranslated chat-facing strings in feishu bridge,
  feishu runtime, editable-message-stream, harness-client,
  connection-test and image-prompt
- dedupe dictionary entries that existed in multiple channel files and
  canonicalize conflicting translations into the shared dictionaries
- extend the i18n unit test to decode string-literal escapes so keys
  containing \n are checked correctly
- document the language switch in README.md, README.en.md and
  CHANGELOG.md, and rebuild lib/index.js

Added English support for bot chat messages across all nine IM
channels and AI Office without changing any Chinese output.
This commit is contained in:
Zaakir 2026-08-23 22:34:11 +05:00
parent 3ff8c2a519
commit dcd7cd64d8
78 changed files with 2283 additions and 992 deletions

View file

@ -14,6 +14,8 @@
* without button callbacks.
*/
import { t } from '../shared/i18n.mjs';
export const MENU_PAGE_SIZE = 10;
function plainText(content) {
@ -56,7 +58,7 @@ function buttonElement(content, actionValue) {
function safeTitle(value) {
const title = String(value ?? '').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').replace(/\s+/gu, ' ').trim();
return title || '暂无标题';
return title || t('暂无标题');
}
function cardWith(headerText, elements) {
@ -69,18 +71,18 @@ function cardWith(headerText, elements) {
/** The main command menu (buttons + number-reply fallback). */
export function menuCard() {
return cardWith('🤖 助手菜单', [
{ tag: 'div', text: markdown('**点击按钮或直接回复数字**') },
button('1 · 会话列表', 'sessions'),
button('2 · 工作区', 'workspaces'),
button('3 · 新会话', 'new'),
button('4 · 状态', 'status'),
button('5 · 帮助', 'help'),
return cardWith(t('🤖 助手菜单'), [
{ tag: 'div', text: markdown(t('**点击按钮或直接回复数字**')) },
button(t('1 · 会话列表'), 'sessions'),
button(t('2 · 工作区'), 'workspaces'),
button(t('3 · 新会话'), 'new'),
button(t('4 · 状态'), 'status'),
button(t('5 · 帮助'), 'help'),
// Repair must remain number-driven. Apps that need this command do not
// have card.action.trigger yet, so rendering it as a callback button would
// send the user straight back to Feishu's broken callback setup popup.
{ tag: 'div', text: markdown('**6 · 修复卡片按钮**(请直接回复数字 **6**)') },
button('7 · 关注列表', 'watchlist'),
{ tag: 'div', text: markdown(t('**6 · 修复卡片按钮**(请直接回复数字 **6**)')) },
button(t('7 · 关注列表'), 'watchlist'),
]);
}
@ -89,10 +91,10 @@ export function cardActionProbeCard(nonce) {
if (typeof nonce !== 'string' || !/^[A-Za-z0-9_-]{16,128}$/.test(nonce)) {
throw new TypeError('A safe card-action probe nonce is required');
}
return cardWith('🧪 验证卡片按钮', [
return cardWith(t('🧪 验证卡片按钮'), [
{
tag: 'div',
text: markdown('授权已提交。请点击下方按钮;机器人真实收到回调后才会判定修复成功。'),
text: markdown(t('授权已提交。请点击下方按钮;机器人真实收到回调后才会判定修复成功。')),
},
{
tag: 'column_set',
@ -103,7 +105,7 @@ export function cardActionProbeCard(nonce) {
weight: 1,
elements: [{
tag: 'button',
text: plainText('完成验证'),
text: plainText(t('完成验证')),
type: 'primary',
width: 'fill',
behaviors: [{
@ -140,74 +142,80 @@ export function sessionListCard(workspace, sessions, page, total, watchedSession
],
});
const elements = [
{ tag: 'div', text: markdown(`**工作区**:\`${workspace}\`\n共 **${total}** 个会话${total > MENU_PAGE_SIZE ? `(第 ${page + 1}/${pageCount} 页)` : ''}`) },
{ tag: 'div', text: markdown(t('**工作区**:{workspace}\n共 **{total}** 个会话{paging}', {
workspace: `\`${workspace}\``,
total,
paging: total > MENU_PAGE_SIZE
? t('(第 {page}/{pageCount} 页)', { page: page + 1, pageCount })
: '',
})) },
...slice.map((session, offset) => {
// Page-local numbering: number replies resolve against this page.
const label = `${offset + 1}. ${safeTitle(session.title)}${session.archived === true ? '(已归档)' : ''}`;
const label = `${offset + 1}. ${safeTitle(session.title)}${session.archived === true ? t('(已归档)') : ''}`;
const watching = watched(session.sessionId);
return row(
buttonElement(watching ? '⭐取关' : '⭐关注', watching ? `unwatch:${session.sessionId}` : `watch:${session.sessionId}`),
buttonElement(watching ? t('⭐取关') : t('⭐关注'), watching ? `unwatch:${session.sessionId}` : `watch:${session.sessionId}`),
buttonElement(label, `use:${session.sessionId}`),
);
}),
];
if (page > 0) elements.push(button('◀ 上一页', `sessions:${page - 1}`));
if (page + 1 < pageCount) elements.push(button('下一页 ▶', `sessions:${page + 1}`));
elements.push({ tag: 'div', text: markdown('回复数字(1~N)绑定本页会话。') });
return cardWith('📂 会话列表', elements);
if (page > 0) elements.push(button(t('◀ 上一页'), `sessions:${page - 1}`));
if (page + 1 < pageCount) elements.push(button(t('下一页 ▶'), `sessions:${page + 1}`));
elements.push({ tag: 'div', text: markdown(t('回复数字(1~N)绑定本页会话。')) });
return cardWith(t('📂 会话列表'), elements);
}
/** The workspace list card (switch-workspace buttons + reply fallback). */
export function workspaceListCard(paths, current) {
const elements = paths.length === 0
? [{ tag: 'div', text: markdown('当前 Host 上没有已登记的工作区。') }]
? [{ tag: 'div', text: markdown(t('当前 Host 上没有已登记的工作区。')) }]
: [
{ tag: 'div', text: markdown(`回复数字切换工作区,或点击按钮:`) },
{ tag: 'div', text: markdown(t('回复数字切换工作区,或点击按钮:')) },
...paths.map((path, index) => button(
`${index + 1}. ${path}${path === current ? '(当前)' : ''}`,
`${index + 1}. ${path}${path === current ? t('(当前)') : ''}`,
`workspace:${path}`,
)),
];
return cardWith('🗂 工作区', elements);
return cardWith(t('🗂 工作区'), elements);
}
/** The card-menu help text (number-driven, no command memorization). */
export function menuHelpText() {
return [
'🤖 助手菜单(回复数字即可,无需记命令)',
t('🤖 助手菜单(回复数字即可,无需记命令)'),
'',
'1 · /sessionlist 列出会话(回复数字绑定)',
'2 · /workspacelist 列出工作区(回复数字切换)',
'3 · /new 开启新会话',
'4 · /status 连接状态',
'5 · /help 本帮助',
'6 · /repair 修复卡片按钮(请回复数字 6)',
'7 · /watchlist 关注列表',
t('1 · /sessionlist 列出会话(回复数字绑定)'),
t('2 · /workspacelist 列出工作区(回复数字切换)'),
t('3 · /new 开启新会话'),
t('4 · /status 连接状态'),
t('5 · /help 本帮助'),
t('6 · /repair 修复卡片按钮(请回复数字 6)'),
t('7 · /watchlist 关注列表'),
'',
'直接发送文字/图片即继续当前会话。',
'/session ID 或序号 绑定已有会话',
'/watch ID 或序号 关注会话(完成后推送)',
'/compact 压缩上下文',
'/workspace 绝对路径 切换工作区',
'/presetlist 列出可用 Agent Preset',
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
'纯数字 ID:/preset id:<ID>',
'/preset --default 跟随 Host 默认',
t('直接发送文字/图片即继续当前会话。'),
t('/session ID 或序号 绑定已有会话'),
t('/watch ID 或序号 关注会话(完成后推送)'),
t('/compact 压缩上下文'),
t('/workspace 绝对路径 切换工作区'),
t('/presetlist 列出可用 Agent Preset'),
t('/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset'),
t('纯数字 ID:/preset id:<ID>'),
t('/preset --default 跟随 Host 默认'),
].join('\n');
}
/** The watch list for one conversation (unwatch buttons + reply fallback). */
export function watchListCard(entries) {
const elements = entries.length === 0
? [{ tag: 'div', text: markdown('当前没有关注的会话。\n`/watch <ID|序号>` 关注后,任务完成会自动推送。') }]
? [{ tag: 'div', text: markdown(t('当前没有关注的会话。\n`/watch <ID|序号>` 关注后,任务完成会自动推送。')) }]
: [
{ tag: 'div', text: markdown('任务完成会自动推送,回复数字或点按钮取消关注:') },
{ tag: 'div', text: markdown(t('任务完成会自动推送,回复数字或点按钮取消关注:')) },
...entries.map((entry, index) => button(
`${index + 1}. ${safeTitle(entry.title)}`,
`unwatch:${entry.sessionId}`,
)),
];
return cardWith('👁 关注列表', elements);
return cardWith(t('👁 关注列表'), elements);
}
/**
@ -216,19 +224,19 @@ export function watchListCard(entries) {
*/
export function completionCard(sessionId, title, reason) {
const reasonText = reason === 'completed'
? '已完成'
? t('已完成')
: reason === 'stopped'
? '已停止'
? t('已停止')
: reason === 'aborted'
? '已中止'
? t('已中止')
: reason === 'cancelled'
? '已取消'
: '已结束';
return cardWith('✅ 任务完成', [
? t('已取消')
: t('已结束');
return cardWith(t('✅ 任务完成'), [
{ tag: 'div', text: markdown(`**${safeTitle(title)}**\n\`${sessionId}\``) },
{ tag: 'div', text: markdown(`**状态**:${reasonText}`) },
button('打开会话列表', 'sessions'),
button('工作区', 'workspaces'),
{ tag: 'div', text: markdown('绑定该会话后可继续追问,输入文字即可。') },
{ tag: 'div', text: markdown(t('**状态**:{reason}', { reason: reasonText })) },
button(t('打开会话列表'), 'sessions'),
button(t('工作区'), 'workspaces'),
{ tag: 'div', text: markdown(t('绑定该会话后可继续追问,输入文字即可。')) },
]);
}