Ollama: создание веб-приложения в стиле ChatGPT

В этой главе мы создадим приложение в стиле ChatGPT: переключение между несколькими сессиями, сохранение истории в базе данных, полноценный интерфейс фронтенда и три способа развёртывания.


Планирование функций и модель данных

Работоспособный чат-продукт должен обладать как минимум четырьмя возможностями: изоляция нескольких сессий, сохранение истории, потоковые ответы и управление сессиями (создание и удаление).

Модель базы данных, поддерживающая эти возможности, требует всего двух таблиц:

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

chats хранит список сессий, а messages хранит каждое сообщение, сгруппированное по внешнему ключу. При переключении сессий собирайте соответствующие сообщения в массив messages Ollama, чтобы восстановить полный контекст.

Технологический стек следует принципу минимальных зависимостей: Flask + sqlite3 (стандартная библиотека Python) + фронтенд на чистом JavaScript, без фронтенд-фреймворков.

Установите flask и расширение ollama:

pip install flask ollama

Загрузите модель:

ollama pull qwen3.5:4b

Бэкенд: Полный API для сессий и сообщений

У бэкенда чёткие обязанности: управлять двумя таблицами и записывать каждый потоковый ответ из /api/chat в базу данных один за другим.

Пример

# Путь к файлу: 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():
    """Открыть подключение к базе данных; строки возвращаются как словари"""
    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()

# Список сессий
@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])

# Создать новую сессию
@app.post('/api/chats')
def create_chat():
    title = (request.json or {}).get('title', 'Новая сессия')
    with db() as conn:
        cur = conn.execute('INSERT INTO chats(title) VALUES (?)', (title,))
        return jsonify({'id': cur.lastrowid, 'title': title})

# Удалить сессию (вместе с её сообщениями)
@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})

# Прочитать историю сессии
@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])

Ядро бэкенда: потоковый диалог и запись истории

Интерфейс чата выполняет три действия: получает историю и формирует контекст, выполняет потоковую генерацию и записывает полный ответ обратно в базу данных.

Пример

# Добавить в server.py
@app.post('/api/chat')
def do_chat():
    data = request.get_json()
    cid, user_input = data['chat_id'], data['message']

    # Получить историю (включая только что вставленное сообщение пользователя) как контекст модели
    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': 'Вы — EXAMPLE-ассистент по программированию. Отвечайте точно и кратко.'}]
    messages += history

    def generate():
        reply = ''
        stream = chat(model='qwen3.5:4b',
                      messages=messages, stream=True)
        for chunk in stream:
            reply += chunk.message.content
            # Отправлять каждый фрагмент на фронтенд как NDJSON
            yield json.dumps(
                {'delta': chunk.message.content},
                ensure_ascii=False) + '\n'
        # Важно: записать полный ответ обратно в базу данных, чтобы сформировать долговременную память
        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')

# Главная страница: вернуть страницу фронтенда
@app.get('/')
def index():
    return open('index.html', encoding='utf-8').read()

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

Фронтенд: Интерфейс чата на Vanilla JS

Фронтенд полностью функционален и достаточно прост: список сессий слева, область сообщений и поле ввода справа, с потоковой отрисовкой через fetch.

Пример

<!DOCTYPE html>
<!-- Путь к файлу: index.html (в той же директории, что и server.py) -->
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>Локальный ChatGPT</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()">+ Новая сессия</button>
  <div id="list"></div>
</aside>
<main>
  <div id="log"></div>
  <form id="f">
    <input id="q" placeholder="Введите вопрос, нажмите Enter для отправки" autocomplete="off">
  </форма>
</main>
<script>
let chatId = null;
const log = document.getElementById('log');
const list = document.getElementById('list');

// Загрузить список сессий
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);
  }
}

// Открыть сессию и восстановить историю
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);
}

// Создать новую сессию
async function newChat() {
  const c = await (await fetch('/api/chats', {method:'POST'})).json();
  await loadChats();
  openChat(c.id);
}

// Добавить сообщение в интерфейс
function addMsg(role, text) {
  const div = document.createElement('div');
  div.className = 'msg';
div.textContent = (role === 'user' ? 'Вы: ' : 'Ассистент: ') + text;
  log.appendChild(div);
  log.scrollTop = log.scrollHeight;
  return div;
}

// Отправить и отобразить ответ в потоковом режиме
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>

Запуск и доступ:

python server.py

Откройте http://localhost:5000 в браузере, чтобы увидеть сгенерированную страницу.

Проверьте три основные функции: история полностью восстанавливается при переключении сессий слева; новые сессии не мешают друг другу; история сохраняется после перезапуска server.py (сохраняется в chats.db).


Развёртывание: три уровня

УровеньПодходПримечания
Локальное личное использованиеЗапустите python server.py напрямуюПо умолчанию прослушивает 127.0.0.1, безопасно
Общий доступ в интрасетиapp.run(host='0.0.0.0'), участники заходят на http://server-IP:5000Без аутентификации; обязательно добавьте Nginx Basic Auth (см. главу о частном развёртывании)
Долгосрочная работа на облачном сервереРегистрация службы systemd + обратный прокси Nginx + доменное имяСначала выполните на сервере ollama pull <model>

Используйте systemd на облачном сервере, чтобы приложение всегда работало:

Пример

# Путь к файлу: /etc/systemd/system/example-chat.service
[Unit]
Description=Пример веб-приложения чата
After=network.target ollama.service

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

[Установка]
WantedBy=multi-user.target

Настройка долгосрочного запуска:

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

Полный контрольный список безопасности для внешнего развертывания (порт loopback, аутентификация обратного прокси, самопроверка поверхности экспозиции) находится в главе «Безопасность и соответствие»; обязательно пройдите его перед запуском в эксплуатацию.

Другие расширения