精选圈子榜单优站
编程综合
编程综合
技术
20关注
编程技术记录、分享 ,记录你的编程生活点点滴滴!

本地Ollama简易客户端


时隔一年没在社区写文章,这里随缘更新下。

最近两年都在做AI相关的东西,玩得不亦乐乎。

本地玩大模型,Ollama算是一个比较方便的方式,只需在Ollama官网下载Ollama客户端,安装,启动,然后就可以通过ollama命令安装、使用大部分市面上的热门模型。

Ollama官网地址:

https://ollama.com

Ollama可用模型列表:

https://ollama.com/search

Ollama拉取模型,以最新qwen3:0.6b为例,需命令行执行:

$ ollama pull qwen3:0.6b

或者也可以直接运行模型,运行时如果模型还没有被下载,这时候会自动拉取:

$ ollama run qwen3:0.6b

当模型运行后,此时就可以通过命令行跟这个模型对话了。


这里让AI写了个简易客户端,简单做了些调整,直接把这段代码保存到本地,作为html文件,使用浏览器打开,就可以连接本地的Ollama里的模型作对话。

需要注意的是,模型要先预先下载好。

客户端代码如下:

<!DOCTYPE html><html lang="zh"><head>  <meta charset="UTF-8" />  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>  <title>Chat Bot</title>  <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>  <script src="https://cdn.uv.cc/markedjs/0.3.5/marked.min.js"></script>  <style>    body {      margin: 0;      font-family: Arial, sans-serif;      background-color: #f4f4f4;      height: 100vh;      display: flex;      flex-direction: column;    }    header {      background-color: #333;      color: white;      padding: 10px 20px;      display: flex;      justify-content: space-between;      align-items: center;      position: fixed;      width: 100%;    }    header button {      background-color: #555;      border: none;      color: white;      padding: 5px 10px;      cursor: pointer;      font-size: 14px;      border-radius: 4px;      margin-right: 35px;    }    #settings-panel {      background-color: #fff;      border-bottom: 1px solid #ccc;      margin-top: 45px;      position: fixed;      width: 100vw;      padding: 15px 60px 15px 15px;    }    .settings-row {      margin-bottom: 10px;    }    .settings-row label {      display: inline-block;      width: 100px;    }    .settings-row input {      width: calc(100% - 150px);      padding: 5px;      box-sizing: border-box;    }    #chat-container {      flex: 1;      overflow-y: auto;      padding: 20px;      display: flex;      flex-direction: column;      margin-top: 40px;      margin-bottom: 50px;    }    .message {      max-width: 100%;      padding: 10px 10px 0;      border-radius: 10px;      word-wrap: break-word;      margin-bottom: 15px;      align-self: flex-end;    }    .user {      background-color: #007bff;      color: white;      float: right;      max-width: 85%;      padding: 10px;      border-radius: 10px;      word-wrap: break-word;      margin-bottom: 15px;      align-self: flex-end;    }    .bot-wrapper {      background-color: white;      max-width: 85%;      padding: 10px;      border-radius: 10px;      word-wrap: break-word;      margin-bottom: 15px;      align-self: flex-end;    }    .bot-answer {    }    .bot-thoughts {      cursor: pointer;      font-weight: bold;      padding: 8px;      border-radius: 5px;      max-width: 70%;      align-self: flex-end;    }    .bot-thoughts.collapsed::after {      content: " ▼";    }    .bot-thoughts.expanded::after {      content: " ▲";    }    .bot-thoughts-content {      padding: 0 10px 10px;      max-width: 100%;      align-self: flex-end;      color: #999;      font-size: 14px;    }    form {      position: fixed;      bottom: 0;      left: 0;      right: 0;      background-color: #fff;      padding: 10px;      border-top: 1px solid #ccc;      display: flex;    }    input[type="text"] {      flex: 1;      padding: 10px;      font-size: 16px;      border: 1px solid #ccc;      border-radius: 5px;    }    .send-input {      flex: 1;      padding: 10px;      font-size: 16px;      border: 1px solid #ccc;      border-radius: 5px;    }    .send-btn {      margin-left: 10px;      padding: 10px 20px;      font-size: 16px;      background-color: #007bff;      color: white;      border: none;      border-radius: 5px;      cursor: pointer;    }    .stop-btn {      margin-left: 10px;      padding: 10px 20px;      font-size: 16px;      background-color: #f44336;      color: white;      border: none;      border-radius: 5px;      cursor: pointer;    }    .clear-btn {      background-color: #f44336;      color: white;      border: none;      padding: 5px 10px;      cursor: pointer;      font-size: 14px;      border-radius: 4px;    }    .reset-btn {      margin-left: 10px;      background-color: white;      color: black;      padding: 5px 10px;      cursor: pointer;      font-size: 14px;      border-radius: 4px;      border: 1px solid #007bff;    }  </style></head><body>  <div id="app">    <header>      <div>Chat Bot</div>      <button @click="toggleSettings">{{showSettings ? '收起面板' : '设置'}}</button>    </header>    <div id="settings-panel" v-show="showSettings">      <div class="settings-row">        <label for="systemPrompt">系统提示词</label>        <input type="text" id="systemPrompt" @change="saveSettings" v-model="settings.systemPrompt" />      </div>      <div class="settings-row">        <label for="maxHistory">记忆轮数</label>        <input type="number" id="maxHistory" @change="saveSettings" v-model.number="settings.maxHistory" min="1" max="20" />      </div>      <div class="settings-row">        <label for="temperature">温度</label>        <input type="number" id="temperature" @change="saveSettings" v-model.number="settings.temperature" step="0.1" min="0" max="1" />      </div>      <div class="settings-row">        <label for="model">模型</label>        <input type="text" id="model" @change="saveSettings" v-model.number="settings.model" step="0.1" min="0" max="1" />      </div>      <div class="settings-row">        <label for="model">服务地址</label>        <input type="text" id="addr" @change="saveSettings" v-model.number="settings.addr" step="0.1" min="0" max="1" />      </div>      <div class="settings-row">        <button class="clear-btn" @click="clearHistory">清空对话</button>        <button class="reset-btn" @click="resetSetting">恢复默认</button>      </div>    </div>    <div id="chat-container" ref="chatContainer">      <div v-for="(msg, index) in messages" :key="index">        <div v-if="msg.role === 'user'" class="user">{{ msg.content }}</div>        <div v-else-if="msg.thoughts !== undefined && msg.answer !== undefined" class="bot-wrapper">          <div v-show="msg.thoughts" class="bot-thoughts" :class="msg.collapsed ? 'collapsed' : 'expanded'" @click="toggleThoughts(msg)" :ref="(el) => setRef(el, index, 'thoughtHeader')">            思考内容          </div>          <div v-show="msg.thoughts && msg.collapsed"               class="bot-thoughts-content" :ref="(el) => setRef(el, index, 'thoughtContent')" v-html="compiledMarkdown(msg.thoughts)"></div>          <hr v-show="msg.thoughts && msg.answer"/>          <div v-show="msg.answer" class="message bot-answer" v-html="compiledMarkdown(msg.answer)"></div>        </div>        <div v-else-if="msg.role === 'assistant' && msg.content !== undefined" class="message bot-answer">          {{ msg.content }}        </div>      </div>    </div>    <form @submit.prevent="sendMessage">      <input type="text" class="send-input" v-model="inputMessage" placeholder="输入消息..." required />      <input type="submit" v-show="!sending" class="send-btn" value="发送" />      <input type="button" v-show="sending" class="stop-btn" value="停止" @click="stopMessage" />    </form>  </div>  <script>    const { createApp, ref, onMounted } = Vue;    createApp({      setup() {        // 输入框消息        const inputMessage = ref('');        // 是否在发送中标识        const sending = ref(false)        // 加载本地缓存消息        const localMessages = JSON.parse(localStorage.getItem('chatMessages')) || []        localMessages.map(item => {          if (item.role === 'assistant') {            // 默认历史消息折叠思考内容            item.collapsed = false          }        })        const messages = ref(localMessages);        // 是否展示设置面板        const showSettings = ref(false);        const chatContainer = ref(null);        // 加载已缓存设置或者默认配置        const defaultSettings = {          systemPrompt: '你是一个助手。',          maxHistory: 5,          temperature: 0.7,          model: 'qwen3:0.6b',          addr: 'http://localhost:11434'        };        const settings = ref(JSON.parse(localStorage.getItem('chatSettings')) || defaultSettings);        // 创建一个AbortController实例,用来控制对话中断        let controller = null;        const refs = ref({});        // 当前正在接收的 assistant 消息索引        const currentBotMsgIndex = ref(null);        function setRef(el, index, key) {          if (!refs.value[index]) refs.value[index] = {};          refs.value[index][key] = el;        }        function toggleThoughts(msg) {          msg.collapsed = !msg.collapsed          saveMessages()        }        function saveMessages() {          localStorage.setItem('chatMessages', JSON.stringify(messages.value));        }        function saveSettings() {            localStorage.setItem('chatSettings', JSON.stringify(settings.value));        }        function resetSetting () {          if (confirm("确定要恢复默认设置吗?")) {            settings.value = defaultSettings            saveSettings()          }        }        function clearHistory() {          if (confirm("确定要清空对话历史吗?")) {            stopMessage()            messages.value = messages.value.filter(msg => msg.role === 'system');            saveMessages();          }        }        function renderMessages() {          setTimeout(() => {            chatContainer.value.scrollTop = chatContainer.value.scrollHeight;          }, 0);        }        function toggleSettings() {          showSettings.value = !showSettings.value;        }        async function sendMessage() {          const message = inputMessage.value.trim();          if (!message) return;          // 标识对话进行中          sending.value = true;          const userMsg = { role: 'user', content: message };          messages.value.push(userMsg);          inputMessage.value = '';          // saveMessages();          renderMessages();          const history = [...messages.value]            .filter(m => m.role === 'user' || (m.role === 'assistant' && m.answer))            .map(m => m.role === 'assistant' ? { role: m.role, content: m.answer } : m)            .slice(-settings.value.maxHistory);          const payload = {            model: settings.value.model,            stream: true,            temperature: settings.value.temperature,            messages: [              { role: 'system', content: settings.value.systemPrompt },              ...history            ]          };          try {            // 创建一个新的AbortController            controller = new AbortController();            // 获取信号            let signal = controller.signal;            const response = await fetch(settings.value.addr + '/v1/chat/completions', {              method: 'POST',              headers: { 'Content-Type': 'application/json' },              body: JSON.stringify(payload),              signal            });            // debugger            // if (!response.ok || !response.body) throw new Error('请求失败或不支持流式响应');            const reader = response.body.getReader();            const decoder = new TextDecoder();            let fullResponse = '';            const botMsg = { role: 'assistant', thoughts: '', answer: '', collapsed: true };            messages.value.push(botMsg);            currentBotMsgIndex.value = messages.value.length - 1;            // saveMessages();            renderMessages();            while (true) {              const { done, value } = await reader.read();              if (done) break;              const chunk = decoder.decode(value, { stream: true });              if (!response.ok) {                alert(chunk)                return              }              const lines = chunk.split('\n');              for (const line of lines) {                if (line.startsWith('data: ')) {                  const dataStr = line.slice(6).trim();                  if (dataStr === '[DONE]') continue;                  try {                    const data = JSON.parse(dataStr);                    const content = data.choices?.[0]?.delta?.content;                    if (!content) continue;                    fullResponse += content;                    console.log(content)                    if (fullResponse.startsWith('<think>') && fullResponse.indexOf("</think>") < 0) {                        messages.value[currentBotMsgIndex.value].thoughts = fullResponse.replace("<think>\n", '')                    } else {                        // 尝试解析 think / answer                        if (fullResponse.startsWith('<think>')) {                          messages.value[currentBotMsgIndex.value].answer = fullResponse.split('</think>\n\n')[1]                        } else {                          messages.value[currentBotMsgIndex.value].answer = fullResponse                        }                    }                    saveMessages();                    renderMessages();                  } catch (error) {                    if (error.name === 'AbortError') {                      console.log('Fetch aborted');                    } else {                      console.error('Fetch error:', error);                    }                  }                }              }            }            // 如果最终没有进入 think/answer 结构,则作为普通回答处理            const bot = messages.value[currentBotMsgIndex.value];            if (bot.content) {              bot.answer = bot.content;              delete bot.content;            }            saveMessages();            renderMessages();          } catch (error) {            console.error('请求失败:', error);            // alert(error.message)          } finally {            controller = null;            sending.value = false;          }        }        function stopMessage () {          if (controller) {            controller.abort();          }        }        function compiledMarkdown (text) {          return marked(text);        }        onMounted(() => {          renderMessages();        });        return {          inputMessage,          sending,          messages,          showSettings,          settings,          setRef,          toggleThoughts,          clearHistory,          sendMessage,          stopMessage,          toggleSettings,          chatContainer,          renderMessages,          saveSettings,          resetSetting,          compiledMarkdown        };      }    }).mount('#app');  </script></body></html>


以上,Enjoy!



  • 若文章侵犯了您的权益,请联系我们进行处理。

  • 2025-06-26
  • 3369阅读
评论