- 𝓕-𝓓𝓻𝓸𝓲𝓭2026-08-1812915
// ==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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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(); })(); ```抛砖引玉,希望佬友们能研究出更好的玩法哈^-^主楼 - VentiByte2026-08-1824317
这不放张效果图,路过也能看看什么效果
- Zane2026-08-196140
佬,可以放张图吗,这样太抽象了哈哈哈哈