split some options to config folder, translation fixes, markdown fixes, ui improvements to settings and ai, cleanup, add additional models

This commit is contained in:
Aidan 2025-07-02 23:06:37 -04:00
parent 1857767213
commit 4409acd79d
8 changed files with 273 additions and 182 deletions

View file

@ -34,7 +34,6 @@ import { Telegraf, Context } from "telegraf"
import type { Message } from "telegraf/types"
import { replyToMessageId } from "../utils/reply-to-message-id"
import { getStrings } from "../plugins/checklang"
import { languageCode } from "../utils/language-code"
import axios from "axios"
import { rateLimiter } from "../utils/rate-limiter"
import { logger } from "../utils/log"
@ -42,6 +41,7 @@ import { ensureUserInDb } from "../utils/ensure-user"
import * as schema from '../db/schema'
import type { NodePgDatabase } from "drizzle-orm/node-postgres"
import { eq, sql } from 'drizzle-orm'
import { models, unloadModelAfterB } from "../../config/ai"
const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch)
export const flash_model = process.env.flashModel || "gemma3:4b"
@ -51,7 +51,7 @@ type TextContext = Context & { message: Message.TextMessage }
type User = typeof schema.usersTable.$inferSelect
interface ModelInfo {
export interface ModelInfo {
name: string;
label: string;
descriptionEn: string;
@ -67,59 +67,6 @@ interface OllamaResponse {
response: string;
}
export const models: ModelInfo[] = [
{
name: 'gemma3n',
label: 'gemma3n',
descriptionEn: 'Gemma3n is a family of open, light on-device models for general tasks.',
descriptionPt: 'Gemma3n é uma família de modelos abertos, leves e para dispositivos locais, para tarefas gerais.',
models: [
{ name: 'gemma3n:e2b', label: 'Gemma3n e2b', parameterSize: '2B' },
{ name: 'gemma3n:e4b', label: 'Gemma3n e4b', parameterSize: '4B' },
]
},
{
name: 'gemma3-abliterated',
label: 'gemma3 Uncensored',
descriptionEn: 'Gemma3-abliterated is a family of open, uncensored models for general tasks.',
descriptionPt: 'Gemma3-abliterated é uma família de modelos abertos, não censurados, para tarefas gerais.',
models: [
{ name: 'huihui_ai/gemma3-abliterated:1b', label: 'Gemma3-abliterated 1B', parameterSize: '1b' },
{ name: 'huihui_ai/gemma3-abliterated:4b', label: 'Gemma3-abliterated 4B', parameterSize: '4b' },
]
},
{
name: 'qwen3',
label: 'Qwen3',
descriptionEn: 'Qwen3 is a multilingual reasoning model series.',
descriptionPt: 'Qwen3 é uma série de modelos multilingues.',
models: [
{ name: 'qwen3:4b', label: 'Qwen3 4B', parameterSize: '4B' },
]
},
{
name: 'deepseek',
label: 'DeepSeek',
descriptionEn: 'DeepSeek is a research model for reasoning tasks.',
descriptionPt: 'DeepSeek é um modelo de pesquisa para tarefas de raciocínio.',
models: [
{ name: 'deepseek-r1:1.5b', label: 'DeepSeek 1.5B', parameterSize: '1.5B' },
{ name: 'deepseek-r1:7b', label: 'DeepSeek 7B', parameterSize: '7B' },
{ name: 'huihui_ai/deepseek-r1-abliterated:1.5b', label: 'DeepSeek Uncensored 1.5B', parameterSize: '1.5B' },
{ name: 'huihui_ai/deepseek-r1-abliterated:7b', label: 'DeepSeek Uncensored 7B', parameterSize: '7B' },
]
},
{
name: 'phi3',
label: 'Phi3',
descriptionEn: 'Phi-3 is a family of lightweight 3B (Mini) and 14B (Medium) state-of-the-art open models by Microsoft.',
descriptionPt: 'Phi-3 é uma família de modelos leves de 3B (Mini) e 14B (Médio) de última geração, abertos pela Microsoft.',
models: [
{ name: 'phi3:3.8b', label: 'Phi3 3.8B', parameterSize: '3.8B' },
]
}
];
async function usingSystemPrompt(ctx: TextContext, db: NodePgDatabase<typeof schema>, botName: string, message: string): Promise<string> {
const user = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(ctx.from!.id)), limit: 1 });
if (user.length === 0) await ensureUserInDb(ctx, db);
@ -263,16 +210,11 @@ function extractAxiosErrorMessage(error: unknown): string {
return 'An unexpected error occurred.';
}
function escapeMarkdown(text: string): string {
return text.replace(/([_*\[\]()`>#\+\-=|{}.!~])/g, '\\$1');
}
function containsUrls(text: string): boolean {
return text.includes('http://') || text.includes('https://');
return text.includes('http://') || text.includes('https://') || text.includes('.com') || text.includes('.net') || text.includes('.org') || text.includes('.io') || text.includes('.ai') || text.includes('.dev')
}
async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Message, model: string, aiTemperature: number, originalMessage: string, db: NodePgDatabase<typeof schema>, userId: string): Promise<{ success: boolean; response?: string; error?: string }> {
const Strings = getStrings(languageCode(ctx));
async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Message, model: string, aiTemperature: number, originalMessage: string, db: NodePgDatabase<typeof schema>, userId: string, Strings: ReturnType<typeof getStrings>, showThinking: boolean): Promise<{ success: boolean; response?: string; error?: string }> {
if (!ctx.chat) {
return {
success: false,
@ -289,6 +231,8 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me
await db.update(schema.usersTable)
.set({ aiCharacters: sql`${schema.usersTable.aiCharacters} + ${promptCharCount}` })
.where(eq(schema.usersTable.telegramId, userId));
const paramSizeStr = models.find(m => m.name === model)?.models.find(m => m.name === model)?.parameterSize?.replace('B', '');
const shouldKeepAlive = paramSizeStr ? Number(paramSizeStr) > unloadModelAfterB : false;
try {
const aiResponse = await axios.post<unknown>(
@ -297,6 +241,7 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me
model,
prompt,
stream: true,
keep_alive: shouldKeepAlive ? '1' : '0',
options: {
temperature: aiTemperature
}
@ -311,6 +256,16 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me
let sentHeader = false;
let firstChunk = true;
const stream: NodeJS.ReadableStream = aiResponse.data as any;
let thinkingMessageSent = false;
let finalResponseText = '';
const formatThinkingMessage = (text: string) => {
const withPlaceholders = text
.replace(/___THINK_START___/g, `${Strings.ai.thinking}`)
.replace(/___THINK_END___/g, `${Strings.ai.finishedThinking}`);
return sanitizeMarkdownForTelegram(withPlaceholders);
};
for await (const chunk of stream) {
const lines = chunk.toString().split('\n');
for (const line of lines) {
@ -322,6 +277,22 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me
console.error("[✨ AI | !] Error parsing chunk");
continue;
}
if (model === thinking_model && !showThinking) {
if (ln.response) {
finalResponseText += ln.response;
if (finalResponseText.includes('<think>') && !thinkingMessageSent) {
await rateLimiter.editMessageWithRetry(
ctx,
ctx.chat.id,
replyGenerating.message_id,
modelHeader + Strings.ai.thinking,
{ parse_mode: 'Markdown' }
);
thinkingMessageSent = true;
}
}
continue;
}
if (model === thinking_model && ln.response) {
if (ln.response.includes('<think>')) {
const thinkMatch = ln.response.match(/<think>([\s\S]*?)<\/think>/);
@ -338,9 +309,9 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me
if (model === thinking_model) {
let patchedThoughts = ln.response;
const thinkTagRx = /<think>([\s\S]*?)<\/think>/g;
patchedThoughts = patchedThoughts.replace(thinkTagRx, (p1) => p1.trim().length > 0 ? '`' + Strings.ai.thinking + '`' + p1 + '`' + Strings.ai.finishedThinking + '`' : '');
patchedThoughts = patchedThoughts.replace(/<think>/g, '`' + Strings.ai.thinking + '`');
patchedThoughts = patchedThoughts.replace(/<\/think>/g, '`' + Strings.ai.finishedThinking + '`');
patchedThoughts = patchedThoughts.replace(thinkTagRx, (p1) => p1.trim().length > 0 ? '___THINK_START___' + p1.trim() + '___THINK_END___' : '');
patchedThoughts = patchedThoughts.replace(/<think>/g, '___THINK_START___');
patchedThoughts = patchedThoughts.replace(/<\/think>/g, '___THINK_END___');
thoughts += patchedThoughts;
fullResponse += patchedThoughts;
} else {
@ -356,7 +327,7 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me
ctx,
ctx.chat.id,
replyGenerating.message_id,
modelHeader + escapeMarkdown(fullResponse),
modelHeader + formatThinkingMessage(fullResponse),
{ parse_mode: 'Markdown' }
);
lastUpdateCharCount = fullResponse.length;
@ -370,7 +341,7 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me
ctx,
ctx.chat.id,
replyGenerating.message_id,
modelHeader + escapeMarkdown(fullResponse),
modelHeader + formatThinkingMessage(fullResponse),
{ parse_mode: 'Markdown' }
);
lastUpdateCharCount = fullResponse.length;
@ -379,6 +350,10 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me
}
}
}
if (model === thinking_model && !showThinking) {
const cleanedResponse = finalResponseText.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
return { success: true, response: cleanedResponse };
}
status = Strings.ai.statusRendering;
modelHeader = Strings.ai.modelHeader
.replace("{model}", model)
@ -388,7 +363,7 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me
ctx,
ctx.chat.id,
replyGenerating.message_id,
modelHeader + escapeMarkdown(fullResponse),
modelHeader + formatThinkingMessage(fullResponse),
{ parse_mode: 'Markdown' }
);
const responseCharCount = fullResponse.length;
@ -432,13 +407,13 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me
console.error("[✨ AI | !] Pull error:", pullMsg);
return {
success: false,
error: `❌ Something went wrong while pulling ${escapeMarkdown(model)}: ${escapeMarkdown(pullMsg)}`,
error: `❌ Something went wrong while pulling ${model}: ${pullMsg}`,
};
}
console.log(`[✨ AI] ${model} pulled successfully`);
return {
success: true,
response: Strings.ai.pulled.replace("{model}", escapeMarkdown(model)),
response: Strings.ai.pulled.replace("{model}", model),
};
}
}
@ -449,9 +424,8 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me
}
}
async function handleAiReply(ctx: TextContext, model: string, prompt: string, replyGenerating: Message, aiTemperature: number, originalMessage: string, db: NodePgDatabase<typeof schema>, userId: string) {
const Strings = getStrings(languageCode(ctx));
const aiResponse = await getResponse(prompt, ctx, replyGenerating, model, aiTemperature, originalMessage, db, userId);
async function handleAiReply(ctx: TextContext, model: string, prompt: string, replyGenerating: Message, aiTemperature: number, originalMessage: string, db: NodePgDatabase<typeof schema>, userId: string, Strings: ReturnType<typeof getStrings>, showThinking: boolean) {
const aiResponse = await getResponse(prompt, ctx, replyGenerating, model, aiTemperature, originalMessage, db, userId, Strings, showThinking);
if (!aiResponse) return;
if (!ctx.chat) return;
if (aiResponse.success && aiResponse.response) {
@ -461,11 +435,17 @@ async function handleAiReply(ctx: TextContext, model: string, prompt: string, re
.replace("{temperature}", aiTemperature)
.replace("{status}", status) + "\n\n";
const urlWarning = containsUrls(originalMessage) ? Strings.ai.urlWarning : '';
let finalResponse = aiResponse.response;
if (model === thinking_model) {
finalResponse = finalResponse.replace(/___THINK_START___/g, `${Strings.ai.thinking}`)
.replace(/___THINK_END___/g, `${Strings.ai.finishedThinking}`);
}
await rateLimiter.editMessageWithRetry(
ctx,
ctx.chat.id,
replyGenerating.message_id,
modelHeader + sanitizeMarkdownForTelegram(aiResponse.response) + urlWarning,
modelHeader + sanitizeMarkdownForTelegram(finalResponse) + urlWarning,
{ parse_mode: 'Markdown' }
);
return;
@ -480,7 +460,7 @@ async function handleAiReply(ctx: TextContext, model: string, prompt: string, re
);
}
async function getUserWithStringsAndModel(ctx: Context, db: NodePgDatabase<typeof schema>): Promise<{ user: User; Strings: ReturnType<typeof getStrings>; languageCode: string; customAiModel: string; aiTemperature: number }> {
async function getUserWithStringsAndModel(ctx: Context, db: NodePgDatabase<typeof schema>): Promise<{ user: User; Strings: ReturnType<typeof getStrings>; languageCode: string; customAiModel: string; aiTemperature: number, showThinking: boolean }> {
const userArr = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(ctx.from!.id)), limit: 1 });
let user = userArr[0];
if (!user) {
@ -488,10 +468,10 @@ async function getUserWithStringsAndModel(ctx: Context, db: NodePgDatabase<typeo
const newUserArr = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(ctx.from!.id)), limit: 1 });
user = newUserArr[0];
const Strings = getStrings(user.languageCode);
return { user, Strings, languageCode: user.languageCode, customAiModel: user.customAiModel, aiTemperature: user.aiTemperature };
return { user, Strings, languageCode: user.languageCode, customAiModel: user.customAiModel, aiTemperature: user.aiTemperature, showThinking: user.showThinking };
}
const Strings = getStrings(user.languageCode);
return { user, Strings, languageCode: user.languageCode, customAiModel: user.customAiModel, aiTemperature: user.aiTemperature };
return { user, Strings, languageCode: user.languageCode, customAiModel: user.customAiModel, aiTemperature: user.aiTemperature, showThinking: user.showThinking };
}
export function getModelLabelByName(name: string): string {
@ -547,7 +527,7 @@ export default (bot: Telegraf<Context>, db: NodePgDatabase<typeof schema>) => {
async function aiCommandHandler(ctx: TextContext, command: 'ask' | 'think' | 'ai') {
const reply_to_message_id = replyToMessageId(ctx);
const { user, Strings, customAiModel, aiTemperature } = await getUserWithStringsAndModel(ctx, db);
const { user, Strings, customAiModel, aiTemperature, showThinking } = await getUserWithStringsAndModel(ctx, db);
const message = ctx.message.text;
const author = ("@" + ctx.from?.username) || ctx.from?.first_name || "Unknown";
@ -586,7 +566,7 @@ export default (bot: Telegraf<Context>, db: NodePgDatabase<typeof schema>) => {
...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } })
});
const prompt = sanitizeForJson(await usingSystemPrompt(ctx, db, botName, fixedMsg));
await handleAiReply(ctx, model, prompt, replyGenerating, aiTemperature, fixedMsg, db, user.telegramId);
await handleAiReply(ctx, model, prompt, replyGenerating, aiTemperature, fixedMsg, db, user.telegramId, Strings, showThinking);
};
if (isProcessing) {