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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
4
apps/frontend/src/app/icon.svg
Normal file
4
apps/frontend/src/app/icon.svg
Normal 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 |
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user