[CELN] 01 本地部署大模型 + 简单 HTML 交互界面

1. 简介活动目标

本次 Workshop 主要目标是:

  • 通过 Ollama 运行 deepseek-r1 1.5B 模型,体验 reasoning 模型的推理过程。
  • 使用 HTML 搭建一个简易的交互式界面。
  • 在有时间的情况下,尝试实现简单的 记忆功能(将对话存入 JSON)。

2. Ollama 的安装

Ollama 是一个轻量级的 LLM 运行框架,支持本地模型推理。

📄下载地址:https://ollama.com/download

安装完成后,使用以下命令检查是否安装成功:

ollama --version

如果输出如下版本号,则安装成功:

ollama version is 0.5.10

3. Ollama 下载并运行模型

使用以下命令下载 deepseek-r1 1.5B 模型(大小约1.1GB),并启动:

ollama pull deepseek-r1:1.5B # 拉取(下载)模型
ollama run deepseek-r1 # 运行模型(进入交互式命令行)

运行成功后,可在命令行中输入问题,模型将返回答案。

一些常用的快捷键:

  • 按下Ctrl + D可退出交互式命令行
  • 按下Ctrl + C可打断当前对话,并重新输入问题
  • 按下Ctrl + L可清空屏幕(仍然可以通过滑轮查看历史记录)

以下是一个简单的命令行中的演示效果:

Demo

其中<think></think>标签中的内容是模型的推理过程,而在其之后的才是模型真正的回答。

4. HTML 简介

我们不需要成为专业的前端开发者,只需要了解 HTML 的基本概念,就可以搭建一个简单的交互界面。(毕竟我们能求助AI辅助开发!)

4.1 HTML 的核心:标签

HTML 是前端开发的基础语言,其主要思路是通过一个个标签对页面进行描述。

示例代码:

<h1>这是一级标题</h1>
<p>这是一个段落</p>
<div>这是一个区块</div>

只要是成对的<xx></xx>标签,就可以将内容包裹在其中,无论其名字是什么。

只是不同的标签有不同的功能,以及默认样式。

4.2 HTML 的两大帮手:CSS 和 JavaScript

  • CSS:用于控制页面的样式,如字体、颜色、布局等。
  • JavaScript:用于控制页面的行为,如点击事件、动画效果等。

它们既可以用单独的文件储存,并在 HTML 中引用,也可以直接写在 HTML 文件中。这里我们使用后者。

5. 简单的 HTML 界面实现

我们创建一个最简单的 HTML 页面,包含:

  • 一个 输入框 供用户输入问题。
  • 一个 按钮 发送请求。
  • 一个 显示框 展示 AI 回复。

我们完全可以求助刚才安装好的 deepseek-r1 模型,让 AI 帮我们写一个简单的 HTML 页面。

我是这么问它的:

请帮我写一个用于制作与本地大模型交互的 HTML 页面。
具体要求如下:
1. 只需要包含一个大标题,一个输入框(用于给用户输入内容)、一个提交按钮(用于提交输入的信息)和一个显示框(用于显示大模型的返回)即可。
2. 不需要有任何实际功能(不需要JavaScript代码),只需要基本的结构即可。
3. 可以添加简单的样式,但是不应过于复杂

请注意,由于大模型输出的是 Markdown 格式,所以代码会包含在 “```html” 和 “```” 之间。
当输出结果不理想时,你完全可以再次询问,也可以去问更强大的AI(毕竟我们使用的仅仅是1.5B的蒸馏模型,相比于原版671B的完整模型还是有很大差距的)。

以下是一个简单的 HTML 页面示例:(由ChatGPT-4o生成)

<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>本地大模型交互</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            height: 100vh;
            margin: 0;
            background-color: #f4f4f4;
        }
        h1 {
            margin-bottom: 20px;
        }
        .container {
            width: 50%;
            max-width: 600px;
            padding: 20px;
            background: white;
            border-radius: 8px;
            box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
        }
        input {
            width: 100%;
            padding: 10px;
            margin: 10px 0;
            border: 1px solid #ccc;
            border-radius: 4px;
        }
        button {
            width: 100%;
            padding: 10px;
            background-color: #007BFF;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
        }
        button:hover {
            background-color: #0056b3;
        }
        .output {
            margin-top: 20px;
            padding: 10px;
            border: 1px solid #ccc;
            border-radius: 4px;
            min-height: 50px;
            background: #fff;
        }
    </style>
</head>
<body>
    <h1>本地大模型交互</h1>
    <div class="container">
        <input type="text" placeholder="请输入内容...">
        <button>提交</button>
        <div class="output"></div>
    </div>
</body>
</html>

6. 后端交互功能实现

6.1 Ollama API

在实际应用中,前后端的交互主要是通过 API 实现的。

首先,Ollama 提供了一个 HTTP API,可以通过 POST 请求发送问题,并返回回答。

在确认 Ollama 运行时,我们可以尝试以下一系列 POST 请求:

  1. 获取所有模型列表:

    curl http://localhost:11434/api/tags
  2. 请求模型信息:

    curl http://localhost:11434/api/show -d '{
      "model": "deepseek-r1:1.5B"
    }'
  3. 生成回答:

    curl http://localhost:11434/api/chat -d '{
      "model": "deepseek-r1:1.5B",
      "messages": [
        {
          "role": "user",
          "content": "why is the sky blue?"
        }
      ]
    }'

    注意:由于是Ollama可以通过流式回答,所以其在回答时会出现非常多行的回复,每一行都是完整回答中的一个词。

📄更多用法见官方文档:https://github.com/ollama/ollama/blob/main/docs/api.md

6.2 JavaScript 实现

⚠️ 注意:由于 CORS 限制,浏览器不允许直接从本地主机(localhost)请求数据。
我的解决方案是临时地修改(添加)环境变量。
Windows:
添加环境变量OLLAMA_ORIGINS,值为*
Linux&Mac:
添加环境变量export OLLAMA_ORIGINS=*
之后重启Ollama服务即可应用(如果不行,可以尝试重启电脑)。

请注意,这样做会使得所有的网站都可以访问你的Ollama服务,所以请在使用完毕后及时删除这个环境变量。

以下 JavaScript 可以为 HTML 提供与 Ollama API 的交互功能。

你需要:

  • 将其放入<body>标签中。
  • 在 HTML 中添加一个 idmodel-input<input>元素。
  • 在 HTML 中添加一个 idmodel-submit<button>元素。
  • 在 HTML 中添加一个 idmodel-output<div>元素。
  • (可选)在 HTML 中添加一个 idsave-chat<button>元素,用于保存对话为 JSON 文件。
<script>
    document.addEventListener("DOMContentLoaded", function () {
        const input = document.getElementById("model-input");
        const button = document.getElementById("model-submit");
        const output = document.getElementById("model-output");
        const saveButton = document.getElementById("save-chat"); // 保存对话按钮

        let messages = []; // 存储所有对话

        button.addEventListener("click", async function () {
            const userMessage = input.value.trim();
            if (!userMessage) return;

            // 记录用户输入并添加到对话框
            messages.push({ role: "user", content: userMessage });
            output.innerHTML += `<div class="user-msg"><b>你:</b> ${userMessage}</div>`;

            // 清空输入框
            input.value = "";

            // 在对话框中添加占位符,模型回复将在此处更新
            const messageContainer = document.createElement("div");
            messageContainer.className = "model-msg";
            messageContainer.innerHTML = `<b>模型:</b> <span class="model-response"></span><br><br>`;
            output.appendChild(messageContainer);

            const responseSpan = messageContainer.querySelector(".model-response");

            try {
                const response = await fetch("http://localhost:11434/api/chat", {
                    method: "POST",
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify({
                        model: "deepseek-r1:1.5B",
                        messages: [{ role: "user", content: userMessage }]
                    })
                });

                if (!response.body) {
                    throw new Error("No response body");
                }

                const reader = response.body.getReader();
                const decoder = new TextDecoder("utf-8");

                let buffer = "";
                let fullResponse = "";

                while (true) {
                    const { value, done } = await reader.read();
                    if (done) break;

                    buffer += decoder.decode(value, { stream: true });
                    let lines = buffer.split("\n");

                    buffer = lines.pop(); // 处理未完成的 JSON 片段

                    for (const line of lines) {
                        if (line.trim()) {
                            try {
                                const parsed = JSON.parse(line.trim());
                                if (parsed.message && parsed.message.content) {
                                    fullResponse += parsed.message.content;
                                    responseSpan.innerHTML = fullResponse;
                                }
                            } catch (err) {
                                console.error("解析 JSON 失败:", line.trim());
                            }
                        }
                    }
                }

                // 处理剩余的 JSON 片段
                if (buffer.trim()) {
                    try {
                        const parsed = JSON.parse(buffer.trim());
                        if (parsed.message && parsed.message.content) {
                            fullResponse += parsed.message.content;
                            responseSpan.innerHTML = fullResponse;
                        }
                    } catch (err) {
                        console.error("解析 JSON 失败:", buffer.trim());
                    }
                }

                // 保存模型的完整回复
                messages.push({ role: "assistant", content: fullResponse });

            } catch (error) {
                console.error("请求失败:", error);
                responseSpan.innerHTML = "<b>请求失败:</b> 请检查 Ollama 是否运行。";
            }
        });

        // 保存对话到 JSON
        saveButton.addEventListener("click", function () {
            const jsonBlob = new Blob([JSON.stringify(messages, null, 2)], { type: "application/json" });
            const a = document.createElement("a");
            a.href = URL.createObjectURL(jsonBlob);
            a.download = "chat_history.json"; // 让用户下载 JSON 文件
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
        });
    });
</script>

😉 如果你成功地运行了这段代码,不妨拍张照片或者录制一个视频,分享给别人看看吧!

6.3 成果展示

Demonstration

7. 记忆功能实现

目前的代码已经实现了简单的记忆功能,但是其实现方式并不能让AI拥有真正的记忆。我们的解决方案是简单粗暴地在提问的时将目前的所有对话都传给AI。

遗憾的是,放眼现有的AI技术,虽然有如Letta一样的项目致力于实现AI的记忆功能,但是目前还没有一个能够真正实现完美记忆的AI。

8. Open WebUI: 更好的交互体验

如果没搞懂以上的响应式网页代码,你也可以尝试使用 Open WebUI。推荐的原因是它的安装可以通过我们熟悉的pip来完成,并且其有一个非常友好的界面。

  • Open WebUI 项目地址:Open WebUI is an extensible, feature-rich, and user-friendly self-hosted AI platform designed to operate entirely offline.

8.1 简略的安装步骤

  1. 创建并进入用于存放 Open WebUI 的虚拟环境:

    你完全可以将其安装在已有的虚拟环境中,不必单独为其创建一个虚拟环境。

    conda create -n local-llm python # local-llm 是环境名,可以自定义
    conda activate local-llm
  2. 安装 Open WebUI:

    pip install open-webui
  3. 运行 Open WebUI:

    open-webui serve

    默认情况下,Open WebUI 会在http://localhost:8080上运行。

  4. 打开浏览器,访问http://localhost:8080,即可看到 Open WebUI 的界面。

    ⚠️注意:Open WebUI 会在第一次使用时要求创建用户,而这个用户信息是完全本地保存的,可以随意设置,但请务必记住(建议使用浏览器的密码管理器)。

Open WebUI

9. 总结

通过本次 Workshop,我们实现了:
✅ 使用 Ollama 本地运行 DeepSeek 模型。
✅ 通过 HTML + JavaScript 构建交互界面。
✅ 在有时间的情况下,扩展支持简单的对话记忆。

希望本次 Workshop 能帮助大家理解 本地大模型推理前端交互 的基础概念,期待在下次 CELN Workshop 见到大家!🚀

😉 希望大家能分享活动的照片或视频!无论是成功的代码运行截图,还是大家一起学习的场景,都可以分享给大家看看!

10. 附录:完整示例代码

<!DOCTYPE html>
<html lang="zh">

    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>本地大模型交互</title>
        <style>
            body {
                font-family: Arial, sans-serif;
                display: flex;
                flex-direction: column;
                align-items: center;
                justify-content: center;
                height: 100vh;
                margin: 0;
                background-color: #f4f4f4;
            }

            h1 {
                margin-bottom: 20px;
            }

            .container {
                width: 50%;
                max-width: 600px;
                padding: 20px;
                background: white;
                border-radius: 8px;
                box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
            }

            input {
                width: 100%;
                padding: 10px;
                margin: 10px 0;
                border: 1px solid #ccc;
                border-radius: 4px;
            }

            button {
                width: 100%;
                padding: 10px;
                background-color: #007BFF;
                color: white;
                border: none;
                border-radius: 4px;
                cursor: pointer;
            }

            button:hover {
                background-color: #0056b3;
            }

            .output {
                margin-top: 20px;
                padding: 10px;
                border: 1px solid #ccc;
                border-radius: 4px;
                min-height: 50px;
                background: #fff;
            }
        </style>
    </head>

    <body>
        <h1>本地大模型交互</h1>
        <div class="container">
            <input id="model-input" type="text" placeholder="请输入内容...">
            <button id="model-submit">提交</button>
            <div id="model-output" class="output"></div>
            <button id="save-chat">保存对话</button>
        </div>
        <script>
            document.addEventListener("DOMContentLoaded", function () {
                const input = document.getElementById("model-input");
                const button = document.getElementById("model-submit");
                const output = document.getElementById("model-output");
                const saveButton = document.getElementById("save-chat"); // 保存对话按钮

                let messages = []; // 存储所有对话记录

                // 监听 Enter 键提交
                input.addEventListener("keydown", function (event) {
                    if (event.key === "Enter") {
                        event.preventDefault(); // 阻止默认换行行为
                        button.click(); // 触发点击事件
                    }
                });

                button.addEventListener("click", async function () {
                    const userMessage = input.value.trim();
                    if (!userMessage) return;

                    // 记录用户输入并添加到对话框
                    messages.push({ role: "user", content: userMessage });
                    output.innerHTML += `<div class="user-msg"><b>你:</b> ${userMessage}</div>`;

                    // 清空输入框
                    input.value = "";

                    // 在对话框中添加占位符,模型回复将在此处更新
                    const messageContainer = document.createElement("div");
                    messageContainer.className = "model-msg";
                    messageContainer.innerHTML = `<b>模型:</b> <span class="model-response"></span><br><br>`;
                    output.appendChild(messageContainer);

                    const responseSpan = messageContainer.querySelector(".model-response");

                    try {
                        const response = await fetch("http://localhost:11434/api/chat", {
                            method: "POST",
                            headers: { "Content-Type": "application/json" },
                            body: JSON.stringify({
                                model: "deepseek-r1:1.5B",
                                messages: messages // 传递完整对话历史
                            })
                        });

                        if (!response.body) {
                            throw new Error("No response body");
                        }

                        const reader = response.body.getReader();
                        const decoder = new TextDecoder("utf-8");

                        let buffer = "";
                        let fullResponse = "";

                        while (true) {
                            const { value, done } = await reader.read();
                            if (done) break;

                            buffer += decoder.decode(value, { stream: true });
                            let lines = buffer.split("\n");

                            buffer = lines.pop(); // 处理未完成的 JSON 片段

                            for (const line of lines) {
                                if (line.trim()) {
                                    try {
                                        const parsed = JSON.parse(line.trim());
                                        if (parsed.message && parsed.message.content) {
                                            fullResponse += parsed.message.content;
                                            responseSpan.innerHTML = fullResponse;
                                        }
                                    } catch (err) {
                                        console.error("解析 JSON 失败:", line.trim());
                                    }
                                }
                            }
                        }

                        // 处理剩余的 JSON 片段
                        if (buffer.trim()) {
                            try {
                                const parsed = JSON.parse(buffer.trim());
                                if (parsed.message && parsed.message.content) {
                                    fullResponse += parsed.message.content;
                                    responseSpan.innerHTML = fullResponse;
                                }
                            } catch (err) {
                                console.error("解析 JSON 失败:", buffer.trim());
                            }
                        }

                        // 保存模型的完整回复
                        messages.push({ role: "assistant", content: fullResponse });

                    } catch (error) {
                        console.error("请求失败:", error);
                        responseSpan.innerHTML = "<b>请求失败:</b> 请检查 Ollama 是否运行。";
                    }
                });

                // 保存对话到 JSON
                saveButton.addEventListener("click", function () {
                    const jsonBlob = new Blob([JSON.stringify(messages, null, 2)], { type: "application/json" });
                    const a = document.createElement("a");
                    a.href = URL.createObjectURL(jsonBlob);
                    a.download = "chat_history.json"; // 让用户下载 JSON 文件
                    document.body.appendChild(a);
                    a.click();
                    document.body.removeChild(a);
                });
            });
        </script>
    </body>

</html>

[CELN] 01 本地部署大模型 + 简单 HTML 交互界面
https://siriusahu.github.io.git/2025/02/22/CELN-01-Ollama_HTML/
Author
Sirius Ahu
Posted on
February 22, 2025
Licensed under