From 3e47d5a7eea8fce19ce02a204f057aa5c2f45233 Mon Sep 17 00:00:00 2001 From: Manuel Vergara Date: Sun, 18 Aug 2024 20:16:34 +0200 Subject: [PATCH] Add Ollama bot --- .../09_ollama_bot/.dockerignore | 3 + .../09_ollama_bot/.env.example | 15 + .../06_bots_telegram/09_ollama_bot/.gitignore | 203 +++++++++ .../06_bots_telegram/09_ollama_bot/Dockerfile | 29 ++ .../06_bots_telegram/09_ollama_bot/README.md | 110 +++++ .../09_ollama_bot/bot/func/interactions.py | 199 ++++++++ .../06_bots_telegram/09_ollama_bot/bot/run.py | 426 ++++++++++++++++++ .../09_ollama_bot/docker-compose.yml | 30 ++ .../09_ollama_bot/requirements.txt | 3 + catch-all/06_bots_telegram/README.md | 1 + 10 files changed, 1019 insertions(+) create mode 100644 catch-all/06_bots_telegram/09_ollama_bot/.dockerignore create mode 100644 catch-all/06_bots_telegram/09_ollama_bot/.env.example create mode 100644 catch-all/06_bots_telegram/09_ollama_bot/.gitignore create mode 100644 catch-all/06_bots_telegram/09_ollama_bot/Dockerfile create mode 100644 catch-all/06_bots_telegram/09_ollama_bot/README.md create mode 100644 catch-all/06_bots_telegram/09_ollama_bot/bot/func/interactions.py create mode 100644 catch-all/06_bots_telegram/09_ollama_bot/bot/run.py create mode 100644 catch-all/06_bots_telegram/09_ollama_bot/docker-compose.yml create mode 100644 catch-all/06_bots_telegram/09_ollama_bot/requirements.txt diff --git a/catch-all/06_bots_telegram/09_ollama_bot/.dockerignore b/catch-all/06_bots_telegram/09_ollama_bot/.dockerignore new file mode 100644 index 0000000..075da0a --- /dev/null +++ b/catch-all/06_bots_telegram/09_ollama_bot/.dockerignore @@ -0,0 +1,3 @@ +README.md +*.md +.github \ No newline at end of file diff --git a/catch-all/06_bots_telegram/09_ollama_bot/.env.example b/catch-all/06_bots_telegram/09_ollama_bot/.env.example new file mode 100644 index 0000000..7a25e27 --- /dev/null +++ b/catch-all/06_bots_telegram/09_ollama_bot/.env.example @@ -0,0 +1,15 @@ +TOKEN=0123 +ADMIN_IDS=000,111 +USER_IDS=000,111 +ALLOW_ALL_USERS_IN_GROUPS=0 +INITMODEL=llama-2 +TIMEOUT=3000 + +# UNCOMMENT ONE OF THE FOLLOWING LINES: +# OLLAMA_BASE_URL=localhost # to run ollama without docker, using run.py +# OLLAMA_BASE_URL=ollama-server # to run ollama in a docker container +# OLLAMA_BASE_URL=host.docker.internal # to run ollama locally + +# Log level +# https://docs.python.org/3/library/logging.html#logging-levels +LOG_LEVEL=DEBUG diff --git a/catch-all/06_bots_telegram/09_ollama_bot/.gitignore b/catch-all/06_bots_telegram/09_ollama_bot/.gitignore new file mode 100644 index 0000000..c53024a --- /dev/null +++ b/catch-all/06_bots_telegram/09_ollama_bot/.gitignore @@ -0,0 +1,203 @@ +# .ollama temp +/ollama + + + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +*.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# Additional patches +.idea/ +### MacOS ### +.DS_Store +.AppleDouble +.LSOverride +Icon +._* +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk +*.icloud +### Windows ### +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db +*.stackdump +[Dd]esktop.ini +$RECYCLE.BIN/ +*.cab +*.msi +*.msix +*.msm +*.msp +*.lnk +### Linux ### +*~ +.fuse_hidden* +.directory +.Trash-* +.nfs* + +# OpenSSH Keys +id_* \ No newline at end of file diff --git a/catch-all/06_bots_telegram/09_ollama_bot/Dockerfile b/catch-all/06_bots_telegram/09_ollama_bot/Dockerfile new file mode 100644 index 0000000..e518990 --- /dev/null +++ b/catch-all/06_bots_telegram/09_ollama_bot/Dockerfile @@ -0,0 +1,29 @@ +FROM python:3.12-alpine + +ARG APPHOMEDIR=code +ARG USERNAME=user +ARG USER_UID=1001 +ARG USER_GID=1001 +ARG PYTHONPATH_=${APPHOMEDIR} + +WORKDIR /${APPHOMEDIR} + +COPY requirements.txt requirements.txt +COPY ./bot /${APPHOMEDIR} + +# Configure app home directory +RUN \ + addgroup -g "$USER_GID" "$USERNAME" \ + && adduser --disabled-password -u "$USER_UID" -G "$USERNAME" -h /"$APPHOMEDIR" "$USERNAME" \ + && chown "$USERNAME:$USERNAME" -R /"$APPHOMEDIR" + +# Install dependency packages, upgrade pip and then install requirements +RUN \ + apk add --no-cache gcc g++ \ + && python -m pip install --upgrade pip \ + && pip install --no-cache-dir -r requirements.txt \ + && apk del --no-cache gcc g++ + +USER ${USERNAME} + +CMD [ "python3", "-u", "run.py"] diff --git a/catch-all/06_bots_telegram/09_ollama_bot/README.md b/catch-all/06_bots_telegram/09_ollama_bot/README.md new file mode 100644 index 0000000..4a891f8 --- /dev/null +++ b/catch-all/06_bots_telegram/09_ollama_bot/README.md @@ -0,0 +1,110 @@ + +# 🦙 Ollama Telegram Bot + +> Repo original: https://github.com/ruecat/ollama-telegram/tree/main + +## Prerrequisitos +- [Token de Telegram-Bot](https://core.telegram.org/bots#6-botfather) + +## Instalación (Sin Docker) ++ Instala la última versión de [Python](https://python.org/downloads) ++ Clona el repositorio + ``` + git clone https://github.com/ruecat/ollama-telegram + ``` ++ Instala los requisitos desde requirements.txt + ``` + pip install -r requirements.txt + ``` ++ Ingresa todos los valores en .env.example + ++ Renombra .env.example a .env + ++ Inicia el bot + + ``` + python3 run.py + ``` +## Instalación (Imagen Docker) +La imagen oficial está disponible en dockerhub: [ruecat/ollama-telegram](https://hub.docker.com/r/ruecat/ollama-telegram) + ++ Descarga el archivo [.env.example](https://github.com/ruecat/ollama-telegram/blob/main/.env.example), renómbralo a .env y completa las variables. ++ Crea un archivo `docker-compose.yml` (opcional: descomenta la parte de GPU en el archivo para habilitar la GPU de Nvidia) + + ```yml + version: '3.8' + services: + ollama-telegram: + image: ruecat/ollama-telegram + container_name: ollama-telegram + restart: on-failure + env_file: + - ./.env + + ollama-server: + image: ollama/ollama:latest + container_name: ollama-server + volumes: + - ./ollama:/root/.ollama + + # Descomenta para habilitar la GPU de NVIDIA + # De lo contrario, se ejecuta solo en la CPU: + + # deploy: + # resources: + # reservations: + # devices: + # - driver: nvidia + # count: all + # capabilities: [gpu] + + restart: always + ports: + - '11434:11434' + ``` + ++ Inicia los contenedores + + ```sh + docker compose up -d + ``` + +## Instalación (Construye tu propia imagen Docker) ++ Clona el repositorio + ``` + git clone https://github.com/ruecat/ollama-telegram + ``` + ++ Ingresa todos los valores en .env.example + ++ Renombra .env.example a .env + ++ Ejecuta UNO de los siguientes comandos de docker compose para iniciar: + 1. Para ejecutar ollama en un contenedor de docker (opcional: descomenta la parte de GPU en el archivo docker-compose.yml para habilitar la GPU de Nvidia) + ``` + docker compose up --build -d + ``` + + 2. Para ejecutar ollama desde una instancia instalada localmente (principalmente para **MacOS**, ya que la imagen de docker aún no soporta la aceleración de GPU de Apple): + ``` + docker compose up --build -d ollama-telegram + ``` + +## Configuración del Entorno +| Parámetro | Descripción | ¿Requerido? | Valor por Defecto | Ejemplo | +|:----------------------------:|:------------------------------------------------------------------------------------------------------------------------:|:-----------:|:-----------------:|:---------------------------------------------------------:| +| `TOKEN` | Tu **token de bot de Telegram**.
[[¿Cómo obtener el token?]](https://core.telegram.org/bots/tutorial#obtain-your-bot-token) | Sí | `yourtoken` | MTA0M****.GY5L5F.****g*****5k | +| `ADMIN_IDS` | IDs de usuarios de Telegram de los administradores.
Pueden cambiar el modelo y controlar el bot. | Sí | | 1234567890
**O**
1234567890,0987654321, etc. | +| `USER_IDS` | IDs de usuarios de Telegram de los usuarios regulares.
Solo pueden chatear con el bot. | Sí | | 1234567890
**O**
1234567890,0987654321, etc. | +| `INITMODEL` | LLM predeterminado | No | `llama2` | mistral:latest
mistral:7b-instruct | +| `OLLAMA_BASE_URL` | Tu URL de OllamaAPI | No | | localhost
host.docker.internal | +| `OLLAMA_PORT` | Tu puerto de OllamaAPI | No | 11434 | | +| `TIMEOUT` | El tiempo de espera en segundos para generar respuestas | No | 3000 | | +| `ALLOW_ALL_USERS_IN_GROUPS` | Permite que todos los usuarios en chats grupales interactúen con el bot sin agregarlos a la lista USER_IDS | No | 0 | | + +## Créditos ++ [Ollama](https://github.com/jmorganca/ollama) + +## Librerías utilizadas ++ [Aiogram 3.x](https://github.com/aiogram/aiogram) + diff --git a/catch-all/06_bots_telegram/09_ollama_bot/bot/func/interactions.py b/catch-all/06_bots_telegram/09_ollama_bot/bot/func/interactions.py new file mode 100644 index 0000000..d157e9c --- /dev/null +++ b/catch-all/06_bots_telegram/09_ollama_bot/bot/func/interactions.py @@ -0,0 +1,199 @@ +# >> interactions +import logging +import os +import aiohttp +import json + +from aiogram import types +from aiohttp import ClientTimeout +from asyncio import Lock +from functools import wraps +from dotenv import load_dotenv + + +load_dotenv('.env') +token = os.getenv("TOKEN") + +allowed_ids = list(map(int, os.getenv("USER_IDS", "").split(","))) +admin_ids = list(map(int, os.getenv("ADMIN_IDS", "").split(","))) + +ollama_base_url = os.getenv("OLLAMA_BASE_URL") +ollama_port = os.getenv("OLLAMA_PORT", "11434") + +log_level_str = os.getenv("LOG_LEVEL", "INFO") + +allow_all_users_in_groups = bool( + int(os.getenv("ALLOW_ALL_USERS_IN_GROUPS", "0"))) + +log_levels = list(logging._levelToName.values()) + +timeout = os.getenv("TIMEOUT", "3000") + +if log_level_str not in log_levels: + + log_level = logging.DEBUG + +else: + + log_level = logging.getLevelName(log_level_str) + +logging.basicConfig(level=log_level) + + +async def model_list(): + + async with aiohttp.ClientSession() as session: + + url = f"http://{ollama_base_url}:{ollama_port}/api/tags" + + async with session.get(url) as response: + + if response.status == 200: + + data = await response.json() + return data["models"] + + else: + + return [] + + +async def generate(payload: dict, modelname: str, prompt: str): + + client_timeout = ClientTimeout(total=int(timeout)) + + async with aiohttp.ClientSession(timeout=client_timeout) as session: + + url = f"http://{ollama_base_url}:{ollama_port}/api/chat" + + try: + + async with session.post(url, json=payload) as response: + + if response.status != 200: + + raise aiohttp.ClientResponseError( + + status=response.status, message=response.reason + + ) + + buffer = b"" + + async for chunk in response.content.iter_any(): + + buffer += chunk + + while b"\n" in buffer: + + line, buffer = buffer.split(b"\n", 1) + line = line.strip() + + if line: + + yield json.loads(line) + + except aiohttp.ClientError as e: + + print(f"Error during request: {e}") + + +def perms_allowed(func): + + @wraps(func) + async def wrapper(message: types.Message = None, query: types.CallbackQuery = None): + + user_id = message.from_user.id if message else query.from_user.id + + if user_id in admin_ids or user_id in allowed_ids: + + if message: + + return await func(message) + + elif query: + + return await func(query=query) + + else: + + if message: + + if message and message.chat.type in ["supergroup", "group"]: + + if allow_all_users_in_groups: + + return await func(message) + + return + + await message.answer("Access Denied") + + elif query: + + if message and message.chat.type in ["supergroup", "group"]: + + return + + await query.answer("Access Denied") + + return wrapper + + +def perms_admins(func): + + @wraps(func) + async def wrapper(message: types.Message = None, query: types.CallbackQuery = None): + + user_id = message.from_user.id if message else query.from_user.id + + if user_id in admin_ids: + + if message: + + return await func(message) + + elif query: + + return await func(query=query) + + else: + + if message: + + if message and message.chat.type in ["supergroup", "group"]: + + return + + await message.answer("Access Denied") + + logging.info( + f"[MSG] {message.from_user.first_name} { + message.from_user.last_name}({message.from_user.id}) is not allowed to use this bot." + ) + + elif query: + + if message and message.chat.type in ["supergroup", "group"]: + + return + + await query.answer("Access Denied") + + logging.info( + f"[QUERY] {message.from_user.first_name} { + message.from_user.last_name}({message.from_user.id}) is not allowed to use this bot." + ) + + return wrapper + + +class contextLock: + + lock = Lock() + + async def __aenter__(self): + await self.lock.acquire() + + async def __aexit__(self, exc_type, exc_value, exc_traceback): + self.lock.release() diff --git a/catch-all/06_bots_telegram/09_ollama_bot/bot/run.py b/catch-all/06_bots_telegram/09_ollama_bot/bot/run.py new file mode 100644 index 0000000..9b6500d --- /dev/null +++ b/catch-all/06_bots_telegram/09_ollama_bot/bot/run.py @@ -0,0 +1,426 @@ +from aiogram import Bot, Dispatcher, types +from aiogram.enums import ParseMode +from aiogram.filters.command import Command, CommandStart +from aiogram.types import Message +from aiogram.utils.keyboard import InlineKeyboardBuilder +from func.interactions import * + +import asyncio +import traceback +import io +import base64 + +bot = Bot(token=token) +dp = Dispatcher() + +start_kb = InlineKeyboardBuilder() + +settings_kb = InlineKeyboardBuilder() + +start_kb.row( + types.InlineKeyboardButton(text="ℹ️ About", callback_data="about"), + types.InlineKeyboardButton(text="⚙️ Settings", callback_data="settings"), +) + +settings_kb.row( + types.InlineKeyboardButton(text="🔄 Switch LLM", callback_data="switchllm"), + types.InlineKeyboardButton( + text="✏️ Edit system prompt", callback_data="editsystemprompt" + ), +) + +commands = [ + types.BotCommand(command="start", description="Start"), + types.BotCommand(command="reset", description="Reset Chat"), + types.BotCommand(command="history", description="Look through messages"), +] + +ACTIVE_CHATS = {} +ACTIVE_CHATS_LOCK = contextLock() + +modelname = os.getenv("INITMODEL") +mention = None + +CHAT_TYPE_GROUP = "group" +CHAT_TYPE_SUPERGROUP = "supergroup" + + +async def get_bot_info(): + + global mention + + if mention is None: + + get = await bot.get_me() + mention = f"@{get.username}" + + return mention + + +@dp.message(CommandStart()) +async def command_start_handler(message: Message) -> None: + + start_message = f"Welcome, {message.from_user.full_name}!" + + await message.answer( + start_message, + parse_mode=ParseMode.HTML, + reply_markup=start_kb.as_markup(), + disable_web_page_preview=True, + ) + + +@dp.message(Command("reset")) +async def command_reset_handler(message: Message) -> None: + + if message.from_user.id in allowed_ids: + + if message.from_user.id in ACTIVE_CHATS: + + async with ACTIVE_CHATS_LOCK: + + ACTIVE_CHATS.pop(message.from_user.id) + + logging.info( + f"Chat has been reset for {message.from_user.first_name}" + ) + + await bot.send_message( + chat_id=message.chat.id, + text="Chat has been reset", + ) + + +@dp.message(Command("history")) +async def command_get_context_handler(message: Message) -> None: + + if message.from_user.id in allowed_ids: + + if message.from_user.id in ACTIVE_CHATS: + + messages = ACTIVE_CHATS.get(message.chat.id)["messages"] + context = "" + + for msg in messages: + + context += f"*{msg['role'].capitalize()}*: {msg['content']}\n" + + await bot.send_message( + chat_id=message.chat.id, + text=context, + parse_mode=ParseMode.MARKDOWN, + ) + + else: + + await bot.send_message( + chat_id=message.chat.id, + text="No chat history available for this user", + ) + + +@dp.callback_query(lambda query: query.data == "settings") +async def settings_callback_handler(query: types.CallbackQuery): + + await bot.send_message( + chat_id=query.message.chat.id, + text=f"Choose the right option.", + parse_mode=ParseMode.HTML, + disable_web_page_preview=True, + reply_markup=settings_kb.as_markup() + ) + + +@dp.callback_query(lambda query: query.data == "switchllm") +async def switchllm_callback_handler(query: types.CallbackQuery): + + models = await model_list() + switchllm_builder = InlineKeyboardBuilder() + + for model in models: + + modelname = model["name"] + modelfamilies = "" + + if model["details"]["families"]: + + modelicon = {"llama": "🦙", "clip": "📷"} + + try: + + modelfamilies = "".join( + [modelicon[family] + for family in model["details"]["families"]] + ) + + except KeyError as e: + + modelfamilies = f"✨" + + switchllm_builder.row( + types.InlineKeyboardButton( + text=f"{modelname} {modelfamilies}", + callback_data=f"model_{modelname}" + ) + ) + + await query.message.edit_text( + f"{len(models)} models available.\n🦙 = Regular\n🦙📷 = Multimodal", reply_markup=switchllm_builder.as_markup(), + ) + + +@dp.callback_query(lambda query: query.data.startswith("model_")) +async def model_callback_handler(query: types.CallbackQuery): + + global modelname + global modelfamily + + modelname = query.data.split("model_")[1] + + await query.answer(f"Chosen model: {modelname}") + + +@dp.callback_query(lambda query: query.data == "about") +@perms_admins +async def about_callback_handler(query: types.CallbackQuery): + + dotenv_model = os.getenv("INITMODEL") + + global modelname + + await bot.send_message( + chat_id=query.message.chat.id, + text=f"""Your LLMs +Currently using: {modelname} +Default in .env: {dotenv_model} +This project is under MIT License. +Source Code +""", + parse_mode=ParseMode.HTML, + disable_web_page_preview=True, + ) + + +@dp.message() +@perms_allowed +async def handle_message(message: types.Message): + + await get_bot_info() + + if message.chat.type == "private": + + await ollama_request(message) + + return + + if await is_mentioned_in_group_or_supergroup(message): + + thread = await collect_message_thread(message) + prompt = format_thread_for_prompt(thread) + + await ollama_request(message, prompt) + + +async def is_mentioned_in_group_or_supergroup(message: types.Message): + + if message.chat.type not in ["group", "supergroup"]: + + return False + + is_mentioned = ( + (message.text and message.text.startswith(mention)) or + (message.caption and message.caption.startswith(mention)) + ) + + is_reply_to_bot = ( + message.reply_to_message and + message.reply_to_message.from_user.id == bot.id + ) + + return is_mentioned or is_reply_to_bot + + +async def collect_message_thread(message: types.Message, thread=None): + + if thread is None: + + thread = [] + + thread.insert(0, message) + + if message.reply_to_message: + + await collect_message_thread(message.reply_to_message, thread) + + return thread + + +def format_thread_for_prompt(thread): + + prompt = "Conversation thread:\n\n" + + for msg in thread: + + sender = "User" if msg.from_user.id != bot.id else "Bot" + content = msg.text or msg.caption or "[No text content]" + prompt += f"{sender}: {content}\n\n" + + prompt += "History:" + + return prompt + + +async def process_image(message): + + image_base64 = "" + + if message.content_type == "photo": + + image_buffer = io.BytesIO() + + await bot.download(message.photo[-1], destination=image_buffer) + + image_base64 = base64.b64encode( + image_buffer.getvalue() + ).decode("utf-8") + + return image_base64 + + +async def add_prompt_to_active_chats(message, prompt, image_base64, modelname): + + async with ACTIVE_CHATS_LOCK: + + if ACTIVE_CHATS.get(message.from_user.id) is None: + + ACTIVE_CHATS[message.from_user.id] = { + "model": modelname, + "messages": [ + { + "role": "user", + "content": prompt, + "images": ([image_base64] if image_base64 else []), + } + ], + "stream": True, + } + + else: + + ACTIVE_CHATS[message.from_user.id]["messages"].append( + { + "role": "user", + "content": prompt, + "images": ([image_base64] if image_base64 else []), + } + ) + + +async def handle_response(message, response_data, full_response): + + full_response_stripped = full_response.strip() + + if full_response_stripped == "": + + return + + if response_data.get("done"): + + text = f"{full_response_stripped}\n\n⚙️ {modelname}\nGenerated in { + response_data.get('total_duration') / 1e9:.2f}s." + + await send_response(message, text) + + async with ACTIVE_CHATS_LOCK: + + if ACTIVE_CHATS.get(message.from_user.id) is not None: + + ACTIVE_CHATS[message.from_user.id]["messages"].append( + {"role": "assistant", "content": full_response_stripped} + ) + + logging.info( + f"[Response]: '{full_response_stripped}' for { + message.from_user.first_name} {message.from_user.last_name}" + ) + + return True + + return False + + +async def send_response(message, text): + + # A negative message.chat.id is a group message + if message.chat.id < 0 or message.chat.id == message.from_user.id: + + await bot.send_message(chat_id=message.chat.id, text=text) + + else: + + await bot.edit_message_text( + chat_id=message.chat.id, + message_id=message.message_id, + text=text + ) + + +async def ollama_request(message: types.Message, prompt: str = None): + + try: + + full_response = "" + await bot.send_chat_action(message.chat.id, "typing") + image_base64 = await process_image(message) + + if prompt is None: + + prompt = message.text or message.caption + + await add_prompt_to_active_chats(message, prompt, image_base64, modelname) + + logging.info( + f"[OllamaAPI]: Processing '{prompt}' for { + message.from_user.first_name} {message.from_user.last_name}" + ) + + payload = ACTIVE_CHATS.get(message.from_user.id) + + async for response_data in generate(payload, modelname, prompt): + + msg = response_data.get("message") + + if msg is None: + continue + + chunk = msg.get("content", "") + full_response += chunk + + if any([c in chunk for c in ".\n!?"]) or response_data.get("done"): + + if await handle_response(message, response_data, full_response): + break + + except Exception as e: + + print(f"""----- +[OllamaAPI-ERR] CAUGHT FAULT! +{traceback.format_exc()} +-----""") + + await bot.send_message( + chat_id=message.chat.id, + text=f"Something went wrong.", + parse_mode=ParseMode.HTML, + ) + + +async def main(): + + await bot.set_my_commands(commands) + await dp.start_polling(bot, skip_update=True) + + +if __name__ == "__main__": + + asyncio.run(main()) diff --git a/catch-all/06_bots_telegram/09_ollama_bot/docker-compose.yml b/catch-all/06_bots_telegram/09_ollama_bot/docker-compose.yml new file mode 100644 index 0000000..18bf4fd --- /dev/null +++ b/catch-all/06_bots_telegram/09_ollama_bot/docker-compose.yml @@ -0,0 +1,30 @@ +# WORK IN PROGRESS +version: '3.8' +services: + ollama-tg: + build: . + container_name: ollama-tg + restart: on-failure + env_file: + - ./.env + + ollama-api: + image: ollama/ollama:latest + container_name: ollama-server + volumes: + - ./ollama:/root/.ollama + + # Uncomment to enable NVIDIA GPU + # Otherwise runs on CPU only: + + # deploy: + # resources: + # reservations: + # devices: + # - driver: nvidia + # count: all + # capabilities: [gpu] + + restart: always + ports: + - '11434:11434' diff --git a/catch-all/06_bots_telegram/09_ollama_bot/requirements.txt b/catch-all/06_bots_telegram/09_ollama_bot/requirements.txt new file mode 100644 index 0000000..ae4f875 --- /dev/null +++ b/catch-all/06_bots_telegram/09_ollama_bot/requirements.txt @@ -0,0 +1,3 @@ +python-dotenv==1.0.0 +aiogram==3.2.0 +ollama \ No newline at end of file diff --git a/catch-all/06_bots_telegram/README.md b/catch-all/06_bots_telegram/README.md index 3724375..a12b9f0 100644 --- a/catch-all/06_bots_telegram/README.md +++ b/catch-all/06_bots_telegram/README.md @@ -16,6 +16,7 @@ | [Bot de películas](./06_movie_bot/) | Bot que devuelve información de películas | intermedio | | [Bot trivial de películas](./07_movie2_bot/README.md) | Bot que devuelve información de series | avanzado | | [Bot de chatgpt](./08_chatgpt_bot/README.md) | Bot que mantiene conversaciones con GPT-3 | avanzado | +| [Bot con Ollama](./09_ollama_bot/README.md) | Bot que mantiene conversaciones con Ollama | intermedio | | **Bot de recetas** (próximamente) | Bot que devuelve recetas de cocina | avanzado | | **Bot de deportes** (próximamente) | Bot que devuelve información de deportes | avanzado | | **Bot de mareas** (próximamente) | Bot que devuelve información de mareas | avanzado |