@ailuntz/minilink 0.8.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/picker-client.js +50 -25
- package/src/picker.html +36 -5
- package/src/picker.mjs +1 -1
package/package.json
CHANGED
package/src/picker-client.js
CHANGED
|
@@ -3,47 +3,72 @@ const token = location.hash.slice(1) || sessionStorage.getItem('picker-token');
|
|
|
3
3
|
if (token) sessionStorage.setItem('picker-token', token);
|
|
4
4
|
history.replaceState(null, '', '/');
|
|
5
5
|
let state, busy = false;
|
|
6
|
-
const
|
|
6
|
+
const effortNames = { none: '关闭', minimal: '轻量', low: '低', medium: '中', high: '高', xhigh: '更高', ultra: '极高', persistent: '持续', default: '默认' };
|
|
7
|
+
const selectedEffort = () => document.querySelector('input[name="effort"]:checked')?.value;
|
|
8
|
+
const currentThread = () => state?.threads.find(t => t.id === $('thread').value);
|
|
9
|
+
const currentPin = () => currentThread()?.pin || ($('thread').value === 'next' ? state?.next : null);
|
|
10
|
+
const say = (text, error = false) => { $('message').textContent = text; $('message').className = `message${error ? ' error' : ''}`; };
|
|
7
11
|
async function api(path, body) {
|
|
8
|
-
|
|
9
|
-
|
|
12
|
+
let response;
|
|
13
|
+
try { response = await fetch(path, { method: body ? 'POST' : 'GET', headers: { Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) }); }
|
|
14
|
+
catch { throw new Error('连接已结束,请重新运行 minilink on。'); }
|
|
15
|
+
if (response.status === 401) throw new Error('入口已失效,请重新运行 minilink on。');
|
|
16
|
+
const data = await response.json(); if (!response.ok) throw new Error(data.error || '操作未完成,请重试。'); return data;
|
|
10
17
|
}
|
|
11
18
|
function fill(select, options, selected) {
|
|
12
19
|
select.replaceChildren(...options.map(([value, text]) => { const option = document.createElement('option'); option.value = value; option.textContent = text; return option; }));
|
|
13
20
|
if (options.some(([value]) => value === selected)) select.value = selected;
|
|
14
21
|
}
|
|
15
|
-
function
|
|
22
|
+
function controls() {
|
|
23
|
+
const thread = currentThread(), available = Boolean(state?.models.length);
|
|
24
|
+
$('form').setAttribute('aria-busy', String(busy));
|
|
25
|
+
$('refresh').disabled = busy; $('refresh').dataset.loading = String(busy);
|
|
26
|
+
$('thread').disabled = busy || !state;
|
|
27
|
+
$('model').disabled = busy || !available;
|
|
28
|
+
for (const input of $('effort').querySelectorAll('input')) input.disabled = busy || !available;
|
|
29
|
+
$('apply').disabled = busy || !available || Boolean(thread && thread.status !== 'idle');
|
|
30
|
+
$('release').disabled = busy || !currentPin();
|
|
31
|
+
}
|
|
32
|
+
function changeModel(preferred) {
|
|
16
33
|
const model = state.models.find(m => m.model === $('model').value);
|
|
17
|
-
|
|
34
|
+
const values = model?.efforts?.length ? model.efforts.map(e => e.reasoningEffort) : ['default'];
|
|
35
|
+
const candidate = preferred ?? model?.effort, selected = values.includes(candidate) ? candidate : values[0];
|
|
36
|
+
if (values.length > 5) $('effort').dataset.columns = values.length > 6 ? '4' : '3';
|
|
37
|
+
else delete $('effort').dataset.columns;
|
|
38
|
+
$('effort').replaceChildren(...values.map(value => {
|
|
39
|
+
const label = document.createElement('label'), input = document.createElement('input');
|
|
40
|
+
label.className = 'segment'; input.type = 'radio'; input.name = 'effort'; input.value = value; input.checked = value === selected;
|
|
41
|
+
label.append(input, document.createTextNode(effortNames[value] || value)); return label;
|
|
42
|
+
}));
|
|
43
|
+
controls();
|
|
18
44
|
}
|
|
19
|
-
function changeThread() {
|
|
20
|
-
const thread =
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
$('
|
|
24
|
-
|
|
25
|
-
changeModel(pin?.effort);
|
|
26
|
-
$('apply').disabled = busy || (thread && thread.status !== 'idle');
|
|
27
|
-
$('release').disabled = busy || !pin;
|
|
45
|
+
function changeThread(draft) {
|
|
46
|
+
const thread = currentThread(), pin = currentPin();
|
|
47
|
+
$('details').textContent = thread ? thread.status !== 'idle' ? '任务正在运行,完成后可切换。' : `${thread.cwd?.split('/').filter(Boolean).pop() || '当前任务'} · ${pin ? '由此页管理' : '由 Desktop 管理'}` : pin ? '已为下一个新任务设置模型。' : '';
|
|
48
|
+
$('details').title = thread?.cwd || '';
|
|
49
|
+
fill($('model'), state.models.length ? state.models.map(m => [m.model, m.name]) : [['', '暂无可用模型']], draft?.model || pin?.model || thread?.model);
|
|
50
|
+
changeModel(draft?.effort ?? pin?.effort);
|
|
28
51
|
}
|
|
29
|
-
async function refresh() {
|
|
30
|
-
const selected = $('thread').value;
|
|
52
|
+
async function refresh(preserve = false) {
|
|
53
|
+
const selected = $('thread').value, draft = preserve && { model: $('model').value, effort: selectedEffort() };
|
|
31
54
|
state = await api('/state');
|
|
32
55
|
fill($('thread'), [['next', '下一个新任务'], ...state.threads.map(t => [t.id, t.name.slice(0, 90)])], selected);
|
|
33
|
-
changeThread();
|
|
56
|
+
changeThread($('thread').value === selected ? draft : null);
|
|
57
|
+
if (!state.models.length) say('暂无可用模型,请检查连接后刷新。', true);
|
|
34
58
|
}
|
|
35
59
|
async function action(operation) {
|
|
36
60
|
if (busy) return;
|
|
37
|
-
busy = true;
|
|
38
|
-
try { await operation(); } catch (error) { say(error.message || '
|
|
39
|
-
finally { busy = false;
|
|
61
|
+
busy = true; controls(); say('');
|
|
62
|
+
try { await operation(); } catch (error) { say(error.message || '操作未完成,请重试。', true); }
|
|
63
|
+
finally { busy = false; controls(); }
|
|
40
64
|
}
|
|
41
65
|
$('thread').onchange = () => { changeThread(); say(''); };
|
|
42
|
-
$('model').onchange = () => changeModel();
|
|
43
|
-
$('refresh').onclick = () => action(refresh);
|
|
66
|
+
$('model').onchange = () => { changeModel(); say(''); };
|
|
67
|
+
$('refresh').onclick = () => action(() => refresh(true));
|
|
44
68
|
$('form').onsubmit = event => { event.preventDefault(); action(async () => {
|
|
45
|
-
|
|
46
|
-
await
|
|
69
|
+
const effort = selectedEffort();
|
|
70
|
+
await api('/choose', { threadId: $('thread').value, model: $('model').value, effort: effort === 'default' ? null : effort });
|
|
71
|
+
await refresh(); say('已设置,回到 Desktop 继续。');
|
|
47
72
|
}); };
|
|
48
|
-
$('release').onclick = () => action(async () => { await api('/release', { threadId: $('thread').value }); await refresh(); say('已交回 Desktop
|
|
73
|
+
$('release').onclick = () => action(async () => { await api('/release', { threadId: $('thread').value }); await refresh(); say('已交回 Desktop。'); });
|
|
49
74
|
action(refresh);
|
package/src/picker.html
CHANGED
|
@@ -1,8 +1,39 @@
|
|
|
1
1
|
<!doctype html>
|
|
2
|
-
<html lang="zh-CN"
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<meta name="color-scheme" content="light dark">
|
|
7
|
+
<title>选择模型 · minilink</title>
|
|
8
|
+
<link rel="icon" href="data:,">
|
|
3
9
|
<style>
|
|
4
|
-
:root{
|
|
10
|
+
:root{font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif;color:#1d1d1f;background:#f5f5f7;color-scheme:light dark;--surface:#fff;--control:#f5f5f7;--line:#e8e8ed;--muted:#6e6e73;--accent:#007aff;--focus:#007aff55;--selected:#fff;--error:#c9342e;--success:#248a3d}
|
|
11
|
+
*{box-sizing:border-box}body{margin:0;min-height:100svh;display:grid;place-items:center;padding:40px 20px;-webkit-font-smoothing:antialiased}main{width:min(100%,520px)}.panel{padding:26px 28px 22px;background:var(--surface);border:1px solid var(--line);border-radius:16px;box-shadow:0 4px 24px #00000004}.toolbar{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px}.brand{font-size:19px;font-weight:650;letter-spacing:-.6px}button,select,input{font:inherit}button{cursor:pointer}button:disabled{cursor:default}button,select{transition:background .15s,box-shadow .15s,opacity .15s}button:focus-visible,select:focus-visible,.segment:has(input:focus-visible){outline:3px solid var(--focus);outline-offset:3px}.refresh{width:30px;height:30px;display:grid;place-items:center;padding:0;border:1px solid var(--line);border-radius:8px;color:var(--muted);background:var(--surface)}.refresh:hover:not(:disabled){background:var(--control);color:inherit}.refresh svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}.refresh[data-loading=true] svg{animation:rotate 1s linear infinite}h1{margin:0;font-size:22px;line-height:1.3;font-weight:650;letter-spacing:-.6px}.subtitle{margin:7px 0 22px;color:var(--muted);font-size:13px}.fields{border-top:1px solid var(--line)}.field{padding:15px 0;border-bottom:1px solid var(--line)}.field>label,.effort-title{display:block;margin-bottom:8px;font-size:13px;font-weight:600}.select-wrap{position:relative}.select-wrap svg{position:absolute;right:12px;top:11px;width:16px;height:16px;fill:none;stroke:var(--muted);stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;pointer-events:none}select{appearance:none;-webkit-appearance:none;display:block;width:100%;min-width:0;height:38px;padding:0 34px 0 12px;border:1px solid var(--line);border-radius:8px;background:var(--control);color:inherit;text-overflow:ellipsis}select:disabled{opacity:.5}.details{margin:9px 1px 0;font-size:12px;color:var(--muted);overflow-wrap:anywhere}.details:empty{display:none}.effort-row{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:62px}.effort-title{margin:0;flex-shrink:0}.segments{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:2px;padding:3px;border-radius:9px;background:var(--control);max-width:100%;min-width:156px}.segments[data-columns]{display:grid;min-width:198px;grid-template-columns:repeat(4,minmax(0,1fr))}.segments[data-columns="3"]{grid-template-columns:repeat(3,minmax(0,1fr))}.segments[data-columns] .segment{min-width:0;padding-inline:7px}.segment{position:relative;display:flex;align-items:center;justify-content:center;min-height:28px;min-width:46px;flex:1;padding:3px 10px;border-radius:6px;color:var(--muted);font-size:12px;white-space:nowrap;cursor:pointer}.segment input{position:absolute;opacity:0;width:1px;height:1px}.segment:has(input:checked){background:var(--selected);color:#1d1d1f;box-shadow:0 1px 4px #00000012}.segment:has(input:disabled){cursor:default;opacity:.5}.actions{padding-top:22px}.apply{width:100%;min-height:38px;border:0;border-radius:8px;background:var(--accent);color:#fff;font-size:13px;font-weight:600}.apply:hover:not(:disabled){background:#0071e3}.apply:active:not(:disabled){background:#0065cc}.apply:disabled{opacity:.4}.release{display:block;margin:12px auto 0;padding:5px 12px;border:0;border-radius:6px;background:transparent;color:var(--muted);font-size:12px}.release:hover:not(:disabled){color:var(--accent);background:var(--control)}.release:disabled{opacity:.45}.message{margin:14px 0 0;font-size:12px;color:var(--success);text-align:center;overflow-wrap:anywhere}.message:empty{display:none}.message.error{color:var(--error)}footer{margin-top:16px;text-align:center;color:var(--muted);font-size:12px}footer p{margin:0}
|
|
12
|
+
@keyframes rotate{to{transform:rotate(360deg)}}
|
|
13
|
+
@media(max-width:480px){body{padding:24px 16px}.panel{padding:22px 20px 18px}.toolbar{margin-bottom:22px}.effort-row{gap:10px;flex-wrap:wrap}.segments{min-width:144px}.segment{min-width:38px;padding-inline:8px}}
|
|
14
|
+
@media(prefers-color-scheme:dark){:root{background:#161618;color:#f5f5f7;--surface:#242426;--control:#303033;--line:#3a3a3d;--muted:#a1a1a6;--accent:#0a84ff;--focus:#0a84ff77;--selected:#56565b;--error:#ff6961;--success:#6bd68b}.panel{box-shadow:0 8px 32px #00000016}.segment:has(input:checked){color:#fff}.apply:hover:not(:disabled){background:#2994ff}}
|
|
15
|
+
@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important}}
|
|
5
16
|
</style>
|
|
6
|
-
|
|
7
|
-
<
|
|
8
|
-
<
|
|
17
|
+
</head>
|
|
18
|
+
<body>
|
|
19
|
+
<main>
|
|
20
|
+
<section class="panel" aria-labelledby="title">
|
|
21
|
+
<header>
|
|
22
|
+
<div class="toolbar"><span class="brand">minilink</span><button id="refresh" class="refresh" type="button" aria-label="刷新" title="刷新" disabled><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M16 7A6.3 6.3 0 1 0 16 13M16 3v4h-4"/></svg></button></div>
|
|
23
|
+
<h1 id="title">选择模型</h1><p class="subtitle">为下一条回复选择模型。</p>
|
|
24
|
+
</header>
|
|
25
|
+
<form id="form" aria-busy="true">
|
|
26
|
+
<div class="fields">
|
|
27
|
+
<div class="field"><label for="thread">任务</label><div class="select-wrap"><select id="thread" aria-describedby="details" required disabled><option>正在连接…</option></select><svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4 6 4 4 4-4"/></svg></div><p id="details" class="details"></p></div>
|
|
28
|
+
<div class="field"><label for="model">模型</label><div class="select-wrap"><select id="model" required disabled><option>正在读取模型…</option></select><svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4 6 4 4 4-4"/></svg></div></div>
|
|
29
|
+
<div class="field effort-row"><span id="effort-label" class="effort-title">推理强度</span><div id="effort" class="segments" role="radiogroup" aria-labelledby="effort-label"></div></div>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="actions"><button id="apply" class="apply" type="submit" disabled>使用此模型</button><button id="release" class="release" type="button" disabled>交回 Desktop</button></div>
|
|
32
|
+
<p id="message" class="message" role="status" aria-live="polite"></p>
|
|
33
|
+
</form>
|
|
34
|
+
</section>
|
|
35
|
+
<footer><p>设置后,回到 Desktop 继续。</p></footer>
|
|
36
|
+
</main>
|
|
37
|
+
<script src="/picker.js"></script>
|
|
38
|
+
</body>
|
|
39
|
+
</html>
|
package/src/picker.mjs
CHANGED
|
@@ -102,7 +102,7 @@ export class Picker {
|
|
|
102
102
|
const server = createServer(async (req, res) => {
|
|
103
103
|
const reply = (code, body, type = 'application/json') => {
|
|
104
104
|
res.writeHead(code, { 'content-type': `${type}; charset=utf-8`, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff',
|
|
105
|
-
'referrer-policy': 'no-referrer', 'content-security-policy': "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'" });
|
|
105
|
+
'referrer-policy': 'no-referrer', 'content-security-policy': "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'" });
|
|
106
106
|
res.end(type === 'application/json' ? JSON.stringify(body) : body);
|
|
107
107
|
};
|
|
108
108
|
if (req.headers.host !== new URL(origin).host || (req.headers.origin && req.headers.origin !== origin)) return reply(403, { error: '访问来源不匹配。' });
|