fix(ai): issue #141 — custom OpenAI-compatible providers work + language selection on first start in sleep mode
- callOllama: reasoning_effort only for provider=ollama (others returned 400 -> stub reply) - endpoint normalization: base URL (Groq /openai/v1) -> /chat/completions appended - reasoning block stripping: <thinking>/<think> tags + thinking/response markers (Groq qwen) - handleStart: sleep mode + language_set=0 -> show language selection keyboard first - handleSetLanguage: sleep mode -> AI dialog in chosen language after selection
This commit is contained in:
@@ -140,7 +140,7 @@ function generateCustomerProfile(messages: ChatMessage[]): string {
|
|||||||
return JSON.stringify(profile);
|
return JSON.stringify(profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Ollama Cloud API call (OpenAI-compatible) ──
|
// ── LLM API call (OpenAI-compatible) ──
|
||||||
async function callOllama(
|
async function callOllama(
|
||||||
messages: { role: string; content: string }[],
|
messages: { role: string; content: string }[],
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
@@ -148,22 +148,34 @@ async function callOllama(
|
|||||||
model: string,
|
model: string,
|
||||||
temperature: number,
|
temperature: number,
|
||||||
maxTokens: number,
|
maxTokens: number,
|
||||||
|
provider: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const res = await fetch(endpoint, {
|
const body: Record<string, unknown> = {
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${apiKey}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model,
|
model,
|
||||||
messages,
|
messages,
|
||||||
temperature,
|
temperature,
|
||||||
max_tokens: maxTokens,
|
max_tokens: maxTokens,
|
||||||
stream: false,
|
stream: false,
|
||||||
// Отключаем reasoning-токены — возвращаем только content
|
};
|
||||||
reasoning_effort: 'none',
|
// reasoning_effort — параметр только Ollama Cloud; другие OpenAI-совместимые API возвращают 400
|
||||||
}),
|
if (provider === 'ollama') {
|
||||||
|
body.reasoning_effort = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нормализация endpoint: если указан базовый URL (без /chat/completions) — добавляем
|
||||||
|
// (Groq: https://api.groq.com/openai/v1 → /openai/v1/chat/completions)
|
||||||
|
let url = endpoint;
|
||||||
|
if (!/\/chat\/completions$/i.test(url)) {
|
||||||
|
url = url.replace(/\/+$/, '') + '/chat/completions';
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -172,7 +184,28 @@ async function callOllama(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return data?.choices?.[0]?.message?.content || 'Извините, не удалось получить ответ.';
|
let content = data?.choices?.[0]?.message?.content || 'Извините, не удалось получить ответ.';
|
||||||
|
// ── Убираем блоки рассуждений (reasoning) ──
|
||||||
|
// Форматы от разных провайдеров:
|
||||||
|
// Groq qwen: "<think>...рассуждения...</think>\n response\n<ответ>"
|
||||||
|
// Ollama: "\n thinking\n<рассуждения>\n response\n<ответ>"
|
||||||
|
// 1) Полные теги <think>...</think> / <thinking>...</thinking>
|
||||||
|
content = content.replace(/<think(?:ing)?>[\s\S]*?<\/think(?:ing)?>/gi, '');
|
||||||
|
// 2) Незакрытый <think> — вырезаем до конца (или до маркера response)
|
||||||
|
content = content.replace(/<think(?:ing)?>[\s\S]*$/gi, '');
|
||||||
|
// 3) Строка-маркер "response" — берём всё после неё
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const respIdx = lines.findIndex((l) => l.trim().toLowerCase() === 'response');
|
||||||
|
if (respIdx !== -1) {
|
||||||
|
content = lines.slice(respIdx + 1).join('\n');
|
||||||
|
} else {
|
||||||
|
// 4) Нет маркера response — строка "thinking": отрезаем рассуждения
|
||||||
|
const thinkIdx = lines.findIndex((l) => l.trim().toLowerCase() === 'thinking');
|
||||||
|
if (thinkIdx !== -1) {
|
||||||
|
content = lines.slice(thinkIdx + 1).join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return content.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -429,7 +462,7 @@ export async function POST(request: NextRequest) {
|
|||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new Error('Ollama API key is not configured');
|
throw new Error('Ollama API key is not configured');
|
||||||
}
|
}
|
||||||
reply = await callOllama(apiMessages, apiEndpoint, apiKey, model, temperature, maxTokens);
|
reply = await callOllama(apiMessages, apiEndpoint, apiKey, model, temperature, maxTokens, provider);
|
||||||
if (typeof reply !== 'string') {
|
if (typeof reply !== 'string') {
|
||||||
reply = JSON.stringify(reply);
|
reply = JSON.stringify(reply);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,19 @@ ${t('profile.member_since')}: ${new Date(userStats.created_at).toLocaleDateStrin
|
|||||||
|
|
||||||
// Sleep mode: если магазин на паузе — живой ИИ-диалог вместо каталога
|
// Sleep mode: если магазин на паузе — живой ИИ-диалог вместо каталога
|
||||||
if (await chatbotService.isSleepMode()) {
|
if (await chatbotService.isSleepMode()) {
|
||||||
const lang = (await UserService.getUserByTelegramId(telegramId))?.language || 'en';
|
const user = await UserService.getUserByTelegramId(telegramId);
|
||||||
|
// Если язык ещё не выбран (первый старт) — сначала выбор языка
|
||||||
|
if (!user?.language_set) {
|
||||||
|
const keyboard = {
|
||||||
|
inline_keyboard: AVAILABLE_LANGUAGES.map(code => [{
|
||||||
|
text: LANGUAGE_NAMES[code],
|
||||||
|
callback_data: `set_language_${code}`
|
||||||
|
}])
|
||||||
|
};
|
||||||
|
await bot.sendMessage(chatId, tForUser('en')('bot.language_select'), { reply_markup: keyboard });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lang = user?.language || 'en';
|
||||||
// Приветствие от ИИ-бота (живой диалог, не заглушка)
|
// Приветствие от ИИ-бота (живой диалог, не заглушка)
|
||||||
const welcome = await chatbotService.sendToChatbot({
|
const welcome = await chatbotService.sendToChatbot({
|
||||||
sessionId: telegramId,
|
sessionId: telegramId,
|
||||||
@@ -176,6 +188,23 @@ ${t('profile.member_since')}: ${new Date(userStats.created_at).toLocaleDateStrin
|
|||||||
|
|
||||||
await bot.answerCallbackQuery(callbackQuery.id);
|
await bot.answerCallbackQuery(callbackQuery.id);
|
||||||
|
|
||||||
|
// Если магазин на паузе — после выбора языка сразу ИИ-диалог на выбранном языке
|
||||||
|
if (await chatbotService.isSleepMode()) {
|
||||||
|
try { await bot.deleteMessage(chatId, callbackQuery.message.message_id); } catch {}
|
||||||
|
const welcome = await chatbotService.sendToChatbot({
|
||||||
|
sessionId: telegramId,
|
||||||
|
message: '/start',
|
||||||
|
telegramId,
|
||||||
|
language: lang,
|
||||||
|
username: callbackQuery.from?.username,
|
||||||
|
name: callbackQuery.from?.first_name,
|
||||||
|
});
|
||||||
|
await bot.sendMessage(chatId, welcome.reply || await chatbotService.getWelcomeMessage(), {
|
||||||
|
reply_markup: { remove_keyboard: true },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const keyboard = {
|
const keyboard = {
|
||||||
reply_markup: {
|
reply_markup: {
|
||||||
keyboard: [
|
keyboard: [
|
||||||
|
|||||||
Reference in New Issue
Block a user