feat: purge legacy agent hosts and land vault Chrome extension path

Retire ACP/Hermes/OpenCode surfaces and rename hermes_tools to
mnote_agent_tools so Page AI stays on Pi Lab only. Add Chrome vault
extension + extension token route, pre-release purge design, and soft-retire
legacy smokes for the small-group production cut.
This commit is contained in:
Agent Board
2026-07-25 14:25:37 +08:00
parent bc6f8488ee
commit 262e66b02e
137 changed files with 9018 additions and 46049 deletions
@@ -163,12 +163,17 @@
function ensureFormUi() {
if (!state._formUi) {
state._formUi = { openAccountIds: {}, expandedSecretPanels: {} };
state._formUi = {
openAccountIds: {},
expandedSecretPanels: {},
folderPickerOpen: {},
};
}
if (!state._formUi.openAccountIds) state._formUi.openAccountIds = {};
if (!state._formUi.expandedSecretPanels) {
state._formUi.expandedSecretPanels = {};
}
if (!state._formUi.folderPickerOpen) state._formUi.folderPickerOpen = {};
return state._formUi;
}
@@ -892,6 +897,8 @@
kind: (s && s.kind) || 'apikey',
label: (s && s.label) || '',
value: (s && s.value) || { state: 'absent' },
// Draft-only flag from reRenderFormKeepingSlots; absent on server items.
valueClear: !!(s && s.valueClear),
accountId: (s && (s.accountId || s.account_id)) || '',
};
}
@@ -924,6 +931,96 @@
return out;
}
/** 将 ISO 时间格式化为本地可读短串;无效则原样返回。 */
function formatVaultDateTime(raw) {
var s = String(raw || '').trim();
if (!s) return '';
var d = new Date(s);
if (isNaN(d.getTime())) return s;
try {
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
} catch (_e) {
return s;
}
}
/**
* 账号级登录态展示(扩展 Cookie session)。
* 优先账号槽位字段;合成单账号时回退条目级 hasLoginSession / sessionUpdatedAt。
*/
function accountSessionView(acc, item, isSyntheticSingle) {
var has =
acc && typeof acc.hasLoginSession === 'boolean'
? acc.hasLoginSession
: isSyntheticSingle && item
? !!item.hasLoginSession
: false;
var savedAt =
(acc && (acc.lastLoginAt || acc.sessionUpdatedAt)) ||
(isSyntheticSingle && item
? item.lastLoginAt || item.sessionUpdatedAt || ''
: '') ||
'';
var expiresAt =
(acc && acc.sessionExpiresAt) ||
(isSyntheticSingle && item ? item.sessionExpiresAt || '' : '') ||
'';
return {
hasLoginSession: !!has,
savedAt: String(savedAt || '').trim(),
expiresAt: String(expiresAt || '').trim(),
source: (acc && acc.sessionSource) || '',
};
}
/** 详情页:账号下「是否保存登录态 + 保存日期」行。 */
function renderAccountSessionRows(sess, showEmpty) {
if (!sess.hasLoginSession && !showEmpty) {
// 未保存时也简短展示一行,避免用户以为功能缺失
return (
'<div class="mnote-vault-field-row mnote-vault-session-row" data-testid="vault-account-session">' +
'<label>登录态</label>' +
'<div><span class="mnote-vault-session-badge is-absent">未保存</span></div>' +
'</div>'
);
}
var badge = sess.hasLoginSession
? '<span class="mnote-vault-session-badge is-saved" data-testid="vault-session-saved">已保存</span>'
: '<span class="mnote-vault-session-badge is-absent" data-testid="vault-session-absent">未保存</span>';
var dateText = sess.hasLoginSession
? formatVaultDateTime(sess.savedAt) || '—'
: '—';
var html =
'<div class="mnote-vault-field-row mnote-vault-session-row" data-testid="vault-account-session">' +
'<label>登录态</label>' +
'<div class="mnote-vault-session-meta">' +
badge +
'</div></div>' +
'<div class="mnote-vault-field-row mnote-vault-session-row" data-testid="vault-account-session-date">' +
'<label>保存日期</label>' +
'<div>' +
(sess.hasLoginSession
? escapeHtml(dateText)
: '<span class="mnote-vault-muted">—</span>') +
'</div></div>';
if (sess.hasLoginSession && sess.expiresAt) {
html +=
'<div class="mnote-vault-field-row mnote-vault-session-row" data-testid="vault-account-session-expires">' +
'<label>过期时间</label>' +
'<div>' +
escapeHtml(formatVaultDateTime(sess.expiresAt) || sess.expiresAt) +
'</div></div>';
}
return html;
}
function normalizeAccountsFromItem(item, forEdit) {
var topSecrets = normalizeSecretsFromItem(item);
var accounts = (item && item.accounts) || [];
@@ -941,6 +1038,25 @@
return s.accountId && s.accountId === a.id;
});
}
// 多账号时:若槽位未挂 session 元数据,首账号可回退条目级(兼容旧投影)
var hasSess =
typeof a.hasLoginSession === 'boolean'
? a.hasLoginSession
: aidx === 0
? !!(item && item.hasLoginSession)
: false;
var lastLogin =
a.lastLoginAt ||
(aidx === 0 && item ? item.lastLoginAt : null) ||
null;
var sessUpdated =
a.sessionUpdatedAt ||
(aidx === 0 && item ? item.sessionUpdatedAt : null) ||
null;
var sessExpires =
a.sessionExpiresAt ||
(aidx === 0 && item ? item.sessionExpiresAt : null) ||
null;
return {
id: a.id || '',
label: a.label || '',
@@ -952,7 +1068,14 @@
: a.email || '',
passwordHint: a.passwordHint || '',
password: a.password || { state: 'absent' },
// Draft-only flag from reRenderFormKeepingSlots; absent on server items.
passwordClear: !!a.passwordClear,
secrets: nested,
hasLoginSession: hasSess,
lastLoginAt: lastLogin,
sessionUpdatedAt: sessUpdated,
sessionExpiresAt: sessExpires,
sessionSource: a.sessionSource || '',
};
});
}
@@ -974,6 +1097,13 @@
passwordHint: (item && item.passwordHint) || '',
password: (item && item.password) || { state: 'absent' },
secrets: topSecrets,
// 合成单账号:登录态取条目级
hasLoginSession: !!(item && item.hasLoginSession),
lastLoginAt: (item && item.lastLoginAt) || null,
sessionUpdatedAt: (item && item.sessionUpdatedAt) || null,
sessionExpiresAt: (item && item.sessionExpiresAt) || null,
sessionSource: '',
_syntheticFromItem: true,
},
];
}
@@ -986,6 +1116,12 @@
passwordHint: '',
password: { state: 'absent' },
secrets: [],
hasLoginSession: !!(item && item.hasLoginSession),
lastLoginAt: (item && item.lastLoginAt) || null,
sessionUpdatedAt: (item && item.sessionUpdatedAt) || null,
sessionExpiresAt: (item && item.sessionExpiresAt) || null,
sessionSource: '',
_syntheticFromItem: true,
},
];
}
@@ -1157,6 +1293,11 @@
acc.username ||
acc.email ||
'账号 ' + (idx + 1);
var sess = accountSessionView(
acc,
item,
!!acc._syntheticFromItem || accounts.length === 1
);
var body =
fieldRow('标签名', acc.label, showEmpty) +
fieldRow('用户名', acc.username, showEmpty) +
@@ -1164,7 +1305,8 @@
secretRow('密码', 'password', acc.password, showEmpty, {
accountId: acc.id || '',
}) +
fieldRow('密码提示', acc.passwordHint, showEmpty);
fieldRow('密码提示', acc.passwordHint, showEmpty) +
renderAccountSessionRows(sess, showEmpty);
var secList = acc.secrets || [];
var nestedSecretsHtml = secList
.map(function (sec, sidx) {
@@ -1220,6 +1362,9 @@
(secList.length
? '<span class="mnote-vault-muted">密钥 ' + secList.length + '</span>'
: '') +
(sess.hasLoginSession
? '<span class="mnote-vault-session-badge is-saved is-compact" title="已保存登录态">登录态</span>'
: '') +
'</summary>' +
'<div class="mnote-vault-slot-body">' +
body +
@@ -1329,7 +1474,7 @@
);
}
/** Unique folderPath values from current list (for select / datalist). */
/** Unique folderPath values from current list (for picker / datalist). */
function collectFolderPaths() {
var set = {};
(state.items || []).forEach(function (item) {
@@ -1346,47 +1491,152 @@
});
}
/**
* Nested tree of known folderPath segments for the expand-style picker.
* @returns {{ name: string, path: string, children: Array }}
*/
function buildFolderPickerTree(paths) {
var root = { name: '', path: '', children: [] };
var byPath = { '': root };
(paths || []).forEach(function (fp) {
var parts = String(fp || '')
.split('/')
.filter(Boolean);
var acc = [];
var parent = root;
parts.forEach(function (part) {
acc.push(part);
var p = acc.join('/');
if (!byPath[p]) {
var node = { name: part, path: p, children: [] };
byPath[p] = node;
parent.children.push(node);
}
parent = byPath[p];
});
});
function sortNodes(nodes) {
nodes.sort(function (a, b) {
return a.name.localeCompare(b.name, 'zh');
});
nodes.forEach(function (n) {
if (n.children && n.children.length) sortNodes(n.children);
});
}
sortNodes(root.children);
return root;
}
/**
* Whether a picker branch starts expanded.
* Default: all collapsed. Only expand ancestors of the current selection
* (so the selected path stays visible), or branches the user toggled open.
*/
function isFolderPickerBranchOpen(path, depth, current) {
var ui = ensureFormUi();
if (!ui.folderPickerOpen) ui.folderPickerOpen = {};
if (Object.prototype.hasOwnProperty.call(ui.folderPickerOpen, path)) {
return !!ui.folderPickerOpen[path];
}
// Only auto-open ancestors of the selected path (not the leaf itself unless it has kids under selection).
if (current && path && current.indexOf(path + '/') === 0) {
return true;
}
return false;
}
function renderFolderPickerNode(node, depth, current) {
var kids = node.children || [];
var hasKids = kids.length > 0;
var selected = current === node.path;
var open = hasKids && isFolderPickerBranchOpen(node.path, depth, current);
var html = '';
html +=
'<div class="mnote-vault-folder-picker-node" data-vault-picker-path="' +
escapeHtml(node.path) +
'" style="--vault-picker-depth:' +
depth +
'">';
html += '<div class="mnote-vault-folder-picker-row' + (selected ? ' is-selected' : '') + '">';
if (hasKids) {
html +=
'<button type="button" class="mnote-vault-folder-picker-chevron" data-vault-folder-picker-toggle="' +
escapeHtml(node.path) +
'" aria-expanded="' +
(open ? 'true' : 'false') +
'" title="' +
(open ? '收起' : '展开') +
'">' +
(open ? '▼' : '▶') +
'</button>';
} else {
html += '<span class="mnote-vault-folder-picker-chevron is-leaf" aria-hidden="true"></span>';
}
html +=
'<button type="button" class="mnote-vault-folder-picker-label" data-vault-folder-pick="' +
escapeHtml(node.path) +
'" data-testid="vault-folder-pick-' +
escapeHtml(node.path || 'root') +
'">' +
escapeHtml(node.name || node.path) +
'</button>';
html += '</div>';
if (hasKids) {
html +=
'<div class="mnote-vault-folder-picker-children"' +
(open ? '' : ' hidden') +
'>';
kids.forEach(function (child) {
html += renderFolderPickerNode(child, depth + 1, current);
});
html += '</div>';
}
html += '</div>';
return html;
}
function formFolderPathField(value) {
var current = (value || '').trim();
var current = normalizeFolderPath(value || '');
var paths = collectFolderPaths();
var seen = {};
paths.forEach(function (p) {
seen[p] = true;
});
var options =
'<option value=""' +
(!current ? ' selected' : '') +
'>(无分组)</option>';
paths.forEach(function (p) {
options +=
'<option value="' +
escapeHtml(p) +
'"' +
(p === current ? ' selected' : '') +
'>' +
escapeHtml(p) +
'</option>';
});
// 当前值若不在列表中,仍挂到树上便于高亮/再选。
var treePaths = paths.slice();
if (current && !seen[current]) {
options +=
'<option value="' +
escapeHtml(current) +
'" selected>' +
escapeHtml(current) +
'</option>';
treePaths.push(current);
var parts = current.split('/').filter(Boolean);
for (var i = 1; i < parts.length; i++) {
var prefix = parts.slice(0, i).join('/');
if (treePaths.indexOf(prefix) < 0) treePaths.push(prefix);
}
}
var tree = buildFolderPickerTree(treePaths);
var treeHtml = '';
treeHtml +=
'<button type="button" class="mnote-vault-folder-picker-none' +
(!current ? ' is-selected' : '') +
'" data-vault-folder-pick="" data-testid="vault-folder-pick-none">(无分组)</button>';
tree.children.forEach(function (child) {
treeHtml += renderFolderPickerNode(child, 0, current);
});
if (!tree.children.length && !current) {
treeHtml +=
'<div class="mnote-vault-folder-picker-empty mnote-vault-muted">暂无已有分组,可在下方输入新建</div>';
}
return (
'<div class="mnote-vault-field-row mnote-vault-folder-field">' +
'<label for="vault-f-folderPath">分组</label>' +
'<div class="mnote-vault-folder-controls">' +
'<select id="vault-f-folderSelect" data-vault-folder-select data-testid="vault-folder-select" aria-label="选择分组">' +
options +
'</select>' +
'<div class="mnote-vault-folder-picker" data-vault-folder-picker data-testid="vault-folder-select" role="listbox" aria-label="选择分组">' +
treeHtml +
'</div>' +
'<input id="vault-f-folderPath" name="folderPath" type="text" ' +
'value="' +
escapeHtml(current) +
'" placeholder="新分组可直接输入用 / 分层" ' +
'data-vault-folder-input data-testid="vault-folder-path" list="vault-folder-path-list" />' +
'" placeholder="点上方分组选择,或直接输入用 / 分层" ' +
'data-vault-folder-input data-testid="vault-folder-path" list="vault-folder-path-list" autocomplete="off" />' +
'<datalist id="vault-folder-path-list">' +
paths
.map(function (p) {
@@ -1394,39 +1644,75 @@
})
.join('') +
'</datalist>' +
'<p class="mnote-vault-form-hint mnote-vault-folder-hint">分组默认折叠;点 ▶ 展开,点名称即可选中。也可在下方直接输入路径。</p>' +
'</div></div>'
);
}
function syncFolderPickerSelection(form, path) {
if (!form) return;
var picker = qs('[data-vault-folder-picker]', form);
if (!picker) return;
var normalized = normalizeFolderPath(path || '');
qsa('[data-vault-folder-pick]', picker).forEach(function (btn) {
var p = btn.getAttribute('data-vault-folder-pick');
if (p == null) p = '';
var row = btn.closest('.mnote-vault-folder-picker-row');
var selected = p === normalized;
btn.classList.toggle('is-selected', selected);
if (row) row.classList.toggle('is-selected', selected);
if (btn.classList.contains('mnote-vault-folder-picker-none')) {
btn.classList.toggle('is-selected', selected);
}
});
}
function bindFolderPathControls(form) {
if (!form) return;
var select = qs('[data-vault-folder-select]', form);
var input = qs('[data-vault-folder-input]', form);
if (select && input) {
select.addEventListener('change', function () {
input.value = select.value || '';
state.dirty = true;
var picker = qs('[data-vault-folder-picker]', form);
if (picker) {
picker.addEventListener('click', function (ev) {
var t = ev.target;
if (!(t instanceof Element)) return;
var toggle = t.closest('[data-vault-folder-picker-toggle]');
if (toggle && picker.contains(toggle)) {
ev.preventDefault();
var tpath = toggle.getAttribute('data-vault-folder-picker-toggle') || '';
var nodeEl = toggle.closest('.mnote-vault-folder-picker-node');
// First matching descendants is the direct children panel for this node.
var kidsEl =
nodeEl && nodeEl.querySelector('.mnote-vault-folder-picker-children');
var open = toggle.getAttribute('aria-expanded') === 'true';
var next = !open;
ensureFormUi().folderPickerOpen = ensureFormUi().folderPickerOpen || {};
ensureFormUi().folderPickerOpen[tpath] = next;
toggle.setAttribute('aria-expanded', next ? 'true' : 'false');
toggle.textContent = next ? '▼' : '▶';
toggle.setAttribute('title', next ? '收起' : '展开');
if (kidsEl) kidsEl.hidden = !next;
return;
}
var pick = t.closest('[data-vault-folder-pick]');
if (pick && picker.contains(pick)) {
ev.preventDefault();
var chosen = pick.getAttribute('data-vault-folder-pick');
if (chosen == null) chosen = '';
chosen = normalizeFolderPath(chosen);
if (input) input.value = chosen;
syncFolderPickerSelection(form, chosen);
state.dirty = true;
}
});
}
if (input) {
input.addEventListener('input', function () {
var n = normalizeFolderPath(input.value);
// keep select in sync when user picks a known path via typing
if (select) {
var match = false;
for (var i = 0; i < select.options.length; i++) {
if (select.options[i].value === n) {
select.selectedIndex = i;
match = true;
break;
}
}
if (!match) select.selectedIndex = 0;
}
syncFolderPickerSelection(form, input.value);
});
input.addEventListener('change', function () {
var n = normalizeFolderPath(input.value);
input.value = n;
syncFolderPickerSelection(form, n);
state.dirty = true;
});
}
@@ -1439,24 +1725,55 @@
var urls = normalizeUrlsFromItem(item);
if (!urls.length) urls = [''];
state._formUrls = urls.slice();
/**
* Rebuild draft slots from item projection.
* reRenderFormKeepingSlots injects in-progress plaintext as
* `{ state: 'revealed', value }` — must become valueText/passwordValue,
* not be wiped to '' (that made the 1st new secret disappear after +密钥).
* valueClear/passwordClear on the pseudo-item are also preserved.
*/
state._formAccounts = accounts.map(function (a) {
var pwd = a.password || { state: 'absent' };
var passwordClear = !!a.passwordClear;
var passwordValue = '';
var passwordState = pwd;
if (passwordClear) {
passwordState = { state: 'absent' };
passwordValue = '';
} else if (pwd && pwd.state === 'revealed') {
// Draft plaintext typed in this session (not yet saved / mid re-render).
passwordValue = String(pwd.value || '');
// Treat as "no stored secret yet" for keep-logic; save uses passwordValue.
passwordState = { state: 'absent' };
}
return {
id: a.id || newClientId('acc'),
label: a.label || '',
username: a.username || '',
email: a.email || '',
passwordHint: a.passwordHint || '',
passwordState: a.password || { state: 'absent' },
passwordClear: false,
passwordValue: '',
passwordState: passwordState,
passwordClear: passwordClear,
passwordValue: passwordValue,
secrets: (a.secrets || []).map(function (s) {
var v = s.value || { state: 'absent' };
var valueClear = !!s.valueClear;
var valueText = '';
var valueState = v;
if (valueClear) {
valueState = { state: 'absent' };
valueText = '';
} else if (v && v.state === 'revealed') {
valueText = String(v.value || '');
valueState = { state: 'absent' };
}
return {
id: s.id || newClientId('sec'),
kind: s.kind || 'apikey',
label: s.label || '',
valueState: s.value || { state: 'absent' },
valueClear: false,
valueText: '',
valueState: valueState,
valueClear: valueClear,
valueText: valueText,
accountId: a.id || '',
};
}),
@@ -1592,9 +1909,11 @@
'<div class="mnote-vault-secret-edit">' +
'<input type="text" autocomplete="off" spellcheck="false" data-acc-field="password" data-acc-id="' +
escapeHtml(acc.id) +
'" class="mnote-vault-secret-input-plain" placeholder="' +
'" class="mnote-vault-secret-input-plain" value="' +
escapeHtml(acc.passwordValue || '') +
'" placeholder="' +
escapeHtml(
!isCreate && hasPwd
!isCreate && hasPwd && !acc.passwordValue
? '留空不改;输入新值覆盖'
: '可选;可用 [Key] 密文片段'
) +
@@ -1602,7 +1921,11 @@
(!isCreate && hasPwd
? '<button type="button" data-acc-show-password="' +
escapeHtml(acc.id) +
'" data-testid="vault-acc-show-password">显示</button>' +
'"' +
(acc.passwordValue ? ' data-shown="1"' : '') +
' data-testid="vault-acc-show-password">' +
(acc.passwordValue ? '隐藏' : '显示') +
'</button>' +
'<button type="button" data-acc-clear-password="' +
escapeHtml(acc.id) +
'">清空</button>'
@@ -1624,6 +1947,7 @@
sec.kind === 'token' ? 'Token' : sec.kind === 'other' ? '其他' : 'API Key';
var title = sec.label || kindLabel + ' ' + (idx + 1);
var hasVal = sec.valueState && sec.valueState.state === 'masked';
var draftText = sec.valueText || '';
return (
'<div class="mnote-vault-nested-secret" data-vault-secret-slot="' +
escapeHtml(sec.id) +
@@ -1672,15 +1996,21 @@
escapeHtml(sec.id) +
'" data-acc-id="' +
escapeHtml(accountId) +
'" value="" placeholder="' +
escapeHtml(hasVal ? '留空不改;输入新值覆盖' : '密钥明文') +
'" value="' +
escapeHtml(draftText) +
'" placeholder="' +
escapeHtml(hasVal && !draftText ? '留空不改;输入新值覆盖' : '密钥明文') +
'" />' +
(hasVal
? '<button type="button" data-sec-show-value="' +
escapeHtml(sec.id) +
'" data-acc-id="' +
escapeHtml(accountId) +
'" data-testid="vault-sec-show-value">显示</button>' +
'"' +
(draftText ? ' data-shown="1"' : '') +
' data-testid="vault-sec-show-value">' +
(draftText ? '隐藏' : '显示') +
'</button>' +
'<button type="button" data-sec-clear-value="' +
escapeHtml(sec.id) +
'" data-acc-id="' +
@@ -1830,6 +2160,8 @@
username: a.username,
email: a.email,
passwordHint: a.passwordHint,
// Flags survive re-render so clear intent is not lost.
passwordClear: !!a.passwordClear,
password: a.passwordClear
? { state: 'absent' }
: a.passwordValue
@@ -1841,6 +2173,7 @@
kind: s.kind,
label: s.label,
accountId: a.id,
valueClear: !!s.valueClear,
value: s.valueClear
? { state: 'absent' }
: s.valueText
@@ -2040,13 +2373,30 @@
var folderPath = normalizeFolderPath(String(fd.get('folderPath') || '').trim());
var accounts = (state._formAccounts || [])
.map(function (acc) {
// Prefer live input draft; fall back to revealed draft left by re-render inject.
var raw = String(acc.passwordValue || '');
if (
!raw &&
acc.passwordState &&
acc.passwordState.state === 'revealed' &&
acc.passwordState.value
) {
raw = String(acc.passwordState.value);
}
if (/^[•*·]+$/.test(raw) || raw === '••••••••') {
toast('不能将掩码写回 password', 'error');
throw new Error('mask');
}
var nestedSecrets = (acc.secrets || []).map(function (sec) {
var sraw = String(sec.valueText || '');
if (
!sraw &&
sec.valueState &&
sec.valueState.state === 'revealed' &&
sec.valueState.value
) {
sraw = String(sec.valueState.value);
}
if (/^[•*·]+$/.test(sraw) || sraw === '••••••••') {
toast('不能将掩码写回 secret', 'error');
throw new Error('mask');