les dejo un script chiquito que hice para interactuar con los modelos.
es fácil de usar, al correrlo les pide elegir el modelo a usar, y la interfaz es intuitiva. alt c para ir al cuadro de texto, alt e para enviar, alt l para ir a la lista de mensajes, alt p para cambiar el prompt de sistema, alt s para cerrar la aplicación.
import wx
import requests
import json
import threading
import subprocess
import sys
import re
import time
def obtener_modelos_disponibles():
"""
Ejecuta 'ollama list' para obtener la lista de modelos disponibles y retorna
una lista con los nombres de los modelos (columna NAME).
"""
try:
result = subprocess.run(["ollama", "list"], capture_output=True, text=True, shell=True)
if result.returncode != 0:
return []
lines = result.stdout.splitlines()
models = []
for line in lines:
line = line.strip()
if not line:
continue
# Ignoramos la cabecera
if "NAME" in line and "ID" in line and "SIZE" in line:
continue
parts = line.split()
if parts:
models.append(parts[0])
return models
except Exception:
return []
def eliminar_texto_entre_think(texto):
"""
Elimina todo lo que esté entre <think>...</think>, incluyendo dichas etiquetas.
"""
return re.sub(r"<think>.*?</think>", "", texto, flags=re.DOTALL)
def extraer_nombre_modelo(modelo_str):
"""
Retorna la parte previa a ':' si existe,
por ejemplo 'gemma:2b' -> 'gemma'.
Si no hay ':', retorna el nombre completo.
"""
return modelo_str.split(':')[0]
class ModeloSelectionFrame(wx.Frame):
"""
Ventana de selección de modelos.
Muestra un ComboBox con los modelos disponibles y los botones Aceptar y Cancelar.
"""
def __init__(self, parent=None, title="Seleccionar Modelo"):
super().__init__(parent, title=title, size=(500, 200))
panel = wx.Panel(self)
label_modelos = wx.StaticText(panel, label="&Elige el modelo con el que quieres chatear:")
self.combobox_modelos = wx.ComboBox(panel, style=wx.CB_READONLY)
self.btn_aceptar = wx.Button(panel, id=wx.ID_OK, label="&Aceptar")
self.btn_cancelar = wx.Button(panel, id=wx.ID_CANCEL, label="&Cancelar")
modelos = obtener_modelos_disponibles()
self.combobox_modelos.AppendItems(modelos)
if modelos:
self.combobox_modelos.SetSelection(0) # Seleccionar el primero por defecto
# Eventos
self.btn_aceptar.Bind(wx.EVT_BUTTON, self.on_aceptar)
self.btn_cancelar.Bind(wx.EVT_BUTTON, self.on_cancelar)
# Sizers
sizer_principal = wx.BoxSizer(wx.VERTICAL)
sizer_combo = wx.BoxSizer(wx.HORIZONTAL)
sizer_botones = wx.BoxSizer(wx.HORIZONTAL)
sizer_combo.Add(label_modelos, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 5) |
---|
sizer_combo.Add(self.combobox_modelos, 1, wx.ALL | wx.EXPAND, 5) |
sizer_botones.Add(self.btn_aceptar, 0, wx.ALL, 5)
sizer_botones.Add(self.btn_cancelar, 0, wx.ALL, 5)
sizer_principal.Add(sizer_combo, 1, wx.EXPAND | wx.ALL, 10) |
---|
sizer_principal.Add(sizer_botones, 0, wx.ALIGN_CENTER | wx.ALL, 5) |
panel.SetSizer(sizer_principal)
self.Centre()
self.Show()
def on_aceptar(self, event):
modelo_seleccionado = self.combobox_modelos.GetValue()
if not modelo_seleccionado:
wx.MessageBox("Por favor, selecciona un modelo.", "Atención", wx.OK | wx.ICON_WARNING)
return
chat_frame = ChatFrame(modelo=modelo_seleccionado)
chat_frame.Show()
self.Close()
def on_cancelar(self, event):
self.Close()
wx.GetApp().ExitMainLoop()
class SystemPromptDialog(wx.Dialog):
"""
Diálogo para editar o añadir el Prompt de Sistema.
"""
def __init__(self, parent, prompt_actual=""):
super().__init__(parent, title="Editar Prompt de Sistema", size=(500, 300))
panel = wx.Panel(self)
etiqueta = wx.StaticText(panel, label="Escribe el Prompt de Sistema aquí:")
self.text_prompt = wx.TextCtrl(panel, style=wx.TE_MULTILINE, size=(450, 150))
self.text_prompt.SetValue(prompt_actual)
btn_aceptar = wx.Button(panel, wx.ID_OK, "&Aceptar")
btn_cancelar = wx.Button(panel, wx.ID_CANCEL, "&Cancelar")
sizer_principal = wx.BoxSizer(wx.VERTICAL)
sizer_botones = wx.BoxSizer(wx.HORIZONTAL)
sizer_principal.Add(etiqueta, 0, wx.ALL, 5)
sizer_principal.Add(self.text_prompt, 1, wx.ALL | wx.EXPAND, 5)
sizer_botones.Add(btn_aceptar, 0, wx.ALL, 5)
sizer_botones.Add(btn_cancelar, 0, wx.ALL, 5)
sizer_principal.Add(sizer_botones, 0, wx.ALIGN_CENTER)
panel.SetSizer(sizer_principal)
self.Centre()
def get_prompt(self):
return self.text_prompt.GetValue().strip()
class MessageDetailFrame(wx.Frame):
"""
Ventana de detalle de un mensaje. Muestra en modo de solo lectura
el contenido completo del mensaje seleccionado.
"""
def __init__(self, parent, title, content):
super().__init__(parent, title=title, size=(500, 400))
panel = wx.Panel(self)
txt = wx.TextCtrl(panel, style=wx.TE_MULTILINE | wx.TE_READONLY | wx.HSCROLL, size=(480, 360))
txt.SetValue(content)
sizer = wx.BoxSizer(wx.VERTICAL)
sizer.Add(txt, 1, wx.ALL | wx.EXPAND, 5)
panel.SetSizer(sizer)
self.Centre()
self.Show()
class ChatFrame(wx.Frame):
"""
Ventana principal del chat con Ollama.
Muestra:
- ListBox con el historial de mensajes (usuario y modelo).
- Cuadro de texto para escribir mensajes.
- Botón para enviar.
- Botón para editar el Prompt de Sistema.
- Botón para cerrar la aplicación.
- Atajos de teclado para:
* Enfocar la lista de mensajes (Alt+L).
* Enfocar el cuadro de chat (Alt+C).
"""
def __init__(self, parent=None, modelo="deepseek-r1:1.5b"):
super().__init__(parent, title=f"Chat con Ollama - Modelo: {modelo}", size=(600, 500))
self.modelo = modelo
self.model_display_name = extraer_nombre_modelo(modelo)
self.system_prompt = ""
self.messages = [] # Lista de tuplas (rol, contenido)
self.panel = wx.Panel(self)
# Controles
self.message_list = wx.ListBox(self.panel, style=wx.LB_SINGLE, size=(550, 200))
self.user_input = wx.TextCtrl(self.panel, style=wx.TE_MULTILINE, size=(550, 100))
self.btn_enviar = wx.Button(self.panel, label="&Enviar")
self.btn_editar_prompt = wx.Button(self.panel, label="Editar &Prompt de Sistema")
self.btn_cerrar_app = wx.Button(self.panel, label="&Salir")
# Temporizador para medir la generación de respuesta
self.response_timer = wx.Timer(self)
self.Bind(wx.EVT_TIMER, self.on_timer_tick, self.response_timer)
self.response_time_start = 0.0
self.index_pending_response = None
# Eventos de botones
self.btn_enviar.Bind(wx.EVT_BUTTON, self.on_send)
self.btn_editar_prompt.Bind(wx.EVT_BUTTON, self.on_edit_prompt)
self.btn_cerrar_app.Bind(wx.EVT_BUTTON, self.on_close_app)
# Eventos de la lista
self.message_list.Bind(wx.EVT_LISTBOX_DCLICK, self.on_message_detail)
self.message_list.Bind(wx.EVT_KEY_DOWN, self.on_list_key_down)
# Layout
sizer_principal = wx.BoxSizer(wx.VERTICAL)
sizer_principal.Add(self.message_list, 1, wx.ALL | wx.EXPAND, 5) |
---|
sizer_principal.Add(self.user_input, 0, wx.ALL | wx.EXPAND, 5) |
sizer_botones = wx.BoxSizer(wx.HORIZONTAL)
sizer_botones.Add(self.btn_enviar, 0, wx.ALL, 5)
sizer_botones.Add(self.btn_editar_prompt, 0, wx.ALL, 5)
sizer_botones.Add(self.btn_cerrar_app, 0, wx.ALL, 5)
sizer_principal.Add(sizer_botones, 0, wx.ALIGN_CENTER)
self.panel.SetSizer(sizer_principal)
# Atajos de teclado (Alt+L para la lista, Alt+C para el chat)
# Creamos IDs para los comandos de menú "ficticios"
ID_FOCUS_LIST = wx.NewIdRef()
ID_FOCUS_CHAT = wx.NewIdRef()
# Definimos la tabla de atajos
accelerators = [
(wx.ACCEL_ALT, ord('L'), ID_FOCUS_LIST),
(wx.ACCEL_ALT, ord('C'), ID_FOCUS_CHAT),
]
accel_tbl = wx.AcceleratorTable(accelerators)
self.SetAcceleratorTable(accel_tbl)
# Vinculamos eventos de menú a métodos que pongan el foco
self.Bind(wx.EVT_MENU, self.on_focus_list, id=ID_FOCUS_LIST)
self.Bind(wx.EVT_MENU, self.on_focus_chat, id=ID_FOCUS_CHAT)
self.Centre()
# Métodos para enfocar controles con atajos de teclado
def on_focus_list(self, event):
self.message_list.SetFocus()
def on_focus_chat(self, event):
self.user_input.SetFocus()
def on_send(self, event):
user_text = self.user_input.GetValue().strip()
if user_text:
# 1. Agregar el mensaje del usuario a 'messages' y a la lista
self.messages.append(("Tú", user_text))
self.message_list.Append(f"Tú: {user_text}")
self.user_input.Clear()
# 2. Agregamos un marcador de mensaje "en generación" en 'messages'
# para que el índice coincida con el de la ListBox
self.messages.append((self.model_display_name, "[Generando respuesta]"))
self.index_pending_response = len(self.messages) - 1
# 3. Insertamos en la lista la indicación de "generando"
self.message_list.Append(f"{self.model_display_name}: [Generando respuesta - 0s]")
index_list = self.message_list.GetCount() - 1 # índice del último elemento
# Aseguramos que index_pending_response y index_list sean equivalentes
# (debido a que se agregaron 2 mensajes a self.messages,
# su índice final coincide con index_list).
# 4. Iniciar temporizador
self.response_time_start = time.time()
self.response_timer.Start(1000)
# 5. Lanzar el hilo que obtendrá la respuesta
threading.Thread(target=self.handle_ollama_interaction, args=(user_text, index_list)).start()
def on_edit_prompt(self, event):
dialog = SystemPromptDialog(self, prompt_actual=self.system_prompt)
if dialog.ShowModal() == wx.ID_OK:
self.system_prompt = dialog.get_prompt()
dialog.Destroy()
def on_close_app(self, event):
self.Close()
wx.GetApp().ExitMainLoop()
def on_list_key_down(self, event):
if event.GetKeyCode() == wx.WXK_RETURN:
self.on_message_detail(None)
else:
event.Skip()
def on_message_detail(self, event):
seleccion = self.message_list.GetSelection()
if seleccion != wx.NOT_FOUND:
# Aseguramos que no haya desfasamiento en el índice
if seleccion < len(self.messages):
rol, contenido = self.messages[seleccion]
MessageDetailFrame(self, title=f"Detalle - {rol}", content=contenido)
def on_timer_tick(self, event):
"""
Cada segundo, se actualiza el texto en la ListBox que indica
"Generando respuesta - Xs".
"""
if self.index_pending_response is not None:
elapsed = int(time.time() - self.response_time_start)
# Actualiza solo el mensaje correspondiente en la ListBox
if elapsed < 3600: # Por si algo se atora, evitar overflow
txt = f"{self.model_display_name}: [Generando respuesta - {elapsed}s]"
# El índice en la ListBox es el mismo que en self.messages
self.message_list.SetString(self.index_pending_response, txt)
def handle_ollama_interaction(self, user_text, list_index):
"""
Hilo que llama a Ollama y, cuando la respuesta llega,
actualiza el historial y la interfaz en el hilo principal.
"""
try:
response = self.get_ollama_response(user_text)
wx.CallAfter(self.finish_response, response)
except Exception as e:
wx.CallAfter(self.show_error, e)
def finish_response(self, response):
"""
Se llama cuando ya tenemos la respuesta del modelo.
Detenemos el temporizador y actualizamos el texto en la ListBox
y en self.messages correspondiente a la respuesta del modelo.
"""
self.response_timer.Stop()
total_time = time.time() - self.response_time_start
# Si el modelo contiene "r1", removemos contenido <think>...</think>
if "r1" in self.modelo.lower():
response = eliminar_texto_entre_think(response)
# Sustituir el contenido en self.messages
if self.index_pending_response is not None:
self.messages[self.index_pending_response] = (self.model_display_name, response)
# Actualizar la ListBox en la misma posición
final_text = f"{self.model_display_name}: {response} (tardó {round(total_time, 2)}s)"
self.message_list.SetString(self.index_pending_response, final_text)
self.index_pending_response = None
def show_error(self, error):
"""
En caso de error, detenemos el temporizador y mostramos un mensaje de error.
"""
if self.response_timer.IsRunning():
self.response_timer.Stop()
wx.MessageBox(f"Error al comunicarse con Ollama: {error}", "Error", wx.OK | wx.ICON_ERROR)
def get_ollama_response(self, prompt):
"""
Llama a la API de Ollama y retorna la respuesta.
"""
url = "http://localhost:11434/api/generate"
headers = {"Content-Type": "application/json"}
data = {
"model": self.modelo,
"system": self.system_prompt,
"prompt": prompt,
"stream": False
}
resp = requests.post(url, headers=headers, json=data)
if resp.status_code == 200:
try:
return resp.json().get("response", "No se recibió respuesta de Ollama.")
except json.JSONDecodeError as e:
contenido = resp.text.strip()
if contenido:
for linea in contenido.splitlines():
linea = linea.strip()
if linea:
try:
json_line = json.loads(linea)
return json_line.get("response", "No se recibió respuesta en la línea.")
except json.JSONDecodeError:
continue
raise Exception(f"Error al decodificar la respuesta JSON: {e}")
else:
raise Exception(f"Error {resp.status_code}: {resp.text}")
class MyApp(wx.App):
def OnInit(self):
"""
Al iniciar la aplicación:
1. Levanta el servidor de Ollama con 'ollama serve'.
2. Muestra la ventana de selección de modelo.
"""
frame = ModeloSelectionFrame()
frame.Show()
return True
def OnExit(self):
"""
Cuando la app sale, cerramos el servidor de Ollama.
"""
if self.server_process and self.server_process.poll() is None:
self.server_process.terminate()
return super().OnExit()
if __name__ == "__main__":
app = MyApp(False)
app.MainLoop()
sys.exit(0)