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/library-svc/Dockerfile
Normal file
15
services/library-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 3009
|
||||
CMD ["node", "dist/index.js"]
|
||||
8
services/library-svc/drizzle/0000_init.sql
Normal file
8
services/library-svc/drizzle/0000_init.sql
Normal 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 '[]'
|
||||
);
|
||||
13
services/library-svc/drizzle/0001_thread_messages.sql
Normal file
13
services/library-svc/drizzle/0001_thread_messages.sql
Normal 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);
|
||||
24
services/library-svc/package.json
Normal file
24
services/library-svc/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
services/library-svc/src/db/index.ts
Normal file
7
services/library-svc/src/db/index.ts
Normal 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 });
|
||||
19
services/library-svc/src/db/push.ts
Normal file
19
services/library-svc/src/db/push.ts
Normal 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);
|
||||
});
|
||||
23
services/library-svc/src/db/schema.ts
Normal file
23
services/library-svc/src/db/schema.ts
Normal 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'),
|
||||
});
|
||||
344
services/library-svc/src/index.ts
Normal file
344
services/library-svc/src/index.ts
Normal 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);
|
||||
}
|
||||
13
services/library-svc/tsconfig.json
Normal file
13
services/library-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