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