feat: default locale Russian, geo determines language for other countries

- localization-svc: defaultLocale ru, resolveLocale only by geo
- web-svc: DEFAULT_LOCALE ru, layout lang=ru, embeddedTranslations fallback ru
- countryToLocale: default ru when no country or unknown country

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
home
2026-02-23 15:10:38 +03:00
parent 8fc82a3b90
commit cd6b7857ba
606 changed files with 26148 additions and 14297 deletions

View File

@@ -0,0 +1,15 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3011
CMD ["node", "dist/index.js"]

View File

@@ -0,0 +1,22 @@
{
"name": "create-svc",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"fastify": "^4.28.1",
"@fastify/cors": "^9.0.1",
"jspdf": "^3.0.4",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/node": "^22.10.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,399 @@
/**
* create-svc — Create (таблицы, дашборды), Export thread (PDF/MD)
* docs/architecture: 01-perplexity-analogue-design.md §5.10
* API: POST /api/v1/create (stub), POST /api/v1/export
*/
import Fastify from 'fastify';
import cors from '@fastify/cors';
import { z } from 'zod';
import { jsPDF } from 'jspdf';
const PORT = parseInt(process.env.PORT ?? '3011', 10);
const app = Fastify({ logger: true });
await app.register(cors, { origin: true });
const exportSectionSchema = z.object({
query: z.string(),
createdAt: z.union([z.string(), z.date()]),
parsedTextBlocks: z.array(z.string()).optional().default([]),
responseBlocks: z
.array(
z.object({
type: z.string(),
data: z.any().optional(),
})
)
.optional()
.default([]),
});
const exportBodySchema = z.object({
format: z.enum(['pdf', 'md']),
title: z.string().optional().default('chat'),
sections: z.array(exportSectionSchema),
});
type ExportSection = z.infer<typeof exportSectionSchema>;
function buildMarkdown(title: string, sections: ExportSection[]): string {
const date = new Date(
(sections[0]?.createdAt as string) || Date.now()
).toLocaleString();
let md = `# Chat Export: ${title}\n\n`;
md += `*Exported on: ${date}*\n\n---\n`;
sections.forEach((section) => {
md += `\n---\n`;
md += `**User** \n`;
md += `*${new Date(section.createdAt as string).toLocaleString()}*\n\n`;
md += `> ${String(section.query).replace(/\n/g, '\n> ')}\n`;
if (section.responseBlocks?.length) {
const textBlock = section.responseBlocks.find((b) => b.type === 'text');
const text = textBlock?.data ?? section.parsedTextBlocks?.join('\n') ?? '';
if (text) {
md += `\n**Assistant** \n`;
md += `*${new Date(section.createdAt as string).toLocaleString()}*\n\n`;
md += `> ${text.replace(/\n/g, '\n> ')}\n`;
}
const sourceBlock = section.responseBlocks.find((b) => b.type === 'source');
const sources = (sourceBlock?.data as { metadata?: { url?: string } }[]) ?? [];
if (sources.length) {
md += `\n**Citations:**\n`;
sources.forEach((src: { metadata?: { url?: string } }, i: number) => {
const url = src.metadata?.url ?? '';
md += `- [${i + 1}] [${url}](${url})\n`;
});
}
}
});
md += '\n---\n';
return md;
}
function buildPDF(title: string, sections: ExportSection[]): Buffer {
const doc = new jsPDF();
const pageHeight = doc.internal.pageSize.height;
let y = 15;
const date = new Date(
(sections[0]?.createdAt as string) || Date.now()
).toLocaleString();
doc.setFontSize(18);
doc.text(`Chat Export: ${title}`, 10, y);
y += 8;
doc.setFontSize(11);
doc.setTextColor(100);
doc.text(`Exported on: ${date}`, 10, y);
y += 8;
doc.setDrawColor(200);
doc.line(10, y, 200, y);
y += 6;
doc.setTextColor(30);
for (const section of sections) {
if (y > pageHeight - 30) {
doc.addPage();
y = 15;
}
doc.setFont('helvetica', 'bold');
doc.text('User', 10, y);
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.setTextColor(120);
doc.text(
`${new Date(section.createdAt as string).toLocaleString()}`,
40,
y
);
y += 6;
doc.setTextColor(30);
doc.setFontSize(12);
const userLines = doc.splitTextToSize(String(section.query), 180);
for (const line of userLines) {
if (y > pageHeight - 20) {
doc.addPage();
y = 15;
}
doc.text(line, 12, y);
y += 6;
}
y += 6;
doc.setDrawColor(230);
if (y > pageHeight - 10) {
doc.addPage();
y = 15;
}
doc.line(10, y, 200, y);
y += 4;
if (section.responseBlocks?.length) {
const text =
section.parsedTextBlocks?.join('\n') ??
section.responseBlocks.find((b) => b.type === 'text')?.data ??
'';
if (text && y < pageHeight - 30) {
doc.setFont('helvetica', 'bold');
doc.text('Assistant', 10, y);
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.setTextColor(120);
doc.text(
`${new Date(section.createdAt as string).toLocaleString()}`,
40,
y
);
y += 6;
doc.setTextColor(30);
doc.setFontSize(12);
const assistantLines = doc.splitTextToSize(String(text), 180);
for (const line of assistantLines) {
if (y > pageHeight - 20) {
doc.addPage();
y = 15;
}
doc.text(line, 12, y);
y += 6;
}
const sourceBlock = section.responseBlocks.find(
(b) => b.type === 'source'
);
const sources = (sourceBlock?.data as { metadata?: { url?: string } }[]) ?? [];
if (sources.length && y < pageHeight - 20) {
doc.setFontSize(11);
doc.setTextColor(80);
doc.text('Citations:', 12, y);
y += 5;
for (const src of sources) {
const url = src.metadata?.url ?? '';
if (y > pageHeight - 15) {
doc.addPage();
y = 15;
}
doc.text(`- ${url}`, 15, y);
y += 5;
}
doc.setTextColor(30);
}
y += 6;
}
doc.setDrawColor(230);
if (y > pageHeight - 10) {
doc.addPage();
y = 15;
}
doc.line(10, y, 200, y);
y += 4;
}
}
return Buffer.from(doc.output('arraybuffer'));
}
app.get('/health', async () => ({ status: 'ok' }));
app.get('/metrics', async (_req, reply) => {
reply.header('Content-Type', 'text/plain; charset=utf-8');
return reply.send('# HELP gooseek_up Service is up (1) or down (0)\n# TYPE gooseek_up gauge\ngooseek_up 1\n');
});
app.get('/ready', async () => ({ status: 'ready' }));
const createBodySchema = z.object({
type: z.enum(['table', 'dashboard', 'image']),
prompt: z.string().min(1),
context: z.string().optional(),
});
app.post<{ Body: unknown }>('/api/v1/create', async (req, reply) => {
const parsed = createBodySchema.safeParse(req.body);
if (!parsed.success) {
return reply.status(400).send({
error: 'Invalid request body',
issues: parsed.error.issues,
});
}
const { type, prompt, context } = parsed.data;
const apiKey = process.env.OPENAI_API_KEY ?? '';
if (type === 'image') {
if (!apiKey) {
return reply.status(503).send({
error: 'OPENAI_API_KEY not configured',
message: 'Image generation requires OPENAI_API_KEY',
});
}
try {
const imgRes = await fetch('https://api.openai.com/v1/images/generations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: 'dall-e-3',
prompt: context ? `${prompt}\n\nContext:\n${context}` : prompt,
n: 1,
size: '1024x1024',
quality: 'standard',
style: 'vivid',
}),
signal: AbortSignal.timeout(120000),
});
if (!imgRes.ok) {
const err = await imgRes.text();
req.log.warn({ status: imgRes.status, err }, 'DALL·E error');
return reply.status(502).send({
error: 'Image generation failed',
message: 'DALL·E API error',
});
}
const imgData = (await imgRes.json()) as { data?: { url?: string; b64_json?: string }[] };
const first = imgData.data?.[0];
const url = first?.url ?? null;
const b64 = first?.b64_json ?? null;
return reply.send({
type: 'image',
data: null,
url,
b64: b64 ?? undefined,
format: 'png',
});
} catch (err) {
req.log.error(err);
return reply.status(500).send({
error: 'Image generation failed',
message: err instanceof Error ? err.message : 'Unknown error',
});
}
}
if (!apiKey) {
return reply.status(503).send({
error: 'OPENAI_API_KEY not configured',
message: 'Create requires OPENAI_API_KEY for table/dashboard generation',
});
}
try {
const sysPrompt =
type === 'table'
? 'Generate a markdown table. Return ONLY valid markdown, no explanation. Use | for columns, --- for header separator.'
: 'Generate dashboard data: a JSON object with title, description, and rows (array of objects). Return ONLY valid JSON.';
const content = context ? `${prompt}\n\nContext:\n${context}` : prompt;
const res = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: sysPrompt },
{ role: 'user', content },
],
max_tokens: 2000,
temperature: 0.2,
}),
signal: AbortSignal.timeout(30000),
});
if (!res.ok) {
const err = await res.text();
req.log.warn({ status: res.status, err }, 'OpenAI create error');
return reply.status(502).send({
error: 'LLM request failed',
message: 'Could not generate content',
});
}
const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
const raw = data.choices?.[0]?.message?.content?.trim() ?? '';
if (type === 'table') {
return reply.send({
type: 'table',
data: raw,
url: null,
format: 'markdown',
});
}
let dashboard = raw;
try {
const jsonStart = raw.indexOf('{');
if (jsonStart >= 0) {
const jsonStr = raw.slice(jsonStart);
const end = jsonStr.lastIndexOf('}') + 1;
dashboard = jsonStr.slice(0, end);
}
JSON.parse(dashboard);
} catch {
dashboard = JSON.stringify({
title: 'Dashboard',
description: prompt,
rows: [{ raw }],
});
}
return reply.send({
type: 'dashboard',
data: dashboard,
url: null,
});
} catch (err) {
req.log.error(err);
return reply.status(500).send({
error: 'Create failed',
message: err instanceof Error ? err.message : 'Unknown error',
});
}
});
app.post<{ Body: unknown }>('/api/v1/export', async (req, reply) => {
const parsed = exportBodySchema.safeParse(req.body);
if (!parsed.success) {
return reply.status(400).send({
error: 'Invalid request body',
issues: parsed.error.issues,
});
}
const { format, title, sections } = parsed.data;
if (!sections.length) {
return reply.status(400).send({ error: 'sections array is required and must not be empty' });
}
try {
if (format === 'md') {
const md = buildMarkdown(title, sections);
return reply
.header('Content-Type', 'text/markdown')
.header('Content-Disposition', `attachment; filename="${title || 'chat'}.md"`)
.send(md);
}
const pdfBuffer = buildPDF(title, sections);
return reply
.header('Content-Type', 'application/pdf')
.header('Content-Disposition', `attachment; filename="${title || 'chat'}.pdf"`)
.send(pdfBuffer);
} catch (err) {
req.log.error(err);
return reply.status(500).send({ error: 'Export failed' });
}
});
try {
await app.listen({ port: PORT, host: '0.0.0.0' });
console.log('create-svc listening on :' + PORT);
} catch (err) {
console.error(err);
process.exit(1);
}

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}