油猴脚本玩出花了?Ollama控制台,支持在线管理模型,拉取模型、测试性能、修改模型上下文全程能做到无命令操作!
𝓕-𝓓𝓻𝓸𝓲𝓭
2026-08-18 22:47
1
// ==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();
})();
抛砖引玉,希望佬友们能研究出更好的玩法哈^-^
最新回复 (2)
-
VentiByte 08-18 22:481楼这不放张效果图,路过也能看看什么效果
-
zane 08-19 08:492楼佬,可以放张图吗,这样太抽象了哈哈哈哈
* 帖子来源Linux.do
附近帖子
- ↑giffgaff?::退款??那开复活赛吧:laughing:
- ↑【DuckMail 临时邮箱】介绍/维护/更新/反馈贴
- ↑还有什么量大管饱的渠道吗
- ↑更便宜的DeepSeek v4 flash 0731?
- ↑X上DS最新民科成果骗局
- 📍 油猴脚本玩出花了?Ollama控制台,支持在线管理模型,拉取模型、测试性能、修改模型上下文全程能做到无命令操作!
- ↓朱雀三号一级成功回收
- ↓大象银行推出跨境汇款服务
- ↓分享image2的一个风控,同样的提示词参考图,web反代可以,codex的plus不行
- ↓放假两周,不想花太多钱,能去哪里呢?
- ↓问下各位佬,有没有必要对codex进行程序级别的分流