diff --git a/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts b/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts
index 41d0233df..64114ae88 100644
--- a/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts
+++ b/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts
@@ -1,16 +1,64 @@
import fs from 'fs';
import path from 'path';
-// TASK-179, lily-shen's ruling (2026-09-27): zh writes a number and its unit
-// together — 「24小时」, 「{{n}}天前」 — which is also what the browser's own zh-CN
+// zh-CN spacing around numerals, in both directions. Two rulings, one rule.
+//
+// TASK-179 (lily-shen, 2026-09-27): zh writes a number and its unit together —
+// 「24小时」, 「{{n}}天前」 — which is also what the browser's own zh-CN
// Intl.RelativeTimeFormat and NumberFormat produce (ux-lead's 75076). The catalog
// was 31 values the other way, so the convention gets a guard rather than a
-// reviewer's eye: this fails on the next value that reintroduces a space.
+// reviewer's eye.
+//
+// TASK-180 (lily-shen's card choice, 2026-09-28 — "Apply the rule, both sides"):
+// the same rule applies to every numeral next to hanzi, not only to the unit case.
+// 159 further spaces were closed across 103 values.
//
-// The detector is deliberately narrow. It is a number — literal digits or a
-// {{placeholder}} — followed by spaces and then a unit. A space after a
-// placeholder that is followed by an ordinary word stays (「{{count}} 个智能体」):
-// that is the placeholder boundary, not the number↔unit one.
+// Which placeholders count as numerals is the load-bearing question, and it CANNOT
+// be decided from this catalog: ``{{agents}}`` is a count in `yourTeam.head.meta`
+// and a name list joined with ' and ' in `inspector.workspace.nothingWorking_other`.
+// A first cut of the sweep declared `agents` a numeral by name and closed
+// 「Scout 可以使用」 in the Tools panel; `V2ConnectorTools.test.tsx` caught it. So the
+// default is opaque, the numeral names are declared here, and the coverage test
+// below makes a new interpolation fail rather than inherit whichever silence looks
+// convenient.
+const NUMERAL_PLACEHOLDERS = [
+ // bare numbers, or numbers formatted with grouping separators (Intl.NumberFormat)
+ 'count', 'formattedCount', 'uses', 'maxUses', 'complete', 'total', 'attempt',
+ 'revision', 'position', 'cap', 'turns', 'notes', 'days', 'signups', 'active',
+ 'working', 'needsYou', 'minutes', 'calls',
+ // a formatted duration, closed because the ruling names this key explicitly:
+ // tools.budgetWindow renders 「每1小时可调用5次」
+ 'window',
+];
+
+// A name, a date, a formatted duration, or an unproven value. The ruling keeps the
+// space wherever hanzi meets one of these. `age` and `time` are durations from a
+// formatter (relativeTime / timeAgo), not bare numbers, so they are not the
+// subject of this rule — neither is `projects`, whose value no consumer in the
+// shell proves either way.
+const OPAQUE_PLACEHOLDERS = [
+ 'age', 'by', 'pod', 'email', 'provider', 'podName', 'creator', 'date', 'agentName',
+ 'name', 'second', 'first', 'handle', 'author', 'affiliations', 'username', 'code',
+ 'label', 'taskId', 'agent', 'machine', 'time', 'section', 'platform', 'projects',
+ 'connector', 'user', 'workspace', 'channel', 'tool', 'tools', 'member', 'agents',
+];
+
+// The exception runs the safe way: a name that is usually opaque and is a number
+// here, named by key. `agents` is a count in yourTeam.head.meta (counts.agents) and
+// a name list in the inspector, so it cannot be declared for the catalog as a whole.
+const NUMERAL_BY_KEY: Array<[string, string]> = [['yourTeam.head.meta', 'agents']];
+
+// A digit is this rule's numeral only when it stands alone, and that is a question
+// about the TOKEN the digit sits inside, so it has two halves. `Apache-2.0 许可` and
+// `D1 回访` keep their space because a Latin letter precedes the digits;
+// `该文件超过 256KB` keeps it because a Latin unit FOLLOWS them. The ruling keeps the
+// space wherever hanzi meets a Latin word or unit, and `256KB` is a Latin unit. The
+// trailing half was found by sprint-review's gate at c66396a5 — the first cut of this
+// guard tested only the preceding character, so it mandated 「超过256KB」 while
+// mandating 「256KB 的」 four characters later. Measured: 1 instance in the catalog,
+// and it was spaced before this sweep touched it.
+const INSIDE_LATIN_TOKEN = /[A-Za-z0-9._-]/;
+const LATIN_FOLLOWS_DIGITS = /[A-Za-z]/;
const UNITS = ['分钟', '小时', '个月', '天', '周', '月', '年', '秒'];
const AMOUNT = String.raw`(?:\d+|\{\{[a-zA-Z]+\}\})`;
@@ -28,20 +76,154 @@ const zhValues = flatten(JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'locales', 'zh-CN.json'), 'utf8'),
));
+type Boundary = { side: 'before' | 'after'; insertion: string; precededBy: string; followedBy: string };
+
+// Every place a numeral is separated from a hanzi by spaces. Both sides are
+// collected and each carries the insertion's name and the characters on either side
+// of it, because `2.0`, `Apache-2.0` and `256KB` are the same digits with different
+// answers.
+const boundaryEvents = (value: string): Boundary[] => {
+ const events: Boundary[] = [];
+ const numeral = String.raw`(?:\d[\d,.]*|\{\{[a-zA-Z]+\}\})`;
+ const name = (token: string) => (token.startsWith('{{') ? token.slice(2, -2) : 'DIGIT');
+ for (const match of value.matchAll(new RegExp(`([\\u4e00-\\u9fff]) +(?=(${numeral}))`, 'g'))) {
+ // The numeral is a lookahead, so it is not in match[0]: it starts where the
+ // match ends and the character after it is what says whether it is a token.
+ const end = match.index + match[0].length + match[2].length;
+ events.push({
+ side: 'after', insertion: name(match[2]), precededBy: match[1], followedBy: value[end] || '',
+ });
+ }
+ for (const match of value.matchAll(new RegExp(`(${numeral}) +(?=[\\u4e00-\\u9fff])`, 'g'))) {
+ const end = match.index + match[1].length;
+ events.push({
+ side: 'before',
+ insertion: name(match[1]),
+ precededBy: match.index === undefined ? '' : value[match.index - 1] || '',
+ followedBy: value[end] || '',
+ });
+ }
+ return events;
+};
+
+const isNumeral = (key: string, event: Boundary): boolean => {
+ if (event.insertion === 'DIGIT') {
+ return !INSIDE_LATIN_TOKEN.test(event.precededBy)
+ && !LATIN_FOLLOWS_DIGITS.test(event.followedBy);
+ }
+ if (NUMERAL_BY_KEY.some(([prefix, placeholder]) => key.startsWith(prefix) && placeholder === event.insertion)) return true;
+ return NUMERAL_PLACEHOLDERS.includes(event.insertion);
+};
+
+const offenders = (side: 'before' | 'after'): string[] => zhValues.flatMap(([key, value]) => (
+ boundaryEvents(value)
+ .filter((event) => event.side === side && isNumeral(key, event))
+ .map((event) => `${key} [${event.insertion}]`)
+));
+
describe('zh-CN number and unit spacing', () => {
it('never separates a number from its unit', () => {
expect(zhValues.filter(([, value]) => spacedAmount.test(value)).map(([key]) => key)).toEqual([]);
});
+ it('never separates a hanzi from the numeral that follows it', () => {
+ // 「在过去 {{days}}天」 → 「在过去{{days}}天」
+ expect(offenders('after')).toEqual([]);
+ });
+
+ it('never separates a numeral from the hanzi that follows it', () => {
+ // 「{{count}} 条更新」 → 「{{count}}条更新」
+ expect(offenders('before')).toEqual([]);
+ });
+
+ it('declares every placeholder that reaches a boundary, so a new one cannot be exempt', () => {
+ // The test that keeps the two lists honest: a placeholder that is neither
+ // declared a numeral nor declared opaque fails here, which forces a call-site
+ // read instead of letting a new interpolation inherit silence. Without it the
+ // guard is green for any name it has not been taught.
+ const declared = [...NUMERAL_PLACEHOLDERS, ...OPAQUE_PLACEHOLDERS];
+ const undeclared = Array.from(new Set(zhValues.flatMap(([key, value]) => (
+ boundaryEvents(value).map((event) => (
+ NUMERAL_BY_KEY.some(([prefix, placeholder]) => key.startsWith(prefix) && placeholder === event.insertion)
+ ? ''
+ : event.insertion
+ ))
+ )))).filter((insertion) => insertion !== '' && insertion !== 'DIGIT' && !declared.includes(insertion));
+ expect(undeclared).toEqual([]);
+ expect(declared.length).toBeGreaterThan(40);
+ });
+
+ it('keeps the space wherever hanzi meets a Latin word, measured against the catalog', () => {
+ // The ruling's other half, and what stops the fix above from generalising into
+ // "delete every space": 242 values space a hanzi↔Latin boundary and 0 do not,
+ // so it is pinnable. It is also the reason {{age}} and {{time}} are exempt —
+ // a formatter's duration is not a bare numeral.
+ //
+ // The Latin side is TWO classes, and the second one is the half this test was
+ // blind to until c66396a5: a Latin word (`GitHub`) and a Latin unit (`256KB`) —
+ // the second begins with digits, so a `[A-Za-z]{2,}` predicate cannot see it.
+ // Measured on this catalog: 218 + 147 letter-led, 1 + 1 digit-led, 0 unspaced in
+ // every arm.
+ const letterLed = '[A-Za-z]{2,}';
+ const digitLed = '\\d[\\d,.]*[A-Za-z][A-Za-z0-9]*';
+ // Each class carries its OWN probe, because a single hand-written pair cannot
+ // exercise both predicates: the letter-led regex cannot match `256KB` at all, so
+ // using it as the probe for both arms reports the digit-led half as blind.
+ for (const [latin, probe] of [[letterLed, 'GitHub'], [digitLed, '256KB']]) {
+ const unspaced = new RegExp(`[\\u4e00-\\u9fff](?=${latin})|(?<=${latin})[\\u4e00-\\u9fff]`);
+ const spaced = new RegExp(`[\\u4e00-\\u9fff] +(?=${latin})|(?<=${latin}) +[\\u4e00-\\u9fff]`);
+ expect(zhValues.filter(([, value]) => unspaced.test(value)).map(([key]) => key)).toEqual([]);
+ // Non-vacuity, per class and per direction: each predicate has to find its own
+ // shape, or the assertion above is satisfied by a regex that matches nothing.
+ expect(unspaced.test(`超过${probe}`)).toBe(true);
+ expect(unspaced.test(`超过 ${probe}`)).toBe(false);
+ expect(spaced.test(`超过 ${probe}`)).toBe(true);
+ expect(spaced.test(`${probe} 的导入上限`)).toBe(true);
+ expect(zhValues.filter(([, value]) => spaced.test(value)).length).toBeGreaterThan(0);
+ }
+ // and the catalog still holds whole classes of both, so a green run means "none
+ // left" rather than "nothing scanned".
+ const anyLatin = new RegExp(`[\\u4e00-\\u9fff] +(?=${letterLed}|${digitLed})`);
+ expect(zhValues.filter(([, value]) => anyLatin.test(value)).length).toBeGreaterThan(200);
+ });
+
it('is not green because the detector matches nothing', () => {
- // Vacuity control. The same detector has to be able to fire — on a
- // hand-written spaced pair and on the attached pairs this catalog does
- // contain — or the test above would pass just as well if the regex were
- // blind, which is the failure it cannot otherwise report (TASK-164's
- // null==null pass was this shape).
+ // Vacuity control. The same detector has to be able to fire — on a hand-written
+ // spaced pair and on the attached pairs this catalog does contain — or the test
+ // above would pass just as well if the regex were blind, which is the failure it
+ // cannot otherwise report (TASK-164's null==null pass was this shape).
expect(spacedAmount.test('24 小时')).toBe(true);
expect(spacedAmount.test('24小时')).toBe(false);
expect(attachedAmount.test('24小时')).toBe(true);
expect(zhValues.filter(([, value]) => attachedAmount.test(value)).length).toBeGreaterThan(0);
+
+ // The same control for the TASK-180 detectors. It has to show four things: that
+ // they fire on the shape, that each declared exemption is what suppresses them
+ // (not a blind pattern), that the by-key exception is load-bearing, and that a
+ // digit inside a Latin token is not a numeral — the last one being a real
+ // false close the first cut made to `landing.footer.copyright`.
+ const found = (key: string, value: string) => boundaryEvents(value)
+ .filter((event) => isNumeral(key, event)).length;
+ expect(found('podChat.thread.moreReplies', '还有 {{count}} 项')).toBe(2);
+ expect(found('agentProfile.pods.activeAgo', '活跃于 {{time}}')).toBe(0);
+ expect(found('inspector.workspace.nothingWorking_other', '没有需要你处理的事项。{{agents}} 正在工作。')).toBe(0);
+ expect(found('yourTeam.head.meta', '{{agents}} 个智能体 · {{working}} 个工作中')).toBe(2);
+ expect(found('landing.footer.copyright', '代码采用 Apache-2.0 许可')).toBe(0);
+ expect(found('adminAnalytics.funnel.table.returnedD1', 'D1 回访')).toBe(0);
+ // The trailing half of the same rule, and the value it was found on: `KB` is a
+ // Latin unit, so the digits are not a numeral and the space stays. Without this
+ // the guard mandates a two-convention string (「超过256KB 的」) and reds anyone who
+ // fixes it.
+ expect(found('agentByo.errors.fileTooLarge', '该文件超过 256KB 的导入上限——请精简。')).toBe(0);
+ // and the mirror, so the arm above cannot pass by suppressing the detector: a
+ // numeral whose unit IS hanzi still closes.
+ expect(found('billing.freeNote', '保留 30天')).toBe(1);
+ expect(found('auth.reset.errors.tooShort', '密码至少需要 8 个字符。')).toBe(2);
+ // and the catalog still holds events on both sides for the detectors to find, so
+ // a green run means "none left" rather than "nothing scanned".
+ expect(zhValues.filter(([, value]) => boundaryEvents(value).some((e) => e.side === 'before')).length)
+ .toBeGreaterThan(0);
+ expect(zhValues.filter(([, value]) => boundaryEvents(value).some((e) => e.side === 'after')).length)
+ .toBeGreaterThan(0);
});
});
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index 3ffb0d890..15de3832b 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -67,8 +67,8 @@
"actionFailed": "回复发送失败,请重试。"
},
"lastActive": "{{time}}前活跃",
- "updatesCount_one": "{{count}} 条更新",
- "updatesCount_other": "{{count}} 条更新",
+ "updatesCount_one": "{{count}}条更新",
+ "updatesCount_other": "{{count}}条更新",
"needsYou": {
"eyebrow": "决策队列",
"title": "需要你处理",
@@ -78,8 +78,8 @@
"countUnavailable": "数量暂不可用",
"emptyTitle": "没有需要你处理的事项。",
"emptyDescription": "需要回复的提及或审批请求会显示在这里。",
- "showMore": "显示更多 · 剩余 {{count}} 条",
- "showMoreSettled": "展开更多已处理 · 剩余 {{count}} 条",
+ "showMore": "显示更多 · 剩余{{count}}条",
+ "showMoreSettled": "展开更多已处理 · 剩余{{count}}条",
"loadingMore": "加载中…",
"retry": "重试",
"kinds": {
@@ -89,7 +89,7 @@
"handoff": "交接",
"press": "待你合并"
},
- "countLabel": "{{count}} 项等你处理",
+ "countLabel": "{{count}}项等你处理",
"emptyLast": "上一个请求已在 {{age}} 前回复。"
},
"approval": {
@@ -145,16 +145,16 @@
"title": "推进的工作",
"description": "按 Pod 分组 · 最新在前",
"empty": "今天还没有更新。",
- "more": "{{pod}} 还有 {{count}} 项",
- "omitted": "{{pod}} 还有 {{count}} 项未显示",
+ "more": "{{pod}} 还有{{count}}项",
+ "omitted": "{{pod}} 还有{{count}}项未显示",
"showLess": "收起",
"unknownPod": "未知 Pod"
},
"footer": "请在源讨论中行动;底层事实变化后,这份回顾会自动离开。",
"getStarted": {
"title": "开始使用",
- "kicker_one": "还有 1 步 · 直到你的第一个请求到来",
- "kicker_other": "还有 {{count}} 步 · 直到你的第一个请求到来",
+ "kicker_one": "还有1步 · 直到你的第一个请求到来",
+ "kicker_other": "还有{{count}}步 · 直到你的第一个请求到来",
"hire": {
"title": "雇用你的第一个智能体",
"description": "选一个角色。它会在这个工作区拥有席位,几秒内回复。",
@@ -262,7 +262,7 @@
"updating": "正在更新…",
"requestNewLink": "申请新链接",
"errors": {
- "tooShort": "密码至少需要 8 个字符。",
+ "tooShort": "密码至少需要8个字符。",
"mismatch": "两次输入的密码不一致。",
"failed": "重置失败,链接可能已过期。"
}
@@ -309,10 +309,10 @@
"dmRefused": "这是一对一私信,无法使用邀请链接加入。",
"joinFailed": "加入失败,请重试。"
},
- "memberCount_one": "{{formattedCount}} 位成员",
- "memberCount_other": "{{formattedCount}} 位成员",
- "previewMeta_one": "{{formattedCount}} 位成员 · 人与智能体在 Commonly 中共同协作",
- "previewMeta_other": "{{formattedCount}} 位成员 · 人与智能体在 Commonly 中共同协作"
+ "memberCount_one": "{{formattedCount}}位成员",
+ "memberCount_other": "{{formattedCount}}位成员",
+ "previewMeta_one": "{{formattedCount}}位成员 · 人与智能体在 Commonly 中共同协作",
+ "previewMeta_other": "{{formattedCount}}位成员 · 人与智能体在 Commonly 中共同协作"
},
"firstRun": {
"eyebrow": "最初的五分钟",
@@ -363,8 +363,8 @@
"maximumUses": "最多使用次数",
"maximumUsesLabel": "邀请最多使用次数",
"unlimited": "不限次数",
- "uses_one": "{{formattedCount}} 次",
- "uses_other": "{{formattedCount}} 次"
+ "uses_one": "{{formattedCount}}次",
+ "uses_other": "{{formattedCount}}次"
},
"generate": "生成邀请链接",
"generating": "正在生成…",
@@ -378,9 +378,9 @@
"revoke": "撤销",
"revoking": "正在撤销…",
"usage": {
- "limited": "已使用 {{uses}} / {{maxUses}} 次",
- "unlimited_one": "已使用 {{formattedCount}} 次 · 不限次数",
- "unlimited_other": "已使用 {{formattedCount}} 次 · 不限次数"
+ "limited": "已使用{{uses}} / {{maxUses}}次",
+ "unlimited_one": "已使用{{formattedCount}}次 · 不限次数",
+ "unlimited_other": "已使用{{formattedCount}}次 · 不限次数"
},
"time": {
"recently": "最近",
@@ -427,8 +427,8 @@
"typing": {
"one": "{{name}} 正在思考…",
"two": "{{first}} 和 {{second}} 正在思考…",
- "many_one": "{{first}}、{{second}} 和另外 {{formattedCount}} 个智能体正在思考…",
- "many_other": "{{first}}、{{second}} 和另外 {{formattedCount}} 个智能体正在思考…"
+ "many_one": "{{first}}、{{second}} 和另外{{formattedCount}}个智能体正在思考…",
+ "many_other": "{{first}}、{{second}} 和另外{{formattedCount}}个智能体正在思考…"
},
"mentions": {
"agentSubtitle": "智能体 · @{{handle}}",
@@ -477,18 +477,18 @@
},
"heartbeat": {
"none": "最近没有收到心跳",
- "recent_one": "最近收到 {{formattedCount}} 次心跳",
- "recent_other": "最近收到 {{formattedCount}} 次心跳"
+ "recent_one": "最近收到{{formattedCount}}次心跳",
+ "recent_other": "最近收到{{formattedCount}}次心跳"
},
"header": {
- "agentsWorking_other": "{{count}} 个智能体正在工作",
- "members_one": "{{count}} 名成员",
- "members_other": "{{count}} 名成员",
- "agents_one": "{{count}} 个智能体",
- "agents_other": "{{count}} 个智能体",
- "boardOpen": "看板 {{count}} 项待办",
+ "agentsWorking_other": "{{count}}个智能体正在工作",
+ "members_one": "{{count}}名成员",
+ "members_other": "{{count}}名成员",
+ "agents_one": "{{count}}个智能体",
+ "agents_other": "{{count}}个智能体",
+ "boardOpen": "看板{{count}}项待办",
"backToPods": "返回 Pod 列表",
- "compactMeta": "{{members}} · {{count}} 个智能体"
+ "compactMeta": "{{members}} · {{count}}个智能体"
},
"team": {
"view": "查看 Pod 团队",
@@ -546,7 +546,7 @@
"collapse": "收起",
"replyOne": "条回复",
"replyOther": "条回复",
- "moreReplies": "还有 {{count}} 条回复",
+ "moreReplies": "还有{{count}}条回复",
"follow": "关注",
"following": "已关注",
"muted": "已静音"
@@ -571,7 +571,7 @@
},
"attachment": {
"open": "打开 {{name}}",
- "showMore": "展开另外 {{count}} 行",
+ "showMore": "展开另外{{count}}行",
"showLess": "收起",
"image": "图片"
},
@@ -582,7 +582,7 @@
"jumpToLatest": "跳到最新",
"searchingOlder": "正在搜索更早的消息({{attempt}}/{{max}})…",
"searchOlderFailed": "无法加载更早的历史记录。自动搜索已停止。",
- "searchOlderBound": "在 {{attempt}} 页更早的消息中未找到目标。你仍可继续浏览对话。",
+ "searchOlderBound": "在{{attempt}}页更早的消息中未找到目标。你仍可继续浏览对话。",
"retrySearch": "重试"
},
"strip": {
@@ -596,7 +596,7 @@
"earlier": "更早的消息"
},
"reactions": {
- "more": "还有 {{count}} 个回应"
+ "more": "还有{{count}}个回应"
}
},
"landing": {
@@ -739,7 +739,7 @@
},
"tax": {
"title": "不按智能体收费。",
- "text": "跑 1 个还是 50 个智能体,价格都一样。"
+ "text": "跑1个还是50个智能体,价格都一样。"
},
"federation": {
"title": "联邦互通已在路线图中。",
@@ -815,10 +815,10 @@
"proof": {
"kicker": "自我验证",
"title": "Commonly 用 Commonly 构建。",
- "sub": "各有专长的 agent 和一位独立创始人一起做出了这个项目——每个 agent 都有自己的记忆和工位。每个由 agent 提交的 PR 都有标注;{{count}} 份架构决策记录写清了缘由。",
+ "sub": "各有专长的 agent 和一位独立创始人一起做出了这个项目——每个 agent 都有自己的记忆和工位。每个由 agent 提交的 PR 都有标注;{{count}}份架构决策记录写清了缘由。",
"stats": {
"agents": "智能体",
- "messages": "过去 24小时消息",
+ "messages": "过去24小时消息",
"pods": "活跃 Pod"
}
},
@@ -857,9 +857,9 @@
"note": "固定价格。无论你跑多少个 agent —— 我们绝不按 agent 收费。",
"items": {
"everything": "包含 Cloud free 的全部功能",
- "history": "你创建的 Pod 内消息历史无限 —— 不再有 30天过期",
+ "history": "你创建的 Pod 内消息历史无限 —— 不再有30天过期",
"community": "把你的 Pod 收录进「社区」,方便团队找到",
- "hosted": "10 个托管 agent 席位 —— 我们的算力,零配置(即将推出,上线即包含)",
+ "hosted": "10个托管 agent 席位 —— 我们的算力,零配置(即将推出,上线即包含)",
"support": "优先支持"
}
},
@@ -900,13 +900,13 @@
"closed": "已关闭"
},
"lifecycle": {
- "joinedDays_one": "加入 {{count}}天",
- "joinedDays_other": "加入 {{count}}天",
+ "joinedDays_one": "加入{{count}}天",
+ "joinedDays_other": "加入{{count}}天",
"joinedUnknown": "加入 —",
- "pods_one": "{{count}} 个 Pod",
- "pods_other": "{{count}} 个 Pod",
- "messages_one": "{{count}} 条消息",
- "messages_other": "{{count}} 条消息"
+ "pods_one": "{{count}}个 Pod",
+ "pods_other": "{{count}}个 Pod",
+ "messages_one": "{{count}}条消息",
+ "messages_other": "{{count}}条消息"
},
"actions": {
"ban": "封禁",
@@ -1024,10 +1024,10 @@
"backToOverview": "返回概览",
"hidePodTeam": "隐藏 Pod 团队",
"createdByMeta": "由 {{name}} 创建 · {{date}}",
- "agentCount": "{{count}} 个智能体",
- "agentCount_other": "{{count}} 个智能体",
- "humanCount": "{{count}} 位成员",
- "humanCount_other": "{{count}} 位成员",
+ "agentCount": "{{count}}个智能体",
+ "agentCount_other": "{{count}}个智能体",
+ "humanCount": "{{count}}位成员",
+ "humanCount_other": "{{count}}位成员",
"untitledAnnouncement": "无标题公告",
"externalLink": "外部链接",
"memberNotFound": "未找到成员。",
@@ -1056,8 +1056,8 @@
},
"progress": {
"title": "进度",
- "stat": "{{total}} 个任务中已完成 {{complete}} 个",
- "stat_other": "{{total}} 个任务中已完成 {{complete}} 个"
+ "stat": "{{total}}个任务中已完成{{complete}}个",
+ "stat_other": "{{total}}个任务中已完成{{complete}}个"
},
"workspace": {
"ariaLabel": "Pod 检视器",
@@ -1078,7 +1078,7 @@
},
"filesIn": "{{pod}} 中的文件",
"noFiles": "暂无文件",
- "allFiles": "全部 {{count}} 个文件"
+ "allFiles": "全部{{count}}个文件"
},
"goal": {
"title": "目标",
@@ -1104,19 +1104,19 @@
"runState": {
"title": "运行状态",
"pending": "待处理",
- "pendingCount": "{{count}} 个待处理",
+ "pendingCount": "{{count}}个待处理",
"blocked": "受阻",
- "blockedCount": "{{count}} 个受阻",
+ "blockedCount": "{{count}}个受阻",
"inProgress": "进行中",
- "inProgressCount": "{{count}} 个进行中",
+ "inProgressCount": "{{count}}个进行中",
"complete": "已完成",
- "completeCount": "{{count}} 个已完成",
+ "completeCount": "{{count}}个已完成",
"viewRunBoard": "查看运行看板",
"recentTasks": "最近任务",
"empty": "还没有任务。智能体工作时会在这里创建任务 —— 或 @提及 某个智能体并向它提出请求。"
},
"approvals": {
- "title_other": "{{count}} 个待批准",
+ "title_other": "{{count}}个待批准",
"waiting": "等待所有者决定"
},
"taskPill": {
@@ -1170,16 +1170,16 @@
"emptySheet": "空工作表",
"emptyDocument": "空文档",
"emptyDeck": "空演示文稿",
- "truncated": "预览已截断至 200 KB。点击「打开」查看完整文件。",
- "moreRows": "还有 {{count}} 行未显示。点击「打开」查看完整文件。",
- "moreRows_other": "还有 {{count}} 行未显示。点击「打开」查看完整文件。"
+ "truncated": "预览已截断至200 KB。点击「打开」查看完整文件。",
+ "moreRows": "还有{{count}}行未显示。点击「打开」查看完整文件。",
+ "moreRows_other": "还有{{count}}行未显示。点击「打开」查看完整文件。"
}
},
"board": {
"title": "看板",
"backToChat": "返回聊天",
"newTask": "新建任务",
- "taskCount_other": "{{count}} 个任务",
+ "taskCount_other": "{{count}}个任务",
"focus": {
"eyebrow": "Pod 方向",
"title": "焦点",
@@ -1192,17 +1192,17 @@
"owner": "负责人",
"updatedBy": "更新者",
"unavailable": "不可用",
- "revision": "修订 {{revision}}",
+ "revision": "修订{{revision}}",
"taskUnavailable": "任务不可用",
"editorTitle": "编辑 Pod 焦点",
"goal": "目标",
"scope": "范围",
"chooseOwner": "选择 Pod 负责人",
- "tasks": "后续任务(最多 10 个,按顺序)",
+ "tasks": "后续任务(最多10个,按顺序)",
"up": "上移",
"down": "下移",
"remove": "移除",
- "moved": "{{taskId}} 已移至第 {{position}} 项,共 {{count}} 项。",
+ "moved": "{{taskId}} 已移至第{{position}}项,共{{count}}项。",
"clear": "清除焦点",
"save": "保存焦点",
"saving": "保存中…",
@@ -1315,7 +1315,7 @@
"raft": {
"name": "Raft",
"what": "我们最接近的产品竞争对手。运行时、工作区和记忆都留在你自己的机器上,也支持外部智能体。",
- "accept": "但协调服务是闭源的,私有化部署标注为“即将推出”,而且 Pro 将每个智能体计作 0.1 个人头席位 —— 包括跑在你自己硬件、用你自己 API 密钥的智能体。"
+ "accept": "但协调服务是闭源的,私有化部署标注为“即将推出”,而且 Pro 将每个智能体计作0.1个人头席位 —— 包括跑在你自己硬件、用你自己 API 密钥的智能体。"
},
"workspaces": {
"name": "Slack、Teams、Lark",
@@ -1350,7 +1350,7 @@
"agentByo": {
"eyebrow": "连接任意运行时",
"title": "接入你自己的智能体",
- "description": "用 MCP 把 Claude Code、Cursor 或 Codex 接入你的 Commonly Pod,约 2分钟。",
+ "description": "用 MCP 把 Claude Code、Cursor 或 Codex 接入你的 Commonly Pod,约2分钟。",
"form": {
"nameLabel": "智能体名称",
"nameHint": "小写字母、数字和连字符。这是你的智能体发帖时使用的身份。",
@@ -1415,7 +1415,7 @@
"installFailed": "安装失败。",
"fileTooLarge": "该文件超过 256KB 的导入上限——请精简,或只粘贴相关部分。",
"importFailed": "导入失败。",
- "hostedCap": "你已有一个托管智能体(测试期允许 {{cap}} 个)。如需更多,请自带运行时。",
+ "hostedCap": "你已有一个托管智能体(测试期允许{{cap}}个)。如需更多,请自带运行时。",
"hostedUnavailable": "此实例暂未开放托管。请改用自带运行时。",
"machineRequired": "请先选择一台电脑。"
},
@@ -1431,12 +1431,12 @@
"mode": {
"label": "运行方式",
"hosted": "在这里运行",
- "hostedHint": "由 Commonly 代为运行,无需安装。测试期:每天 {{turns}} 轮。",
+ "hostedHint": "由 Commonly 代为运行,无需安装。测试期:每天{{turns}}轮。",
"byo": "自带运行时",
"byoHint": "从你的电脑连接 Claude Code、Cursor 或 Codex。",
"recommended": "推荐",
"yours": "你的电脑",
- "hostedMeta": "约 10秒上线——无需终端与密钥",
+ "hostedMeta": "约10秒上线——无需终端与密钥",
"byoMeta": "MCP 令牌 + 编辑器代码片段",
"machine": "在我的电脑上",
"machineHint": "在你已添加的电脑上运行——你的文件、你的 CLI",
@@ -1448,7 +1448,7 @@
"starting": "正在启动 {{name}}…",
"running": "{{name}} 已在监听。",
"slow": "{{name}} 尚未签到。可以先 @ 它,启动后会处理消息。",
- "meter": "测试期限制:每天 {{turns}} 轮,每个账号一个托管智能体。",
+ "meter": "测试期限制:每天{{turns}}轮,每个账号一个托管智能体。",
"statTurns": "轮 / 天",
"statAgents": "托管智能体",
"latencyHint": "回复通常约十秒内送达。"
@@ -1501,10 +1501,10 @@
},
"hero": {
"official": "官方",
- "activeInPods_one": "活跃于 {{count}} 个 Pod",
- "activeInPods_other": "活跃于 {{count}} 个 Pod",
- "memories_one": "{{count}} 条记忆",
- "memories_other": "{{count}} 条记忆",
+ "activeInPods_one": "活跃于{{count}}个 Pod",
+ "activeInPods_other": "活跃于{{count}}个 Pod",
+ "memories_one": "{{count}}条记忆",
+ "memories_other": "{{count}}条记忆",
"talkTo": "与 {{name}} 对话",
"opening": "正在打开…",
"openRoomFailed": "无法打开与该智能体的一对一对话。"
@@ -1528,10 +1528,10 @@
"roleOwner": "所有者",
"roleAdmin": "管理员",
"viewTag": "{{role}}视图 · 私有",
- "indexSummary_one": "{{notes}} 条笔记,分布在 {{count}} 个板块{{updated}}。仅你可见;对公开资料页隐藏。",
- "indexSummary_other": "{{notes}} 条笔记,分布在 {{count}} 个板块{{updated}}。仅你可见;对公开资料页隐藏。",
+ "indexSummary_one": "{{notes}}条笔记,分布在{{count}}个板块{{updated}}。仅你可见;对公开资料页隐藏。",
+ "indexSummary_other": "{{notes}}条笔记,分布在{{count}}个板块{{updated}}。仅你可见;对公开资料页隐藏。",
"lastSavedClause": " · 最后保存至 {{section}},{{time}}",
- "more": "还有 {{count}} 条",
+ "more": "还有{{count}}条",
"entriesRemembered": "条已记住的条目",
"persistent": "持久记忆会跟随该智能体,延续到它加入的每个 Pod 和运行时。",
"lastSaved": "最后保存至 {{section}},{{time}}。",
@@ -1552,8 +1552,8 @@
},
"activity": {
"title": "近期活动",
- "turnsAgo_one": "{{count}} 轮 · {{time}}",
- "turnsAgo_other": "{{count}} 轮 · {{time}}"
+ "turnsAgo_one": "{{count}}轮 · {{time}}",
+ "turnsAgo_other": "{{count}}轮 · {{time}}"
},
"footer": {
"tagline": "Commonly 上的每个智能体都保有唯一的身份和记忆——跨越任何运行时。"
@@ -1572,11 +1572,11 @@
"cards": {
"dau": {
"label": "今日活跃",
- "hint": "过去 24小时内活跃的人类用户"
+ "hint": "过去24小时内活跃的人类用户"
},
"wau": {
"label": "本周活跃",
- "hint": "过去 7天内活跃的人类用户"
+ "hint": "过去7天内活跃的人类用户"
},
"signups": {
"label": "注册数({{days}}天)",
@@ -1603,7 +1603,7 @@
},
"funnel": {
"title": "激活漏斗",
- "summary": "在过去 {{days}}天的 {{signups}} 个注册中:{{attachRate}}% 挂载了智能体 · {{messageRate}}% 发送了消息 · {{d1Rate}}% 在第 1天后回访 · {{d7Rate}}% 在第 7天后回访。",
+ "summary": "在过去{{days}}天的{{signups}}个注册中:{{attachRate}}% 挂载了智能体 · {{messageRate}}% 发送了消息 · {{d1Rate}}% 在第1天后回访 · {{d7Rate}}% 在第7天后回访。",
"table": {
"cohort": "群组",
"signups": "注册数",
@@ -1665,10 +1665,10 @@
},
"meta": {
"directMessage": "私信",
- "agentCount_one": "{{count}} 个智能体",
- "agentCount_other": "{{count}} 个智能体",
- "memberCount_one": "{{count}} 名成员",
- "memberCount_other": "{{count}} 名成员"
+ "agentCount_one": "{{count}}个智能体",
+ "agentCount_other": "{{count}}个智能体",
+ "memberCount_one": "{{count}}名成员",
+ "memberCount_other": "{{count}}名成员"
},
"errors": {
"createFailed": "无法创建 Pod。请确认你已登录后重试。"
@@ -1703,7 +1703,7 @@
"new": "新建",
"connect": "连接 {{platform}}",
"untitled": "未命名 Pod",
- "needsYouCount": "{{count}} 项待处理",
+ "needsYouCount": "{{count}}项待处理",
"listTitle": "Pod",
"search": "搜索 Pod",
"noMatch": "没有匹配的 Pod",
@@ -1729,11 +1729,11 @@
"subtitle": {
"empty": "还没有智能体 —— 雇佣第一个智能体开始使用。",
"summary": "{{agents}},分布在 {{projects}} 中",
- "agentCount_one": "{{count}} 个智能体",
- "agentCount_other": "{{count}} 个智能体",
- "projectCount_one": "{{count}} 个项目",
- "projectCount_other": "{{count}} 个项目",
- "kicker": "{{agents}} · 本周活跃 {{active}} 个"
+ "agentCount_one": "{{count}}个智能体",
+ "agentCount_other": "{{count}}个智能体",
+ "projectCount_one": "{{count}}个项目",
+ "projectCount_other": "{{count}}个项目",
+ "kicker": "{{agents}} · 本周活跃{{active}}个"
},
"actions": {
"addComputer": "添加电脑",
@@ -1807,11 +1807,11 @@
"internal": "已连接的智能体",
"quietCount": "沉寂({{count}})",
"internalCount": "已连接的智能体({{count}})",
- "activeMeta_one": "活跃于{{time}} · {{count}} 个 Pod",
- "activeMeta_other": "活跃于{{time}} · {{count}} 个 Pod"
+ "activeMeta_one": "活跃于{{time}} · {{count}}个 Pod",
+ "activeMeta_other": "活跃于{{time}} · {{count}}个 Pod"
},
"head": {
- "meta": "{{agents}} 个智能体 · {{working}} 个工作中 · {{needsYou}} 个等你"
+ "meta": "{{agents}}个智能体 · {{working}}个工作中 · {{needsYou}}个等你"
},
"own": {
"title": "你自己的智能体",
@@ -1826,7 +1826,7 @@
"free": "免费版",
"pro": "Pro · $12/月"
},
- "freeNote": "自带 agent 不限数量,消息历史保留 30天。升级后,你创建的 Pod 内历史记录无限保留,并可将 Pod 收录进「社区」。",
+ "freeNote": "自带 agent 不限数量,消息历史保留30天。升级后,你创建的 Pod 内历史记录无限保留,并可将 Pod 收录进「社区」。",
"proNote": "你创建的 Pod 内历史记录无限保留、社区收录,以及托管 agent 席位(上线即包含)。",
"cancelling": "你的方案将在本计费周期结束时取消。在此之前仍可使用 Pro。",
"upgrade": "升级到 Pro —— $12/月",
@@ -1848,7 +1848,7 @@
"enableHint": "打开与 Commonly 机器人的私聊",
"enableHintSend": ",然后发送:",
"waitingTelegram": "在 Telegram 聊天中发送 /commonly-enable 命令。",
- "codeExpires": "代码将在 {{minutes}}分钟后过期",
+ "codeExpires": "代码将在{{minutes}}分钟后过期",
"liveRelay": "中继",
"liveRelayHint": "聊天消息会发布到 Pod 并唤醒被提及的智能体;智能体的升级消息会回到频道。",
"newTitle": "连接频道",
@@ -1995,7 +1995,7 @@
"size": "大小"
},
"foot": "{{shown}} / {{total}} · 最新在前",
- "more": "还有 {{count}} 项",
+ "more": "还有{{count}}项",
"loadingMore": "加载中…",
"empty": "还没有共享内容。在任意 Pod 里附加文件,就会出现在这里。",
"emptyFiltered": "没有匹配项。清除筛选或搜索。",
@@ -2017,8 +2017,8 @@
"asksNothing": "无需确认",
"awaiting": "等待人工确认",
"budget": "额度",
- "budgetTotal": "{{calls}} 次调用",
- "budgetWindow": "每 {{window}}可调用 {{calls}} 次",
+ "budgetTotal": "{{calls}}次调用",
+ "budgetWindow": "每{{window}}可调用{{calls}}次",
"calls": "调用",
"cancel": "取消",
"changeAccess": "更改访问权限",
@@ -2042,7 +2042,7 @@
"grantDetails": "授权详情",
"grantError": "无法授权。",
"grantedByOn": "{{rel}}由 {{member}} 授权。",
- "grantedCount": "已授权 {{count}} 个",
+ "grantedCount": "已授权{{count}}个",
"grantedOn": "{{rel}}授权。",
"grantedToPod": "授权给 {{pod}}",
"grantedToPodBy": "由 {{member}} 授权给 {{pod}}",
@@ -2058,7 +2058,7 @@
"modeRead": "读取",
"modeWrite": "读取和写入",
"modeWriteConfirm": "读取和写入,写入前需确认",
- "moreCount": "另有 {{count}} 个",
+ "moreCount": "另有{{count}}个",
"noConnection": "请先安装 GitHub App · 管理员只需安装一次",
"noSeats": "此 Pod 中还没有智能体",
"noTools": "允许列表中没有工具",
diff --git a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx
index bdfbefe64..fb795cfa7 100644
--- a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx
+++ b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx
@@ -194,7 +194,10 @@ test('TASK-164: the grant, budget and trail times read in zh-CN, not English', a
const aside = await screen.findByRole('complementary');
expect(within(aside).getByText(/前由 sam 授权。/)).toBeInTheDocument();
expect(within(aside).getByText(/后结束。/)).toBeInTheDocument();
- expect(within(aside).getByText(/每 1小时可调用 50 次/)).toBeInTheDocument();
+ // lily-shen's TASK-180 ruling names this key explicitly — 「每{{window}}可调用
+ // {{calls}}次」 — so the budget line loses both of its spaces. This assertion
+ // pinned the old spacing, which is why it had to move with the ruling.
+ expect(within(aside).getByText(/每1小时可调用50次/)).toBeInTheDocument();
await waitFor(() => expect(within(aside).getAllByRole('listitem')).toHaveLength(4));
const lines = within(aside).getAllByRole('listitem');
expect(lines.map((line) => line.textContent)).toEqual([
@@ -242,7 +245,9 @@ test('TASK-179: the grant line is one sentence per language, and an empty audien
const rowSaying = (text: string) => rows().find((row) => row.textContent?.includes(text)) as HTMLElement;
expect(rowSaying('Launch pod').querySelector('strong')?.textContent).toBe('由 sam 授权给 Launch pod');
// The audience keeps its space before a Latin name, and loses it when there
- // is no audience at all — which is why the empty case is a separate key.
+ // is no audience at all — which is why the empty case is a separate key. This
+ // is also the assertion that caught the first cut of TASK-180, where `agents`
+ // was declared a numeral by name and closed the space before the audience.
expect(rowSaying('Launch pod').textContent).toContain('Scout 可以使用');
expect(rowSaying('Ops').textContent).toContain('没有智能体可以使用');
// The separator key is zh-empty: two sentences that each end in 。 join with