• 𝓕-𝓓𝓻𝓸𝓲𝓭
    // ==UserScript==
    // @name         Ollama控制台
    // @namespace    ollama-panel
    // @version      1.0.0
    // @description  远程Ollama全功能管理:模型/加载卸载/聊天(含思考块)/嵌入/编辑参数/创建/复制/重命名/删除/拉取/推送/详情
    // @author       you
    // @match        http://localhost:11434/*
    // @match        http://127.0.0.1:11434/*
    // @icon           https://ollama.com/public/icon-32x32.png
    // @run-at       document-idle
    // @grant        none
    // ==/UserScript==
    
    (function () {
      'use strict';
    
      const API = location.origin;
    
      // ================= 工具 =================
      const $ = (s, el = document) => el.querySelector(s);
      const $$ = (s, el = document) => [...el.querySelectorAll(s)];
      const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
      const fmtBytes = b => { if (!b && b !== 0) return '-'; const u=['B','KB','MB','GB','TB']; let i=0; while(b>=1024&&i<u.length-1){b/=1024;i++} return b.toFixed(b>=100?0:1)+' '+u[i]; };
      const fmtGB = b => (b / 1e9).toFixed(1) + ' GB';
      async function errText(res) {
        try { const t = await res.text(); return t ? (JSON.parse(t).error || res.statusText) : res.statusText; }
        catch { return res.statusText; }
      }
      const api = async (path, body, method) => {
        const res = await fetch(API + path, {
          method: method || (body !== undefined ? 'POST' : 'GET'),
          headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
          body: body !== undefined ? JSON.stringify(body) : undefined
        });
        if (!res.ok) throw new Error(await errText(res));
        if (res.status === 204) return null;
        const text = await res.text();
        return text ? JSON.parse(text) : null;
      };
      async function readStream(res, onLine) {
        const reader = res.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.trim()) {
            let j; try { j = JSON.parse(line); } catch { continue; }
            if (j.error) throw new Error(j.error);
            onLine(j);
          }
        }
      }
      function parseModelfile(mf, fallbackFrom) {
        const p = { from: fallbackFrom, system: '', template: '', params: {} };
        for (const line of (mf || '').split('\n')) {
          const t = line.trim();
          if (t.startsWith('FROM ')) p.from = t.slice(5).trim();
          else if (t.startsWith('SYSTEM ')) p.system = t.slice(7).trim().replace(/^"(.*)"$/, '$1');
          else if (t.startsWith('TEMPLATE ')) p.template = t.slice(9).trim();
          else if (t.startsWith('PARAMETER ')) {
            const bits = t.slice(10).trim().split(/\s+/);
            const v = bits.slice(1).join(' ');
            p.params[bits[0]] = (v === '' || isNaN(+v)) ? v : +v;
          }
        }
        if (p.from.includes('blobs')) p.from = fallbackFrom;
        return p;
      }
    
      // ================= 样式 =================
      const style = `
      #ocp-root { all: initial; font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; }
      #ocp-root *, #ocp-root *::before, #ocp-root *::after { box-sizing: border-box; margin: 0; padding: 0; }
      #ocp-fab { position: fixed; right: 24px; bottom: 24px; z-index: 2147483646; width: 54px; height: 54px;
        border-radius: 50%; border: none; cursor: pointer; font-size: 24px; color: #fff;
        background: linear-gradient(135deg, #7c3aed, #4f46e5); box-shadow: 0 8px 24px rgba(99,102,241,.5);
        transition: transform .2s; display: flex; align-items: center; justify-content: center; }
      #ocp-fab:hover { transform: scale(1.1); }
      #ocp-panel { position: fixed; right: 24px; bottom: 92px; z-index: 2147483647; width: 540px; height: 740px;
        max-width: calc(100vw - 32px); max-height: calc(100vh - 120px); display: flex; flex-direction: column;
        background: rgba(17,19,32,.96); backdrop-filter: blur(20px); border: 1px solid rgba(255,255,255,.08);
        border-radius: 18px; box-shadow: 0 24px 64px rgba(0,0,0,.55); color: #e5e7eb; overflow: hidden; }
      #ocp-panel.hidden { display: none; }
      #ocp-head { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid rgba(255,255,255,.06);
        background: linear-gradient(135deg, rgba(124,58,237,.18), rgba(79,70,229,.10)); }
      #ocp-head .logo { font-size: 20px; }
      #ocp-head .title { font-weight: 700; font-size: 15px; letter-spacing: .3px; }
      #ocp-head .ver { font-size: 11px; color: #94a3b8; background: rgba(255,255,255,.07); padding: 2px 8px; border-radius: 99px; }
      #ocp-head .spacer { flex: 1; }
      #ocp-head button { background: rgba(255,255,255,.08); color: #e5e7eb; border: none; width: 30px; height: 30px; border-radius: 8px; cursor: pointer; font-size: 14px; }
      #ocp-head button:hover { background: rgba(255,255,255,.16); }
      #ocp-tabs { display: flex; gap: 4px; padding: 8px 12px; border-bottom: 1px solid rgba(255,255,255,.06); background: rgba(255,255,255,.02); }
      #ocp-tabs button { flex: 1; padding: 8px 0; background: transparent; border: none; color: #94a3b8; cursor: pointer;
        font-size: 13px; font-weight: 600; border-radius: 8px; transition: all .15s; }
      #ocp-tabs button:hover { color: #e5e7eb; background: rgba(255,255,255,.06); }
      #ocp-tabs button.active { color: #fff; background: linear-gradient(135deg, #7c3aed, #4f46e5); box-shadow: 0 4px 12px rgba(99,102,241,.35); }
      #ocp-body { flex: 1; overflow-y: auto; padding: 14px; scrollbar-width: thin; }
      #ocp-body::-webkit-scrollbar { width: 6px; } #ocp-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,.15); border-radius: 3px; }
      #ocp-foot { padding: 8px 14px; border-top: 1px solid rgba(255,255,255,.06); font-size: 11px; color: #64748b;
        display: flex; gap: 12px; align-items: center; background: rgba(255,255,255,.02); }
      #ocp-foot .dot { width: 7px; height: 7px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 8px #22c55e; }
      #ocp-body h3 { font-size: 12px; color: #64748b; text-transform: uppercase; letter-spacing: 1px; margin: 6px 2px 10px; }
      .card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.07); border-radius: 12px; padding: 12px; margin-bottom: 10px; }
      .card .row1 { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
      .card .name { font-weight: 600; font-size: 13px; word-break: break-all; }
      .badge { font-size: 10px; padding: 2px 7px; border-radius: 99px; background: rgba(255,255,255,.08); color: #94a3b8; white-space: nowrap; }
      .badge.green { background: rgba(34,197,94,.15); color: #4ade80; }
      .badge.violet { background: rgba(139,92,246,.18); color: #a78bfa; }
      .badge.amber { background: rgba(245,158,11,.15); color: #fbbf24; }
      .badge.blue { background: rgba(59,130,246,.15); color: #60a5fa; }
      .card .meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; font-size: 11px; color: #94a3b8; }
      .card .ops { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
      .btn { padding: 5px 11px; font-size: 12px; border: none; border-radius: 7px; cursor: pointer; color: #e5e7eb;
        background: rgba(255,255,255,.08); transition: all .15s; font-weight: 500; }
      .btn:hover { background: rgba(255,255,255,.16); }
      .btn.primary { background: linear-gradient(135deg, #7c3aed, #4f46e5); }
      .btn.primary:hover { filter: brightness(1.15); }
      .btn.danger { background: rgba(239,68,68,.16); color: #f87171; }
      .btn.danger:hover { background: rgba(239,68,68,.28); }
      .btn.ok { background: rgba(34,197,94,.15); color: #4ade80; }
      .btn.ok:hover { background: rgba(34,197,94,.28); }
      .btn:disabled { opacity: .45; cursor: not-allowed; }
      .empty { text-align: center; color: #64748b; font-size: 13px; padding: 40px 0; }
      .toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); z-index: 2147483647;
        background: rgba(17,19,32,.96); color: #e5e7eb; padding: 10px 18px; border-radius: 10px; font-size: 13px;
        box-shadow: 0 12px 32px rgba(0,0,0,.5); border: 1px solid rgba(255,255,255,.1); animation: ocpSlide .25s; max-width: 80vw; word-break: break-all; }
      .toast.err { border-color: rgba(239,68,68,.4); }
      @keyframes ocpSlide { from { transform: translate(-50%, 12px); opacity: 0; } to { transform: translate(-50%, 0); opacity: 1; } }
      #ocp-modal { position: fixed; inset: 0; z-index: 2147483647; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,.6); }
      #ocp-modal.hidden { display: none; }
      #ocp-modal .box { width: min(620px, 94vw); max-height: 86vh; background: #16182b; border: 1px solid rgba(255,255,255,.1);
        border-radius: 14px; display: flex; flex-direction: column; overflow: hidden; }
      #ocp-modal .box header { display: flex; align-items: center; padding: 12px 16px; border-bottom: 1px solid rgba(255,255,255,.08); }
      #ocp-modal .box header b { flex: 1; font-size: 14px; word-break: break-all; }
      #ocp-modal .box pre { flex: 1; overflow: auto; padding: 14px 16px; font-size: 11.5px; color: #a5b4fc; white-space: pre-wrap; word-break: break-all; margin: 0; }
      #ocp-modal .box .mbody { flex: 1; overflow: auto; padding: 14px 16px; font-size: 13px; color: #e5e7eb; }
      #ocp-modal .box footer { padding: 10px 16px; border-top: 1px solid rgba(255,255,255,.08); text-align: right; display: flex; gap: 8px; justify-content: flex-end; align-items: center; }
      .chat-msgs { display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px; }
      .chat-msgs .msg { max-width: 85%; padding: 9px 12px; border-radius: 12px; font-size: 13px; line-height: 1.55; white-space: pre-wrap; word-break: break-word; }
      .chat-msgs .msg.user { align-self: flex-end; background: linear-gradient(135deg, #7c3aed, #4f46e5); color: #fff; border-bottom-right-radius: 3px; }
      .chat-msgs .msg.assistant { align-self: flex-start; background: rgba(255,255,255,.07); color: #e5e7eb; border-bottom-left-radius: 3px; }
      .think-block { background: rgba(139,92,246,.08); border: 1px solid rgba(139,92,246,.2); border-radius: 10px;
        padding: 8px 12px; margin-bottom: 8px; font-size: 12px; max-width: 85%; }
      .think-head { color: #a78bfa; cursor: pointer; font-weight: 600; user-select: none; display: flex; gap: 6px; align-items: center; }
      .think-body { color: #94a3b8; white-space: pre-wrap; word-break: break-word; margin-top: 6px; line-height: 1.6; }
      .chat-bar { display: flex; gap: 8px; }
      .chat-bar input, .chat-bar select { flex: 1; background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.1); color: #e5e7eb;
        padding: 9px 12px; border-radius: 9px; font-size: 13px; outline: none; min-width: 0; }
      .chat-bar input:focus, .chat-bar select:focus { border-color: #7c3aed; }
      .field { margin-bottom: 10px; }
      .field label { display: block; font-size: 12px; color: #94a3b8; margin-bottom: 5px; }
      .field input, .field select, .field textarea { width: 100%; background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.1);
        color: #e5e7eb; padding: 8px 11px; border-radius: 8px; font-size: 13px; outline: none; }
      .field input:focus, .field select:focus, .field textarea:focus { border-color: #7c3aed; }
      .field textarea { font-family: 'Cascadia Code', Consolas, monospace; font-size: 12px; resize: vertical; line-height: 1.5; }
      .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
      .spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid rgba(255,255,255,.25); border-top-color: #fff;
        border-radius: 50%; animation: ocpSpin .6s linear infinite; vertical-align: -1px; }
      @keyframes ocpSpin { to { transform: rotate(360deg); } }
      .bar { height: 6px; background: rgba(255,255,255,.08); border-radius: 99px; overflow: hidden; margin-top: 6px; }
      .bar > div { height: 100%; width: 0; background: linear-gradient(90deg, #7c3aed, #4f46e5); border-radius: 99px; transition: width .2s; }
      .stat-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 14px; }
      .stat { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.07); border-radius: 10px; padding: 10px; text-align: center; }
      .stat .n { font-size: 17px; font-weight: 700; background: linear-gradient(135deg, #a78bfa, #60a5fa); -webkit-background-clip: text; background-clip: text; color: transparent; }
      .stat .l { font-size: 10.5px; color: #64748b; margin-top: 3px; }
      .ops-out { font-size: 12px; color: #a5b4fc; background: rgba(0,0,0,.25); border-radius: 8px; padding: 8px 10px; margin-top: 8px;
        font-family: Consolas, monospace; white-space: pre-wrap; word-break: break-all; max-height: 130px; overflow-y: auto; }
      .hint { font-size: 11px; color: #64748b; line-height: 1.6; margin-top: 6px; }
      `;
      document.head.insertAdjacentHTML('beforeend', `<style>${style}</style>`);
    
      // ================= UI 骨架 =================
      document.body.insertAdjacentHTML('beforeend', `
      <div id="ocp-root">
        <button id="ocp-fab" title="Ollama控制台">⚡</button>
        <div id="ocp-panel" class="hidden">
          <div id="ocp-head">
            <span class="logo">🦙</span><span class="title">Ollama控制台</span><span class="ver" id="ocp-ver">…</span>
            <span class="spacer"></span>
            <button id="ocp-refresh" title="刷新">⟳</button>
            <button id="ocp-min" title="最小化">–</button>
          </div>
          <div id="ocp-tabs">
            <button data-tab="models" class="active">模型</button>
            <button data-tab="running">运行中</button>
            <button data-tab="chat">聊天</button>
            <button data-tab="manage">管理</button>
          </div>
          <div id="ocp-body"></div>
          <div id="ocp-foot"><span class="dot"></span><span id="ocp-host" style="word-break:break-all">${API}</span><span id="ocp-status" style="flex:1;text-align:right"></span></div>
        </div>
        <div id="ocp-modal" class="hidden"><div class="box">
          <header><b id="ocp-mt">详情</b><button class="btn" id="ocp-mclose">✕</button></header>
          <div id="ocp-mbody" class="mbody"></div><footer id="ocp-mfoot"></footer>
        </div></div>
      </div>`);
    
      const panel = $('#ocp-panel'), body = $('#ocp-body');
    
      // ================= 状态 =================
      let chatModel = '', chatHistory = [];
    
      // ================= 通用 =================
      function toast(msg, isErr = false) {
        const el = document.createElement('div');
        el.className = 'toast' + (isErr ? ' err' : '');
        el.textContent = msg;
        document.body.appendChild(el);
        setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity .3s'; setTimeout(() => el.remove(), 320); }, 2600);
      }
      function showModal(title, text) {
        $('#ocp-mt').textContent = title;
        $('#ocp-mbody').className = 'mbody';
        $('#ocp-mbody').innerHTML = `<pre>${esc(text)}</pre>`;
        $('#ocp-mfoot').innerHTML = '';
        $('#ocp-modal').classList.remove('hidden');
      }
      function promptModal(title, fields) {
        return new Promise(resolve => {
          $('#ocp-mt').textContent = title;
          $('#ocp-mbody').className = 'mbody';
          $('#ocp-mbody').innerHTML = fields.map(f => {
            const id = 'pm-' + f.id;
            if (f.type === 'textarea')
              return `<div class="field"><label>${esc(f.label)}</label><textarea id="${id}" spellcheck="false" style="min-height:${f.rows || 130}px">${esc(f.value || '')}</textarea></div>`;
            return `<div class="field"><label>${esc(f.label)}</label>
              <input id="${id}" type="${f.type || 'text'}" value="${esc(f.value || '')}" placeholder="${esc(f.placeholder || '')}" autocomplete="off"></div>`;
          }).join('');
          $('#ocp-mfoot').innerHTML = `<button class="btn" id="pm-cancel">取消</button><button class="btn primary" id="pm-ok">确定</button>`;
          $('#ocp-modal').classList.remove('hidden');
          const first = $('#ocp-mbody input, #ocp-mbody textarea'); if (first) first.focus();
          const done = val => { $('#ocp-modal').classList.add('hidden'); $('#ocp-mfoot').innerHTML = ''; resolve(val); };
          $('#pm-ok').onclick = () => done(Object.fromEntries(fields.map(f => [f.id, $('#' + 'pm-' + f.id).value.trim()])));
          $('#pm-cancel').onclick = () => done(null);
          const onKey = e => {
            if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) $('#pm-ok').click();
            else if (e.key === 'Enter' && !e.target.matches('textarea')) $('#pm-ok').click();
          };
          $$('#ocp-mbody input, #ocp-mbody textarea').forEach(i => i.addEventListener('keydown', onKey));
        });
      }
      function setStatus(t) { $('#ocp-status').textContent = t; }
      async function withLoading(fn) {
        body.innerHTML = `<div class="empty"><span class="spinner"></span> 加载中…</div>`;
        try { await fn(); } catch (e) { body.innerHTML = `<div class="empty">⚠️ ${esc(e.message)}</div>`; }
      }
    
      // ================= 模型列表 =================
      async function renderModels() {
        await withLoading(async () => {
          const [tags, ps] = await Promise.all([api('/api/tags'), api('/api/ps')]);
          const running = new Set(ps.models.map(m => m.name));
          const local = tags.models.filter(m => !m.remote_model);
          const cloud = tags.models.filter(m => m.remote_model);
          const disk = [...new Map(local.map(m => [m.digest, m.size])).values()].reduce((a, b) => a + b, 0);
          let h = `<div class="stat-cards">
            <div class="stat"><div class="n">${tags.models.length}</div><div class="l">模型标签</div></div>
            <div class="stat"><div class="n">${fmtGB(disk)}</div><div class="l">磁盘占用</div></div>
            <div class="stat"><div class="n">${ps.models.length}</div><div class="l">驻留显存</div></div></div>`;
          h += `<h3>📦本地模型·${local.length}</h3>` + local.map(m => modelCard(m, running.has(m.name), false)).join('');
          if (cloud.length) h += `<h3 style="margin-top:14px">☁️云端模型·${cloud.length}</h3>` + cloud.map(m => modelCard(m, running.has(m.name), true)).join('');
          if (!tags.models.length) h += `<div class="empty">暂无模型</div>`;
          body.innerHTML = h;
        });
      }
      function modelCard(m, isRunning, isCloud) {
        const d = m.details || {};
        return `<div class="card">
          <div class="row1"><span class="name">${esc(m.name)}</span>
            ${isRunning ? '<span class="badge green">●驻留</span>' : ''}
            ${isCloud ? '<span class="badge blue">☁️云端</span>' : ''}
            ${d.quantization_level ? `<span class="badge amber">${esc(d.quantization_level)}</span>` : ''}
            ${d.parameter_size ? `<span class="badge violet">${esc(d.parameter_size)}</span>` : ''}</div>
          <div class="meta">
            <span>💾${isCloud ? fmtBytes(m.size) : fmtGB(m.size)}</span>
            ${d.family ? `<span>🏷${esc(d.family)}</span>` : ''}
            ${m.remote_model ? `<span>🔗${esc(m.remote_model)}</span>` : ''}
            <span>🕐${(m.modified_at || '').slice(0, 10)}</span></div>
          <div class="ops">
            <button class="btn primary" data-act="load" data-model="${esc(m.name)}">加载</button>
            <button class="btn" data-act="chat" data-model="${esc(m.name)}">对话</button>
            <button class="btn" data-act="embed" data-model="${esc(m.name)}">嵌入</button>
            <button class="btn" data-act="show" data-model="${esc(m.name)}">详情</button>
            <button class="btn ok" data-act="edit" data-model="${esc(m.name)}">编辑</button>
            ${isCloud ? '' : `
            <button class="btn" data-act="rename" data-model="${esc(m.name)}">重命名</button>
            <button class="btn" data-act="copy" data-model="${esc(m.name)}">复制</button>
            <button class="btn" data-act="push" data-model="${esc(m.name)}">推送</button>
            <button class="btn danger" data-act="del" data-model="${esc(m.name)}">删除</button>`}
          </div></div>`;
      }
    
      // ================= 运行中 =================
      async function renderRunning() {
        await withLoading(async () => {
          const ps = await api('/api/ps');
          if (!ps.models.length) { body.innerHTML = `<div class="empty">🟢无模型驻留,显存已全部释放</div>`; return; }
          let tv = 0, tr = 0;
          const cards = ps.models.map(m => {
            tv += m.size_vram; tr += m.size - m.size_vram;
            return `<div class="card"><div class="row1"><span class="name">${esc(m.name)}</span><span class="badge green">● 驻留</span></div>
            <div class="meta"><span>🖥显存 ${fmtGB(m.size_vram)}</span><span>🧠内存 ${fmtGB(m.size - m.size_vram)}</span><span>📏 ctx ${m.context_length || '-'}</span>
            <span>⏳到期 ${(m.expires_at || '').replace('T', ' ').slice(5, 16)}</span></div>
            <div class="ops"><button class="btn danger" data-act="unload" data-model="${esc(m.name)}">卸载</button>
            <button class="btn" data-act="chat" data-model="${esc(m.name)}">对话</button>
            <button class="btn" data-act="show" data-model="${esc(m.name)}">详情</button></div></div>`;
          }).join('');
          body.innerHTML = `<div class="stat-cards">
            <div class="stat"><div class="n">${ps.models.length}</div><div class="l">驻留模型</div></div>
            <div class="stat"><div class="n">${fmtGB(tv)}</div><div class="l">显存占用</div></div>
            <div class="stat"><div class="n">${fmtGB(tr)}</div><div class="l">内存占用</div></div></div>` + cards;
        });
      }
    
      // ================= 聊天(含 <think> 思考块) =================
      async function renderChat() {
        const tags = await api('/api/tags');
        const opts = tags.models.map(m => `<option value="${esc(m.name)}" ${m.name === chatModel ? 'selected' : ''}>${esc(m.name)}</option>`).join('');
        if (!chatModel && tags.models.length) chatModel = tags.models[0].name;
        body.innerHTML = `
          <div class="chat-bar" style="margin-bottom:10px">
            <select id="ocp-cmodel">${opts}</select>
            <button class="btn" id="ocp-clear">清空</button>
          </div>
          <div class="chat-msgs" id="ocp-cmsgs"></div>
          <div class="chat-bar">
            <input id="ocp-cinput" placeholder="输入消息,回车发送…" autocomplete="off">
            <button class="btn primary" id="ocp-csend">发送</button>
          </div>`;
        $('#ocp-cmodel').addEventListener('change', e => chatModel = e.target.value);
        $('#ocp-clear').addEventListener('click', () => { chatHistory = []; $('#ocp-cmsgs').innerHTML = ''; toast('对话已清空'); });
        const send = async () => {
          const input = $('#ocp-cinput'); const text = input.value.trim();
          if (!text || !chatModel) return toast('请先选择模型', true);
          input.value = '';
          addMsg('user', text);
          chatHistory.push({ role: 'user', content: text });
          const aiEl = addMsg('assistant', '…');
          let inThink = false, thinkBody = null;
          try {
            await chatStream(chatModel, chatHistory, delta => {
              const c = delta.message?.content;
              if (!c) return;
              const parts = c.split(/(<\/?think>)/);
              for (const part of parts) {
                if (!part) continue;
                if (part === '<think>') {
                  if (aiEl.textContent === '…') aiEl.textContent = '';
                  const block = document.createElement('div');
                  block.className = 'think-block';
                  block.innerHTML = `<div class="think-head">🤔思考过程 <span style="font-size:10px;opacity:.7">点击展开</span></div><div class="think-body" style="display:none"></div>`;
                  const head = block.querySelector('.think-head');
                  const tb = block.querySelector('.think-body');
                  head.addEventListener('click', () => {
                    const show = tb.style.display === 'none';
                    tb.style.display = show ? '' : 'none';
                    head.innerHTML = `🤔思考过程 <span style="font-size:10px;opacity:.7">${show ? '点击收起' : '点击展开'}</span>`;
                  });
                  $('#ocp-cmsgs').insertBefore(block, aiEl);
                  thinkBody = tb; inThink = true;
                } else if (part === '</think>') {
                  inThink = false; thinkBody = null;
                } else if (part) {
                  if (inThink && thinkBody) {
                    thinkBody.textContent += part;
                    thinkBody.style.display = '';
                  } else {
                    if (aiEl.textContent === '…') aiEl.textContent = '';
                    aiEl.textContent += part;
                  }
                }
              }
              body.scrollTop = body.scrollHeight;
            });
            chatHistory.push({ role: 'assistant', content: aiEl.textContent });
          } catch (e) {
            if (aiEl.textContent === '…') aiEl.textContent = '';
            aiEl.textContent += `\n⚠️ ${e.message}`;
          }
        };
        $('#ocp-csend').addEventListener('click', send);
        $('#ocp-cinput').addEventListener('keydown', e => { if (e.key === 'Enter') send(); });
      }
      function addMsg(role, text) {
        const el = document.createElement('div');
        el.className = 'msg ' + role; el.textContent = text;
        $('#ocp-cmsgs').appendChild(el);
        body.scrollTop = body.scrollHeight;
        return el;
      }
      async function chatStream(model, messages, onDelta) {
        const res = await fetch(API + '/api/chat', {
          method: 'POST', headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ model, messages, stream: true })
        });
        if (!res.ok) throw new Error(await errText(res));
        await readStream(res, onDelta);
      }
    
      // ================= 编辑模型参数(新版结构化 create API) =================
      async function editModelParams(model) {
        let d;
        try { d = await api('/api/show', { model }); }
        catch (e) { return toast('读取模型配置失败: ' + e.message, true); }
        const p = parseModelfile(d.modelfile, model);
        const val = k => p.params[k] ?? '';
        $('#ocp-mt').textContent = '✏️编辑参数 — ' + model;
        $('#ocp-mbody').className = 'mbody';
        $('#ocp-mbody').innerHTML = `
          <div class="hint" style="margin-bottom:10px">基于 <b>${esc(p.from)}</b> · 留空 = 不修改 · 保存后同名覆盖原模型</div>
          <div class="grid2">
            <div class="field"><label>temperature 温度</label><input id="pm-temperature" type="number" step="0.1" min="0" max="2" value="${esc(val('temperature'))}"></div>
            <div class="field"><label>top_p</label><input id="pm-top_p" type="number" step="0.05" min="0" max="1" value="${esc(val('top_p'))}"></div>
            <div class="field"><label>top_k</label><input id="pm-top_k" type="number" value="${esc(val('top_k'))}"></div>
            <div class="field"><label>num_ctx 上下文</label><input id="pm-num_ctx" type="number" step="256" min="256" value="${esc(val('num_ctx'))}"></div>
            <div class="field"><label>num_gpu(0=纯CPU, -1=全GPU)</label><input id="pm-num_gpu" type="number" value="${esc(val('num_gpu'))}"></div>
          </div>
          <div class="field"><label>system 系统提示词</label><textarea id="pm-system" spellcheck="false" style="min-height:70px">${esc(p.system)}</textarea></div>
          <div class="field"><label>template 对话模板(一般不用改)</label><textarea id="pm-template" spellcheck="false" style="min-height:50px">${esc(p.template)}</textarea></div>`;
        $('#ocp-mfoot').innerHTML = `<span id="pm-prog" style="flex:1;font-size:12px;color:#a5b4fc;text-align:left"></span>
          <button class="btn" id="pm-cancel">取消</button><button class="btn primary" id="pm-save">保存并应用</button>`;
        $('#ocp-modal').classList.remove('hidden');
        const close = () => { $('#ocp-modal').classList.add('hidden'); $('#ocp-mfoot').innerHTML = ''; };
        $('#pm-cancel').onclick = close;
        $('#pm-save').onclick = async () => {
          const btn = $('#pm-save'); btn.disabled = true; const prog = $('#pm-prog');
          prog.textContent = '⏳ 应用中…';
          try {
            const body = { name: model, from: p.from };
            const params = {};
            ['temperature','top_p','top_k','num_ctx','num_gpu'].forEach(k => {
              const el = $('#' + 'pm-' + k);
              if (el) { const v = el.value.trim(); if (v !== '') params[k] = (isNaN(+v)) ? v : +v; }
            });
            if (Object.keys(params).length) body.parameters = params;
            const sysEl = $('#pm-system'), tplEl = $('#pm-template');
            const sys = sysEl ? sysEl.value.trim() : '';
            if (sys) body.system = sys;
            const tpl = tplEl ? tplEl.value.trim() : '';
            if (tpl) body.template = tpl;
            const res = await fetch(API + '/api/create', {
              method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
            });
            if (!res.ok) throw new Error(await errText(res));
            await readStream(res, j => { if (j.status && prog) prog.textContent = j.status; });
            if (prog) prog.textContent = '✅ 完成';
            toast(`✅ ${model} 参数已更新`);
            close(); renderModels();
          } catch (e) { if (prog) prog.textContent = '✗ ' + e.message; toast('更新失败: ' + e.message, true); if (btn) btn.disabled = false; }
        };
      }
    
      // ================= 管理 =================
      function renderManage() {
        body.innerHTML = `
          <h3>⬇️拉取模型</h3>
          <div class="card"><div class="field"><label>模型名(如 llama3.2:3b / qwen3.6:27b)</label>
            <input id="ocp-pullname" placeholder="model:tag">
          </div>
            <div class="bar" id="ocp-pullbar" style="display:none;height:8px"><div id="ocp-pullfill"></div></div>
            <div style="font-size:12px;margin:6px 0;display:flex;align-items:center;gap:8px;min-height:18px">
              <span id="ocp-pullinfo" style="color:#94a3b8;flex:1;word-break:break-all"></span>
              <button id="ocp-pullclear" class="btn" style="padding:2px 8px;display:none">✕</button>
            </div>
            <button class="btn primary" id="ocp-pull">拉取</button></div>
    
          <h3 style="margin-top:14px">⚙️加载参数</h3>
          <div class="card"><div class="grid2">
            <div class="field"><label>模型</label><select id="ocp-loadmodel"></select></div>
            <div class="field"><label>驻留时长</label>
              <select id="ocp-keepalive"><option>5m</option><option selected>30m</option><option>1h</option><option>0</option></select></div>
          </div>
          <div class="grid2">
            <div class="field"><label>上下文长度 num_ctx</label><input id="ocp-ctx" type="number" value="4096" min="256" step="256"></div>
            <div class="field"><label>温度temperature</label><input id="ocp-temp" type="number" value="0.7" min="0" max="2" step="0.1"></div>
            <div class="field"><label>Top P</label><input id="ocp-topp" type="number" value="0.9" min="0" max="1" step="0.05"></div>
            <div class="field"><label>GPU层数(-1 全部)</label><input id="ocp-gpu" type="number" value="-1" min="-1"></div>
          </div>
          <button class="btn ok" id="ocp-loadbtn">加载到显存</button></div>
    
          <h3 style="margin-top:14px">🧬创建模型</h3>
          <div class="card">
            <div class="grid2">
              <div class="field"><label>新模型名</label><input id="ocp-crname" placeholder="my-model:latest"></div>
              <div class="field"><label>基于(FROM)</label><select id="ocp-crfrom"></select></div>
            </div>
            <div class="field"><label>system系统提示词</label><textarea id="ocp-crsys" spellcheck="false" style="min-height:60px" placeholder="You are a helpful assistant."></textarea></div>
            <div class="grid2">
              <div class="field"><label>temperature</label><input id="ocp-crtemp" type="number" step="0.1" min="0" max="2" value="0.7"></div>
              <div class="field"><label>num_ctx上下文</label><input id="ocp-crctx" type="number" step="256" min="256" value="4096"></div>
            </div>
            <button class="btn primary" id="ocp-create">创建</button>
            <div class="ops-out" id="ocp-crout" style="display:none"></div></div>
    
          <h3 style="margin-top:14px">📤推送模型</h3>
          <div class="card"><div class="field"><label>本地模型</label><select id="ocp-pushmodel"></select></div>
            <button class="btn" id="ocp-push">推送到仓库</button>
            <div class="ops-out" id="ocp-pushout" style="display:none"></div></div>
    
          <h3 style="margin-top:14px">🧠嵌入测试</h3>
          <div class="card"><div class="field"><label>嵌入模型</label><select id="ocp-embedmodel"></select></div>
            <div class="field"><label>文本</label><input id="ocp-embedtext" placeholder="要编码的文本"></div>
            <button class="btn" id="ocp-embedbtn">生成向量</button>
            <div class="ops-out" id="ocp-embedout" style="display:none"></div></div>
    
          <h3 style="margin-top:14px">🖥系统</h3>
          <div class="card" style="display:flex;gap:8px;flex-wrap:wrap">
            <button class="btn" id="ocp-sysinfo">API 版本</button>
            <button class="btn danger" id="ocp-unloadall">卸载全部</button>
          </div>`;
        const fill = (sel, names) => { const el = $(sel); if (el) el.innerHTML = names.map(n => `<option>${esc(n)}</option>`).join(''); };
        api('/api/tags').then(t => {
          const all = t.models.map(m => m.name), local = t.models.filter(m => !m.remote_model).map(m => m.name);
          fill('#ocp-loadmodel', all); fill('#ocp-pushmodel', local); fill('#ocp-embedmodel', all); fill('#ocp-crfrom', all);
        }).catch(() => {});
    
        const $pullclear = $('#ocp-pullclear');
        if ($pullclear) $pullclear.addEventListener('click', () => {
          const bar = $('#ocp-pullbar'), info = $('#ocp-pullinfo');
          if (bar) bar.style.display = 'none';
          if (info) info.textContent = '';
          if ($pullclear) $pullclear.style.display = 'none';
        });
        const $pull = $('#ocp-pull');
        if ($pull) $pull.addEventListener('click', async () => {
          const nameEl = $('#ocp-pullname');
          const name = nameEl ? nameEl.value.trim() : '';
          if (!name) return toast('请输入模型名', true);
          const btn = $pull; btn.disabled = true; btn.textContent = '拉取中…';
          const bar = $('#ocp-pullbar'), fill = $('#ocp-pullfill'), info = $('#ocp-pullinfo');
          if (bar) bar.style.display = '';
          if (fill) fill.style.width = '0%';
          if (info) info.textContent = '⏳连接服务器…';
          try {
            const res = await fetch(API + '/api/pull', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, stream: true }) });
            if (!res.ok) throw new Error(await errText(res));
            let sawProgress = false;
            await readStream(res, j => {
              if (j.total && j.completed) {
                sawProgress = true;
                const pct = Math.min(100, j.completed / j.total * 100);
                if (fill) fill.style.width = pct.toFixed(1) + '%';
                if (info) info.textContent = `⬇️${(j.completed/1e9).toFixed(2)} / ${(j.total/1e9).toFixed(2)} GB · ${pct.toFixed(1)}%`;
              } else if (j.status && info) {
                info.textContent = (j.status === 'success' && !sawProgress)
                  ? 'ℹ️模型已存在,无需下载'
                  : '🔄' + j.status;
              }
            });
            if (fill) fill.style.width = '100%';
            if (info) info.textContent = `✅${name} 拉取完成`;
            toast(`✅${name} 拉取完成`);
            renderModels();
          } catch (e) {
            if (info) info.textContent = '✗ ' + e.message;
            toast('拉取失败: ' + e.message, true);
          } finally {
            btn.disabled = false; btn.textContent = '拉取';
            if ($pullclear) $pullclear.style.display = '';
          }
        });
    
        const $loadbtn = $('#ocp-loadbtn');
        if ($loadbtn) $loadbtn.addEventListener('click', async () => {
          const modelEl = $('#ocp-loadmodel');
          const model = modelEl ? modelEl.value : '';
          const ka = $('#ocp-keepalive') ? $('#ocp-keepalive').value : '30m';
          const ctx = $('#ocp-ctx') ? +$('#ocp-ctx').value : 4096;
          const temp = $('#ocp-temp') ? +$('#ocp-temp').value : 0.7;
          const topp = $('#ocp-topp') ? +$('#ocp-topp').value : 0.9;
          const gpu = $('#ocp-gpu') ? +$('#ocp-gpu').value : -1;
          if (!model) return toast('请选择模型', true);
          const btn = $loadbtn; btn.disabled = true;
          try {
            await api('/api/generate', { model, prompt: '', keep_alive: ka, options: { num_ctx: ctx, temperature: temp, top_p: topp, num_gpu: gpu } });
            toast(`已加载 ${model}(ctx=${ctx}, temp=${temp}, top_p=${topp}, gpu=${gpu}层, 驻留${ka})`);
          } catch (e) { toast('加载失败: ' + e.message, true); }
          finally { btn.disabled = false; }
        });
    
        const $create = $('#ocp-create');
        if ($create) $create.addEventListener('click', async () => {
          const nameEl = $('#ocp-crname'), fromEl = $('#ocp-crfrom');
          const name = nameEl ? nameEl.value.trim() : '';
          const from = fromEl ? fromEl.value : '';
          if (!name || !from) return toast('请填写模型名并选择 FROM', true);
          const body = { name, from };
          const params = {};
          const tempEl = $('#ocp-crtemp'), ctxEl = $('#ocp-crctx');
          const temp = tempEl ? tempEl.value.trim() : '';
          if (temp !== '') params.temperature = +temp;
          const ctx = ctxEl ? ctxEl.value.trim() : '';
          if (ctx !== '') params.num_ctx = +ctx;
          if (Object.keys(params).length) body.parameters = params;
          const sysEl = $('#ocp-crsys');
          const sys = sysEl ? sysEl.value.trim() : '';
          if (sys) body.system = sys;
          const btn = $create; btn.disabled = true;
          const out = $('#ocp-crout');
          if (out) { out.style.display = ''; out.textContent = '创建中…'; }
          try {
            const res = await fetch(API + '/api/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
            if (!res.ok) throw new Error(await errText(res));
            await readStream(res, j => { if (j.status && out) { out.textContent += (out.textContent.endsWith('…') ? '' : '\n') + j.status; out.scrollTop = out.scrollHeight; } });
            toast(`✅模型 ${name} 创建成功`);
            if (out) out.textContent += '\n✓ 完成';
          } catch (e) { toast('创建失败: ' + e.message, true); if (out) out.textContent = '✗ ' + e.message; }
          finally { btn.disabled = false; }
        });
    
        const $push = $('#ocp-push');
        if ($push) $push.addEventListener('click', async () => {
          const modelEl = $('#ocp-pushmodel');
          const model = modelEl ? modelEl.value : '';
          if (!model) return toast('没有可推送的本地模型', true);
          const r = await promptModal('推送 ' + model, [{ id: 'dest', label: '目标仓库名(如 username/model:tag)', placeholder: model }]);
          if (!r) return;
          const name = r.dest || model;
          const btn = $push; btn.disabled = true;
          const out = $('#ocp-pushout');
          if (out) { out.style.display = ''; out.textContent = '推送中…'; }
          try {
            const res = await fetch(API + '/api/push', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, stream: true }) });
            if (!res.ok) throw new Error(await errText(res));
            await readStream(res, j => {
              if (out) {
                if (j.status) out.textContent += (out.textContent.endsWith('…') ? '' : '\n') + j.status;
                else if (j.digest) out.textContent += '\n🔑 ' + j.digest;
                out.scrollTop = out.scrollHeight;
              }
            });
            toast('推送完成 ✓');
          } catch (e) { toast('推送失败: ' + e.message, true); if (out) out.textContent = '✗ ' + e.message; }
          finally { btn.disabled = false; }
        });
    
        const $embedbtn = $('#ocp-embedbtn');
        if ($embedbtn) $embedbtn.addEventListener('click', async () => {
          const modelEl = $('#ocp-embedmodel'), textEl = $('#ocp-embedtext');
          const model = modelEl ? modelEl.value : '';
          const text = textEl ? textEl.value.trim() : '';
          if (!model || !text) return toast('请选择模型并输入文本', true);
          const btn = $embedbtn; btn.disabled = true;
          const out = $('#ocp-embedout');
          if (out) { out.style.display = ''; out.textContent = '计算中…'; }
          try {
            const d = await api('/api/embed', { model, input: text });
            const vec = d.embeddings?.[0] || [];
            const head = vec.slice(0, 6).map(v => v.toFixed(4)).join(', ');
            if (out) out.innerHTML = `维度: ${d.dimensions ?? vec.length}(共 ${d.embeddings?.length || 0} 个向量)\n前 6 维: [${esc(head)}]\n耗时: ${((d.total_duration||0)/1e9).toFixed(2)}s · 加载 ${((d.load_duration||0)/1e9).toFixed(2)}s`;
          } catch (e) { toast('嵌入失败: ' + e.message, true); if (out) out.textContent = '✗ ' + e.message; }
          finally { btn.disabled = false; }
        });
    
        const $sysinfo = $('#ocp-sysinfo');
        if ($sysinfo) $sysinfo.addEventListener('click', async () => {
          try { const v = await api('/api/version'); showModal('API 版本', JSON.stringify(v, null, 2)); } catch (e) { toast(e.message, true); }
        });
        const $unloadall = $('#ocp-unloadall');
        if ($unloadall) $unloadall.addEventListener('click', async () => {
          const ps = await api('/api/ps');
          for (const m of ps.models) await api('/api/generate', { model: m.name, keep_alive: 0, prompt: '' });
          toast(`已卸载全部 ${ps.models.length} 个模型`);
          renderRunning();
        });
      }
    
      // ================= 模型操作 =================
      async function renameModel(model) {
        const r = await promptModal('✏️ 重命名 ' + model, [{ id: 'newname', label: '新名称(如 gemma4:e4b-v2)', placeholder: 'new-name:latest' }]);
        if (!r || !r.newname) return;
        if (r.newname === model) return toast('新名称与旧名称相同');
        const btn = [...$$('#ocp-body [data-act="rename"]')].find(b => b.dataset.model === model);
        if (btn) btn.disabled = true;
        try {
          await api('/api/generate', { model, keep_alive: 0, prompt: '' }).catch(() => {});
          await api('/api/copy', { source: model, destination: r.newname });
          await api('/api/delete', { model }, 'DELETE');
          toast(`✅${model} → ${r.newname}`);
          renderModels();
        } catch (e) { toast('重命名失败: ' + e.message, true); if (btn) btn.disabled = false; }
      }
      async function copyModel(model) {
        const r = await promptModal('📋复制 ' + model, [{ id: 'dest', label: '目标名称', placeholder: model + '-copy' }]);
        if (!r || !r.dest) return;
        try { await api('/api/copy', { source: model, destination: r.dest }); toast(`✅ 已复制为 ${r.dest}`); renderModels(); }
        catch (e) { toast('复制失败: ' + e.message, true); }
      }
    
      // ================= 动作分发 =================
      body.addEventListener('click', async e => {
        const btn = e.target.closest('[data-act]');
        if (!btn) return;
        const act = btn.dataset.act, model = btn.dataset.model;
        try {
          if (act === 'load') {
            btn.disabled = true;
            await api('/api/generate', { model, prompt: '', keep_alive: '30m' });
            toast(`✅${model} 已加载(驻留 30m)`);
            btn.disabled = false;
          } else if (act === 'unload') {
            btn.disabled = true;
            await api('/api/generate', { model, keep_alive: 0, prompt: '' });
            toast(`💨${model} 已卸载`);
            renderRunning();
          } else if (act === 'chat') {
            chatModel = model; switchTab('chat'); renderChat();
          } else if (act === 'embed') {
            chatModel = model; switchTab('manage');
            setTimeout(() => {
              const sel = $('#ocp-embedmodel');
              if (sel) sel.value = model;
              const inp = $('#ocp-embedtext');
              if (inp) inp.focus();
            }, 80);
          } else if (act === 'show') {
            const d = await api('/api/show', { model });
            showModal(model + ' · 详情', JSON.stringify(d, null, 2));
          } else if (act === 'edit') {
            await editModelParams(model);
          } else if (act === 'rename') {
            await renameModel(model);
          } else if (act === 'copy') {
            await copyModel(model);
          } else if (act === 'push') {
            chatModel = model; switchTab('manage');
            setTimeout(() => { const p = $('#ocp-push'); if (p) p.click(); }, 80);
          } else if (act === 'del') {
            if (!confirm(`确定删除模型 ${model}?此操作不可恢复!`)) return;
            btn.disabled = true;
            await api('/api/generate', { model, keep_alive: 0, prompt: '' }).catch(() => {});
            await api('/api/delete', { model }, 'DELETE');
            toast(`🗑${model}已删除`);
            renderModels();
          }
        } catch (err) { toast(err.message, true); if (btn) btn.disabled = false; }
      });
    
      // ================= Tab 切换 =================
      function switchTab(name) {
        $$('#ocp-tabs button').forEach(b => b.classList.toggle('active', b.dataset.tab === name));
        if (name === 'models') renderModels();
        else if (name === 'running') renderRunning();
        else if (name === 'chat') renderChat();
        else if (name === 'manage') renderManage();
      }
      $$('#ocp-tabs button').forEach(b => b.addEventListener('click', () => switchTab(b.dataset.tab)));
    
      // ================= 面板开关 =================
      $('#ocp-fab').addEventListener('click', () => {
        panel.classList.toggle('hidden');
        if (!panel.classList.contains('hidden')) { renderModels(); loadVersion(); }
      });
      $('#ocp-min').addEventListener('click', () => panel.classList.add('hidden'));
      $('#ocp-refresh').addEventListener('click', () => {
        const active = $('#ocp-tabs button.active').dataset.tab;
        if (active === 'models') renderModels(); else if (active === 'running') renderRunning();
      });
      $('#ocp-mclose').addEventListener('click', () => $('#ocp-modal').classList.add('hidden'));
      $('#ocp-modal').addEventListener('click', e => { if (e.target.id === 'ocp-modal') e.target.classList.add('hidden'); });
      document.addEventListener('keydown', e => { if (e.key === 'Escape') $('#ocp-modal').classList.add('hidden'); });
    
      async function loadVersion() {
        try { const v = await api('/api/version'); $('#ocp-ver').textContent = 'v' + v.version; } catch {}
      }
    
      $('#ocp-fab').click();
    })();
    ```抛砖引玉,希望佬友们能研究出更好的玩法哈^-^
    主楼
  • VentiByte

    这不放张效果图,路过也能看看什么效果

    #1
  • Zane

    佬,可以放张图吗,这样太抽象了哈哈哈哈

    #2

发表回复

登录后回复