v0.8.3: WebUI models, providers and keys; garbled thinking fixed
Streaming: Chinese reasoning showed up garbled in the WebUI. The agent loop now feeds visible/thinking text in runs instead of byte by byte, and the WebUI holds an incomplete UTF-8 tail until the next chunk (cst_utf8_hold_feed), so every think_delta/assistant_delta is whole. src/provider_switch.c: model list fetch/cache, key verify-and-save and provider switching moved out of tui.c and shared by TUI and WebUI. WebUI API: - GET /api/providers, GET /api/models[?refresh=1] (401 on a rejected key, still listing built-ins), POST /api/provider, POST /api/key (key checked against /v1/models, saved to credentials 0600, never echoed in replies, events or logs) - /api/config validates everything before applying, persists model, effort and approval (not yolo), warns when effort does not apply; plan_mode no longer forces read-only - state: turn_usage, api_base, key_set/key_from, model_info, effort_applies, sees_images, max_steps, tool_profile, perm_timeout; permission events carry their timeout Frontend: searchable model picker with refresh and manual entry, provider picker and key dialog, effort/vision hints, toasts instead of silent failures, Chinese error text, real protocol in the settings and usage panels, approval countdown from the server's timeout. Tests: utf8 hold unit test, scripts/e2e_webui_settings.py (fake /v1/models, wrong key, switching, persistence, Chinese thinking) run from e2e_webui.sh, scripts/tui_keys/provider.py.
This commit is contained in:
+335
-65
@@ -24,21 +24,61 @@ function api(path, opt) {
|
||||
if (TOKEN) path += (path.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(TOKEN);
|
||||
return fetch(path, opt);
|
||||
}
|
||||
async function getJSON(path) {
|
||||
const r = await api(path);
|
||||
if (!r.ok) throw new Error((await r.text()) || r.statusText);
|
||||
return r.json();
|
||||
/* the server answers errors as {"error": "..."}; status and body ride along
|
||||
* so a caller can tell "needs a key" from "rejected key" */
|
||||
class ApiError extends Error {
|
||||
constructor(msg, status, data) { super(msg); this.status = status; this.data = data || {}; }
|
||||
}
|
||||
async function post(path, body) {
|
||||
async function readReply(r) {
|
||||
const text = await r.text();
|
||||
let data = null;
|
||||
try { data = text ? JSON.parse(text) : {}; } catch { data = null; }
|
||||
if (!r.ok) throw new ApiError(errorText(r.status, (data && data.error) || text || r.statusText), r.status, data);
|
||||
return data === null ? text : data;
|
||||
}
|
||||
async function getJSON(path) {
|
||||
return readReply(await api(path));
|
||||
}
|
||||
async function post(path, body, method) {
|
||||
const r = await api(path, {
|
||||
method: 'POST',
|
||||
method: method || 'POST',
|
||||
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const text = await r.text();
|
||||
if (!r.ok) throw new Error(text || r.statusText);
|
||||
return text ? JSON.parse(text) : {};
|
||||
return readReply(r);
|
||||
}
|
||||
/* engine messages that are still English, in the words the page uses */
|
||||
const ERR_ZH = [
|
||||
[/^busy: wait for the running turn/, '正在运行,等这一轮结束再试'],
|
||||
[/^a turn is already running/, '已经有一轮在运行'],
|
||||
[/^another change is running/, '另一项修改正在进行,稍后再试'],
|
||||
[/^nothing to undo/, '没有可以撤销的写入'],
|
||||
[/^no such session/, '找不到这个会话'],
|
||||
[/^no such permission request/, '这个许可请求已经结束'],
|
||||
[/^no such question/, '这个问题已经结束'],
|
||||
[/^unauthorized|^forbidden/i, '没有权限:请带上 token 打开页面'],
|
||||
[/^out of memory/, '服务器内存不足'],
|
||||
[/^path is required/, '需要填写路径'],
|
||||
[/^empty body/, '内容为空'],
|
||||
];
|
||||
function errorText(status, msg) {
|
||||
msg = String(msg || '');
|
||||
for (const [re, zh] of ERR_ZH) if (re.test(msg)) return zh;
|
||||
if (status === 401 && !msg) return '没有权限:请带上 token 打开页面';
|
||||
return msg || ('HTTP ' + status);
|
||||
}
|
||||
const errMsg = (e) => (e && e.message) || String(e);
|
||||
let toastTimer = null;
|
||||
function toast(msg, kind) {
|
||||
const t = document.getElementById('toast');
|
||||
if (!t) return;
|
||||
t.textContent = String(msg);
|
||||
t.className = 'toast ' + (kind || 'err');
|
||||
t.hidden = false;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { t.hidden = true; }, kind === 'ok' ? 2500 : 6000);
|
||||
}
|
||||
const toastErr = (e) => toast(errMsg(e), 'err');
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const el = (tag, cls, text) => {
|
||||
@@ -466,7 +506,9 @@ function applyState(s) {
|
||||
state = Object.assign({}, state, s);
|
||||
const model = (s.provider ? s.provider + ' · ' : '') + (s.model || '-');
|
||||
$('chip-model').querySelector('.lbl').textContent = model;
|
||||
$('chip-model').title = model + (s.effort ? ' · ' + s.effort : '');
|
||||
$('chip-model').title = model + (s.effort ? ' · 推理强度 ' + s.effort : '') +
|
||||
(s.key_set === false ? ' · 还没有 API key' : '');
|
||||
$('chip-model').classList.toggle('warn', s.key_set === false);
|
||||
const ap = $('chip-approval');
|
||||
const cur = APPROVALS.find((a) => a.key === (s.approval || 'auto'));
|
||||
ap.querySelector('.lbl').textContent = (cur ? cur.title : s.approval || 'auto');
|
||||
@@ -507,10 +549,12 @@ function applyState(s) {
|
||||
const x = el('button', 'icon tiny');
|
||||
x.appendChild(icon('close'));
|
||||
x.title = '清空待发图片';
|
||||
x.onclick = () => api('api/attach', { method: 'DELETE' }).catch(() => {});
|
||||
x.onclick = () => post('api/attach', undefined, 'DELETE').catch(toastErr);
|
||||
chip.appendChild(x);
|
||||
p.appendChild(chip);
|
||||
}
|
||||
if (s.sees_images === 0)
|
||||
p.appendChild(el('span', 'chip warn', (s.model || '当前模型') + ' 不识图:图片不会发给模型,换个识图模型再发'));
|
||||
}
|
||||
renderTodo(s.todo);
|
||||
}
|
||||
@@ -545,7 +589,7 @@ function connect() {
|
||||
on('session', (d) => {
|
||||
closePop();
|
||||
live = null; steps = null; stepIndex = new Map();
|
||||
loadSession().then(loadSessions).catch(() => {});
|
||||
loadSession().then(loadSessions).catch(toastErr);
|
||||
closeDrawers();
|
||||
if (d && d.id) $('status').textContent = '已切换会话';
|
||||
});
|
||||
@@ -557,7 +601,7 @@ function connect() {
|
||||
if (live) live.body.classList.remove('live');
|
||||
live = null; steps = null; stepIndex = new Map();
|
||||
if (d && d.rc) bubble('error').body.textContent = d.error || ('rc=' + d.rc);
|
||||
loadSession().then(loadSessions).catch(() => {});
|
||||
loadSession().then(loadSessions).catch(toastErr);
|
||||
});
|
||||
/* the browser resumes from Last-Event-ID; a restarted server has none of
|
||||
* those events, so its state (and maybe session) is fetched afresh */
|
||||
@@ -570,8 +614,8 @@ function connect() {
|
||||
const was = state.session;
|
||||
getJSON('api/state').then((s) => {
|
||||
applyState(s);
|
||||
if (s.session !== was) loadSession().then(loadSessions).catch(() => {});
|
||||
}).catch(() => {});
|
||||
if (s.session !== was) loadSession().then(loadSessions).catch(toastErr);
|
||||
}).catch(toastErr);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -600,7 +644,7 @@ function showGate(ev) {
|
||||
}
|
||||
$('gate-keys').textContent = '焦点不在输入框时,按 y 允许一次,按 n 拒绝;超时未回答会被拒绝。';
|
||||
$('status').textContent = '等待你的许可…';
|
||||
let left = 600;
|
||||
let left = ev.timeout || state.perm_timeout || 600;
|
||||
clearInterval(gateTimer);
|
||||
const tick = () => {
|
||||
$('gate-timer').textContent = left >= 0 ? left + 's' : '';
|
||||
@@ -626,7 +670,7 @@ async function answerGate(value) {
|
||||
await post('api/permission', { id, answer: value });
|
||||
$('status').textContent = value ? '已允许,继续执行…' : '已拒绝';
|
||||
} catch (e) {
|
||||
$('status').textContent = '回答失败:' + (e.message || e);
|
||||
toast('回答失败:' + errMsg(e));
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', (e) => {
|
||||
@@ -642,7 +686,7 @@ window.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (!$('modal').hidden) return closeModal();
|
||||
if (!$('pop').hidden) return closePop();
|
||||
if (busy) return void post('api/cancel', {}).catch(() => {});
|
||||
if (busy) return void post('api/cancel', {}).catch(toastErr);
|
||||
}
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'b') { e.preventDefault(); toggleSide(); }
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'j') { e.preventDefault(); togglePanel(); }
|
||||
@@ -668,7 +712,7 @@ function showAsk(ev) {
|
||||
box.appendChild(el('h3', null, ev.question || '模型想确认一件事'));
|
||||
if (ev.detail && ev.detail !== ev.question) box.appendChild(el('pre', 'step-out', ev.detail));
|
||||
const send = (text, dismiss) => {
|
||||
post('api/ask_user', { id: ev.id, text, dismiss: !!dismiss }).catch(() => {});
|
||||
post('api/ask_user', { id: ev.id, text, dismiss: !!dismiss }).catch(toastErr);
|
||||
closeModal();
|
||||
};
|
||||
const row = el('div', 'buttons');
|
||||
@@ -693,7 +737,7 @@ function timeLabel(id) {
|
||||
}
|
||||
async function loadSessions() {
|
||||
let data;
|
||||
try { data = await getJSON('api/sessions'); } catch { return; }
|
||||
try { data = await getJSON('api/sessions'); } catch (e) { toastErr(e); return; }
|
||||
sessionsCache = data.sessions || [];
|
||||
const box = $('sessions-list');
|
||||
box.textContent = '';
|
||||
@@ -707,10 +751,12 @@ async function loadSessions() {
|
||||
b.title = s.id + ' · ' + s.messages + ' 条消息';
|
||||
b.onclick = async () => {
|
||||
if (s.id === (state.session || '')) { closeDrawers(); return; }
|
||||
await post('api/sessions/switch', { id: s.id });
|
||||
await loadSession();
|
||||
await loadSessions();
|
||||
closeDrawers();
|
||||
try {
|
||||
await post('api/sessions/switch', { id: s.id });
|
||||
await loadSession();
|
||||
await loadSessions();
|
||||
closeDrawers();
|
||||
} catch (e) { toastErr(e); }
|
||||
};
|
||||
box.appendChild(b);
|
||||
}
|
||||
@@ -737,10 +783,12 @@ async function showSessions() {
|
||||
row.appendChild(el('span', 'id', s.messages + ' 条'));
|
||||
row.appendChild(Object.assign(el('button', null, '打开'), {
|
||||
onclick: async () => {
|
||||
await post('api/sessions/switch', { id: s.id });
|
||||
closeModal();
|
||||
await loadSession();
|
||||
await loadSessions();
|
||||
try {
|
||||
await post('api/sessions/switch', { id: s.id });
|
||||
closeModal();
|
||||
await loadSession();
|
||||
await loadSessions();
|
||||
} catch (e) { toastErr(e); }
|
||||
},
|
||||
}));
|
||||
box.appendChild(row);
|
||||
@@ -748,10 +796,12 @@ async function showSessions() {
|
||||
const row = el('div', 'buttons');
|
||||
row.appendChild(Object.assign(el('button', 'ok', '新建会话'), {
|
||||
onclick: async () => {
|
||||
await post('api/sessions/new', {});
|
||||
closeModal();
|
||||
await loadSession();
|
||||
await loadSessions();
|
||||
try {
|
||||
await post('api/sessions/new', {});
|
||||
closeModal();
|
||||
await loadSession();
|
||||
await loadSessions();
|
||||
} catch (e) { toastErr(e); }
|
||||
},
|
||||
}));
|
||||
row.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal }));
|
||||
@@ -761,40 +811,245 @@ async function showSessions() {
|
||||
|
||||
/* --- pickers as popovers ------------------------------------------------- */
|
||||
|
||||
/* "1M" / "200K" for a context window in tokens */
|
||||
function fmtCtx(n) {
|
||||
if (!n) return '';
|
||||
if (n >= 1000000) return (n / 1000000).toFixed(n % 1000000 ? 1 : 0) + 'M';
|
||||
return Math.round(n / 1000) + 'K';
|
||||
}
|
||||
function modelDesc(m) {
|
||||
const parts = [];
|
||||
if (m.context) parts.push(fmtCtx(m.context) + ' 上下文');
|
||||
if (m.vision === true) parts.push('识图');
|
||||
else if (m.vision === false) parts.push('纯文本');
|
||||
if (m.reasoning) parts.push('推理强度');
|
||||
if (m.price) parts.push((m.price.currency || '') + m.price.miss + '/' + m.price.out + ' 每百万');
|
||||
parts.push(m.api ? '提供商列出' : '内置');
|
||||
return parts.join(' · ');
|
||||
}
|
||||
let modelsCache = null; /* {provider, data} of the last GET api/models */
|
||||
async function fetchModels(refresh) {
|
||||
const r = await api('api/models' + (refresh ? '?refresh=1' : ''));
|
||||
let d = null;
|
||||
try { d = await readReply(r); } catch (e) {
|
||||
/* 401 = the key was rejected: the body still has the built-in list */
|
||||
if (e.status !== 401) throw e;
|
||||
d = e.data || {};
|
||||
}
|
||||
modelsCache = { provider: d.provider, data: d };
|
||||
return d;
|
||||
}
|
||||
function setModel(id) {
|
||||
post('api/config', { model: id })
|
||||
.then(() => { toast('模型 → ' + id, 'ok'); })
|
||||
.catch(toastErr);
|
||||
}
|
||||
function modelPopover(anchor) {
|
||||
openPop(anchor, (pop) => {
|
||||
pop.appendChild(el('div', 'pop-title', '模型'));
|
||||
const head = el('div', 'pop-title pop-head');
|
||||
head.appendChild(el('span', null, '模型 · ' + (state.provider || '-')));
|
||||
const rb = el('button', 'icon tiny');
|
||||
rb.title = '从提供商重新拉取模型列表';
|
||||
rb.appendChild(icon('refresh'));
|
||||
head.appendChild(rb);
|
||||
pop.appendChild(head);
|
||||
const row = el('div', 'pop-input');
|
||||
const input = el('input');
|
||||
input.value = state.model || '';
|
||||
input.placeholder = '模型名';
|
||||
input.placeholder = '搜索,或直接输入模型名';
|
||||
input.spellcheck = false;
|
||||
row.appendChild(input);
|
||||
const apply = el('button', null, '应用');
|
||||
apply.onclick = () => { post('api/config', { model: input.value }).catch(() => {}); };
|
||||
row.appendChild(apply);
|
||||
pop.appendChild(row);
|
||||
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') apply.click(); });
|
||||
const note = el('div', 'pop-note');
|
||||
pop.appendChild(note);
|
||||
const list = el('div', 'pop-list');
|
||||
pop.appendChild(list);
|
||||
let data = (modelsCache && modelsCache.provider === state.provider) ? modelsCache.data : null;
|
||||
let loading = false;
|
||||
const pick = (id) => { closePop(); if (id && id !== state.model) setModel(id); };
|
||||
const render = () => {
|
||||
list.textContent = '';
|
||||
const q = input.value.trim();
|
||||
const ql = q.toLowerCase();
|
||||
const ms = (data && data.models) || [];
|
||||
const shown = ms.filter((m) => !ql || m.id.toLowerCase().includes(ql)).slice(0, 300);
|
||||
for (const m of shown)
|
||||
list.appendChild(popRow({ title: m.id, desc: modelDesc(m), on: m.id === state.model, onClick: () => pick(m.id) }));
|
||||
if (q && !ms.some((m) => m.id === q))
|
||||
list.appendChild(popRow({ icon: 'plus', title: '使用「' + q + '」', desc: '不在列表里也可以直接用', onClick: () => pick(q) }));
|
||||
if (!list.children.length)
|
||||
list.appendChild(el('div', 'pop-empty', loading ? '加载中…' : '没有匹配的模型'));
|
||||
};
|
||||
const setNote = () => {
|
||||
note.textContent = '';
|
||||
note.className = 'pop-note';
|
||||
if (loading) { note.textContent = '正在向 ' + (state.provider || '提供商') + ' 拉取模型列表…'; return; }
|
||||
if (!data) return;
|
||||
if (data.auth) {
|
||||
note.classList.add('bad');
|
||||
note.appendChild(el('span', null, data.error || 'API key 被拒绝'));
|
||||
const fix = el('button', 'link', '更换 key');
|
||||
fix.onclick = (e) => { e.stopPropagation(); closePop(); changeKey(); };
|
||||
note.appendChild(fix);
|
||||
} else if (data.api_error) {
|
||||
note.classList.add('warn');
|
||||
note.textContent = data.api_error + ';下面是内置列表';
|
||||
} else if (!data.key_set) {
|
||||
note.classList.add('warn');
|
||||
note.textContent = '还没有 API key,只能显示内置列表';
|
||||
} else if (data.api_count >= 0) {
|
||||
note.textContent = data.provider + ' 列出 ' + data.api_count + ' 个模型';
|
||||
}
|
||||
};
|
||||
const load = (refresh) => {
|
||||
loading = true; setNote(); render();
|
||||
fetchModels(refresh)
|
||||
.then((d) => { data = d; })
|
||||
.catch((e) => { data = data || { models: [] }; toastErr(e); })
|
||||
.finally(() => { loading = false; setNote(); render(); });
|
||||
};
|
||||
rb.onclick = (e) => { e.stopPropagation(); load(true); };
|
||||
input.addEventListener('input', render);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
const q = input.value.trim();
|
||||
const ms = (data && data.models) || [];
|
||||
const hit = ms.filter((m) => m.id.toLowerCase().includes(q.toLowerCase()));
|
||||
pick(hit.length === 1 ? hit[0].id : q);
|
||||
});
|
||||
if (data) { setNote(); render(); } else load(false);
|
||||
|
||||
pop.appendChild(el('div', 'pop-sep'));
|
||||
pop.appendChild(el('div', 'pop-title', '推理强度'));
|
||||
if (state.effort_applies === false)
|
||||
pop.appendChild(el('div', 'pop-note warn', '当前模型不接受推理强度参数:可以先选好,换到支持的模型时生效'));
|
||||
const LV = { low: 'lv1', medium: 'lv2', high: 'lv3', max: 'lv4' };
|
||||
for (const e2 of EFFORTS)
|
||||
pop.appendChild(popRow({
|
||||
for (const e2 of EFFORTS) {
|
||||
const r = popRow({
|
||||
icon: LV[e2.key] || 'lv1',
|
||||
title: e2.title,
|
||||
desc: e2.desc,
|
||||
on: e2.key === state.effort,
|
||||
onClick: () => post('api/config', { effort: e2.key }).catch(() => {}),
|
||||
}));
|
||||
onClick: () => post('api/config', { effort: e2.key })
|
||||
.then((d) => { if (d && d.warning) toast(d.warning, 'warn'); })
|
||||
.catch(toastErr),
|
||||
});
|
||||
if (state.effort_applies === false) r.classList.add('dim');
|
||||
pop.appendChild(r);
|
||||
}
|
||||
pop.appendChild(el('div', 'pop-sep'));
|
||||
pop.appendChild(popRow({
|
||||
icon: 'settings',
|
||||
title: 'provider:' + (state.provider || '-'),
|
||||
desc: '在设置里改连接端点',
|
||||
onClick: showSettings,
|
||||
icon: 'cpu',
|
||||
title: '提供商:' + (state.provider || '-'),
|
||||
desc: (state.api_base || '') + ' · 点这里切换',
|
||||
onClick: showProviders,
|
||||
}));
|
||||
setTimeout(() => input.focus(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
/* --- providers and keys -------------------------------------------------- */
|
||||
|
||||
function keyFromText(p) {
|
||||
if (!p.has_key) return '未设置 key';
|
||||
if (p.key_from === 'saved') return 'key 已保存';
|
||||
if (p.key_from === 'session') return 'key 仅本次运行';
|
||||
return 'key 来自环境变量 ' + p.key_from;
|
||||
}
|
||||
/* one password field; submit(key) returns a promise, a rejection stays in
|
||||
* the dialog so the user can paste another key */
|
||||
function keyDialog(title, sub, submit) {
|
||||
const box = document.createDocumentFragment();
|
||||
box.appendChild(el('h3', null, title));
|
||||
if (sub) box.appendChild(el('div', 'muted tiny', sub));
|
||||
box.appendChild(el('div', 'muted tiny', 'key 会先用 /models 验证,再保存到配置目录的 credentials 文件(仅你可读),不会出现在页面、日志或事件里。'));
|
||||
const input = el('input', 'key-input');
|
||||
input.type = 'password';
|
||||
input.autocomplete = 'off';
|
||||
input.placeholder = 'sk-…';
|
||||
box.appendChild(input);
|
||||
const err = el('div', 'key-err');
|
||||
box.appendChild(err);
|
||||
const row = el('div', 'buttons');
|
||||
const ok = el('button', 'ok', '验证并保存');
|
||||
const go = async () => {
|
||||
const k = input.value.trim();
|
||||
if (!k) { err.textContent = '请先粘贴 key'; return; }
|
||||
ok.disabled = true;
|
||||
err.textContent = '验证中…';
|
||||
try {
|
||||
await submit(k);
|
||||
input.value = '';
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
err.textContent = errMsg(e);
|
||||
ok.disabled = false;
|
||||
input.select();
|
||||
}
|
||||
};
|
||||
ok.onclick = go;
|
||||
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') go(); });
|
||||
row.appendChild(ok);
|
||||
row.appendChild(Object.assign(el('button', null, '取消'), { onclick: closeModal }));
|
||||
box.appendChild(row);
|
||||
openModal(box);
|
||||
setTimeout(() => input.focus(), 0);
|
||||
}
|
||||
function afterSwitch(r) {
|
||||
modelsCache = null;
|
||||
toast('已切换到 ' + r.provider + (r.model ? ' · ' + r.model : '(请选一个模型)'), 'ok');
|
||||
if (r.warning) setTimeout(() => toast(r.warning, 'warn'), 1200);
|
||||
}
|
||||
function askKeyFor(p) {
|
||||
keyDialog('API key · ' + p.name, p.api_base, async (key) => {
|
||||
afterSwitch(await post('api/provider', { name: p.name, key }));
|
||||
});
|
||||
}
|
||||
async function switchProvider(p) {
|
||||
if (!p.has_key) return askKeyFor(p);
|
||||
try {
|
||||
closeModal();
|
||||
afterSwitch(await post('api/provider', { name: p.name }));
|
||||
} catch (e) {
|
||||
if (e.data && e.data.need_key) return askKeyFor(p);
|
||||
toastErr(e);
|
||||
}
|
||||
}
|
||||
async function showProviders() {
|
||||
let d;
|
||||
try { d = await getJSON('api/providers'); } catch (e) { return toastErr(e); }
|
||||
const box = document.createDocumentFragment();
|
||||
box.appendChild(el('h3', null, '切换提供商'));
|
||||
for (const p of d.providers || []) {
|
||||
const row = el('div', 'prov' + (p.current ? ' on' : ''));
|
||||
const m = el('div', 'pm');
|
||||
m.appendChild(el('b', null, p.name + (p.current ? ' · 当前' : '')));
|
||||
m.appendChild(el('span', 'muted tiny', p.api_base));
|
||||
m.appendChild(el('span', 'tiny ' + (p.has_key ? 'ok-text' : 'warn-text'),
|
||||
keyFromText(p) + ' · ' + (p.model || '模型从 API 获取') + (p.source === 'user' ? ' · models.json' : '')));
|
||||
row.appendChild(m);
|
||||
const acts = el('div', 'prov-acts');
|
||||
if (p.current)
|
||||
acts.appendChild(Object.assign(el('button', null, '换 key'), { onclick: changeKey }));
|
||||
else
|
||||
acts.appendChild(Object.assign(el('button', 'ok', p.has_key ? '切换' : '设 key 并切换'), {
|
||||
onclick: () => switchProvider(p),
|
||||
}));
|
||||
row.appendChild(acts);
|
||||
box.appendChild(row);
|
||||
}
|
||||
const row = el('div', 'buttons');
|
||||
row.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal }));
|
||||
box.appendChild(row);
|
||||
openModal(box);
|
||||
}
|
||||
function changeKey() {
|
||||
keyDialog('更换 API key · ' + (state.provider || ''), state.api_base || '', async (key) => {
|
||||
const r = await post('api/key', { key });
|
||||
modelsCache = null;
|
||||
toast('key 已验证并保存', 'ok');
|
||||
if (r.warning) setTimeout(() => toast(r.warning, 'warn'), 1200);
|
||||
});
|
||||
}
|
||||
function approvalPopover(anchor) {
|
||||
openPop(anchor, (pop) => {
|
||||
pop.appendChild(el('div', 'pop-title', '审批权限'));
|
||||
@@ -805,7 +1060,7 @@ function approvalPopover(anchor) {
|
||||
desc: a.desc,
|
||||
on: a.key === (state.approval || 'auto'),
|
||||
danger: a.key === 'yolo',
|
||||
onClick: () => post('api/config', { approval: a.key }).catch(() => {}),
|
||||
onClick: () => post('api/config', { approval: a.key }).catch(toastErr),
|
||||
}));
|
||||
}, { align: 'right' });
|
||||
}
|
||||
@@ -835,7 +1090,7 @@ function plusPopover(anchor) {
|
||||
pop.appendChild(popRow({
|
||||
icon: 'list', title: '计划模式', desc: '只读:先出方案,不改文件',
|
||||
on: !!state.plan_mode,
|
||||
onClick: () => post('api/config', { plan_mode: !state.plan_mode }).catch(() => {}),
|
||||
onClick: () => post('api/config', { plan_mode: !state.plan_mode }).catch(toastErr),
|
||||
}));
|
||||
pop.appendChild(el('div', 'pop-sep'));
|
||||
pop.appendChild(el('div', 'pop-title', '工作目录'));
|
||||
@@ -856,7 +1111,7 @@ function plusPopover(anchor) {
|
||||
await loadSessions();
|
||||
$('status').textContent = '工作目录已切换';
|
||||
} catch (e) {
|
||||
$('status').textContent = String(e.message || e);
|
||||
toastErr(e);
|
||||
wgo.disabled = false;
|
||||
}
|
||||
};
|
||||
@@ -877,11 +1132,16 @@ function usagePopover(anchor) {
|
||||
};
|
||||
kv('工作区', state.workspace || '-');
|
||||
kv('会话', state.session || '-');
|
||||
kv('上下文占用', num(state.context_tokens) + ' tok');
|
||||
const mi = state.model_info || {};
|
||||
kv('上下文占用', num(state.context_tokens) + (mi.context ? ' / ' + fmtCtx(mi.context) : '') + ' tok');
|
||||
kv('模型往返', String(u.requests || 0));
|
||||
kv('Token', num(u.prompt_tokens) + ' / ' + num(u.completion_tokens));
|
||||
kv('费用', (u.priced && u.cost) ? (u.currency || '') + u.cost.toFixed(4) : (u.requests ? '未定价' : '-'));
|
||||
kv('协议', state.protocol || '-');
|
||||
const t = state.turn_usage || {};
|
||||
if (t.requests)
|
||||
kv('上一轮', t.requests + ' 往返 · ' + num(t.prompt_tokens) + ' / ' + num(t.completion_tokens) +
|
||||
((t.priced && t.cost) ? ' · ' + (t.currency || '') + t.cost.toFixed(4) : ''));
|
||||
kv('工具协议', state.protocol_tools || '-');
|
||||
pop.appendChild(el('div', 'pop-sep'));
|
||||
pop.appendChild(popRow({ icon: 'copy', title: '复制会话 ID', onClick: () => copyText(state.session || '') }));
|
||||
pop.appendChild(popRow({ icon: 'compress', title: '压缩上下文', desc: '把旧消息收成摘要', onClick: compact }));
|
||||
@@ -898,7 +1158,7 @@ function menuPopover(anchor) {
|
||||
}));
|
||||
pop.appendChild(el('div', 'pop-sep'));
|
||||
pop.appendChild(popRow({ icon: 'moon', title: '切换主题', onClick: toggleTheme }));
|
||||
pop.appendChild(popRow({ icon: 'close', title: '退出服务', danger: true, onClick: () => post('api/quit', {}).catch(() => {}) }));
|
||||
pop.appendChild(popRow({ icon: 'close', title: '退出服务', danger: true, onClick: () => post('api/quit', {}).then(() => toast('服务已退出', 'ok')).catch(toastErr) }));
|
||||
}, { align: 'right' });
|
||||
}
|
||||
function showSettings() {
|
||||
@@ -912,10 +1172,17 @@ function showSettings() {
|
||||
};
|
||||
kv('目录', state.workspace);
|
||||
kv('会话', state.session);
|
||||
kv('模型', (state.provider ? state.provider + ' · ' : '') + state.model);
|
||||
kv('协议', state.protocol);
|
||||
kv('提供商', state.provider);
|
||||
kv('端点', state.api_base);
|
||||
kv('API key', keyFromText({ has_key: state.key_set, key_from: state.key_from }));
|
||||
kv('模型', state.model);
|
||||
kv('工具协议', state.protocol_tools);
|
||||
kv('工具集', state.tool_profile);
|
||||
kv('每轮最多步数', String(state.max_steps || '-'));
|
||||
pop.appendChild(el('div', 'pop-sep'));
|
||||
pop.appendChild(popRow({ icon: 'cpu', title: '模型与推理', onClick: () => modelPopover($('chip-model')) }));
|
||||
pop.appendChild(popRow({ icon: 'refresh', title: '切换提供商', desc: '换端点,需要时输入 key', onClick: showProviders }));
|
||||
pop.appendChild(popRow({ icon: 'shield', title: '更换 API key', desc: '验证后保存,只对当前提供商', onClick: changeKey }));
|
||||
pop.appendChild(popRow({ icon: 'shield', title: '审批权限', onClick: () => approvalPopover($('chip-approval')) }));
|
||||
pop.appendChild(popRow({ icon: 'moon', title: '切换主题', onClick: toggleTheme }));
|
||||
});
|
||||
@@ -935,11 +1202,11 @@ function syncSend() {
|
||||
async function compact() {
|
||||
$('status').textContent = '压缩中…';
|
||||
try { await post('api/compact', {}); $('status').textContent = '上下文已压缩'; }
|
||||
catch (e) { $('status').textContent = '压缩失败:' + (e.message || e); }
|
||||
catch (e) { $('status').textContent = '就绪'; toast('压缩失败:' + errMsg(e)); }
|
||||
}
|
||||
async function undo() {
|
||||
try { const t = await post('api/undo', {}); $('status').textContent = '已撤销:' + t; }
|
||||
catch (e) { $('status').textContent = '撤销失败:' + (e.message || e); }
|
||||
catch (e) { toast('撤销失败:' + errMsg(e)); }
|
||||
}
|
||||
async function send(text) {
|
||||
const input = $('input');
|
||||
@@ -959,7 +1226,7 @@ async function send(text) {
|
||||
await post('api/turn', { text: body });
|
||||
$('status').textContent = '已开始…';
|
||||
} catch (e) {
|
||||
bubble('error').body.textContent = String(e.message || e);
|
||||
bubble('error').body.textContent = errMsg(e);
|
||||
}
|
||||
}
|
||||
function autoGrow() {
|
||||
@@ -973,7 +1240,8 @@ async function upload(file) {
|
||||
headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
||||
body: file,
|
||||
});
|
||||
if (!r.ok) $('status').textContent = '上传失败:' + (await r.text());
|
||||
try { await readReply(r); } catch (e) { toast('上传失败:' + errMsg(e)); return; }
|
||||
if (state.sees_images === 0) toast((state.model || '当前模型') + ' 不识图,这张图不会发给模型', 'warn');
|
||||
}
|
||||
|
||||
/* --- layout -------------------------------------------------------------- */
|
||||
@@ -1010,9 +1278,9 @@ function toggleTheme() {
|
||||
/* --- wiring -------------------------------------------------------------- */
|
||||
|
||||
$('btn-send').onclick = () => send();
|
||||
$('btn-stop').onclick = () => post('api/cancel', {}).catch(() => {});
|
||||
$('btn-stop').onclick = () => post('api/cancel', {}).catch(toastErr);
|
||||
$('btn-new').onclick = async () => {
|
||||
try { await post('api/sessions/new', {}); } catch (e) { $('status').textContent = String(e.message || e); }
|
||||
try { await post('api/sessions/new', {}); } catch (e) { toastErr(e); }
|
||||
await loadSession();
|
||||
await loadSessions();
|
||||
closeDrawers();
|
||||
@@ -1035,8 +1303,10 @@ $('btn-undo').onclick = undo;
|
||||
$('to-bottom').onclick = () => scrollDown(true);
|
||||
for (const b of $('seg-mode').children) {
|
||||
b.onclick = async () => {
|
||||
await post('api/config', { plan_mode: b.dataset.mode === 'plan' });
|
||||
$('status').textContent = b.dataset.mode === 'plan' ? '计划模式:只读' : '构建模式:可写';
|
||||
try {
|
||||
await post('api/config', { plan_mode: b.dataset.mode === 'plan' });
|
||||
$('status').textContent = b.dataset.mode === 'plan' ? '计划模式:只读' : '构建模式:可写';
|
||||
} catch (e) { toastErr(e); }
|
||||
};
|
||||
}
|
||||
$('input').addEventListener('keydown', (e) => {
|
||||
@@ -1075,9 +1345,9 @@ autoGrow();
|
||||
syncSend();
|
||||
renderFilesPanel();
|
||||
connect();
|
||||
loadSession().catch((e) => { $('status').textContent = String(e.message || e); });
|
||||
loadSession().catch(toastErr);
|
||||
loadSessions();
|
||||
getJSON('api/state').then(applyState).catch(() => {});
|
||||
getJSON('api/state').then(applyState).catch(toastErr);
|
||||
/* deep link: ?open=chip-approval / ?open=cost opens that popover once the
|
||||
* state is in, so a screenshot or a bookmark can land on it directly */
|
||||
const wantsOpen = new URLSearchParams(location.search).get('open');
|
||||
|
||||
Reference in New Issue
Block a user