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 3009
CMD ["node", "dist/index.js"]

View File

@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS library_threads (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
title TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW() NOT NULL,
sources JSONB DEFAULT '[]',
files JSONB DEFAULT '[]'
);

View File

@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS library_thread_messages (
id SERIAL PRIMARY KEY,
thread_id TEXT NOT NULL REFERENCES library_threads(id) ON DELETE CASCADE,
message_id TEXT NOT NULL,
backend_id TEXT NOT NULL DEFAULT '',
query TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW() NOT NULL,
response_blocks JSONB DEFAULT '[]',
status TEXT DEFAULT 'answering'
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_thread_messages_thread_message
ON library_thread_messages(thread_id, message_id);

View File

@@ -0,0 +1,24 @@
{
"name": "library-svc",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:migrate": "tsx src/db/push.ts"
},
"dependencies": {
"fastify": "^4.28.1",
"@fastify/cors": "^9.0.1",
"drizzle-orm": "^0.40.1",
"postgres": "^3.4.4",
"drizzle-kit": "^0.30.5"
},
"devDependencies": {
"@types/node": "^22.10.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,7 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema.js';
const connectionString = process.env.POSTGRES_URL ?? 'postgresql://gooseek:gooseek@localhost:5432/gooseek';
const client = postgres(connectionString);
export const db = drizzle(client, { schema });

View File

@@ -0,0 +1,19 @@
import postgres from 'postgres';
import { readFileSync } from 'fs';
import { join } from 'path';
const connectionString = process.env.POSTGRES_URL ?? 'postgresql://gooseek:gooseek@localhost:5432/gooseek';
const client = postgres(connectionString);
async function main() {
const sql0 = readFileSync(join(process.cwd(), 'drizzle/0000_init.sql'), 'utf8');
await client.unsafe(sql0);
const sql1 = readFileSync(join(process.cwd(), 'drizzle/0001_thread_messages.sql'), 'utf8');
await client.unsafe(sql1);
console.log('Schema applied');
await client.end();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,23 @@
import { integer, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
export const threads = pgTable('library_threads', {
id: text('id').primaryKey(),
userId: text('user_id').notNull(),
title: text('title').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
sources: jsonb('sources').$type<string[]>().default([]),
files: jsonb('files').$type<{ fileId: string; name: string }[]>().default([]),
});
export type Block = { type: string; id?: string; data?: unknown };
export const threadMessages = pgTable('library_thread_messages', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
threadId: text('thread_id').notNull().references(() => threads.id, { onDelete: 'cascade' }),
messageId: text('message_id').notNull(),
backendId: text('backend_id').notNull().default(''),
query: text('query').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
responseBlocks: jsonb('response_blocks').$type<Block[]>().default([]),
status: text('status', { enum: ['answering', 'completed', 'error'] }).default('answering'),
});

View File

@@ -0,0 +1,344 @@
/**
* library-svc — история тредов и сообщений для аккаунтов
* docs/architecture: 01-perplexity-analogue-design.md §2.2.G, §5.6
* API: GET/POST/DELETE /api/v1/library/threads
* GET /api/v1/library/threads/:id (thread + messages)
* POST/PATCH /api/v1/library/threads/:id/messages
* GET /api/v1/library/threads/:id/export?format=pdf|md
*/
import Fastify from 'fastify';
import cors from '@fastify/cors';
import { and, asc, desc, eq } from 'drizzle-orm';
import { db } from './db/index.js';
import { threads, threadMessages, type Block } from './db/schema.js';
const PORT = parseInt(process.env.PORT ?? '3009', 10);
const AUTH_SERVICE_URL = process.env.AUTH_SERVICE_URL ?? '';
const app = Fastify({ logger: true });
await app.register(cors, { origin: true });
async function getUserIdFromToken(authHeader: string | undefined): Promise<string | null> {
if (!authHeader?.startsWith('Bearer ')) return null;
const token = authHeader.slice(7);
if (!AUTH_SERVICE_URL) {
return token ? 'guest' : null;
}
try {
const res = await fetch(`${AUTH_SERVICE_URL.replace(/\/$/, '')}/api/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return null;
const data = (await res.json()) as { id?: string };
return data.id ?? null;
} catch {
return null;
}
}
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' }));
app.get('/api/v1/library/threads', async (req, reply) => {
const userId = await getUserIdFromToken(req.headers.authorization);
if (!userId) {
return reply.status(401).send({ error: 'Authorization required' });
}
try {
const list = await db
.select()
.from(threads)
.where(eq(threads.userId, userId))
.orderBy(desc(threads.createdAt));
const chats = list.map((r) => ({
id: r.id,
title: r.title,
createdAt: r.createdAt?.toISOString?.() ?? r.createdAt,
sources: (r.sources as string[]) ?? [],
files: (r.files as { fileId: string; name: string }[]) ?? [],
}));
return { chats };
} catch (err) {
req.log.error(err);
return reply.status(500).send({ error: 'Failed to fetch threads' });
}
});
app.post<{
Body: { id?: string; title: string; sources?: string[]; files?: { fileId: string; name: string }[] };
}>('/api/v1/library/threads', async (req, reply) => {
const userId = await getUserIdFromToken(req.headers.authorization);
if (!userId) {
return reply.status(401).send({ error: 'Authorization required' });
}
const { id, title, sources = [], files = [] } = req.body ?? {};
if (!title?.trim()) {
return reply.status(400).send({ error: 'title is required' });
}
const threadId = id ?? crypto.randomUUID();
try {
const [inserted] = await db
.insert(threads)
.values({
id: threadId,
userId,
title: title.trim(),
sources,
files,
})
.returning();
return {
id: inserted.id,
title: inserted.title,
createdAt: inserted.createdAt?.toISOString?.() ?? inserted.createdAt,
sources: (inserted.sources as string[]) ?? [],
files: (inserted.files as { fileId: string; name: string }[]) ?? [],
};
} catch (err) {
req.log.error(err);
return reply.status(500).send({ error: 'Failed to save thread' });
}
});
app.delete<{ Params: { id: string } }>('/api/v1/library/threads/:id', async (req, reply) => {
const userId = await getUserIdFromToken(req.headers.authorization);
if (!userId) {
return reply.status(401).send({ error: 'Authorization required' });
}
const { id } = req.params;
try {
const [t] = await db.select({ id: threads.id }).from(threads).where(and(eq(threads.id, id), eq(threads.userId, userId)));
if (!t) {
return reply.status(404).send({ error: 'Thread not found' });
}
await db.delete(threads).where(eq(threads.id, id));
return { ok: true };
} catch (err) {
req.log.error(err);
return reply.status(500).send({ error: 'Failed to delete thread' });
}
});
/** GET thread by id with messages */
app.get<{ Params: { id: string } }>('/api/v1/library/threads/:id', async (req, reply) => {
const userId = await getUserIdFromToken(req.headers.authorization);
if (!userId) {
return reply.status(401).send({ error: 'Authorization required' });
}
const { id } = req.params;
try {
const [t] = await db.select().from(threads).where(and(eq(threads.id, id), eq(threads.userId, userId)));
if (!t) {
return reply.status(404).send({ error: 'Thread not found' });
}
const msgList = await db
.select()
.from(threadMessages)
.where(eq(threadMessages.threadId, id))
.orderBy(asc(threadMessages.id));
const messages = msgList.map((m) => ({
messageId: m.messageId,
chatId: id,
backendId: m.backendId,
query: m.query,
createdAt: m.createdAt?.toISOString?.() ?? m.createdAt,
responseBlocks: (m.responseBlocks ?? []) as Block[],
status: m.status,
}));
return {
chat: {
id: t.id,
title: t.title,
createdAt: t.createdAt?.toISOString?.() ?? t.createdAt,
sources: (t.sources as string[]) ?? [],
files: (t.files as { fileId: string; name: string }[]) ?? [],
},
messages,
};
} catch (err) {
req.log.error(err);
return reply.status(500).send({ error: 'Failed to fetch thread' });
}
});
/** POST upsert message (для chat-svc при стриме). Если thread не существует — создаётся с title=query. */
app.post<{
Params: { id: string };
Body: {
messageId: string;
query: string;
backendId?: string;
responseBlocks?: Block[];
status?: string;
sources?: string[];
files?: { fileId: string; name: string }[];
};
}>('/api/v1/library/threads/:id/messages', async (req, reply) => {
const userId = await getUserIdFromToken(req.headers.authorization);
if (!userId) {
return reply.status(401).send({ error: 'Authorization required' });
}
const { id } = req.params;
const {
messageId,
query,
backendId = '',
responseBlocks = [],
status = 'answering',
sources = [],
files = [],
} = req.body ?? {};
if (!messageId?.trim() || !query?.trim()) {
return reply.status(400).send({ error: 'messageId and query are required' });
}
try {
let [t] = await db.select({ id: threads.id }).from(threads).where(and(eq(threads.id, id), eq(threads.userId, userId)));
if (!t) {
await db.insert(threads).values({
id,
userId,
title: query.length > 100 ? query.slice(0, 100) + '...' : query,
sources,
files,
});
t = { id };
}
const statusVal = (['answering', 'completed', 'error'] as const).includes(status as 'answering' | 'completed' | 'error') ? (status as 'answering' | 'completed' | 'error') : 'answering';
const [existing] = await db.select().from(threadMessages).where(and(eq(threadMessages.threadId, t.id), eq(threadMessages.messageId, messageId)));
if (existing) {
await db
.update(threadMessages)
.set({ backendId, responseBlocks, status: statusVal })
.where(eq(threadMessages.id, existing.id));
} else {
await db.insert(threadMessages).values({
threadId: t.id,
messageId,
backendId,
query,
responseBlocks,
status: statusVal,
});
}
return { ok: true };
} catch (err) {
req.log.error(err);
return reply.status(500).send({ error: 'Failed to save message' });
}
});
/** PATCH update message (responseBlocks, status) */
app.patch<{
Params: { id: string; messageId: string };
Body: { responseBlocks?: Block[]; status?: string };
}>('/api/v1/library/threads/:id/messages/:messageId', async (req, reply) => {
const userId = await getUserIdFromToken(req.headers.authorization);
if (!userId) {
return reply.status(401).send({ error: 'Authorization required' });
}
const { id, messageId } = req.params;
const body = req.body as { responseBlocks?: Block[]; status?: string } | undefined;
if (!body?.responseBlocks && !body?.status) {
return reply.status(400).send({ error: 'responseBlocks or status required' });
}
try {
const [t] = await db.select({ id: threads.id }).from(threads).where(and(eq(threads.id, id), eq(threads.userId, userId)));
if (!t) {
return reply.status(404).send({ error: 'Thread not found' });
}
const [m] = await db.select().from(threadMessages).where(and(eq(threadMessages.threadId, id), eq(threadMessages.messageId, messageId)));
if (!m) {
return reply.status(404).send({ error: 'Message not found' });
}
const updates: { responseBlocks?: Block[]; status?: 'answering' | 'completed' | 'error' } = {};
if (body.responseBlocks !== undefined) updates.responseBlocks = body.responseBlocks;
if (body.status !== undefined && ['answering', 'completed', 'error'].includes(body.status)) {
updates.status = body.status as 'answering' | 'completed' | 'error';
}
await db.update(threadMessages).set(updates).where(eq(threadMessages.id, m.id));
return { ok: true };
} catch (err) {
req.log.error(err);
return reply.status(500).send({ error: 'Failed to update message' });
}
});
/** GET export thread to PDF/MD */
const CREATE_SVC_URL = process.env.CREATE_SVC_URL ?? 'http://localhost:3011';
function extractTextFromBlocks(blocks: Block[]): string {
const textBlock = blocks?.find((b) => b.type === 'text');
if (textBlock && 'data' in textBlock && typeof textBlock.data === 'string') {
return textBlock.data;
}
return '';
}
app.get<{ Params: { id: string }; Querystring: { format?: string } }>('/api/v1/library/threads/:id/export', async (req, reply) => {
const userId = await getUserIdFromToken(req.headers.authorization);
if (!userId) {
return reply.status(401).send({ error: 'Authorization required' });
}
const { id } = req.params;
const format = ((req.query?.format ?? 'pdf') as string).toLowerCase();
if (format !== 'pdf' && format !== 'md') {
return reply.status(400).send({ error: 'Invalid format. Use pdf or md.' });
}
try {
const [t] = await db.select().from(threads).where(and(eq(threads.id, id), eq(threads.userId, userId)));
if (!t) {
return reply.status(404).send({ error: 'Thread not found' });
}
const msgList = await db.select().from(threadMessages).where(eq(threadMessages.threadId, id)).orderBy(asc(threadMessages.id));
const sections = msgList.map((m) => {
const blocks = (m.responseBlocks ?? []) as Block[];
const text = extractTextFromBlocks(blocks);
return {
query: m.query ?? '',
createdAt: m.createdAt?.toISOString?.() ?? m.createdAt,
parsedTextBlocks: text ? [text] : [],
responseBlocks: blocks.map((b) => ({ type: b.type, data: 'data' in b ? (b as { data?: unknown }).data : undefined })),
};
});
if (sections.length === 0) {
return reply.status(400).send({ error: 'Thread has no messages to export' });
}
const res = await fetch(`${CREATE_SVC_URL.replace(/\/$/, '')}/api/v1/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
format,
title: t.title ?? `chat-${id.slice(0, 8)}`,
sections,
}),
signal: AbortSignal.timeout(60000),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
return reply.status(res.status).send(err);
}
const blob = await res.blob();
const buf = Buffer.from(await blob.arrayBuffer());
const contentType = res.headers.get('Content-Type') ?? 'application/octet-stream';
const disposition =
res.headers.get('Content-Disposition') ??
`attachment; filename="${(t.title ?? 'chat').replace(/[^a-zA-Z0-9-_]/g, '_')}.${format}"`;
return reply.header('Content-Type', contentType).header('Content-Disposition', disposition).send(buf);
} 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(`library-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"]
}