Skip to content

Commit 116541e

Browse files
committed
feat: add localized placeholder messages support in user resource
1 parent 65bfc6b commit 116541e

2 files changed

Lines changed: 207 additions & 34 deletions

File tree

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import type { CompletionAdapter, HttpExtra } from 'adminforth';
2+
import { createRequire } from 'node:module';
3+
4+
const require = createRequire(import.meta.url);
5+
6+
const ISO_3166_1_ALPHA_2_RE = /^[A-Z]{2}$/;
7+
8+
const EN_PLACEHOLDER_MESSAGES = [
9+
'Find most costy apartment',
10+
'Give me apartments price histogram',
11+
'What is sum cost of all apratments',
12+
'Who modifiend most costy appartment',
13+
] as const;
14+
15+
const LOCALIZED_PLACEHOLDER_MESSAGES_SCHEMA = {
16+
name: 'localized_placeholder_messages',
17+
strict: true,
18+
schema: {
19+
type: 'object',
20+
additionalProperties: false,
21+
required: ['messages'],
22+
properties: {
23+
messages: {
24+
type: 'array',
25+
items: {
26+
type: 'string',
27+
},
28+
},
29+
},
30+
},
31+
} as const;
32+
33+
type TerritoryInfoJson = {
34+
supplemental?: {
35+
territoryInfo?: Record<
36+
string,
37+
{
38+
languagePopulation?: Record<
39+
string,
40+
{
41+
_populationPercent?: number | string;
42+
}
43+
>;
44+
}
45+
>;
46+
};
47+
};
48+
49+
const territoryInfoJson = require('cldr-core/supplemental/territoryInfo.json') as TerritoryInfoJson;
50+
const localizedPlaceholderMessagesCache = new Map<string, Promise<string[]>>();
51+
52+
function normalizeCountryCode(countryCode?: string): string {
53+
const normalizedCountryCode = countryCode?.trim().toUpperCase() ?? '';
54+
return ISO_3166_1_ALPHA_2_RE.test(normalizedCountryCode)
55+
? normalizedCountryCode
56+
: 'XX';
57+
}
58+
59+
export function getClientCountry(httpExtra: HttpExtra): string {
60+
return normalizeCountryCode(
61+
httpExtra.headers['cf-ipcountry'] ?? httpExtra.headers['CF-IPCountry'],
62+
);
63+
}
64+
65+
function getTopTerritoryLanguage(countryCode: string): string | null {
66+
const territory = territoryInfoJson.supplemental?.territoryInfo?.[countryCode];
67+
const languagePopulation = territory?.languagePopulation;
68+
69+
if (!languagePopulation) {
70+
return null;
71+
}
72+
73+
const topLanguage = Object.entries(languagePopulation)
74+
.map(([language, meta]) => {
75+
const primaryLanguage = String(language).split('-')[0]?.toLowerCase() ?? '';
76+
const populationPercent = Number(meta?._populationPercent ?? 0);
77+
78+
return {
79+
language: primaryLanguage,
80+
populationPercent: Number.isFinite(populationPercent)
81+
? populationPercent
82+
: 0,
83+
};
84+
})
85+
.filter((item) => item.language.length === 2)
86+
.filter((item) => item.language !== 'en')
87+
.sort((left, right) => right.populationPercent - left.populationPercent)[0];
88+
89+
return topLanguage?.language ?? null;
90+
}
91+
92+
async function translatePlaceholderMessages({
93+
completionAdapter,
94+
countryCode,
95+
language,
96+
}: {
97+
completionAdapter: CompletionAdapter;
98+
countryCode: string;
99+
language: string;
100+
}): Promise<string[]> {
101+
const content = [
102+
'You are a UI placeholder translation engine.',
103+
'Translate the `messages` array to the language in `target_language_iso639_1`.',
104+
'Keep each message concise, natural, and suitable for a chat placeholder prompt.',
105+
'Preserve the array length and ordering.',
106+
'When the target language distinguishes between informal and formal second-person pronouns, always use the informal singular form.',
107+
'Respond with a single JSON object matching the schema.',
108+
JSON.stringify({
109+
country_iso3166_1: countryCode,
110+
target_language_iso639_1: language,
111+
messages: EN_PLACEHOLDER_MESSAGES,
112+
}),
113+
].join('\n\n');
114+
115+
const result = await completionAdapter.complete(
116+
content,
117+
300,
118+
LOCALIZED_PLACEHOLDER_MESSAGES_SCHEMA,
119+
'low',
120+
);
121+
122+
if (result.error) {
123+
throw new Error(result.error);
124+
}
125+
126+
if (!result.content) {
127+
throw new Error('Completion adapter returned an empty localized placeholder response');
128+
}
129+
130+
const parsed = JSON.parse(result.content) as { messages: string[] };
131+
132+
if (!Array.isArray(parsed.messages) || parsed.messages.length !== EN_PLACEHOLDER_MESSAGES.length) {
133+
throw new Error('Completion adapter returned invalid localized placeholder messages');
134+
}
135+
136+
return parsed.messages.map((message) => message.trim());
137+
}
138+
139+
export async function getLocalizedPlaceholderMessages({
140+
completionAdapter,
141+
httpExtra,
142+
}: {
143+
completionAdapter: CompletionAdapter;
144+
httpExtra: HttpExtra;
145+
}): Promise<string[]> {
146+
const countryCode = getClientCountry(httpExtra);
147+
const topLanguage = getTopTerritoryLanguage(countryCode);
148+
149+
if (!topLanguage) {
150+
return [...EN_PLACEHOLDER_MESSAGES];
151+
}
152+
153+
const cacheKey = `${countryCode}:${topLanguage}`;
154+
const cachedMessages = localizedPlaceholderMessagesCache.get(cacheKey);
155+
156+
if (cachedMessages) {
157+
return cachedMessages;
158+
}
159+
160+
const localizationPromise = translatePlaceholderMessages({
161+
completionAdapter,
162+
countryCode,
163+
language: topLanguage,
164+
}).catch((error) => {
165+
console.error('Failed to localize agent placeholder messages', error);
166+
return [...EN_PLACEHOLDER_MESSAGES];
167+
});
168+
169+
localizedPlaceholderMessagesCache.set(cacheKey, localizationPromise);
170+
171+
return localizationPromise;
172+
}

live-demo/app/resources/users.ts

Lines changed: 35 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,31 @@
11
import AdminForth, { AdminForthDataTypes, AdminForthResourceColumn } from 'adminforth';
2+
import type { AdminForthResource } from 'adminforth';
23
import AdminForthAgent from '@adminforth/agent';
34
import CompletionAdapterOpenAIChatGPT from '@adminforth/completion-adapter-open-ai-chat-gpt';
45
import ForeignInlineListPlugin from '@adminforth/foreign-inline-list';
56
import { randomUUID } from 'crypto';
7+
import { getLocalizedPlaceholderMessages } from './agent_resources/placeholderMessages';
68

7-
const blockDemoUsers = async ({ record, adminUser, resource }) => {
9+
const openAiApiKey = process.env.OPENAI_API_KEY as string;
10+
11+
const createCompletionAdapter = (
12+
model: string,
13+
effort: 'low' | 'medium' | 'xhigh',
14+
) => new CompletionAdapterOpenAIChatGPT({
15+
openAiApiKey,
16+
model,
17+
extraRequestBodyParameters: {
18+
reasoning: {
19+
effort,
20+
},
21+
},
22+
});
23+
24+
const balancedCompletionAdapter = createCompletionAdapter('gpt-5.4-mini', 'medium');
25+
const fastCompletionAdapter = createCompletionAdapter('gpt-5.4-mini', 'low');
26+
const smartThinkingCompletionAdapter = createCompletionAdapter('gpt-5.4', 'xhigh');
27+
28+
const blockDemoUsers = async ({ adminUser }: { adminUser: any }) => {
829
if (adminUser.dbUser && adminUser.dbUser.role !== 'superadmin') {
930
return { ok: false, error: "You can't do this on demo.adminforth.dev" }
1031
}
@@ -15,56 +36,36 @@ export default {
1536
table: 'users',
1637
resourceId: 'users',
1738
label: 'Users',
18-
recordLabel: (r) => `👤 ${r.email}`,
39+
recordLabel: (r: any) => `👤 ${r.email}`,
1940
plugins: [
2041
new ForeignInlineListPlugin({
2142
foreignResourceId: 'aparts',
2243
modifyTableResourceConfig: (resourceConfig: AdminForthResource) => {
2344
// hide column 'square_meter' from both 'list' and 'filter'
24-
const column = resourceConfig.columns.find((c: AdminForthResourceColumn) => c.name === 'square_meter')!.showIn = [];
45+
const column = resourceConfig.columns.find((c: AdminForthResourceColumn) => c.name === 'square_meter')!.showIn = [] as any;
2546
// feel free to console.log and edit resourceConfig as you need
2647
},
2748
}),
2849
new ForeignInlineListPlugin({
2950
foreignResourceId: 'audit_logs',
3051
}),
3152
new AdminForthAgent({
53+
placeholderMessages: async ({ httpExtra }: any) => getLocalizedPlaceholderMessages({
54+
completionAdapter: fastCompletionAdapter as any,
55+
httpExtra,
56+
}),
3257
modes: [
3358
{
3459
name: 'Balanced',
35-
completionAdapter: new CompletionAdapterOpenAIChatGPT({
36-
openAiApiKey: process.env.OPENAI_API_KEY as string,
37-
model: 'gpt-5.4-mini',
38-
extraRequestBodyParameters: {
39-
reasoning: {
40-
effort: 'medium',
41-
},
42-
},
43-
}),
60+
completionAdapter: balancedCompletionAdapter,
4461
},
4562
{
4663
name: 'Fast',
47-
completionAdapter: new CompletionAdapterOpenAIChatGPT({
48-
openAiApiKey: process.env.OPENAI_API_KEY as string,
49-
model: 'gpt-5.4-mini',
50-
extraRequestBodyParameters: {
51-
reasoning: {
52-
effort: 'low',
53-
},
54-
},
55-
}),
64+
completionAdapter: fastCompletionAdapter,
5665
},
5766
{
5867
name: 'Smart Thinking',
59-
completionAdapter: new CompletionAdapterOpenAIChatGPT({
60-
openAiApiKey: process.env.OPENAI_API_KEY as string,
61-
model: 'gpt-5.4',
62-
extraRequestBodyParameters: {
63-
reasoning: {
64-
effort: 'xhigh',
65-
},
66-
},
67-
}),
68+
completionAdapter: smartThinkingCompletionAdapter,
6869
},
6970
],
7071
maxTokens: 10000,
@@ -84,7 +85,7 @@ export default {
8485
promptField: 'prompt',
8586
responseField: 'response',
8687
},
87-
}),
88+
} as any),
8889
],
8990
columns: [
9091
{
@@ -110,7 +111,7 @@ export default {
110111
name: 'created_at',
111112
type: AdminForthDataTypes.DATETIME,
112113
showIn: ['list', 'filter', 'show'],
113-
fillOnCreate: ({initialRecord, adminUser}) => (new Date()).toISOString(),
114+
fillOnCreate: ({initialRecord, adminUser}: any) => (new Date()).toISOString(),
114115
},
115116
{
116117
name: 'role',
@@ -135,7 +136,7 @@ export default {
135136
create: {
136137
beforeSave: [
137138
blockDemoUsers,
138-
async ({ record, adminUser, resource }) => {
139+
async ({ record, adminUser, resource }: any) => {
139140
record.password_hash = await AdminForth.Utils.generatePasswordHash(record.password);
140141
return { ok: true };
141142
}
@@ -144,7 +145,7 @@ export default {
144145
edit: {
145146
beforeSave: [
146147
blockDemoUsers,
147-
async ({ record, adminUser, resource}) => {
148+
async ({ record, adminUser, resource}: any) => {
148149
if (record.password) {
149150
record.password_hash = await AdminForth.Utils.generatePasswordHash(record.password);
150151
}

0 commit comments

Comments
 (0)