Skip to content

Ollama 搭建类似 ChatGPT 网页应用

本章节我们将搭建类似 ChatGPT 应用:多会话切换、历史持久化到数据库、完整前端界面,以及三种部署方式。


功能规划与数据模型

一个可用的聊天产品至少需要四个能力:多会话隔离、历史持久化、流式回复、会话管理(新建与删除)。

支撑这些能力的数据库模型只需要两张表:

chat-er.svg

chats 存会话列表,messages 按外键归组存每条消息。切换会话时,把对应 messages 组装成 Ollama 的 messages 数组即可恢复完整上下文。

技术栈延续最小依赖原则:Flask + sqlite3(Python 标准库)+ 原生 JavaScript 前端,零前端框架。

安装 flask 和 ollama 扩展:

bash
pip install flask ollama

载入模型:

bash
ollama pull qwen3.5:4b

后端:会话与消息的完整 API

后端职责清晰:管理两张表,并把 /api/chat 的流式回复逐条写库。

实例

sql

# 文件路径: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])

后端核心:流式对话与历史写回

对话接口做三件事:取历史拼上下文、流式生成、把完整回复写回数据库。

实例

sql

# 追加到 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': '你是 RUNOOB 编程助手,回答准确简洁。'}]

    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)

前端:原生 JS 的聊天界面

前端做到功能完整且足够简单:左侧会话列表,右侧消息区加输入框,fetch 流式渲染。

实例

html

<!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="输入问题,回车发送" autocomplete="off">

  </form>

</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>

启动并访问:

bash
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://服务器IP:5000无鉴权,务必叠加 Nginx Basic Auth(见私有化部署章节)
云服务器长期运行systemd 注册服务 + Nginx 反代 + 域名先在服务器上 ollama pull 模型

云服务器上用 systemd 让应用常驻:

实例

bash

# 文件路径:/etc/systemd/system/runoob-chat.service

[Unit]

Description=Runoob Chat Web App

After=network.target ollama.service

[Service]

WorkingDirectory=/opt/runoob-chat

ExecStart=/usr/bin/python3 server.py

Restart=always

RestartSec=3

[Install]

WantedBy=multi-user.target

设置长期运行:

bash
sudo systemctl daemon-reload
sudo systemctl enable --now runoob-chat

对外部署的完整安全清单(回环端口、反代鉴权、暴露面自查)在安全与合规章节,上线前务必过一遍。

AI 思考中...

Ollama 本地 AI 编程助手

Ollama 多模型协作与 Agent 工作流

基于 VitePress 构建,部署于 GitHub Pages