Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/28064-data-lifecycle-settings-translations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/service-settings': patch
---

fix(i18n): the Data Lifecycle settings page reads in the admin's language

Clause-②: no
Metadata-protocol: none

Setup → All Settings → Data Lifecycle read English under Chinese (zh-CN), Japanese (ja-JP) and Spanish (es-ES): the hub card's title and description and every field on the page, while every other settings page and the Setup sidebar entry for this page were translated. The page now reads in those languages too: its title, description, and each field's label and help (Enforce lifecycle policies, Retention overrides, Row quotas, Per-class quota defaults, Growth alert threshold). The hub card and the sidebar entry now carry the same name in every shipped locale.

Nothing to do: the translations ship in the built-in settings bundle and load with the settings service, as the other pages' do. The JSON examples inside the help text stay as written, since they are the values you type.
18 changes: 18 additions & 0 deletions .changeset/28069-ai-knowledge-help-business-words.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/service-settings': patch
---

fix(service-settings): AI & Embedder and Knowledge help is in business words

Clause-②: no
Metadata-protocol: none

On Setup → Settings → AI & Embedder and Knowledge, the help under several fields read like code comments, in every language: "Forwarded as OPENAI_API_KEY", traces saved "to sys_ai_trace", "Chunks per embed() call", "Per-source values on KnowledgeSource.adapterConfig take precedence", "topK × overFetch". The help now says what each setting does:

- A provider API key or the gateway model is "used instead of the one set in the server configuration".
- Record traces keeps a record of each AI request and its response, so a conversation can be inspected and replayed.
- Batch size is the number of chunks sent to the embedding provider in one request.
- Indexing defaults: a value set on a knowledge source itself wins over these defaults.
- Over-fetch multiplier: search fetches this many times the requested number of results, so enough are left after filtering.

The English, Chinese, Japanese and Spanish text changed together. No setting, default or accepted value changed, and model examples such as openai/gpt-4o stay as written because they are what you type.
20 changes: 10 additions & 10 deletions packages/services/service-settings/src/manifests/ai.manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@ const manifest = {
// ── Vercel AI Gateway ─────────────────────────────────────────
{ type: 'group', id: 'gateway', label: 'Vercel AI Gateway', required: false,
visible: "${data.provider === 'gateway'}",
description: 'Multi-provider router. The model spec follows `provider/model`, e.g. `openai/gpt-4o`.' },
description: 'Multi-provider router. The model is written provider/model, e.g. openai/gpt-4o.' },
{ type: 'text', key: 'gateway_model', label: 'Gateway model', required: true,
description: 'Forwarded as AI_GATEWAY_MODEL. Format: provider/model, e.g. openai/gpt-4o or anthropic/claude-sonnet-4.6.',
description: 'Used instead of the model set in the server configuration. Format: provider/model, e.g. openai/gpt-4o or anthropic/claude-sonnet-4.6.',
pattern: '^[A-Za-z0-9_-]+\\/[A-Za-z0-9._:-]+$',
visible: "${data.provider === 'gateway'}" },
{ type: 'password', key: 'gateway_api_key', label: 'Gateway API key',
Expand All @@ -72,7 +72,7 @@ const manifest = {
visible: "${data.provider === 'openai'}" },
{ type: 'password', key: 'openai_api_key', label: 'OpenAI API key',
required: true, encrypted: true,
description: 'Forwarded as OPENAI_API_KEY. Stored encrypted at rest.',
description: 'Used instead of the key set in the server configuration. Stored encrypted at rest.',
visible: "${data.provider === 'openai'}" },
{ type: 'text', key: 'openai_model', label: 'Model', required: false, default: 'gpt-4o',
description: 'Default model id. Per-agent overrides take precedence.',
Expand All @@ -86,7 +86,7 @@ const manifest = {
visible: "${data.provider === 'anthropic'}" },
{ type: 'password', key: 'anthropic_api_key', label: 'Anthropic API key',
required: true, encrypted: true,
description: 'Forwarded as ANTHROPIC_API_KEY. Stored encrypted at rest.',
description: 'Used instead of the key set in the server configuration. Stored encrypted at rest.',
visible: "${data.provider === 'anthropic'}" },
{ type: 'text', key: 'anthropic_model', label: 'Model', required: false,
default: 'claude-sonnet-4-20250514',
Expand All @@ -97,7 +97,7 @@ const manifest = {
visible: "${data.provider === 'google'}" },
{ type: 'password', key: 'google_api_key', label: 'Google API key',
required: true, encrypted: true,
description: 'Forwarded as GOOGLE_GENERATIVE_AI_API_KEY. Stored encrypted at rest.',
description: 'Used instead of the key set in the server configuration. Stored encrypted at rest.',
visible: "${data.provider === 'google'}" },
{ type: 'text', key: 'google_model', label: 'Model', required: false,
default: 'gemini-2.0-flash',
Expand Down Expand Up @@ -138,14 +138,14 @@ const manifest = {
{ type: 'group', id: 'cloudflare', label: 'Cloudflare AI Gateway', required: false,
visible: "${data.provider === 'cloudflare'}",
description:
'Uses the /compat endpoint so the model id is `provider/model` (e.g. ' +
'`openai/gpt-4o-mini`, `anthropic/claude-3-5-sonnet`, `deepseek/deepseek-chat`). ' +
'Uses the /compat endpoint, so the model is written provider/model (e.g. ' +
'openai/gpt-4o-mini, anthropic/claude-3-5-sonnet, deepseek/deepseek-chat). ' +
'Note: alibaba/qwen* is NOT supported by Cloudflare /compat — use the DashScope provider for Qwen.' },
{ type: 'text', key: 'cloudflare_account_id', label: 'Cloudflare account id', required: true,
description: 'The 32-char hex id from your Cloudflare dashboard URL.',
visible: "${data.provider === 'cloudflare'}" },
{ type: 'text', key: 'cloudflare_gateway_id', label: 'Gateway id', required: false, default: 'default',
description: 'Gateway name configured in Cloudflare → AI Gateway. Defaults to `default`.',
description: 'Gateway name configured in Cloudflare → AI Gateway. Defaults to "default".',
visible: "${data.provider === 'cloudflare'}" },
{ type: 'password', key: 'cloudflare_api_key', label: 'Cloudflare AI Gateway token',
required: true, encrypted: true,
Expand Down Expand Up @@ -226,7 +226,7 @@ const manifest = {
{ type: 'group', id: 'observability', label: 'Observability', required: false },
{ type: 'toggle', key: 'trace_enabled', label: 'Record traces',
required: false, default: true,
description: 'Persist prompt/response traces to sys_ai_trace for debugging and replay.' },
description: 'Keep a record of each AI request and its response, so a conversation can be inspected and replayed when something goes wrong.' },
{ type: 'toggle', key: 'log_prompts', label: 'Log full prompts',
required: false, default: false,
description: 'Include rendered prompts (not just metadata) in trace rows.' },
Expand Down Expand Up @@ -303,7 +303,7 @@ const manifest = {
visible: "${data.embedder_provider && data.embedder_provider !== 'none'}" },
{ type: 'number', key: 'embedder_batch_size', label: 'Batch size',
required: false, default: 64, min: 1, max: 2048,
description: 'Chunks per embed() call. Reduce if hitting provider rate / size limits.',
description: 'Chunks sent to the embedding provider in one request. Reduce if hitting provider rate / size limits.',
visible: "${data.embedder_provider && data.embedder_provider !== 'none'}" },
{ type: 'action_button', id: 'test_embedder', label: 'Test embedder',
required: false, icon: 'Plug',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ const manifest = {
visible: "${data.adapter === 'turso'}",
description:
'Works against managed Turso (libsql://…), local file (file:./knowledge.db), ' +
'or in-memory (:memory:). For per-tenant cloud deployments, leave blank to ' +
'reuse the tenant\'s primary libSQL connection.' },
'or in-memory (:memory:). In a cloud deployment with a database per organization, leave blank to ' +
'reuse that organization\'s own database connection.' },
{ type: 'text', key: 'turso_url', label: 'Connection URL', required: false,
description: 'Examples: libsql://your-tenant.turso.io · file:./.objectstack/knowledge.db · :memory:',
visible: "${data.adapter === 'turso'}" },
Expand All @@ -95,12 +95,12 @@ const manifest = {
visible: "${data.adapter === 'ragflow'}" },
{ type: 'text', key: 'ragflow_default_dataset', label: 'Default dataset id',
required: false,
description: 'Used when a KnowledgeSource does not specify its own RAGFlow dataset.',
description: 'Used when a knowledge source does not name its own RAGFlow dataset.',
visible: "${data.adapter === 'ragflow'}" },

// ── Indexing defaults ─────────────────────────────────────────
{ type: 'group', id: 'indexing', label: 'Indexing defaults', required: false,
description: 'Per-source values on KnowledgeSource.adapterConfig take precedence.',
description: 'A value set on a knowledge source itself wins over these defaults.',
visible: "${data.adapter !== 'memory'}" },
{ type: 'number', key: 'chunk_target', label: 'Target chunk size (chars)',
required: false, default: 800, min: 64, max: 8192,
Expand All @@ -112,14 +112,14 @@ const manifest = {
visible: "${data.adapter !== 'memory'}" },
{ type: 'number', key: 'over_fetch', label: 'Over-fetch multiplier',
required: false, default: 4, min: 1, max: 20,
description: 'Internal `topK * overFetch` candidates fetched so JS-side metadata filtering still has rows to return.',
description: 'Search fetches this many times the requested number of results, so enough are left to return after they are filtered by document details.',
visible: "${data.adapter === 'turso'}" },

// ── Permissions ───────────────────────────────────────────────
{ type: 'group', id: 'permissions', label: 'Permissions', required: false },
{ type: 'toggle', key: 'enforce_rls', label: 'Enforce RLS on search',
required: false, default: true,
description: 'Re-check every hit against the caller\'s record-level permissions via IDataEngine.' },
description: 'Re-check every hit against the caller\'s record-level permissions.' },
{ type: 'info_banner', id: 'enforce_rls_warning', label: 'Leave RLS on in production',
required: false, bannerSeverity: 'warning',
bannerText:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@
* The technical detail lives in the deployment docs; the help names the
* effect in business words.
*
* The sweep reads every string an administrator is shown for those three
* AI & Embedder and Knowledge had the same problem (#28069): the help named the
* env vars a saved key or model stands in for (`OPENAI_API_KEY`,
* `AI_GATEWAY_MODEL`), the trace object, the embedding call and the knowledge
* source's config path. They now say what the setting does, and a saved key
* is described as used instead of the one in the server configuration.
*
* The sweep reads every string an administrator is shown for those five
* namespaces — the manifest literals (the English fallback) and each shipped
* locale's copy — and refuses the four shapes that only a developer can read.
* It pins SHAPES, never wording: a rewrite that stays in business words keeps
Expand All @@ -24,12 +30,17 @@ import type { SettingsManifest } from '@objectstack/spec/system';
import { authSettingsManifest } from './manifests/auth.manifest.js';
import { featureFlagsSettingsManifest } from './manifests/feature-flags.manifest.js';
import { mailSettingsManifest } from './manifests/mail.manifest.js';
import { aiSettingsManifest } from './manifests/ai.manifest.js';
import { knowledgeSettingsManifest } from './manifests/knowledge.manifest.js';
import { en, zhCN, jaJP, esES } from './translations/index.js';

const MANIFESTS: SettingsManifest[] = [
authSettingsManifest,
featureFlagsSettingsManifest,
mailSettingsManifest,
// #28069
aiSettingsManifest,
knowledgeSettingsManifest,
];

const LOCALES: Array<[string, { settings?: Record<string, any>; settingsCommon?: Record<string, any> }]> = [
Expand Down Expand Up @@ -89,7 +100,7 @@ function packStrings(tr: Record<string, any> | undefined, ns: string): Array<[st
return out;
}

describe('Settings help is in business words (#27853)', () => {
describe('Settings help is in business words (#27853, #28069)', () => {
it('the sweep has teeth: each shape catches the string it was written for', () => {
// Every shape is exercised by a string the old help carried, so a regex
// that stops matching anything goes red here instead of passing quietly.
Expand All @@ -98,6 +109,12 @@ describe('Settings help is in business words (#27853)', () => {
expect(shapesIn('emailAndPassword.requireEmailVerification: false')).not.toEqual([]);
expect(shapesIn('declared via defineWebhook() is read')).not.toEqual([]);
expect(shapesIn('Pin via env vars (e.g. `X`)')).not.toEqual([]);
// The strings #28069 was filed for.
expect(shapesIn('Forwarded as GOOGLE_GENERATIVE_AI_API_KEY.')).not.toEqual([]);
expect(shapesIn('Persist traces to sys_ai_trace for replay.')).not.toEqual([]);
expect(shapesIn('Per-source values on KnowledgeSource.adapterConfig take precedence.')).not.toEqual([]);
expect(shapesIn('Chunks per embed() call.')).not.toEqual([]);
expect(shapesIn('Format: provider/model, e.g. openai/gpt-4o. Examples: deepseek-chat (V3).')).toEqual([]);
expect(shapesIn('Invitation only closes self-registration (SCIM, e.g. smtp.example.com).')).toEqual([]);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@
* the bundled manifests (as `os serve` does) and registers
* `lifecycleSettingsManifest` the way `ObjectQLPlugin` does at `kernel:ready`.
*
* Data Lifecycle is `@objectstack/objectql`'s manifest, and no bundle carries a
* translated title for it, so its hub card reads "Data Lifecycle" in every
* locale. That gap is pinned by name below ({@link HUB_UNTRANSLATED}), so a
* translation added later turns the label comparison on for it rather than
* leaving it unchecked.
* Every page the hub lists carries a translated title in each shipped locale,
* Data Lifecycle included: it is `@objectstack/objectql`'s manifest, and its
* copy lives in {@link settingsBuiltinTranslations} beside the bundled ones
* (the coverage test there holds it to the same rule), so the label comparison
* runs for every page and no page is exempt from it.
*/

import { describe, expect, it } from 'vitest';
Expand All @@ -48,9 +48,6 @@ const SETTINGS_PAGE = /^\/apps\/setup\/system\/settings\/([a-z_]+)$/;

const TRANSLATED_LOCALES = ['zh-CN', 'ja-JP', 'es-ES'] as const;

/** Namespaces the hub lists with no translated title in any shipped locale. */
const HUB_UNTRANSLATED = ['lifecycle'];

interface NavItem { id?: string; type?: string; label?: string; url?: string; children?: NavItem[] }

async function bootHub(): Promise<{ manifests: SettingsManifest[]; registered: unknown[] }> {
Expand Down Expand Up @@ -145,8 +142,8 @@ describe('Setup sidebar and settings hub list the same pages under the same name
expect(drift).toEqual([]);
});

it(`${locale}: the hub pages with no translated title are exactly the named ones`, () => {
expect(manifests.filter((m) => hubTitle(locale, m) == null).map((m) => m.namespace).sort()).toEqual(HUB_UNTRANSLATED);
it(`${locale}: every hub page carries a translated title`, () => {
expect(manifests.filter((m) => hubTitle(locale, m) == null).map((m) => m.namespace)).toEqual([]);
});
}
});
Loading
Loading