Ollama: construir una aplicación web similar a ChatGPT

En este capítulo construiremos una aplicación similar a ChatGPT: cambio entre múltiples sesiones, persistencia del historial en la base de datos, interfaz de frontend completa y tres formas de despliegue.


Planificación de funciones y modelo de datos

Un producto de chat utilizable necesita al menos cuatro capacidades: aislamiento de múltiples sesiones, persistencia del historial, respuestas en streaming y gestión de sesiones (crear y eliminar).

El modelo de base de datos que respalda estas capacidades solo necesita dos tablas:

多会话数据模型:chats 与 messages 一对多

chats almacena la lista de sesiones, messages almacena cada mensaje agrupado por clave foránea. Al cambiar de sesión, basta con ensamblar los messages correspondientes en el array de messages de Ollama para restaurar el contexto completo.

La pila tecnológica continúa el principio de dependencias mínimas: Flask + sqlite3 (biblioteca estándar de Python) + frontend de JavaScript nativo, cero frameworks de frontend.

Instalar las extensiones flask y ollama:

pip install flask ollama

Cargar el modelo:

ollama pull qwen3.5:4b

Backend: API completa para sesiones y mensajes

Las responsabilidades del backend son claras: gestionar dos tablas y escribir en la base de datos, una por una, las respuestas en streaming de /api/chat.

Ejemplo

# Ruta del archivo: server.py
import json
import sqlite3
from flask import Flask, request, Response, jsonify, stream_with_context
from ollama import chat

app = Flask(__name__)
DB = 'chats.db'

def db():
    """Abrir la conexión a la base de datos, las filas se devuelven como diccionarios"""
    conn = sqlite3.connect(DB)
    conn.row_factory = sqlite3.Row
    return conn

def init_db():
    with db() as conn:
        conn.executescript('''
        CREATE TABLE IF NOT EXISTS chats(
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            title TEXT NOT NULL,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        );
        CREATE TABLE IF NOT EXISTS messages(
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            chat_id INTEGER NOT NULL,
            role TEXT NOT NULL,
            content TEXT NOT NULL
        );
        '''
)

init_db()

# Lista de sesiones
@app.get('/api/chats')
def list_chats():
    with db() as conn:
        rows = conn.execute(
            'SELECT id, title, created_at FROM chats ORDER BY id DESC'
        ).fetchall()
    return jsonify([dict(r) for r in rows])

# Nueva sesión
@app.post('/api/chats')
def create_chat():
    title = (request.json or {}).get('title', 'Nueva sesión')
    with db() as conn:
        cur = conn.execute('INSERT INTO chats(title) VALUES (?)', (title,))
        return jsonify({'id': cur.lastrowid, 'title': title})

# Eliminar sesión (junto con los mensajes)
@app.delete('/api/chats/<int:cid>')
def delete_chat(cid):
    with db() as conn:
        conn.execute('DELETE FROM messages WHERE chat_id=?', (cid,))
        conn.execute('DELETE FROM chats WHERE id=?', (cid,))
    return jsonify({'ok': True})

# Leer el historial de una sesión
@app.get('/api/chats/<int:cid>/messages')
def get_messages(cid):
    with db() as conn:
        rows = conn.execute(
            'SELECT role, content FROM messages WHERE chat_id=? ORDER BY id',
            (cid,)
        ).fetchall()
    return jsonify([dict(r) for r in rows])

Núcleo del backend: diálogo en streaming y escritura del historial

La interfaz de diálogo hace tres cosas: obtener el historial y componer el contexto, generar en streaming y escribir la respuesta completa en la base de datos.

Ejemplo

# Añadir a server.py
@app.post('/api/chat')
def do_chat():
    data = request.get_json()
    cid, user_input = data['chat_id'], data['message']

    # Obtener el historial (incluido el mensaje del usuario recién insertado) como contexto del modelo
    with db() as conn:
        conn.execute(
            'INSERT INTO messages(chat_id, role, content) VALUES (?,?,?)',
            (cid, 'user', user_input)
        )
        history = [dict(r) for r in conn.execute(
            'SELECT role, content FROM messages WHERE chat_id=? ORDER BY id',
            (cid,)
        )]

    messages = [{'role': 'system',
                 'content': 'Eres el asistente de programación EJEMPLO, responde con precisión y concisión.'}]
    messages += history

    def generate():
        reply = ''
        stream = chat(model='qwen3.5:4b',
                      messages=messages, stream=True)
        for chunk in stream:
            reply += chunk.message.content
            # Enviar al frontend por fragmentos en NDJSON
            yield json.dumps(
                {'delta': chunk.message.content},
                ensure_ascii=False) + '\n'
        # Clave: escribir la respuesta completa en la base de datos para formar memoria persistente
        with db() as conn:
            conn.execute(
                'INSERT INTO messages(chat_id, role, content) VALUES (?,?,?)',
                (cid, 'assistant', reply))
        yield json.dumps({'done': True}, ensure_ascii=False) + '\n'

    return Response(
        stream_with_context(generate()),
        mimetype='application/x-ndjson')

# Página de inicio: devolver la página del frontend
@app.get('/')
def index():
    return open('index.html', encoding='utf-8').read()

if __name__ == '__main__':
    app.run(port=5000)

Frontend: interfaz de chat con JavaScript nativo

El frontend debe ser completo y lo bastante simple: lista de sesiones a la izquierda, área de mensajes y cuadro de entrada a la derecha, renderizado en streaming mediante fetch.

Ejemplo

<!DOCTYPE html>
<!-- Ruta del archivo: index.html (en el mismo directorio que server.py) -->
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>ChatGPT local</title>
<style>
  body { display:flex; height:100vh; margin:0; font-family: sans-serif; }
  aside { width:220px; border-right:1px solid #ddd; overflow-y:auto; }
  aside button { display:block; width:100%; text-align:left;
                 padding:10px; border:0; background:none; cursor:pointer; }
  aside button:hover { background:#f2f2f2; }
  main { flex:1; display:flex; flex-direction:column; }
  #log { flex:1; overflow-y:auto; padding:20px; }
  .msg { margin:8px 0; line-height:1.6; white-space:pre-wrap; }
  form { display:flex; border-top:1px solid #ddd; }
  input { flex:1; padding:12px; border:0; }
</style>
</head>
<body>
<aside>
  <button onclick="newChat()">+ Nueva sesión</button>
  <div id="list"></div>
</aside>
<main>
  <div id="log"></div>
  <form id="f">
    <input id="q" placeholder="Escribe una pregunta y pulsa Enter para enviar" autocomplete="off">
  </form>
</main>
<script>
let chatId = null;
const log = document.getElementById('log');
const list = document.getElementById('list');

// Cargar lista de sesiones
async function loadChats() {
  const chats = await (await fetch('/api/chats')).json();
  list.innerHTML = '';
  for (const c of chats) {
    const b = document.createElement('button');
    b.textContent = c.title;
    b.onclick = () => openChat(c.id);
    list.appendChild(b);
  }
}

// Abrir una sesión y restaurar el historial
async function openChat(id) {
  chatId = id;
  log.innerHTML = '';
  const msgs = await (await fetch(`/api/chats/${id}/messages`)).json();
  for (const m of msgs) addMsg(m.role, m.content);
}

// Nueva sesión
async function newChat() {
  const c = await (await fetch('/api/chats', {method:'POST'})).json();
  await loadChats();
  openChat(c.id);
}

// Añadir un mensaje a la interfaz
function addMsg(role, text) {
  const div = document.createElement('div');
  div.className = 'msg';
div.textContent = (role === 'user' ? 'Tú: ' : 'Asistente: ') + text;
  log.appendChild(div);
  log.scrollTop = log.scrollHeight;
  return div;
}

// Enviar y renderizar la respuesta en streaming
document.getElementById('f').onsubmit = async e => {
  e.preventDefault();
  const input = document.getElementById('q');
  const q = input.value.trim();
  if (!q || !chatId) return;
  input.value = '';
  addMsg('user', q);

  const div = addMsg('assistant', '');
  const resp = await fetch('/api/chat', {method:'POST',
    headers:{'Content-Type':'application/json'},
    body: JSON.stringify({chat_id: chatId, message: q})});
  const reader = resp.body.getReader();
  const dec = new TextDecoder();
  let buf = '';
  while (true) {
    const {done, value} = await reader.read();
    if (done) break;
    buf += dec.decode(value, {stream:true});
    const lines = buf.split('\n');
    buf = lines.pop();
    for (const line of lines) {
      if (!line) continue;
      const obj = JSON.parse(line);
      if (obj.delta) {
        div.textContent += obj.delta;
        log.scrollTop = log.scrollHeight;
      }
    }
  }
};

newChat();
loadChats();
</script>
</body>
</html>

Iniciar y acceder:

python server.py

Abre http://localhost:5000 en el navegador para ver la página generada.

Verifica tres funciones principales: después de cambiar de sesión a la izquierda, el historial se restaura por completo; las nuevas sesiones no interfieren entre sí; después de reiniciar server.py, el historial sigue existiendo (ya persistido en chats.db).


Despliegue en producción: tres niveles

NivelMétodoPrecauciones
Uso localEjecutar python server.py directamentePor defecto escucha en 127.0.0.1, seguro
Compartir en red localapp.run(host='0.0.0.0'), los miembros acceden a http://IP_del_servidor:5000Sin autenticación, asegúrate de añadir Nginx Basic Auth (consulta el capítulo de despliegue privado).
Ejecución a largo plazo en servidor en la nubeRegistrar servicio con systemd + proxy inverso con Nginx + dominioPrimero, en el servidor, ejecuta ollama pull del modelo

En el servidor en la nube, usa systemd para mantener la aplicación en ejecución:

Ejemplo

# Ruta del archivo: /etc/systemd/system/ejemplo-chat.service
[Unit]
Description=Ejemplo Chat Web App
After=network.target ollama.service

[Service]
WorkingDirectory=/opt/ejemplo-chat
ExecStart=/usr/bin/python3 server.py
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target

Configurar ejecución a largo plazo:

sudo systemctl daemon-reload
sudo systemctl enable --now ejemplo-chat

La lista de seguridad completa para el despliegue público (puerto de loopback, autenticación del proxy inverso, autoevaluación de la superficie expuesta) está en la sección de seguridad y cumplimiento; asegúrate de revisarla antes del lanzamiento.

Otras extensiones