@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ailuntz/minilink",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Use your Codex profiles in the Desktop model menu.",
5
5
  "type": "module",
6
6
  "license": "MIT AND Apache-2.0",
@@ -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 say = (text, error = false) => { $('message').textContent = text; $('message').className = error ? 'error' : ''; };
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
- const response = await fetch(path, { method: body ? 'POST' : 'GET', headers: { Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) });
9
- const data = await response.json(); if (!response.ok) throw new Error(data.error); return data;
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 changeModel(effort) {
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
- fill($('effort'), model?.efforts?.length ? model.efforts.map(e => [e.reasoningEffort, e.reasoningEffort]) : [['default', '默认']], effort || model?.effort);
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 = state.threads.find(t => t.id === $('thread').value);
21
- const pin = thread?.pin || ($('thread').value === 'next' ? state.next : null);
22
- const current = state.models.find(m => m.model === pin?.model);
23
- $('details').textContent = thread ? `${thread.cwd || ''}\n${thread.status === 'active' ? '正在回复,请等待完成。' : '任务已载入。'}${pin ? ` 由此页管理:${current?.name || pin.model}` : ' 由 Desktop 菜单管理。'}` : pin ? `下一个新任务将使用 ${current?.name || pin.model}。` : '先在这里选好模型,再回 Desktop 新建任务并发送。';
24
- fill($('model'), state.models.map(m => [m.model, m.name]), pin?.model || thread?.model);
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; $('apply').disabled = true; $('release').disabled = true;
38
- try { await operation(); } catch (error) { say(error.message || '连接已结束,请重新打开选择页。', true); }
39
- finally { busy = false; if (state) changeThread(); }
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
- await api('/choose', { threadId: $('thread').value, model: $('model').value, effort: $('effort').value === 'default' ? null : $('effort').value });
46
- await refresh(); say('已设置。回到 Desktop 继续发送消息即可。');
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"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>模型选择 · minilink</title>
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{color-scheme:light dark;font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f5f5f3;color:#242623}body{margin:0;padding:56px 20px}main{max-width:640px;margin:auto}header{margin-bottom:32px}.eyebrow{font-size:12px;letter-spacing:.1em;color:#687368}h1{font-size:32px;line-height:1.2;margin:12px 0}p{color:#646d65;margin:12px 0}form{background:#fff;border:1px solid #e2e6df;border-radius:18px;padding:28px;box-shadow:0 8px 28px #18371a08}label{display:block;font-weight:600;font-size:14px;margin:16px 0 7px}label:first-child{margin-top:0}select,button{font:inherit;border-radius:9px}select{width:100%;padding:11px;border:1px solid #cad2c8;background:#fafbf9;color:inherit}button{cursor:pointer;border:0;padding:11px 17px;background:#255b3d;color:white;font-weight:600}button:disabled{opacity:.45;cursor:default}.secondary{background:#eef1ec;color:#425044}nav{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}.detail{font-size:13px;white-space:pre-wrap;overflow-wrap:anywhere}.note{font-size:13px;margin:20px 0 0}#message{min-height:26px;margin-top:18px;color:#255b3d}#message.error{color:#ad3434}footer{font-size:12px;color:#778076;margin-top:24px}a{color:inherit}#refresh{float:right;padding:5px 10px;font-size:13px}@media(prefers-color-scheme:dark){:root{background:#171c18;color:#e0e8e1}form{background:#202721;border-color:#394238}p,footer{color:#abb7ad}select{background:#252e27;border-color:#526152}.secondary{background:#364337;color:#d4e0d6}#message{color:#87d6a4}#message.error{color:#ffa0a0}}
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
- <main><header><button id="refresh" class="secondary" type="button">刷新</button><div class="eyebrow">MINILINK</div><h1>选择下一条回复的模型</h1><p>Desktop 的额度提示挡住菜单时,在这里切换。</p></header>
7
- <form id="form"><label for="thread">任务</label><select id="thread" required></select><p id="details" class="detail"></p><label for="model">模型</label><select id="model" required></select><label for="effort">推理强度</label><select id="effort" required></select><nav><button id="apply" type="submit">使用此模型</button><button id="release" class="secondary" type="button">交回 Desktop 菜单</button></nav><p class="note">启用后,该任务的模型与推理强度由此页管理,直到交回菜单或退出 Desktop。原生菜单可能仍显示额度提示,以此页的选择为准。</p><div id="message" role="status" aria-live="polite"></div></form>
8
- <footer>模型和推理选项来自客户端及 profile 指定的目录。此页不改变账户额度。新任务选项只作用于下一次正式创建的本地任务。</footer></main><script src="/picker.js"></script></html>
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: '访问来源不匹配。' });