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:
15
services/create-svc/Dockerfile
Normal file
15
services/create-svc/Dockerfile
Normal 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"]
|
||||
22
services/create-svc/package.json
Normal file
22
services/create-svc/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
399
services/create-svc/src/index.ts
Normal file
399
services/create-svc/src/index.ts
Normal 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);
|
||||
}
|
||||
13
services/create-svc/tsconfig.json
Normal file
13
services/create-svc/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user