diff --git a/backend/__tests__/unit/integrations/manifestReadiness.test.js b/backend/__tests__/unit/integrations/manifestReadiness.test.js index 24cd249e2..f4581e99f 100644 --- a/backend/__tests__/unit/integrations/manifestReadiness.test.js +++ b/backend/__tests__/unit/integrations/manifestReadiness.test.js @@ -65,4 +65,17 @@ describe('installable connector manifest readiness', () => { expect(manifests.telegram.catalog.description).toBe('One Telegram chat, one pod.'); expect(manifests.telegram.catalog.description).not.toMatch(/ingest|summar/i); }); + + it('ships the first-party locale copy beside each canonical catalog description', () => { + expect(manifests.telegram.catalog.descriptions).toEqual({ + en: 'One Telegram chat, one pod.', + 'zh-CN': '一个 Telegram 聊天,一个 Pod。', + }); + expect(manifests.slack.catalog.descriptions).toEqual({ + en: 'Your Slack DM, every pod you\'re in.', + 'zh-CN': '你的 Slack 私信,你所在的每个 Pod。', + }); + expect(manifests.discord.catalog.descriptions['zh-CN']).toBe('接入 Discord 频道动态,发布 Pod 摘要。'); + expect(manifests.groupme.catalog.descriptions['zh-CN']).toBe('缓存 GroupMe 消息,汇总进 Pod。'); + }); }); diff --git a/backend/__tests__/unit/routes/installables.test.js b/backend/__tests__/unit/routes/installables.test.js index 1c4845e42..6837ec76f 100644 --- a/backend/__tests__/unit/routes/installables.test.js +++ b/backend/__tests__/unit/routes/installables.test.js @@ -95,6 +95,18 @@ describe('installable connector routes', () => { expect(catalogService.catalogFor).toHaveBeenCalledWith('64b64c48c4f37a6b2f34c111'); }); + it('does not let the browser Accept-Language header choose the catalog locale', async () => { + catalogService.catalogFor.mockResolvedValue({ installables: [] }); + + const res = await request(app) + .get('/api/installables') + .set(auth) + .set('Accept-Language', 'zh-CN,zh;q=0.9,en;q=0.8'); + + expect(res.status).toBe(200); + expect(catalogService.catalogFor).toHaveBeenCalledWith('64b64c48c4f37a6b2f34c111'); + }); + it('refuses an unavailable provider before reading a pod or claiming a parent', async () => { catalogService.providerReadiness.mockReturnValue({ available: false, reason: 'not_configured' }); diff --git a/backend/__tests__/unit/scripts/seed-builtin-tools.test.js b/backend/__tests__/unit/scripts/seed-builtin-tools.test.js index b5150f331..e0bebeb7d 100644 --- a/backend/__tests__/unit/scripts/seed-builtin-tools.test.js +++ b/backend/__tests__/unit/scripts/seed-builtin-tools.test.js @@ -35,6 +35,7 @@ describe('seed-builtin-tools', () => { const rows = await Installable.find({ installableId: 'github' }).lean(); expect(rows).toHaveLength(1); expect(rows[0].source).toBe('builtin'); + expect(rows[0].descriptions['zh-CN']).toBe('装有 GitHub App 的那个仓库里的 issue 和 pull request,由 Commonly 的代理按 Pod 授权代为调用。'); expect(rows[0].components[0].enabledTools).toEqual(buildGithubToolInstallable().components[0].enabledTools); }); diff --git a/backend/__tests__/unit/services/installableCatalogService.test.js b/backend/__tests__/unit/services/installableCatalogService.test.js index 2c36f8c50..b1804ff2e 100644 --- a/backend/__tests__/unit/services/installableCatalogService.test.js +++ b/backend/__tests__/unit/services/installableCatalogService.test.js @@ -34,6 +34,10 @@ const githubTool = () => ({ installableId: 'github', name: 'GitHub', description: 'Issues and pull requests.', + descriptions: { + en: 'Issues and pull requests.', + 'zh-CN': '问题和代码审查请求。', + }, components: [{ name: 'commonly-grant-broker', type: 'mcp-server', enabledTools: ['github.list_issues', 'github.create_issue'] }], }); @@ -129,6 +133,58 @@ describe('installable catalog service', () => { expect(Integration.find).not.toHaveBeenCalled(); }); + it('projects the complete first-party description map and keeps canonical English', async () => { + mockInstallables([ + { + installableId: 'telegram', + name: 'Telegram', + source: 'builtin', + description: 'One Telegram chat, one pod.', + descriptions: { + en: 'One Telegram chat, one pod.', + 'zh-CN': '一个 Telegram 聊天,一个 Pod。', + }, + }, + { + installableId: 'slack', + name: 'Slack', + source: 'builtin', + description: 'Your Slack DM, every pod you\'re in.', + descriptions: { en: 'Your Slack DM, every pod you\'re in.' }, + }, + ]); + InstallableInstallation.find.mockReturnValue(lean([])); + + const catalog = await catalogFor(userId); + expect(catalog.installables.find((entry) => entry.installableId === 'telegram')).toEqual(expect.objectContaining({ + description: 'One Telegram chat, one pod.', + descriptions: { + en: 'One Telegram chat, one pod.', + 'zh-CN': '一个 Telegram 聊天,一个 Pod。', + }, + })); + expect(catalog.installables.find((entry) => entry.installableId === 'slack')).toEqual(expect.objectContaining({ + description: 'Your Slack DM, every pod you\'re in.', + descriptions: { en: 'Your Slack DM, every pod you\'re in.' }, + })); + }); + + it('never translates a non-builtin row even when it carries a locale map', async () => { + mockInstallables([], [{ + ...githubTool(), + source: 'marketplace', + descriptions: { en: 'Marketplace English', 'zh-CN': '不应投影' }, + }]); + InstallableInstallation.find.mockReturnValue(lean([])); + Integration.find.mockReturnValue(lean([])); + + const catalog = await catalogFor(userId); + expect(catalog.installables.find((entry) => entry.list === 'tools')).toEqual(expect.objectContaining({ + description: 'Issues and pull requests.', + descriptions: { en: 'Issues and pull requests.' }, + })); + }); + it('the catalogue returns the GitHub tool Installable with its tools, broker and the caller\'s connections, on the tools list', async () => { process.env.GITHUB_APP_ID = 'app-1'; process.env.GITHUB_APP_PRIVATE_KEY = 'pem'; @@ -152,6 +208,10 @@ describe('installable catalog service', () => { list: 'tools', label: 'GitHub', description: 'Issues and pull requests.', + descriptions: { + en: 'Issues and pull requests.', + 'zh-CN': '问题和代码审查请求。', + }, available: true, broker: { id: 'commonly-grant-broker' }, tools: [ @@ -162,6 +222,7 @@ describe('installable catalog service', () => { installation: null, integration: null, }); + expect(Integration.find).toHaveBeenCalledWith(expect.objectContaining({ type: { $in: ['github-app'] }, createdBy: userId, status: 'connected', revokedAt: null, })); diff --git a/backend/__tests__/unit/services/toolInstallables.test.js b/backend/__tests__/unit/services/toolInstallables.test.js index f5a97dfb4..e50b88c8f 100644 --- a/backend/__tests__/unit/services/toolInstallables.test.js +++ b/backend/__tests__/unit/services/toolInstallables.test.js @@ -20,6 +20,10 @@ describe('the builtin GitHub tool Installable', () => { test('the builtin GitHub Installable enables exactly the broker\'s GitHub tools', () => { const installable = buildGithubToolInstallable(); + expect(installable.descriptions).toEqual({ + en: installable.description, + 'zh-CN': '装有 GitHub App 的那个仓库里的 issue 和 pull request,由 Commonly 的代理按 Pod 授权代为调用。', + }); const component = mcpComponentOf(installable); expect(installable).toMatchObject({ installableId: 'github', source: 'builtin', kind: 'app', scope: 'pod', status: 'active' }); expect(installable.components).toHaveLength(1); diff --git a/backend/integrations/manifests.ts b/backend/integrations/manifests.ts index 77b23c51e..26accddf5 100644 --- a/backend/integrations/manifests.ts +++ b/backend/integrations/manifests.ts @@ -16,6 +16,8 @@ interface IntegrationManifest { category: string; docsPath: string; description: string; + /** Locale-specific first-party copy; `description` remains the en source. */ + descriptions?: Record; capabilities: string[]; } | null; } @@ -60,6 +62,10 @@ const manifests: Record = { category: 'chat', docsPath: 'docs/discord/DISCORD.md', description: 'Ingest Discord channel activity and post pod summaries.', + descriptions: { + en: 'Ingest Discord channel activity and post pod summaries.', + 'zh-CN': '接入 Discord 频道动态,发布 Pod 摘要。', + }, capabilities: ['webhook', 'gateway', 'summary', 'commands'], }, }), @@ -81,7 +87,11 @@ const manifests: Record = { provider: 'slack', category: 'chat', docsPath: 'docs/slack/README.md', - description: 'Ingest Slack Events API messages into pod summaries.', + description: 'Your Slack DM, every pod you\'re in.', + descriptions: { + en: 'Your Slack DM, every pod you\'re in.', + 'zh-CN': '你的 Slack 私信,你所在的每个 Pod。', + }, capabilities: ['webhook', 'summary', 'commands'], }, }), @@ -95,6 +105,10 @@ const manifests: Record = { category: 'chat', docsPath: 'docs/groupme/README.md', description: 'Buffer GroupMe messages and summarize them into pods.', + descriptions: { + en: 'Buffer GroupMe messages and summarize them into pods.', + 'zh-CN': '缓存 GroupMe 消息,汇总进 Pod。', + }, capabilities: ['webhook', 'commands', 'summary'], }, }), @@ -114,6 +128,10 @@ const manifests: Record = { category: 'chat', docsPath: 'docs/telegram/README.md', description: 'One Telegram chat, one pod.', + descriptions: { + en: 'One Telegram chat, one pod.', + 'zh-CN': '一个 Telegram 聊天,一个 Pod。', + }, capabilities: ['webhook', 'summary', 'commands'], }, }), diff --git a/backend/models/Installable.ts b/backend/models/Installable.ts index e1927d547..5a25ac5f3 100644 --- a/backend/models/Installable.ts +++ b/backend/models/Installable.ts @@ -278,6 +278,8 @@ export interface IInstallable extends Document { installableId: string; name: string; description: string; + /** First-party locale copy; `description` remains the canonical English value. */ + descriptions?: Record; version: string; // Marketplace surface hint — which aisle to shelve this in. See @@ -529,6 +531,22 @@ const InstallableSchema = new Schema( }, name: { type: String, required: true }, description: { type: String, required: true, default: '' }, + descriptions: { + type: Schema.Types.Mixed, + default: undefined, + validate: { + validator: (value: unknown): boolean => ( + value === undefined + || (value !== null + && typeof value === 'object' + && !Array.isArray(value) + && Object.entries(value as Record).every(([locale, text]) => ( + locale.trim().length > 0 && typeof text === 'string' && text.trim().length > 0 + ))) + ), + message: 'descriptions must be a map of locale keys to non-empty strings', + }, + }, version: { type: String, required: true }, kind: { diff --git a/backend/scripts/seed-builtin-connectors.ts b/backend/scripts/seed-builtin-connectors.ts index b9e8b9e45..e59b350db 100644 --- a/backend/scripts/seed-builtin-connectors.ts +++ b/backend/scripts/seed-builtin-connectors.ts @@ -24,6 +24,7 @@ const TELEGRAM_CONNECTOR = { // package wrapper, so it must not fork a second source of truth. name: telegramCatalog.label, description: telegramCatalog.description, + descriptions: telegramCatalog.descriptions, version: '1.0.0', kind: 'app', source: 'builtin', @@ -58,7 +59,8 @@ if (!slackCatalog) { const SLACK_CONNECTOR = { installableId: 'slack', name: slackCatalog.label, - description: 'Link your Slack DM to Commonly — every pod you\'re in gets a voice where you already talk.', + description: slackCatalog.description, + descriptions: slackCatalog.descriptions, version: '1.0.0', kind: 'app', source: 'builtin', @@ -108,9 +110,15 @@ export const seedBuiltinConnectors = async (): Promise => { // create their uniqueness/TTL indexes during the deterministic boot seed // rather than discovering a missing index on the first external request. await Promise.all([ConnectorSecret.syncIndexes(), WebhookDelivery.syncIndexes()]); - await Promise.all([TELEGRAM_CONNECTOR, SLACK_CONNECTOR].map((connector) => ( - Installable.findOneAndUpdate( - { installableId: connector.installableId }, + await Promise.all([TELEGRAM_CONNECTOR, SLACK_CONNECTOR].map(async (connector) => { + const holder = await Installable.findOne({ installableId: connector.installableId }) + .select('source').lean() as { source?: string } | null; + if (holder && holder.source !== 'builtin') { + console.warn(`[builtin-connectors] installableId '${connector.installableId}' is held by a '${holder.source}' row; the builtin seed leaves it alone`); + return; + } + return Installable.findOneAndUpdate( + { installableId: connector.installableId, source: 'builtin' }, { $set: connector, $setOnInsert: { @@ -118,8 +126,8 @@ export const seedBuiltinConnectors = async (): Promise => { }, }, { upsert: true, new: true, setDefaultsOnInsert: true }, - ) - ))); + ); + })); console.log('[builtin-connectors] Telegram and Slack manifests ready'); } catch (error) { console.error('[builtin-connectors] seed failed:', (error as Error).message); diff --git a/backend/services/installable/installableCatalogService.ts b/backend/services/installable/installableCatalogService.ts index 5b6d8dde3..c10f54fd3 100644 --- a/backend/services/installable/installableCatalogService.ts +++ b/backend/services/installable/installableCatalogService.ts @@ -18,6 +18,36 @@ type ProviderManifest = { readiness?: () => ProviderReadiness; }; +type LocalizedInstallable = { + description?: unknown; + descriptions?: unknown; + source?: string; +}; + +/** + * Project the locale map without choosing a language on the server. The page's + * in-app language is authoritative; `description` remains the canonical + * English fallback. Marketplace rows are never translated or persisted here. + */ +const projectDescriptions = (installable: LocalizedInstallable | null | undefined): Record => { + const canonical = typeof installable?.description === 'string' ? installable.description : ''; + if (installable?.source && installable.source !== 'builtin') return { en: canonical }; + + const raw = installable?.descriptions; + const entries = raw instanceof Map + ? Array.from(raw.entries()) + : raw && typeof raw === 'object' && !Array.isArray(raw) + ? Object.entries(raw as Record) + : []; + const projected = Object.fromEntries(entries.filter(([key, value]) => ( + typeof key === 'string' && key.trim().length > 0 + && typeof value === 'string' && value.trim().length > 0 + ))) as Record; + // Keep the canonical field authoritative if both fields contain English. + projected.en = canonical || projected.en || ''; + return projected; +}; + const providerInstallableIds = (): string[] => Object.values(manifests as Record) .filter((manifest) => typeof manifest.readiness === 'function') .map((manifest) => manifest.id); @@ -101,7 +131,8 @@ const toolEntriesFor = async (userId: string): Promise => { installableId: row.installableId, list: 'tools', label: row.name || row.installableId, - description: row.description || '', + description: typeof row.description === 'string' ? row.description : '', + descriptions: projectDescriptions(row), available: readiness.available, ...(readiness.available ? {} : { unavailableReason: readiness.reason }), broker: { id: String(component?.name || '') }, @@ -154,7 +185,8 @@ const catalogFor = async (userId: string): Promise<{ installables: unknown[] }> installableId, list: 'channels', label: installable?.name || installableId, - description: installable?.description || '', + description: typeof installable?.description === 'string' ? installable.description : '', + descriptions: projectDescriptions(installable), available: readiness.available, ...(readiness.reason ? { unavailableReason: readiness.reason } : {}), installation: publicInstallation(installation), @@ -166,6 +198,11 @@ const catalogFor = async (userId: string): Promise<{ installables: unknown[] }> }; }; -module.exports = { catalogFor, providerReadiness, publicIntegration }; +module.exports = { + catalogFor, + providerReadiness, + publicIntegration, + projectDescriptions, +}; export {}; diff --git a/backend/services/installable/toolInstallables.ts b/backend/services/installable/toolInstallables.ts index 1cd8c9990..985f990f8 100644 --- a/backend/services/installable/toolInstallables.ts +++ b/backend/services/installable/toolInstallables.ts @@ -68,10 +68,17 @@ const githubToolNames = (): string[] => toolDefinitions() .filter((definition) => definition.connectionType === 'github-app') .map((definition) => definition.name); +const GITHUB_DESCRIPTION = 'Issues and pull requests in the repository the GitHub App is installed on, called through Commonly\'s broker on a room grant.'; +const GITHUB_DESCRIPTIONS = { + en: GITHUB_DESCRIPTION, + 'zh-CN': '装有 GitHub App 的那个仓库里的 issue 和 pull request,由 Commonly 的代理按 Pod 授权代为调用。', +}; + export const buildGithubToolInstallable = () => ({ installableId: 'github', name: 'GitHub', - description: 'Issues and pull requests in the repository the GitHub App is installed on, called through Commonly\'s broker on a room grant.', + description: GITHUB_DESCRIPTION, + descriptions: GITHUB_DESCRIPTIONS, version: '1.0.0', kind: 'app', source: 'builtin', diff --git a/docs/design/evidence/connectors-tools-zh-CN-1200.png b/docs/design/evidence/connectors-tools-zh-CN-1200.png new file mode 100644 index 000000000..d1ab29431 Binary files /dev/null and b/docs/design/evidence/connectors-tools-zh-CN-1200.png differ diff --git a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx index b1fc6cf8b..6c46c467a 100644 --- a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx @@ -6,6 +6,7 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea import { MemoryRouter, Route, Routes } from 'react-router-dom'; import V2ConnectorsPage, { INSTALL_LOCK_TTL_MS, installableLifecyclePath } from '../components/V2ConnectorsPage'; import { AuthContext } from '../../context/AuthContext'; +import i18n, { i18nReady, LANGUAGE_STORAGE_KEY } from '../../i18n'; jest.mock('axios', () => { const mock = { @@ -511,6 +512,37 @@ describe('V2ConnectorsPage', () => { )); }); + it('uses the in-app language for catalog descriptions, independent of the browser language', async () => { + await i18nReady; + const originalNavigatorLanguage = navigator.language; + Object.defineProperty(navigator, 'language', { configurable: true, value: 'en-US' }); + const localized = entry({ + descriptions: { + en: 'One Telegram chat, one pod.', + 'zh-CN': '一个 Telegram 聊天,一个 Pod。', + }, + }); + + try { + window.localStorage.setItem(LANGUAGE_STORAGE_KEY, 'zh-CN'); + await i18n.changeLanguage('zh-CN'); + mockCatalog([localized]); + const zhView = renderPage(); + expect(await screen.findByText('一个 Telegram 聊天,一个 Pod。')).toBeInTheDocument(); + zhView.unmount(); + + window.localStorage.setItem(LANGUAGE_STORAGE_KEY, 'en'); + await i18n.changeLanguage('en'); + mockCatalog([localized]); + renderPage(); + expect(await screen.findByText('One Telegram chat, one pod.')).toBeInTheDocument(); + } finally { + window.localStorage.removeItem(LANGUAGE_STORAGE_KEY); + await i18n.changeLanguage('en'); + Object.defineProperty(navigator, 'language', { configurable: true, value: originalNavigatorLanguage }); + } + }); + it('labels the row step separately from the final connect action', async () => { mockCatalog([entry()]); renderPage(); diff --git a/frontend/src/v2/components/V2ConnectorTools.tsx b/frontend/src/v2/components/V2ConnectorTools.tsx index b3605de75..bf181318b 100644 --- a/frontend/src/v2/components/V2ConnectorTools.tsx +++ b/frontend/src/v2/components/V2ConnectorTools.tsx @@ -16,6 +16,7 @@ import { useV2Api } from '../hooks/useV2Api'; import { useAuth } from '../../context/AuthContext'; import { V2Pod } from '../hooks/useV2Pods'; import { PlatformGlyph } from '../icons/platforms'; +import { localizeInstallableDescription } from '../utils/localizeInstallableDescription'; export type GrantWriteMode = 'read' | 'write' | 'write-with-confirm'; @@ -62,6 +63,7 @@ export interface ToolCatalogEntry { list?: 'channels' | 'tools'; label: string; description: string; + descriptions?: Record; available: boolean; unavailableReason?: string; broker?: { id: string }; @@ -484,7 +486,7 @@ const V2ConnectorTools: React.FC = ({ pods }) => { {entry.label} - {entry.description} + {localizeInstallableDescription(entry)} {!entry.available ? t('tools.notEnabled', { defaultValue: 'not enabled on this instance · ask your operator' }) @@ -584,7 +586,7 @@ const V2ConnectorTools: React.FC = ({ pods }) => {

{draft.replaces ? t('tools.changeAccess', { defaultValue: 'Change access' }) : t('tools.grant', { defaultValue: 'grant' })}

{draftEntry.label} · {podName(draft.podId)}

-

{draftEntry.description}

+

{localizeInstallableDescription(draftEntry)}

{!draft.replaces && podIds.length > 1 && (