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,71 @@
/**
* master-agents-svc — Master Agent с динамическими под-агентами и инструментами
* API: POST /api/v1/agents/execute
*/
import Fastify from 'fastify';
import cors from '@fastify/cors';
import { z } from 'zod';
import { loadChatModel } from './lib/models/registry.js';
import { runMasterAgent } from './lib/agent/master.js';
const PORT = parseInt(process.env.PORT ?? '3018', 10);
const chatModelSchema = z.object({
providerId: z.string(),
key: z.string(),
});
const bodySchema = z.object({
task: z.string().min(1),
chatHistory: z.array(z.tuple([z.string(), z.string()])).optional().default([]),
chatModel: chatModelSchema,
maxSteps: z.number().min(1).max(25).optional().default(15),
});
const app = Fastify({ logger: true });
await app.register(cors, { origin: true });
app.get('/health', async () => ({ status: 'ok' }));
app.post('/api/v1/agents/execute', async (req, reply) => {
try {
const parsed = bodySchema.safeParse((req as { body?: unknown }).body);
if (!parsed.success) {
return reply.status(400).send({
error: 'Invalid body',
details: parsed.error.flatten(),
});
}
const { task, chatHistory, chatModel, maxSteps } = parsed.data;
const llm = await loadChatModel(chatModel.providerId, chatModel.key);
const result = await runMasterAgent(llm, {
task,
chatHistory,
context: {
chatModel,
chatHistory,
},
maxSteps,
});
return reply.send({
content: result.content,
steps: result.steps,
});
} catch (err) {
req.log.error(err);
const msg = err instanceof Error ? err.message : 'Agent execution failed';
return reply.status(500).send({ error: msg });
}
});
try {
await app.listen({ port: PORT, host: '0.0.0.0' });
console.log(`master-agents-svc listening on :${PORT}`);
} catch (err) {
console.error(err);
process.exit(1);
}

View File

@@ -0,0 +1,106 @@
import type { BaseLLM } from '../models/base/llm.js';
import type { Message } from '../types.js';
import { getTools, getTool, getToolSchemasForLLM } from '../tools/registry.js';
import type { AgentContext } from '../tools/types.js';
import { MASTER_SYSTEM_PROMPT } from '../prompts/master.js';
export type MasterAgentInput = {
task: string;
chatHistory?: [string, string][];
context: AgentContext;
maxSteps?: number;
};
export type MasterAgentOutput = {
content: string;
steps: number;
}
export async function runMasterAgent(
llm: BaseLLM,
input: MasterAgentInput,
): Promise<MasterAgentOutput> {
const maxSteps = input.maxSteps ?? 15;
const tools = getTools();
const toolSchemas = getToolSchemasForLLM();
const historyStr =
input.chatHistory?.length
? input.chatHistory
.slice(-6)
.map(([role, content]) => `${role === 'human' ? 'User' : 'Assistant'}: ${content}`)
.join('\n')
: '';
const userContent = historyStr
? `<recent_conversation>\n${historyStr}\n</recent_conversation>\n\n<current_task>\n${input.task}\n</current_task>`
: input.task;
const messages: Message[] = [
{ role: 'system', content: MASTER_SYSTEM_PROMPT },
{ role: 'user', content: userContent },
];
let steps = 0;
while (steps < maxSteps) {
steps += 1;
const result = await llm.generateText({
messages,
tools: toolSchemas,
});
if (result.toolCalls.length === 0) {
return {
content: result.content.trim() || 'Нет ответа.',
steps,
};
}
messages.push({
role: 'assistant',
content: result.content,
tool_calls: result.toolCalls,
});
for (const tc of result.toolCalls) {
const tool = getTool(tc.name);
if (!tool) {
messages.push({
role: 'tool',
id: tc.id,
name: tc.name,
content: JSON.stringify({ error: `Unknown tool: ${tc.name}` }),
});
continue;
}
let toolResult: string;
try {
toolResult = await tool.execute(tc.arguments, input.context);
} catch (err) {
toolResult = JSON.stringify({
error: err instanceof Error ? err.message : String(err),
});
}
messages.push({
role: 'tool',
id: tc.id,
name: tc.name,
content: toolResult.length > 12000 ? toolResult.slice(0, 12000) + '\n...[truncated]' : toolResult,
});
}
}
const finalResult = await llm.generateText({
messages,
tools: [],
});
return {
content: finalResult.content.trim() || 'Достигнут лимит шагов. Попробуйте уточнить запрос.',
steps,
};
}

View File

@@ -0,0 +1,36 @@
import path from 'node:path';
import fs from 'node:fs';
export type ConfigModelProvider = {
id: string;
type: string;
chatModels: { key: string; name: string }[];
config: Record<string, unknown>;
};
function getConfigPath(): string {
return path.join(
process.env.DATA_DIR ? path.resolve(process.cwd(), process.env.DATA_DIR) : process.cwd(),
'data',
'config.json',
);
}
let cached: ConfigModelProvider[] | null = null;
export function getProviderById(id: string): ConfigModelProvider | undefined {
if (cached === null) {
const p = getConfigPath();
if (!fs.existsSync(p)) {
cached = [];
return undefined;
}
try {
const raw = JSON.parse(fs.readFileSync(p, 'utf-8'));
cached = raw.modelProviders ?? [];
} catch {
cached = [];
}
}
return cached?.find((x) => x.id === id);
}

View File

@@ -0,0 +1,28 @@
import type z from 'zod';
import type { Message } from '../../types.js';
export type Tool = {
name: string;
description: string;
schema: z.ZodObject<Record<string, z.ZodTypeAny>>;
};
export type ToolCall = {
id: string;
name: string;
arguments: Record<string, unknown>;
};
export type GenerateTextInput = {
messages: Message[];
tools?: Tool[];
};
export type GenerateTextOutput = {
content: string;
toolCalls: ToolCall[];
};
export abstract class BaseLLM {
abstract generateText(input: GenerateTextInput): Promise<GenerateTextOutput>;
}

View File

@@ -0,0 +1,74 @@
import { Ollama } from 'ollama';
import z from 'zod';
import crypto from 'node:crypto';
import { BaseLLM } from './base/llm.js';
import type { Message } from '../types.js';
type OllamaConfig = { baseURL: string; model: string };
export class OllamaLLM extends BaseLLM {
private client: Ollama;
private model: string;
constructor(config: OllamaConfig) {
super();
this.model = config.model;
this.client = new Ollama({
host: config.baseURL || 'http://localhost:11434',
});
}
private toOllama(msg: Message): { role: string; content: string; tool_calls?: { id: string; type: string; function: { name: string; arguments: unknown } }[] } {
if (msg.role === 'tool') return { role: 'tool', content: msg.content } as { role: string; content: string };
if (msg.role === 'assistant') {
const m = msg as { content: string; tool_calls?: { id: string; name: string; arguments: Record<string, unknown> }[] };
return {
role: 'assistant',
content: m.content,
tool_calls: m.tool_calls?.map((tc) => ({
id: tc.id,
type: 'function',
function: { name: tc.name, arguments: tc.arguments },
})),
};
}
return { role: msg.role, content: (msg as { content: string }).content };
}
async generateText(input: { messages: Message[]; tools?: { name: string; description: string; schema: z.ZodObject<any> }[] }): Promise<{ content: string; toolCalls: { id: string; name: string; arguments: Record<string, unknown> }[] }> {
const ollamaTools = input.tools?.map((t) => ({
type: 'function' as const,
function: {
name: t.name,
description: t.description,
parameters: (z.toJSONSchema(t.schema) as Record<string, unknown>).properties ?? {},
},
}));
const res = await this.client.chat({
model: this.model,
messages: input.messages.map((m) => this.toOllama(m)) as Parameters<typeof this.client.chat>[0]['messages'],
tools: ollamaTools?.length ? ollamaTools : undefined,
options: { temperature: 0.7, num_predict: 4096 },
});
const toolCalls = (res.message.tool_calls ?? []).map((tc) => ({
id: crypto.randomUUID(),
name: tc.function.name,
arguments: (() => {
try {
return typeof tc.function.arguments === 'string'
? (JSON.parse(tc.function.arguments) as Record<string, unknown>)
: (tc.function.arguments as Record<string, unknown> ?? {});
} catch {
return {};
}
})(),
}));
return {
content: res.message.content ?? '',
toolCalls,
};
}
}

View File

@@ -0,0 +1,90 @@
import OpenAI from 'openai';
import z from 'zod';
import { BaseLLM } from './base/llm.js';
import type { Message, ToolMessage } from '../types.js';
type OpenAIConfig = {
apiKey: string;
model: string;
baseURL?: string;
};
export class OpenAILLM extends BaseLLM {
private client: OpenAI;
private model: string;
constructor(config: OpenAIConfig) {
super();
this.model = config.model;
this.client = new OpenAI({
apiKey: config.apiKey,
baseURL: config.baseURL ?? 'https://api.openai.com/v1',
});
}
private toOpenAI(msg: Message): OpenAI.Chat.Completions.ChatCompletionMessageParam {
if (msg.role === 'system') return { role: 'system', content: msg.content };
if (msg.role === 'tool') {
return {
role: 'tool',
tool_call_id: msg.id,
content: msg.content,
};
}
if (msg.role === 'assistant') {
const m = msg as { content: string; tool_calls?: { id: string; name: string; arguments: Record<string, unknown> }[] };
return {
role: 'assistant',
content: m.content,
tool_calls: m.tool_calls?.map((tc) => ({
id: tc.id,
type: 'function' as const,
function: { name: tc.name, arguments: JSON.stringify(tc.arguments) },
})),
};
}
return { role: 'user', content: msg.content };
}
async generateText(input: { messages: Message[]; tools?: { name: string; description: string; schema: z.ZodObject<any> }[] }): Promise<{ content: string; toolCalls: { id: string; name: string; arguments: Record<string, unknown> }[] }> {
const tools = input.tools?.map((t) => ({
type: 'function' as const,
function: {
name: t.name,
description: t.description,
parameters: z.toJSONSchema(t.schema),
},
}));
const res = await this.client.chat.completions.create({
model: this.model,
messages: input.messages.map((m) => this.toOpenAI(m)),
tools: tools?.length ? tools : undefined,
temperature: 0.7,
max_tokens: 4096,
});
const msg = res.choices?.[0]?.message;
if (!msg) throw new Error('No response from OpenAI');
const toolCalls = (msg.tool_calls ?? []).map((tc) => {
const fn = 'function' in tc ? (tc as { function?: { name?: string; arguments?: string } }).function : null;
return {
id: tc.id ?? crypto.randomUUID(),
name: fn?.name ?? 'unknown',
arguments: (() => {
try {
return JSON.parse(fn?.arguments ?? '{}') as Record<string, unknown>;
} catch {
return {};
}
})(),
};
});
return {
content: msg.content ?? '',
toolCalls,
};
}
}

View File

@@ -0,0 +1,61 @@
import { getProviderById } from '../config.js';
import { OpenAILLM } from './openai.js';
import { OllamaLLM } from './ollama.js';
import type { BaseLLM } from './base/llm.js';
function getEnvProvider(): { type: string; config: Record<string, unknown> } | null {
const p = (process.env.LLM_PROVIDER ?? '').toLowerCase();
if (p === 'ollama') {
const baseURL =
process.env.OLLAMA_BASE_URL ??
(process.env.DOCKER ? 'http://host.docker.internal:11434' : 'http://localhost:11434');
return { type: 'ollama', config: { baseURL } };
}
if (p === 'openai' || process.env.OPENAI_API_KEY) {
return {
type: 'openai',
config: {
apiKey: process.env.OPENAI_API_KEY ?? '',
baseURL: process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1',
},
};
}
return null;
}
export async function loadChatModel(providerId: string, modelKey: string): Promise<BaseLLM> {
let provider = getProviderById(providerId);
if (!provider) {
const env = getEnvProvider();
if (env) {
provider = {
id: 'env',
type: env.type,
chatModels: [{ key: modelKey, name: modelKey }],
config: env.config,
};
}
}
if (!provider) throw new Error(`Provider not found: ${providerId}`);
const hasModel = provider.chatModels?.some((m) => m.key === modelKey);
if (!hasModel && provider.id !== 'env') {
throw new Error(`Model ${modelKey} not found in provider ${providerId}`);
}
const cfg = provider.config as Record<string, unknown>;
if (provider.type === 'openai') {
const apiKey = (cfg.apiKey as string) || process.env.OPENAI_API_KEY;
const baseURL = (cfg.baseURL as string) || 'https://api.openai.com/v1';
if (!apiKey) throw new Error('OpenAI API key not configured');
return new OpenAILLM({ apiKey, model: modelKey, baseURL });
}
if (provider.type === 'ollama') {
const baseURL = (cfg.baseURL as string) || process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
return new OllamaLLM({ baseURL, model: modelKey });
}
throw new Error(`Unsupported provider type: ${provider.type}`);
}

View File

@@ -0,0 +1,22 @@
export const MASTER_SYSTEM_PROMPT = `You are a Master Agent that adapts to any task. You have access to tools and automatically decide which to use.
## Your role
- Analyze the user's request and plan your approach
- Use tools to gather information (web search, scrape URLs, get stock quotes, search images, calculator)
- Synthesize results into a clear, helpful response
- If a task requires multiple steps, use tools iteratively until you have enough information
## Available tools
- web_search: Search the web. Use SEO-friendly keywords. Up to 3 queries per call.
- scrape_url: Fetch and extract content from a specific URL. Use when user asks about a page.
- calculator: Evaluate math expressions (arithmetic, percentages, sqrt, etc).
- get_stock_quote: Get current stock price for a ticker (AAPL, TSLA, etc).
- image_search: Search for relevant images by query.
## Rules
- Always use tools when you need external information. Do not guess.
- Prefer web_search for general knowledge; scrape_url only when user points to specific URLs.
- Use get_stock_quote for stock prices; calculator for math.
- After gathering info, provide a concise, accurate answer. Cite sources when relevant.
- If a tool fails, try alternatives or inform the user.
- Respond in the same language as the user's query unless asked otherwise.`;

View File

@@ -0,0 +1,22 @@
import z from 'zod';
import { evaluate } from 'mathjs';
import type { ToolDef } from './types.js';
const schema = z.object({
expression: z.string().describe('Math expression to evaluate, e.g. "2+3*4", "sqrt(16)"'),
});
export const calculatorTool: ToolDef = {
name: 'calculator',
description: 'Evaluate a mathematical expression. Use for arithmetic, percentages, unit conversions.',
schema,
execute: async (params, _ctx) => {
const { expression } = schema.parse(params);
try {
const result = evaluate(expression);
return JSON.stringify({ expression, result: String(result) });
} catch (err) {
return JSON.stringify({ error: err instanceof Error ? err.message : 'Invalid expression' });
}
},
};

View File

@@ -0,0 +1,26 @@
import z from 'zod';
import type { ToolDef } from './types.js';
const FINANCE_SVC = process.env.FINANCE_SVC_URL?.trim() ?? 'http://localhost:3003';
const quoteSchema = z.object({
ticker: z.string().describe('Stock ticker symbol (e.g. AAPL, TSLA)'),
});
export const quoteTool: ToolDef = {
name: 'get_stock_quote',
description: 'Get current stock price and quote data for a ticker symbol.',
schema: quoteSchema,
execute: async (params, _ctx) => {
const { ticker } = quoteSchema.parse(params);
const url = `${FINANCE_SVC.replace(/\/$/, '')}/api/v1/finance/quote/${encodeURIComponent(ticker)}`;
try {
const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
if (!res.ok) return JSON.stringify({ error: `HTTP ${res.status}` });
const data = await res.json();
return JSON.stringify(data, null, 2);
} catch (err) {
return JSON.stringify({ error: err instanceof Error ? err.message : 'Finance service unavailable' });
}
},
};

View File

@@ -0,0 +1,36 @@
import z from 'zod';
import type { ToolDef } from './types.js';
const MEDIA_SVC = process.env.MEDIA_SVC_URL?.trim() ?? 'http://localhost:3016';
const imageSchema = z.object({
query: z.string().describe('Image search query'),
});
export const imageSearchTool: ToolDef = {
name: 'image_search',
description: 'Search for images by query. Use when user wants to see relevant images.',
schema: imageSchema,
execute: async (params, ctx) => {
const parsed = imageSchema.parse(params);
const chatModel = ctx?.chatModel ?? { providerId: 'env', key: process.env.LLM_CHAT_MODEL ?? 'gpt-4o-mini' };
const url = `${MEDIA_SVC.replace(/\/$/, '')}/api/v1/media/images`;
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: parsed.query,
chatHistory: ctx?.chatHistory ?? [],
chatModel,
}),
signal: AbortSignal.timeout(15000),
});
if (!res.ok) return JSON.stringify({ error: `HTTP ${res.status}` });
const data = (await res.json()) as { images?: { img_src: string; url: string; title: string }[] };
return JSON.stringify({ images: data.images ?? [] }, null, 2);
} catch (err) {
return JSON.stringify({ error: err instanceof Error ? err.message : 'Media service unavailable' });
}
},
};

View File

@@ -0,0 +1,30 @@
import type { ToolDef } from './types.js';
import { webSearchTool } from './search.js';
import { scrapeUrlTool } from './scrape.js';
import { calculatorTool } from './calculator.js';
import { quoteTool } from './finance.js';
import { imageSearchTool } from './media.js';
const ALL_TOOLS: ToolDef[] = [
webSearchTool,
scrapeUrlTool,
calculatorTool,
quoteTool,
imageSearchTool,
];
export function getTools(): ToolDef[] {
return ALL_TOOLS;
}
export function getTool(name: string): ToolDef | undefined {
return ALL_TOOLS.find((t) => t.name === name);
}
export function getToolSchemasForLLM(): { name: string; description: string; schema: ToolDef['schema'] }[] {
return ALL_TOOLS.map((t) => ({
name: t.name,
description: t.description,
schema: t.schema,
}));
}

View File

@@ -0,0 +1,31 @@
import z from 'zod';
import type { ToolDef } from './types.js';
const schema = z.object({
url: z.string().url().describe('URL to fetch and extract text from'),
});
export const scrapeUrlTool: ToolDef = {
name: 'scrape_url',
description: 'Fetch and extract readable text from a URL. Use when user asks about specific page content.',
schema,
execute: async (params, _ctx) => {
const { url } = schema.parse(params);
const res = await fetch(url, {
signal: AbortSignal.timeout(10000),
headers: { 'User-Agent': 'GooSeek-MasterAgent/1.0' },
});
const html = await res.text();
const title = html.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1]?.trim() ?? url;
const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
const body = bodyMatch ? bodyMatch[1] : html;
const text = body
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 8000);
return JSON.stringify({ title, url, content: text }, null, 2);
},
};

View File

@@ -0,0 +1,51 @@
import z from 'zod';
import type { ToolDef } from './types.js';
const SEARCH_SVC = process.env.SEARCH_SVC_URL?.trim() ?? '';
const SEARXNG = process.env.SEARXNG_URL?.trim() ?? 'https://searx.tiekoetter.com';
const schema = z.object({
queries: z.array(z.string()).max(3).describe('Search queries (keywords, SEO-friendly)'),
});
async function doSearch(q: string): Promise<{ results: { title: string; url: string; content?: string }[] }> {
if (SEARCH_SVC) {
const url = `${SEARCH_SVC.replace(/\/$/, '')}/api/v1/search?q=${encodeURIComponent(q)}`;
const res = await fetch(url, { signal: AbortSignal.timeout(15000) });
if (!res.ok) throw new Error(`Search HTTP ${res.status}`);
const data = (await res.json()) as { results?: { title?: string; url?: string; content?: string }[] };
return {
results: (data.results ?? []).map((r) => ({
title: r.title ?? '',
url: r.url ?? '',
content: r.content ?? '',
})),
};
}
const url = `${SEARXNG}/search?format=json&q=${encodeURIComponent(q)}`;
const res = await fetch(url, { signal: AbortSignal.timeout(15000) });
const data = (await res.json()) as { results?: { title?: string; url?: string; content?: string }[] };
return {
results: (data.results ?? []).map((r) => ({
title: r.title ?? '',
url: r.url ?? '',
content: r.content ?? '',
})),
};
}
export const webSearchTool: ToolDef = {
name: 'web_search',
description: 'Search the web for information. Use SEO-friendly keywords. Up to 3 queries per call.',
schema,
execute: async (params, _ctx) => {
const { queries } = schema.parse(params);
const results = await Promise.all(queries.map(doSearch));
const all = results.flatMap((r) => r.results).slice(0, 15);
return JSON.stringify(
all.map((r) => ({ title: r.title, url: r.url, snippet: (r.content ?? '').slice(0, 200) })),
null,
2,
);
},
};

View File

@@ -0,0 +1,13 @@
import type z from 'zod';
export type AgentContext = {
chatModel?: { providerId: string; key: string };
chatHistory?: [string, string][];
};
export type ToolDef = {
name: string;
description: string;
schema: z.ZodObject<Record<string, z.ZodTypeAny>>;
execute: (params: Record<string, unknown>, ctx?: AgentContext) => Promise<string>;
};

View File

@@ -0,0 +1,11 @@
export type UserMessage = { role: 'user'; content: string };
export type AssistantMessage = {
role: 'assistant';
content: string;
tool_calls?: { id: string; name: string; arguments: Record<string, unknown> }[];
};
export type SystemMessage = { role: 'system'; content: string };
export type ToolMessage = { role: 'tool'; id: string; name: string; content: string };
export type Message = UserMessage | AssistantMessage | SystemMessage | ToolMessage;
export type ChatTurnMessage = UserMessage | AssistantMessage;