feat: статья из Discover, локализация, подсказки

- Статья: заголовок + ссылка (truncate), title в URL, articleTitle в Message
- Локализация Sources, Research Progress, Answer, шагов, formingAnswer
- Подсказки: промпт без жёсткого примера, разнообразие, label 'Что ещё спросить'
- embeddedTranslations, countryToLocale, locale инструкция для LLM

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
home
2026-02-21 00:37:06 +03:00
parent f4d945a2b5
commit 3fa83bc605
68 changed files with 2301 additions and 345 deletions

View File

@@ -24,13 +24,14 @@ const chatModelSchema: z.ZodType<ModelWithProvider> = z.object({
key: z.string({ message: 'Chat model key must be provided' }),
});
const embeddingModelSchema: z.ZodType<ModelWithProvider> = z.object({
providerId: z.string({
message: 'Embedding model provider id must be provided',
}),
key: z.string({ message: 'Embedding model key must be provided' }),
const embeddingModelSchema = z.object({
providerId: z.string().optional().default(''),
key: z.string().optional().default(''),
});
const LOCALIZATION_SERVICE_URL =
process.env.LOCALIZATION_SERVICE_URL ?? 'http://localhost:4003';
const bodySchema = z.object({
message: messageSchema,
optimizationMode: z.enum(['speed', 'balanced', 'quality'], {
@@ -43,8 +44,10 @@ const bodySchema = z.object({
.default([]),
files: z.array(z.string()).optional().default([]),
chatModel: chatModelSchema,
embeddingModel: embeddingModelSchema,
embeddingModel: embeddingModelSchema.optional().default({ providerId: '', key: '' }),
systemInstructions: z.string().nullable().optional().default(''),
/** locale (ru, en и т.д.) — язык ответа, по geo если не передан */
locale: z.string().optional(),
});
type Body = z.infer<typeof bodySchema>;
@@ -116,6 +119,27 @@ export const POST = async (req: Request) => {
const body = parseBody.data as Body;
const { message } = body;
let locale = body.locale;
if (!locale) {
try {
const localeRes = await fetch(`${LOCALIZATION_SERVICE_URL}/api/locale`, {
headers: {
'x-forwarded-for': req.headers.get('x-forwarded-for') ?? '',
'x-real-ip': req.headers.get('x-real-ip') ?? '',
'user-agent': req.headers.get('user-agent') ?? '',
'accept-language': req.headers.get('accept-language') ?? '',
},
});
if (localeRes.ok) {
const data = (await localeRes.json()) as { locale?: string };
locale = data.locale ?? undefined;
}
} catch {
/* localization-service недоступен */
}
}
const effectiveLocale = locale ?? 'en';
if (message.content === '') {
return Response.json(
{
@@ -127,13 +151,18 @@ export const POST = async (req: Request) => {
const registry = new ModelRegistry();
const [llm, embedding] = await Promise.all([
registry.loadChatModel(body.chatModel.providerId, body.chatModel.key),
registry.loadEmbeddingModel(
const llm = await registry.loadChatModel(
body.chatModel.providerId,
body.chatModel.key,
);
let embedding: Awaited<ReturnType<ModelRegistry['loadEmbeddingModel']>> | null = null;
if (body.embeddingModel?.providerId) {
embedding = await registry.loadEmbeddingModel(
body.embeddingModel.providerId,
body.embeddingModel.key,
),
]);
);
}
const history: ChatTurnMessage[] = body.history.map((msg) => {
if (msg[0] === 'human') {
@@ -210,20 +239,30 @@ export const POST = async (req: Request) => {
}
});
agent.searchAsync(session, {
chatHistory: history,
followUp: message.content,
chatId: body.message.chatId,
messageId: body.message.messageId,
config: {
llm,
embedding: embedding,
sources: body.sources as SearchSources[],
mode: body.optimizationMode,
fileIds: body.files,
systemInstructions: body.systemInstructions || 'None',
},
});
agent
.searchAsync(session, {
chatHistory: history,
followUp: message.content,
chatId: body.message.chatId,
messageId: body.message.messageId,
config: {
llm,
embedding: embedding,
sources: body.sources as SearchSources[],
mode: body.optimizationMode,
fileIds: body.files,
systemInstructions: body.systemInstructions || 'None',
locale: effectiveLocale,
},
})
.catch((err: Error) => {
console.error('[Chat] searchAsync failed:', err);
session.emit('error', {
data:
err?.message ||
'Ошибка при поиске. Проверьте настройки SearXNG или LLM в Settings.',
});
});
ensureChatExists({
id: body.message.chatId,

View File

@@ -1,4 +1,5 @@
import configManager from '@/lib/config';
import { isEnvOnlyMode } from '@/lib/config/serverRegistry';
import ModelRegistry from '@/lib/models/registry';
import { NextRequest, NextResponse } from 'next/server';
import { ConfigModelProvider } from '@/lib/config/types';
@@ -32,6 +33,7 @@ export const GET = async (req: NextRequest) => {
return NextResponse.json({
values,
fields,
envOnlyMode: isEnvOnlyMode(),
});
} catch (err) {
console.error('Error in getting config: ', err);

View File

@@ -1,4 +1,4 @@
import { searchSearxng } from '@/lib/searxng';
import { searchSearxng, type SearxngSearchResult } from '@/lib/searxng';
import configManager from '@/lib/config';
import { getSearxngURL } from '@/lib/config/serverRegistry';
@@ -206,7 +206,7 @@ export const GET = async (req: Request) => {
);
const settled = await Promise.allSettled(searchPromises);
const allResults = settled
.filter((r): r is PromiseFulfilledResult<{ url?: string; title?: string }[]> => r.status === 'fulfilled')
.filter((r): r is PromiseFulfilledResult<SearxngSearchResult[]> => r.status === 'fulfilled')
.flatMap((r) => r.value);
data = allResults

View File

@@ -1,19 +1,40 @@
import {
getConfiguredModelProviders,
isEnvOnlyMode,
} from '@/lib/config/serverRegistry';
import ModelRegistry from '@/lib/models/registry';
import { NextRequest } from 'next/server';
export const GET = async (req: Request) => {
export const GET = async () => {
try {
const registry = new ModelRegistry();
const envOnlyMode = isEnvOnlyMode();
const configuredProviders = getConfiguredModelProviders();
const registry = new ModelRegistry();
const activeProviders = await registry.getActiveProviders();
const filteredProviders = activeProviders.filter((p) => {
return !p.chatModels.some((m) => m.key === 'error');
});
// env-only: если у провайдера пустой chatModels, подставляем модель из конфига
const providers =
envOnlyMode && configuredProviders.length > 0
? filteredProviders.map((p) => {
if (p.chatModels.length > 0) return p;
const configProvider = configuredProviders.find((c) => c.id === p.id);
const fallbackChat =
(configProvider?.chatModels?.length ?? 0) > 0
? configProvider!.chatModels
: [{ key: 'gpt-4', name: 'gpt-4' }];
return { ...p, chatModels: fallbackChat };
})
: filteredProviders;
return Response.json(
{
providers: filteredProviders,
providers,
envOnlyMode: envOnlyMode,
},
{
status: 200,
@@ -33,6 +54,10 @@ export const GET = async (req: Request) => {
};
export const POST = async (req: NextRequest) => {
if (isEnvOnlyMode()) {
return Response.json({ message: 'Not available.' }, { status: 405 });
}
try {
const body = await req.json();
const { type, name, config } = body;
@@ -49,7 +74,6 @@ export const POST = async (req: NextRequest) => {
}
const registry = new ModelRegistry();
const newProvider = await registry.addProvider(type, name, config);
return Response.json(

View File

@@ -17,7 +17,8 @@ export const POST = async (
const writer = responseStream.writable.getWriter();
const encoder = new TextEncoder();
const disconnect = session.subscribe((event, data) => {
let unsub: () => void = () => {};
unsub = session.subscribe((event, data) => {
if (event === 'data') {
if (data.type === 'block') {
writer.write(
@@ -56,7 +57,7 @@ export const POST = async (
),
);
writer.close();
disconnect();
setImmediate(() => unsub());
} else if (event === 'error') {
writer.write(
encoder.encode(
@@ -67,12 +68,12 @@ export const POST = async (
),
);
writer.close();
disconnect();
setImmediate(() => unsub());
}
});
req.signal.addEventListener('abort', () => {
disconnect();
unsub();
writer.close();
});

View File

@@ -5,6 +5,7 @@ import { ModelWithProvider } from '@/lib/models/types';
interface SuggestionsGenerationBody {
chatHistory: any[];
chatModel: ModelWithProvider;
locale?: string;
}
export const POST = async (req: Request) => {
@@ -24,6 +25,7 @@ export const POST = async (req: Request) => {
role: role === 'human' ? 'user' : 'assistant',
content,
})),
locale: body.locale,
},
llm,
);

View File

@@ -1,4 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import {
getEmbeddedTranslations,
translationLocaleFor,
} from '@/lib/localization/embeddedTranslations';
const LOCALIZATION_SERVICE_URL =
process.env.LOCALIZATION_SERVICE_URL ?? 'http://localhost:4003';
@@ -7,31 +11,27 @@ export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ locale: string }> },
) {
try {
const { locale } = await params;
if (!locale) {
return NextResponse.json(
{ error: 'Locale is required' },
{ status: 400 },
);
}
const res = await fetch(
`${LOCALIZATION_SERVICE_URL}/api/translations/${encodeURIComponent(locale)}`,
);
if (!res.ok) {
return NextResponse.json(
{ error: 'Failed to fetch translations' },
{ status: 502 },
);
}
const data = await res.json();
return NextResponse.json(data);
} catch {
const resolved = await params;
const localeParam = resolved?.locale ?? '';
if (!localeParam) {
return NextResponse.json(
{ error: 'Failed to fetch translations' },
{ status: 500 },
{ error: 'Locale is required' },
{ status: 400 },
);
}
try {
const fetchLocale = translationLocaleFor(localeParam);
const res = await fetch(
`${LOCALIZATION_SERVICE_URL}/api/translations/${encodeURIComponent(fetchLocale)}`,
);
if (res.ok) {
const data = await res.json();
return NextResponse.json(data);
}
} catch {
/* localization-service недоступен */
}
return NextResponse.json(getEmbeddedTranslations(localeParam));
}

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#EA580C"/>
<text x="16" y="22" font-family="Arial" font-size="18" font-weight="bold" fill="white" text-anchor="middle">G</text>
</svg>

After

Width:  |  Height:  |  Size: 242 B

View File

@@ -9,8 +9,10 @@ import { Toaster } from 'sonner';
import ThemeProvider from '@/components/theme/Provider';
import { LocalizationProvider } from '@/lib/localization/context';
import configManager from '@/lib/config';
import { isEnvOnlyMode } from '@/lib/config/serverRegistry';
import SetupWizard from '@/components/Setup/SetupWizard';
import { ChatProvider } from '@/lib/hooks/useChat';
import { ClientOnly } from '@/components/ClientOnly';
const roboto = Roboto({
weight: ['300', '400', '500', '700'],
@@ -39,6 +41,13 @@ export default function RootLayout({
<ThemeProvider>
<LocalizationProvider>
{setupComplete ? (
<ClientOnly
fallback={
<div className="flex min-h-screen items-center justify-center bg-light-primary dark:bg-dark-primary">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#EA580C] border-t-transparent" />
</div>
}
>
<ChatProvider>
<Sidebar>{children}</Sidebar>
<Toaster
@@ -46,13 +55,17 @@ export default function RootLayout({
unstyled: true,
classNames: {
toast:
'bg-light-secondary dark:bg-dark-secondary dark:text-white/70 text-black-70 rounded-lg p-4 flex flex-row items-center space-x-2',
'bg-light-secondary dark:bg-dark-secondary dark:text-white/70 text-black/70 rounded-lg p-4 flex flex-row items-center space-x-2',
},
}}
/>
</ChatProvider>
</ClientOnly>
) : (
<SetupWizard configSections={configSections} />
<SetupWizard
configSections={configSections}
envConfigRequired={!isEnvOnlyMode()}
/>
)}
</LocalizationProvider>
</ThemeProvider>