Add files via upload

This commit is contained in:
公明
2026-07-23 18:56:18 +08:00
committed by GitHub
parent dbc4ead040
commit a83490f29c
15 changed files with 5102 additions and 668 deletions
+1 -1
View File
@@ -868,7 +868,7 @@ async function openAssetProjectModal() {
}
const subtitle = document.getElementById('asset-project-subtitle');
if (subtitle) subtitle.textContent = assetT('assets.bindProjectCount', `将更新 ${assets.length} 个资产`, { count: assets.length });
if (typeof openAppModal === 'function') openAppModal('asset-project-modal');
if (typeof openAppModal === 'function') openAppModal('asset-project-modal', { display: 'flex' });
else document.getElementById('asset-project-modal').style.display = 'flex';
if (select) select.focus();
}
+593 -88
View File
@@ -14,6 +14,8 @@
tasksPageSize: 10,
tasksTotal: 0,
tasksPendingQueuedCount: null,
tasksStatusCounts: { success: 0, failed: 0, pending: 0, cancelled: 0 },
tasksFilter: { status: '', task_type: '', session_id: '', since: '', timePreset: 'all' },
events: [],
eventsPage: 1,
eventsPageSize: 10,
@@ -47,6 +49,111 @@
// API 基础路径
const API_BASE = '/api/c2';
function activeC2ProjectId() {
try {
return (typeof getActiveProjectId === 'function' ? getActiveProjectId() : '') || '';
} catch (e) {
return '';
}
}
function c2ResourceProjectId(item) {
return String((item && (item.project_id || item.projectId)) || '').trim();
}
function c2LooksLikeUuid(value) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(value || '').trim());
}
function c2ProjectNameMap() {
try {
return (window.projectNameById && typeof window.projectNameById === 'object')
? window.projectNameById
: {};
} catch (e) {
return {};
}
}
/** 下拉展示用项目名:优先可读名称,绝不回退成 UUID */
function c2ProjectDisplayName(id, nameHint) {
const idStr = String(id || '').trim();
if (!idStr) return '';
let name = String(nameHint || '').trim();
if (!name || name === idStr || c2LooksLikeUuid(name)) {
const mapped = String(c2ProjectNameMap()[idStr] || '').trim();
if (mapped && mapped !== idStr && !c2LooksLikeUuid(mapped)) name = mapped;
}
if (!name || name === idStr || c2LooksLikeUuid(name)) {
if (typeof window.getProjectName === 'function') {
const viaFn = String(window.getProjectName(idStr) || '').trim();
if (viaFn && viaFn !== idStr && !c2LooksLikeUuid(viaFn)) name = viaFn;
}
}
if (!name || name === idStr || c2LooksLikeUuid(name)) {
return c2t('batchManageModal.unknownProject') || '未知项目';
}
return name;
}
function c2ProjectOptionsHtml(selectedId, projectList) {
const selected = String(selectedId || '').trim();
let html = `<option value="">${escapeHtml(c2t('assets.unboundProject') || '暂不绑定')}</option>`;
let entries = [];
if (Array.isArray(projectList) && projectList.length) {
entries = projectList
.filter((p) => p && p.id)
.map((p) => [String(p.id), String(p.name || '').trim()]);
} else {
entries = Object.entries(c2ProjectNameMap());
}
const seen = new Set();
entries.sort((a, b) => c2ProjectDisplayName(a[0], a[1]).localeCompare(c2ProjectDisplayName(b[0], b[1]), undefined, { sensitivity: 'base' }));
entries.forEach(([id, name]) => {
if (!id || seen.has(id)) return;
seen.add(id);
const label = c2ProjectDisplayName(id, name);
html += `<option value="${escapeHtml(id)}"${id === selected ? ' selected' : ''}>${escapeHtml(label)}</option>`;
});
if (selected && !seen.has(selected)) {
html += `<option value="${escapeHtml(selected)}" selected>${escapeHtml(c2ProjectDisplayName(selected))}</option>`;
}
return html;
}
function ensureC2ProjectsLoaded() {
if (typeof window.ensureProjectsLoaded === 'function') return window.ensureProjectsLoaded();
if (typeof window.fetchAllProjects === 'function') {
return window.fetchAllProjects(false).then((list) => {
if (typeof window.rebuildProjectNameMap === 'function') window.rebuildProjectNameMap(list || []);
return list || [];
});
}
return Promise.resolve([]);
}
/** 确保当前选中项目有可读名称(孤儿 / 未进缓存的 ID) */
function ensureC2SelectedProjectNamed(projectId) {
const id = String(projectId || '').trim();
if (!id) return Promise.resolve();
const mapped = String(c2ProjectNameMap()[id] || '').trim();
if (mapped && mapped !== id && !c2LooksLikeUuid(mapped)) return Promise.resolve();
if (typeof window.fetchProjectSummary !== 'function') return Promise.resolve();
return window.fetchProjectSummary(id).then((p) => {
if (p && p.id && typeof window.rememberProjectsInNameMap === 'function') {
window.rememberProjectsInNameMap([p]);
}
}).catch(function () { /* ignore */ });
}
function c2ProjectBindSelectHtml(listener) {
return `<select class="c2-project-bind-select" data-id="${escapeHtml(listener.id || '')}" title="${escapeHtml(c2t('assets.project') || '所属项目')}" onclick="event.stopPropagation()" onchange="C2.bindListenerProject(this.dataset.id, this.value)">${c2ProjectOptionsHtml(c2ResourceProjectId(listener))}</select>`;
}
function withC2ProjectQuery(url) {
return url;
}
window.__c2DownloadPayload = function(filename) {
const url = `${API_BASE}/payloads/${filename}/download`;
const fetchFn = (typeof apiFetch === 'function') ? apiFetch : fetch;
@@ -339,56 +446,116 @@
return String(os || '?').substring(0, 3).toLowerCase();
}
function isEmptyInfoValue(value) {
if (value == null) return true;
const s = String(value).trim();
if (!s || s === '-') return true;
const lower = s.toLowerCase();
return lower === 'unknown' || lower === 'n/a' || lower === 'null' || lower === 'undefined';
}
function displayInfoValue(value, fallback) {
if (isEmptyInfoValue(value)) return fallback != null ? fallback : '—';
return String(value);
}
function sessionMetaLine(s) {
const user = displayInfoValue(s.username, '');
const os = displayInfoValue(s.os, '');
const arch = displayInfoValue(s.arch, '');
const right = [os, arch].filter(Boolean).join('/') || '—';
if (!user) return right;
return user + '@' + right;
}
function sessionInfoRow(label, value, opts) {
const empty = isEmptyInfoValue(value);
const display = empty ? '—' : String(value);
const valueClasses = ['c2-session-info-dl__value'];
if (opts && opts.mono) valueClasses.push('is-mono');
if (opts && opts.accent) valueClasses.push('is-accent');
if (opts && opts.warn) valueClasses.push('is-warn');
if (opts && opts.accent && !empty) valueClasses.push('is-accent');
if (opts && opts.warn && !empty) valueClasses.push('is-warn');
if (empty) valueClasses.push('is-empty');
const rowCls = opts && opts.full ? ' c2-session-info-dl__row--full' : '';
const copyBtn = (opts && opts.copy && !empty)
? `<button type="button" class="c2-session-info-copy" title="${escapeHtml(c2t('c2.sessions.infoCopy'))}" aria-label="${escapeHtml(c2t('c2.sessions.infoCopy'))}" onclick="event.stopPropagation(); C2.copyText(${JSON.stringify(String(value))})">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2" stroke="currentColor" stroke-width="1.8"/><path d="M6 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
</button>`
: '';
return `
<div class="c2-session-info-dl__row${rowCls}">
<dt class="c2-session-info-dl__label">${escapeHtml(label)}</dt>
<dd class="${valueClasses.join(' ')}">${escapeHtml(value == null || value === '' ? '-' : String(value))}</dd>
<dd class="${valueClasses.join(' ')}">
<span class="c2-session-info-dl__text" title="${escapeHtml(empty ? '' : display)}">${escapeHtml(display)}</span>
${copyBtn}
</dd>
</div>`;
}
function renderSessionInfoPanel(s, adminVal, sleepLine) {
const lastCheckin = formatTime(s.lastCheckIn);
const lastRel = formatRelativeTime(s.lastCheckIn);
const checkinDisplay = lastRel ? `${lastCheckin} (${lastRel})` : lastCheckin;
const checkinDisplay = lastRel ? `${lastCheckin} · ${lastRel}` : lastCheckin;
const noteText = s.note && String(s.note).trim() ? String(s.note) : '';
const noteEmpty = !noteText;
return `
<div class="c2-session-info-panel">
<section class="c2-session-info-block">
<div class="c2-session-info-block__head">${escapeHtml(c2t('c2.sessions.infoSectionIdentity'))}</div>
<dl class="c2-session-info-dl">
${sessionInfoRow(c2t('c2.sessions.infoSessionId'), s.id, { mono: true, accent: true, full: true })}
${sessionInfoRow(c2t('c2.sessions.infoImplantUuid'), s.implantUuid, { mono: true, full: true })}
${sessionInfoRow(c2t('c2.sessions.infoHostname'), s.hostname)}
${sessionInfoRow(c2t('c2.sessions.infoUsername'), s.username)}
</dl>
</section>
<section class="c2-session-info-block">
<div class="c2-session-info-block__head">${escapeHtml(c2t('c2.sessions.infoSectionSystem'))}</div>
<dl class="c2-session-info-dl">
${sessionInfoRow(c2t('c2.sessions.infoOs'), s.os)}
${sessionInfoRow(c2t('c2.sessions.infoArch'), s.arch, { mono: true })}
${sessionInfoRow(c2t('c2.sessions.infoPid'), s.pid, { mono: true })}
${sessionInfoRow(c2t('c2.sessions.infoProcess'), s.processName || '-', { mono: true })}
${sessionInfoRow(c2t('c2.sessions.infoAdmin'), adminVal, { warn: s.isAdmin, full: true })}
</dl>
</section>
<section class="c2-session-info-block">
<div class="c2-session-info-block__head">${escapeHtml(c2t('c2.sessions.infoSectionNetwork'))}</div>
<dl class="c2-session-info-dl">
${sessionInfoRow(c2t('c2.sessions.infoInternalIp'), s.internalIp || '-', { mono: true, accent: true })}
${sessionInfoRow(c2t('c2.sessions.infoSleep'), sleepLine)}
${sessionInfoRow(c2t('c2.sessions.infoFirstSeen'), formatTime(s.firstSeenAt), { mono: true })}
${sessionInfoRow(c2t('c2.sessions.infoLastCheckin'), checkinDisplay, { mono: true, accent: true })}
</dl>
</section>
<section class="c2-session-info-block c2-session-info-block--note">
<div class="c2-session-info-block__head">${escapeHtml(c2t('c2.sessions.infoSectionNote'))}</div>
<div class="c2-session-info-note">${escapeHtml(s.note || c2t('c2.sessions.infoNoteEmpty'))}</div>
<div class="c2-session-info-grid">
<section class="c2-session-info-block">
<div class="c2-session-info-block__head">
<span class="c2-session-info-block__icon c2-session-info-block__icon--id" aria-hidden="true"></span>
<span>${escapeHtml(c2t('c2.sessions.infoSectionIdentity'))}</span>
</div>
<dl class="c2-session-info-dl">
${sessionInfoRow(c2t('c2.sessions.infoSessionId'), s.id, { mono: true, accent: true, copy: true })}
${sessionInfoRow(c2t('c2.sessions.infoImplantUuid'), s.implantUuid, { mono: true, copy: true })}
${sessionInfoRow(c2t('c2.sessions.infoHostname'), s.hostname)}
${sessionInfoRow(c2t('c2.sessions.infoUsername'), s.username)}
</dl>
</section>
<section class="c2-session-info-block">
<div class="c2-session-info-block__head">
<span class="c2-session-info-block__icon c2-session-info-block__icon--sys" aria-hidden="true"></span>
<span>${escapeHtml(c2t('c2.sessions.infoSectionSystem'))}</span>
</div>
<dl class="c2-session-info-dl">
${sessionInfoRow(c2t('c2.sessions.infoOs'), s.os)}
${sessionInfoRow(c2t('c2.sessions.infoArch'), s.arch, { mono: true })}
${sessionInfoRow(c2t('c2.sessions.infoPid'), s.pid, { mono: true })}
${sessionInfoRow(c2t('c2.sessions.infoProcess'), s.processName || '-', { mono: true })}
${sessionInfoRow(c2t('c2.sessions.infoAdmin'), adminVal, { warn: !!s.isAdmin })}
</dl>
</section>
<section class="c2-session-info-block">
<div class="c2-session-info-block__head">
<span class="c2-session-info-block__icon c2-session-info-block__icon--net" aria-hidden="true"></span>
<span>${escapeHtml(c2t('c2.sessions.infoSectionNetwork'))}</span>
</div>
<dl class="c2-session-info-dl">
${sessionInfoRow(c2t('c2.sessions.infoInternalIp'), s.internalIp || '-', { mono: true, accent: true, copy: true })}
${sessionInfoRow(c2t('c2.sessions.infoSleep'), sleepLine)}
${sessionInfoRow(c2t('c2.sessions.infoFirstSeen'), formatTime(s.firstSeenAt), { mono: true })}
${sessionInfoRow(c2t('c2.sessions.infoLastCheckin'), checkinDisplay, { mono: true, accent: true })}
</dl>
</section>
</div>
<section class="c2-session-info-block c2-session-info-block--note${noteEmpty ? ' is-empty' : ''}" id="c2-session-note-block">
<div class="c2-session-info-block__head">
<span class="c2-session-info-block__icon c2-session-info-block__icon--note" aria-hidden="true"></span>
<span>${escapeHtml(c2t('c2.sessions.infoSectionNote'))}</span>
<button type="button" class="c2-session-note-edit-btn" data-require-permission="c2:write" onclick='C2.beginEditSessionNote(${JSON.stringify(s.id)})'>${escapeHtml(c2t('c2.sessions.noteEdit'))}</button>
</div>
<div class="c2-session-info-note${noteEmpty ? ' is-empty' : ''}" id="c2-session-note-view">${escapeHtml(noteText || c2t('c2.sessions.infoNoteEmpty'))}</div>
<div class="c2-session-note-editor" id="c2-session-note-editor" hidden>
<textarea id="c2-session-note-input" class="c2-session-note-textarea" maxlength="2000" rows="4" placeholder="${escapeHtml(c2t('c2.sessions.notePlaceholder'))}">${escapeHtml(noteText)}</textarea>
<div class="c2-session-note-editor__footer">
<span class="c2-session-note-counter" id="c2-session-note-counter">${noteText.length}/2000</span>
<div class="c2-session-note-editor__actions">
<button type="button" class="btn-secondary btn-sm" onclick="C2.cancelEditSessionNote()">${escapeHtml(c2t('common.cancel'))}</button>
<button type="button" class="btn-primary btn-sm" id="c2-session-note-save" onclick='C2.saveSessionNote(${JSON.stringify(s.id)})'>${escapeHtml(c2t('common.save'))}</button>
</div>
</div>
</div>
</section>
</div>`;
}
@@ -398,6 +565,7 @@
// ============================================================================
function apiRequest(method, url, data) {
if (method === 'GET') url = withC2ProjectQuery(url);
const options = {
method: method,
headers: { 'Content-Type': 'application/json' }
@@ -448,6 +616,21 @@
return c2t('c2.fmt.durationMin', { n: (ms / 60000).toFixed(1) });
}
function formatBytes(n) {
const num = Number(n);
if (!Number.isFinite(num) || num < 0) return String(n == null ? '-' : n);
if (num < 1024) return String(Math.round(num)) + ' B';
const units = ['KB', 'MB', 'GB', 'TB'];
let v = num / 1024;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i += 1;
}
const digits = v >= 100 ? 0 : (v >= 10 ? 1 : 2);
return v.toFixed(digits) + ' ' + units[i];
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
@@ -556,6 +739,11 @@
}
}
C2.copyText = function(text) {
if (text == null || text === '') return;
copyToClipboard(String(text));
};
// ============================================================================
// 页面初始化
// ============================================================================
@@ -611,7 +799,8 @@
C2.loadListeners = function() {
Promise.all([
apiRequest('GET', `${API_BASE}/listeners`),
apiRequest('GET', `${API_BASE}/profiles`).catch(function() { return {}; })
apiRequest('GET', `${API_BASE}/profiles`).catch(function() { return {}; }),
ensureC2ProjectsLoaded().catch(function() { return []; })
]).then(function(results) {
var ldata = results[0];
var pdata = results[1];
@@ -670,6 +859,7 @@
? '<div class="c2-listener-kv"><span class="c2-listener-kv-label">' + escapeHtml(c2t('c2.listeners.callbackShort')) + '</span><span class="c2-listener-kv-val c2-listener-mono">' + escapeHtml(cb) + '</span></div>'
: '';
const remarkRow = l.remark ? '<div class="c2-listener-remark">' + escapeHtml(l.remark) + '</div>' : '';
const projectRow = '<div class="c2-listener-kv"><span class="c2-listener-kv-label">' + escapeHtml(c2t('assets.project') || '所属项目') + '</span><span class="c2-listener-kv-val">' + c2ProjectBindSelectHtml(l) + '</span></div>';
const startedHtml = formatListenerStartedHtml(l.startedAt);
const pillLabel = escapeHtml(listenerCardStatusPillLabel(st));
const typeMark = escapeHtml(listenerTypeShortLabel(l.type));
@@ -697,6 +887,7 @@
<span class="c2-listener-kv-val c2-listener-mono"><span class="c2-status-dot ${escapeHtml(st)}"></span>${bindVal}</span>
</div>
${cbRow}
${projectRow}
${profileBadge}
${remarkRow}
${startedHtml}
@@ -751,8 +942,15 @@
</div>
`;
C2.ensureProfilesLoaded().then(() => {
const createSelectedProjectId = activeC2ProjectId();
Promise.all([
C2.ensureProfilesLoaded(),
ensureC2ProjectsLoaded().catch(() => []),
ensureC2SelectedProjectNamed(createSelectedProjectId)
]).then(function (results) {
const projects = results[1] || [];
const profileOpts = listenerProfileSelectHtml('');
const projectOpts = c2ProjectOptionsHtml(createSelectedProjectId, projects);
const emptyProfHintCreate = (C2.profiles && C2.profiles.length > 0)
? ''
: `<div class="form-hint form-hint--warning" style="margin-bottom:6px;">${escapeHtml(c2t('c2.listeners.malleableProfileEmptyListHint'))}</div>`;
@@ -777,6 +975,10 @@
</select>
</div>
</div>
<div class="c2-form-group">
<label>${escapeHtml(c2t('assets.project') || '所属项目')}</label>
<select id="c2-listener-project-id" class="form-control c2-form-select-native">${projectOpts}</select>
</div>
<div class="c2-form-row">
<div class="c2-form-group">
<label>${escapeHtml(c2t('c2.listeners.bindHost'))}</label>
@@ -864,6 +1066,7 @@
const body = {
name, type, bind_host: bindHost, bind_port: bindPort, remark,
callback_host: callbackHost,
project_id: (document.getElementById('c2-listener-project-id')?.value || '').trim(),
profile_id: profileId
};
if (type === 'tcp_reverse' && legacyShell) {
@@ -909,6 +1112,38 @@
});
};
C2.bindListenerProject = function(id, projectId) {
if (typeof requirePermission === 'function' && !requirePermission('c2:write')) {
C2.renderListeners();
return;
}
const listener = C2.listeners.find(x => x.id === id);
if (!listener) return;
const cfg = C2.getListenerConfig(listener);
const body = {
name: listener.name || '',
bind_host: listener.bindHost || '127.0.0.1',
bind_port: parseInt(listener.bindPort, 10) || 0,
remark: listener.remark || '',
callback_host: C2.getListenerCallbackHost(listener),
project_id: String(projectId || '').trim(),
profile_id: listenerResolvedProfileId(listener),
config: cfg
};
apiRequest('PUT', `${API_BASE}/listeners/${id}`, body).then(data => {
if (data && data.error) {
showToast(data.error, 'error');
C2.renderListeners();
return;
}
showToast(c2t('projects.projectBound') || '已绑定项目', 'success');
C2.loadListeners();
}).catch(err => {
showToast(err && err.message ? err.message : '绑定项目失败', 'error');
C2.renderListeners();
});
};
C2.editListener = function(id) {
const l = C2.listeners.find(x => x.id === id);
if (!l) return;
@@ -931,9 +1166,16 @@
</div>
`;
C2.ensureProfilesLoaded().then(() => {
const editSelectedProjectId = c2ResourceProjectId(l);
Promise.all([
C2.ensureProfilesLoaded(),
ensureC2ProjectsLoaded().catch(() => []),
ensureC2SelectedProjectNamed(editSelectedProjectId)
]).then(function (results) {
const projects = results[1] || [];
const resolvedPid = listenerResolvedProfileId(l);
const profileOpts = listenerProfileSelectHtml(resolvedPid);
const projectOpts = c2ProjectOptionsHtml(editSelectedProjectId, projects);
const lt = String(l.type || '').toLowerCase();
const httpHint = (lt === 'http_beacon' || lt === 'https_beacon')
? ''
@@ -951,6 +1193,10 @@
<label>${escapeHtml(c2t('c2.listeners.name'))}</label>
<input type="text" id="c2-listener-name" class="form-control" value="${escapeHtml(l.name)}">
</div>
<div class="c2-form-group">
<label>${escapeHtml(c2t('assets.project') || '所属项目')}</label>
<select id="c2-listener-project-id" class="form-control c2-form-select-native">${projectOpts}</select>
</div>
<div class="c2-form-row">
<div class="c2-form-group">
<label>${escapeHtml(c2t('c2.listeners.bindHost'))}</label>
@@ -1009,6 +1255,7 @@
const body = {
name, bind_host: bindHost, bind_port: bindPort, remark,
callback_host: callbackHost,
project_id: (document.getElementById('c2-listener-project-id')?.value || '').trim(),
profile_id: profileId
};
if (legacyEl) {
@@ -1119,9 +1366,16 @@
const batchBtn = document.getElementById('c2-sessions-batch-delete');
const filteredBtn = document.getElementById('c2-sessions-delete-filtered');
const ids = C2.collectCheckedSessionIds();
if (batchBtn) batchBtn.disabled = ids.length === 0;
if (batchBtn) {
batchBtn.disabled = ids.length === 0;
batchBtn.classList.toggle('btn-danger', ids.length > 0);
batchBtn.classList.toggle('btn-secondary', ids.length === 0);
}
const total = (C2.sessions || []).length;
if (filteredBtn) filteredBtn.disabled = total === 0;
if (filteredBtn) {
filteredBtn.disabled = total === 0;
filteredBtn.classList.toggle('is-danger-soft', total > 0);
}
const countEl = document.getElementById('c2-sessions-count');
if (countEl) {
countEl.textContent = c2t('c2.sessions.filterCount', { n: total, selected: ids.length });
@@ -1191,7 +1445,12 @@
return;
}
list.innerHTML = C2.sessions.map(s => `
list.innerHTML = C2.sessions.map(s => {
const userLabel = displayInfoValue(s.username, '—');
const osArch = [displayInfoValue(s.os, ''), displayInfoValue(s.arch, '')].filter(Boolean).join('/') || '—';
const userEmpty = isEmptyInfoValue(s.username);
const osEmpty = isEmptyInfoValue(s.os) && isEmptyInfoValue(s.arch);
return `
<div class="c2-session-item ${s.id === C2.selectedSessionId ? 'active' : ''}"
data-status="${escapeHtml(s.status || '')}"
onclick="C2.selectSession('${s.id}')">
@@ -1206,21 +1465,21 @@
<span class="c2-session-status ${s.status}">${escapeHtml(sessionStatusLabel(s.status))}</span>
</div>
<div class="c2-session-chips">
<span class="c2-session-chip">${escapeHtml(s.username)}</span>
<span class="c2-session-chip c2-session-chip--mono">${escapeHtml(s.os)}/${escapeHtml(s.arch)}</span>
<span class="c2-session-chip${userEmpty ? ' c2-session-chip--empty' : ''}">${escapeHtml(userLabel)}</span>
<span class="c2-session-chip c2-session-chip--mono${osEmpty ? ' c2-session-chip--empty' : ''}">${escapeHtml(osArch)}</span>
${s.isAdmin ? '<span class="c2-session-chip c2-session-chip--warn">' + escapeHtml(c2t('c2.sessions.rootBadge')) + '</span>' : ''}
</div>
<div class="c2-session-chips c2-session-chips--sub">
<span class="c2-session-chip c2-session-chip--dim">${escapeHtml(s.internalIp || '-')}</span>
<span class="c2-session-chip c2-session-chip--dim">PID ${s.pid}</span>
<span class="c2-session-chip c2-session-chip--dim">${escapeHtml(s.internalIp || '')}</span>
<span class="c2-session-chip c2-session-chip--dim">PID ${escapeHtml(String(s.pid != null ? s.pid : '—'))}</span>
</div>
<div class="c2-session-item-footer">
<span class="c2-session-meta c2-session-item-time" title="${escapeHtml(formatTime(s.lastCheckIn))}">${escapeHtml(formatRelativeTime(s.lastCheckIn) || formatTime(s.lastCheckIn))}</span>
<button type="button" class="c2-session-card-delete" data-require-permission="c2:delete" onclick="event.stopPropagation(); C2.deleteSessionRecord('${s.id}');">${escapeHtml(c2t('c2.sessions.cardDeleteSession'))}</button>
</div>
</div>
</div>
`).join('');
</div>`;
}).join('');
if (C2.selectedSessionId && !C2.sessions.find(s => s.id === C2.selectedSessionId)) {
C2.selectedSessionId = null;
@@ -1270,11 +1529,11 @@
<span class="c2-session-badge ${escapeHtml(s.status || '')}">${escapeHtml(sessionStatusLabel(s.status))}</span>
${s.isAdmin ? '<span class="c2-session-hero-root">' + escapeHtml(c2t('c2.sessions.rootBadge')) + '</span>' : ''}
</div>
<div class="c2-session-hero__sub">${escapeHtml(s.username)}@${escapeHtml(s.os)}/${escapeHtml(s.arch)}</div>
<div class="c2-session-hero__sub${isEmptyInfoValue(s.username) && isEmptyInfoValue(s.os) ? ' is-muted' : ''}">${escapeHtml(sessionMetaLine(s))}</div>
<div class="c2-session-hero__chips">
<span class="c2-session-hero-chip is-mono" title="${escapeHtml(c2t('c2.sessions.infoSessionId'))}">${escapeHtml(s.id)}</span>
<span class="c2-session-hero-chip">${escapeHtml(s.internalIp || '-')}</span>
<span class="c2-session-hero-chip">PID ${s.pid}</span>
<span class="c2-session-hero-chip">${escapeHtml(s.internalIp || '')}</span>
<span class="c2-session-hero-chip">PID ${escapeHtml(String(s.pid != null ? s.pid : '—'))}</span>
</div>
</div>
</div>
@@ -1601,6 +1860,88 @@
});
};
C2.beginEditSessionNote = function(id) {
const block = document.getElementById('c2-session-note-block');
const view = document.getElementById('c2-session-note-view');
const editor = document.getElementById('c2-session-note-editor');
const input = document.getElementById('c2-session-note-input');
const editBtn = block && block.querySelector('.c2-session-note-edit-btn');
if (!view || !editor || !input) return;
const s = (C2.sessions || []).find(x => x.id === id);
const note = s && s.note ? String(s.note) : '';
input.value = note;
view.hidden = true;
editor.hidden = false;
if (block) block.classList.remove('is-empty');
if (editBtn) editBtn.hidden = true;
C2.syncSessionNoteCounter();
if (!input.dataset.boundCounter) {
input.dataset.boundCounter = '1';
input.addEventListener('input', function () { C2.syncSessionNoteCounter(); });
}
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
};
C2.syncSessionNoteCounter = function() {
const input = document.getElementById('c2-session-note-input');
const counter = document.getElementById('c2-session-note-counter');
if (!input || !counter) return;
counter.textContent = String(input.value.length) + '/2000';
};
C2.cancelEditSessionNote = function() {
const block = document.getElementById('c2-session-note-block');
const view = document.getElementById('c2-session-note-view');
const editor = document.getElementById('c2-session-note-editor');
const editBtn = block && block.querySelector('.c2-session-note-edit-btn');
if (!view || !editor) return;
editor.hidden = true;
view.hidden = false;
if (editBtn) editBtn.hidden = false;
if (block) {
const s = (C2.sessions || []).find(x => x.id === C2.selectedSessionId);
const empty = !(s && s.note && String(s.note).trim());
block.classList.toggle('is-empty', empty);
}
};
C2.saveSessionNote = function(id) {
if (!id) return;
const input = document.getElementById('c2-session-note-input');
const saveBtn = document.getElementById('c2-session-note-save');
if (!input) return;
const note = String(input.value || '').trim();
if (note.length > 2000) {
showToast(c2t('c2.sessions.toastNoteTooLong'), 'warn');
return;
}
if (saveBtn) saveBtn.disabled = true;
apiRequest('PUT', `${API_BASE}/sessions/${encodeURIComponent(id)}/note`, { note: note }).then(data => {
if (saveBtn) saveBtn.disabled = false;
if (data.error) {
showToast(String(data.error), 'error');
return;
}
const saved = data.note != null ? String(data.note) : note;
const s = (C2.sessions || []).find(x => x.id === id);
if (s) s.note = saved;
showToast(c2t('c2.sessions.toastNoteSaved'), 'success');
const block = document.getElementById('c2-session-note-block');
const view = document.getElementById('c2-session-note-view');
const empty = !saved.trim();
if (view) {
view.textContent = empty ? c2t('c2.sessions.infoNoteEmpty') : saved;
view.classList.toggle('is-empty', empty);
}
if (block) block.classList.toggle('is-empty', empty);
C2.cancelEditSessionNote();
}).catch(err => {
if (saveBtn) saveBtn.disabled = false;
showToast(err.message || String(err), 'error');
});
};
C2.killSession = function(id) {
if (!confirm(c2t('c2.sessions.confirmExitSession'))) return;
apiRequest('POST', `${API_BASE}/tasks`, {
@@ -2072,6 +2413,8 @@
});
container.setAttribute('tabindex', '0');
C2.bindTerminalScrollbarAutoHide(container);
C2.terminalInstance = term;
if (C2.terminalResizeObserver) {
@@ -2090,6 +2433,42 @@
});
};
C2.bindTerminalScrollbarAutoHide = function(container) {
if (!container) return;
if (C2._terminalScrollbarHideTimer) {
clearTimeout(C2._terminalScrollbarHideTimer);
C2._terminalScrollbarHideTimer = null;
}
const show = function () {
container.classList.add('is-scrollbar-visible');
if (C2._terminalScrollbarHideTimer) clearTimeout(C2._terminalScrollbarHideTimer);
C2._terminalScrollbarHideTimer = setTimeout(function () {
container.classList.remove('is-scrollbar-visible');
C2._terminalScrollbarHideTimer = null;
}, 900);
};
const bindViewport = function () {
const vp = container.querySelector('.xterm-viewport');
if (!vp) return false;
if (vp.dataset.scrollbarBound === '1') return true;
vp.dataset.scrollbarBound = '1';
vp.addEventListener('scroll', show, { passive: true });
return true;
};
if (!container.dataset.scrollbarUiBound) {
container.dataset.scrollbarUiBound = '1';
container.addEventListener('wheel', show, { passive: true });
container.addEventListener('touchmove', show, { passive: true });
}
if (!bindViewport()) {
const mo = new MutationObserver(function () {
if (bindViewport()) mo.disconnect();
});
mo.observe(container, { childList: true, subtree: true });
setTimeout(function () { mo.disconnect(); }, 3000);
}
};
C2.fitTerminal = function() {
const container = document.getElementById('c2-terminal-container');
if (!container || !C2.terminalFitAddon || !C2.terminalInstance) return;
@@ -2392,18 +2771,22 @@
</thead>
<tbody>
${entries.map(function(entry) {
const sizeLabel = entry.isDir ? '—' : formatBytes(entry.size);
const iconCls = entry.isDir ? 'c2-file-icon c2-file-icon--dir' : 'c2-file-icon c2-file-icon--file';
return `
<tr>
<td class="c2-file-name">
<span class="c2-file-icon">${entry.isDir ? '📁' : '📄'}</span>
${escapeHtml(entry.name)}
<td>
<div class="c2-file-name">
<span class="${iconCls}" aria-hidden="true"></span>
<span class="c2-file-name-text" title="${escapeHtml(entry.name)}">${escapeHtml(entry.name)}</span>
</div>
</td>
<td>${escapeHtml(entry.size)}</td>
<td title="${escapeHtml(String(entry.size || ''))}">${escapeHtml(sizeLabel)}</td>
<td>${escapeHtml(entry.mode)}</td>
<td>
${entry.isDir
? `<button class="btn-ghost btn-sm" onclick='C2.openDirectory(${JSON.stringify(entry.name)})'>${escapeHtml(c2t('c2.files.open'))}</button>`
: `<button class="btn-ghost btn-sm" onclick='C2.downloadFile(${JSON.stringify(entry.name)})'>${escapeHtml(c2t('c2.files.download'))}</button>`
? `<button class="btn-ghost btn-sm c2-file-action-btn" onclick='C2.openDirectory(${JSON.stringify(entry.name)})'>${escapeHtml(c2t('c2.files.open'))}</button>`
: `<button class="btn-ghost btn-sm c2-file-action-btn" onclick='C2.downloadFile(${JSON.stringify(entry.name)})'>${escapeHtml(c2t('c2.files.download'))}</button>`
}
</td>
</tr>
@@ -2717,16 +3100,20 @@
// ============================================================================
C2.loadTasks = function(page) {
bindTasksFilterUiOnce();
readTasksFilterFromDom();
const p = page != null ? page : (C2.tasksPage || 1);
C2.tasksPage = p;
const ps = C2.tasksPageSize || 10;
apiRequest('GET', `${API_BASE}/tasks?page=${encodeURIComponent(String(p))}&page_size=${encodeURIComponent(String(ps))}`).then(data => {
const qs = buildTasksQueryParams(p, ps);
apiRequest('GET', `${API_BASE}/tasks?${qs}`).then(data => {
if (data.error) {
showToast(String(data.error), 'error');
return;
}
C2.tasks = data.tasks || [];
C2.tasksTotal = typeof data.total === 'number' ? data.total : (C2.tasks.length || 0);
C2.tasksStatusCounts = data.status_counts || { success: 0, failed: 0, pending: 0, cancelled: 0 };
if (typeof data.pending_queued_count === 'number') {
C2.tasksPendingQueuedCount = data.pending_queued_count;
}
@@ -2736,8 +3123,10 @@
return;
}
C2.renderTasks();
C2.renderTasksSummary();
C2.renderTasksPagination();
C2.syncTasksToolbar();
syncTasksTimePresetButtons();
}).catch(err => {
showToast(err.message || String(err), 'error');
});
@@ -2857,6 +3246,105 @@
}).catch(err => showToast(err.message || String(err), 'error'));
};
function readTasksFilterFromDom() {
const statusEl = document.getElementById('c2-tasks-filter-status');
const typeEl = document.getElementById('c2-tasks-filter-type');
const sessionEl = document.getElementById('c2-tasks-filter-session');
C2.tasksFilter = C2.tasksFilter || { status: '', task_type: '', session_id: '', since: '', timePreset: 'all' };
C2.tasksFilter.status = statusEl ? statusEl.value.trim() : '';
C2.tasksFilter.task_type = typeEl ? typeEl.value.trim() : '';
C2.tasksFilter.session_id = sessionEl ? sessionEl.value.trim() : '';
}
function buildTasksQueryParams(page, pageSize) {
const params = new URLSearchParams();
params.set('page', String(page));
params.set('page_size', String(pageSize));
const f = C2.tasksFilter || {};
if (f.status) params.set('status', f.status);
if (f.task_type) params.set('task_type', f.task_type);
if (f.session_id) params.set('session_id', f.session_id);
if (f.since) params.set('since', f.since);
return params.toString();
}
function tasksTimePresetToSince(preset) {
if (!preset || preset === 'all') return '';
const now = Date.now();
const map = { '15m': 15 * 60 * 1000, '1h': 60 * 60 * 1000, '24h': 24 * 60 * 60 * 1000, '7d': 7 * 24 * 60 * 60 * 1000 };
const ms = map[preset];
if (!ms) return '';
return new Date(now - ms).toISOString();
}
function syncTasksTimePresetButtons() {
const wrap = document.getElementById('c2-tasks-time-presets');
if (!wrap) return;
const active = (C2.tasksFilter && C2.tasksFilter.timePreset) || 'all';
wrap.querySelectorAll('.c2-tasks-time-preset-btn').forEach(btn => {
btn.classList.toggle('is-active', btn.getAttribute('data-preset') === active);
});
}
function bindTasksFilterUiOnce() {
if (document.documentElement.dataset.c2TasksFilterBound === '1') return;
document.documentElement.dataset.c2TasksFilterBound = '1';
const presets = document.getElementById('c2-tasks-time-presets');
if (presets) {
presets.addEventListener('click', (e) => {
const btn = e.target.closest('.c2-tasks-time-preset-btn');
if (!btn) return;
const preset = btn.getAttribute('data-preset') || 'all';
C2.tasksFilter = C2.tasksFilter || { status: '', task_type: '', session_id: '', since: '', timePreset: 'all' };
C2.tasksFilter.timePreset = preset;
C2.tasksFilter.since = tasksTimePresetToSince(preset);
syncTasksTimePresetButtons();
C2.loadTasks(1);
});
}
const sessionInput = document.getElementById('c2-tasks-filter-session');
if (sessionInput) {
sessionInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') C2.applyTasksFilters();
});
}
}
C2.applyTasksFilters = function() {
readTasksFilterFromDom();
C2.loadTasks(1);
};
C2.resetTasksFilters = function() {
C2.tasksFilter = { status: '', task_type: '', session_id: '', since: '', timePreset: 'all' };
const statusEl = document.getElementById('c2-tasks-filter-status');
const typeEl = document.getElementById('c2-tasks-filter-type');
const sessionEl = document.getElementById('c2-tasks-filter-session');
if (statusEl) statusEl.value = '';
if (typeEl) typeEl.value = '';
if (sessionEl) sessionEl.value = '';
syncTasksTimePresetButtons();
C2.loadTasks(1);
};
C2.renderTasksSummary = function() {
const summary = document.getElementById('c2-tasks-summary');
if (!summary) return;
const total = C2.tasksTotal || 0;
const counts = C2.tasksStatusCounts || { success: 0, failed: 0, pending: 0 };
const set = (id, val) => {
const el = document.getElementById(id);
if (el) el.textContent = String(val);
};
set('c2-tasks-stat-total', total);
set('c2-tasks-stat-success', counts.success || 0);
set('c2-tasks-stat-failed', counts.failed || 0);
set('c2-tasks-stat-pending', counts.pending || 0);
summary.hidden = total === 0;
};
C2.loadSessionTasks = function(sessionId) {
apiRequest('GET', `${API_BASE}/tasks?session_id=${encodeURIComponent(sessionId)}&limit=50`).then(data => {
const container = document.getElementById('c2-session-tasks-list');
@@ -2941,7 +3429,7 @@
}
if (C2.tasks.length === 0) {
container.innerHTML = '<div class="c2-empty">' + escapeHtml(c2t('c2.tasks.emptyAll')) + '</div>';
container.innerHTML = '<div class="c2-tasks-empty">' + escapeHtml(c2t('c2.tasks.emptyAll')) + '</div>';
if (selAll) selAll.disabled = true;
C2.syncTasksToolbar();
return;
@@ -2949,59 +3437,76 @@
if (selAll) selAll.disabled = false;
const delTitle = escapeHtml(c2t('c2.tasks.deleteOne'));
const cancelTitle = escapeHtml(c2t('c2.tasks.cancelBtn'));
const dash = '<span class="c2-tasks-cell-muted">—</span>';
const deleteIcon = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>';
container.innerHTML = `
<table class="c2-task-table">
<table class="c2-tasks-table">
<thead>
<tr>
<th class="c2-task-table-col-check"></th>
<th>${escapeHtml(c2t('c2.tasks.colTask'))}</th>
<th>${escapeHtml(c2t('c2.tasks.colSession'))}</th>
<th class="c2-tasks-table-col-check">
<label class="c2-task-check-label" title="${escapeHtml(c2t('c2.tasks.selectAll'))}">
<input type="checkbox" id="c2-tasks-select-all" onchange="C2.onTasksSelectAll(this.checked)">
</label>
</th>
<th>${escapeHtml(c2t('c2.tasks.colCreated'))}</th>
<th>${escapeHtml(c2t('c2.tasks.colStatus'))}</th>
<th>${escapeHtml(c2t('c2.tasks.colType'))}</th>
<th>${escapeHtml(c2t('c2.tasks.colCommand'))}</th>
<th>${escapeHtml(c2t('c2.tasks.colStatus'))}</th>
<th>${escapeHtml(c2t('c2.tasks.colSession'))}</th>
<th>${escapeHtml(c2t('c2.tasks.colTask'))}</th>
<th>${escapeHtml(c2t('c2.tasks.colDuration'))}</th>
<th>${escapeHtml(c2t('c2.tasks.colCreated'))}</th>
<th class="c2-task-table-col-actions">${escapeHtml(c2t('c2.tasks.colActions'))}</th>
<th class="c2-tasks-table-col-actions">${escapeHtml(c2t('c2.tasks.colActions'))}</th>
</tr>
</thead>
<tbody>
${C2.tasks.map(t => {
const rawId = t.id || '';
const shortTaskId = rawId.length > 14 ? escapeHtml(rawId.substring(0, 12)) + '\u2026' : escapeHtml(rawId);
const sid = t.sessionId ? escapeHtml(String(t.sessionId).substring(0, 8)) + '\u2026' : '-';
const tid = escapeHtml(rawId);
const status = String(t.status || '');
const rowStatus = escapeHtml(status || 'queued');
const typeCat = taskTypeCategory(t.taskType);
const sessionFull = t.sessionId ? String(t.sessionId) : '';
const sessionShort = sessionFull
? escapeHtml(sessionFull.substring(0, 10)) + (sessionFull.length > 10 ? '\u2026' : '')
: '';
const taskShort = rawId
? escapeHtml(rawId.substring(0, 10)) + (rawId.length > 10 ? '\u2026' : '')
: '';
const cmd = formatTaskCommand(t);
const cmdShort = truncateCommand(cmd, 48);
const cmdEsc = escapeHtml(cmd);
const canCancel = status === 'queued' || status === 'sent';
return `
<tr>
<td class="c2-task-table-col-check">
<label class="c2-task-check-label" onclick="event.stopPropagation();">
<input type="checkbox" class="c2-task-row-check" data-id="${escapeHtml(rawId)}" onchange="C2.syncTasksToolbar()">
<tr class="c2-tasks-row c2-tasks-row--${rowStatus}" data-task-id="${tid}" onclick="C2.viewTask('${tid}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();C2.viewTask('${tid}')}" role="button" tabindex="0">
<td class="c2-tasks-table-col-check" onclick="event.stopPropagation();">
<label class="c2-task-check-label">
<input type="checkbox" class="c2-task-row-check" data-id="${tid}" onchange="C2.syncTasksToolbar()">
</label>
</td>
<td>${shortTaskId}</td>
<td>${sid}</td>
<td>${escapeHtml(t.taskType || '')}</td>
<td class="c2-task-command-cell" title="${escapeHtml(cmd)}">${cmdShort ? escapeHtml(cmdShort) : '<span class="c2-muted">-</span>'}</td>
<td><span class="c2-status-badge ${escapeHtml(t.status || '')}">${escapeHtml(taskStatusLabel(t.status))}</span></td>
<td>${formatDuration(t.durationMs)}</td>
<td>${formatTime(t.createdAt)}</td>
<td class="c2-task-table-col-actions">
<div class="c2-task-table-actions">
<button type="button" class="btn-secondary btn-small" data-c2-task-action="view" data-task-id="${escapeHtml(rawId)}">${escapeHtml(c2t('c2.tasks.view'))}</button>
${t.status === 'queued' || t.status === 'sent'
? `<button type="button" class="btn-danger btn-small" data-c2-task-action="cancel" data-task-id="${escapeHtml(rawId)}">${escapeHtml(c2t('c2.tasks.cancelBtn'))}</button>`
: ''}
<button type="button" class="btn-danger btn-small" data-c2-task-action="delete" data-task-id="${escapeHtml(rawId)}" title="${delTitle}" aria-label="${delTitle}">${escapeHtml(c2t('c2.tasks.deleteBtn'))}</button>
<td class="c2-tasks-col-time">${escapeHtml(formatTime(t.createdAt))}</td>
<td><span class="c2-status-badge ${escapeHtml(status)}">${escapeHtml(taskStatusLabel(status))}</span></td>
<td><span class="c2-task-type-badge c2-task-type-badge--${typeCat}">${escapeHtml(t.taskType || '-')}</span></td>
<td class="c2-tasks-col-command" title="${cmdEsc}">${cmdEsc || dash}</td>
<td class="c2-tasks-col-mono" title="${escapeHtml(sessionFull)}">${sessionShort || dash}</td>
<td class="c2-tasks-col-mono" title="${tid}">${taskShort || dash}</td>
<td class="c2-tasks-col-duration">${formatDuration(t.durationMs)}</td>
<td class="c2-tasks-table-col-actions" onclick="event.stopPropagation();">
<div class="c2-tasks-row-actions">
${canCancel
? `<button type="button" class="c2-tasks-cancel-btn" data-c2-task-action="cancel" data-task-id="${tid}" title="${cancelTitle}" aria-label="${cancelTitle}">${escapeHtml(c2t('c2.tasks.cancelBtn'))}</button>`
: ''}
<button type="button" class="c2-tasks-delete-btn" data-require-permission="c2:delete" data-c2-task-action="delete" data-task-id="${tid}" title="${delTitle}" aria-label="${delTitle}">${deleteIcon}</button>
</div>
</td>
</tr>
`;
</tr>`;
}).join('')}
</tbody>
</table>
`;
C2.syncTasksToolbar();
if (typeof applyTranslations === 'function') applyTranslations(container);
if (typeof rbacAfterDynamicRender === 'function') rbacAfterDynamicRender(container);
};
C2.viewTask = function(id) {
+354 -10
View File
@@ -80,11 +80,16 @@ let chatAttachmentSeq = 0;
// 对话模式:eino_single = Eino ADK 单代理(/api/eino-agent/stream);deep / plan_execute / supervisor = Eino 多代理(/api/multi-agent/stream,请求体 orchestration
const AGENT_MODE_STORAGE_KEY = 'cyberstrike-chat-agent-mode';
const AI_CHANNEL_STORAGE_KEY = 'cyberstrike-chat-ai-channel';
const REASONING_MODE_LS = 'cyberstrike-chat-reasoning-mode';
const REASONING_EFFORT_LS = 'cyberstrike-chat-reasoning-effort';
const CHAT_AI_CHANNEL_SUMMARY_NAME_MAX = 10;
const CHAT_AGENT_MODE_EINO_SINGLE = 'eino_single';
const CHAT_AGENT_EINO_MODES = ['deep', 'plan_execute', 'supervisor'];
let multiAgentAPIEnabled = false;
let chatAIChannels = {};
let chatDefaultAIChannel = '';
let chatAIChannelIdByNormalizedId = {};
// 人机协同(HITL)会话级配置
const HITL_STORAGE_PREFIX = 'cyberstrike-chat-hitl';
@@ -96,6 +101,190 @@ const HITL_MODE_APPROVAL = 'approval';
const HITL_MODE_REVIEW_EDIT = 'review_edit';
const HITL_MODE_OPTIONS = [HITL_MODE_OFF, HITL_MODE_APPROVAL, HITL_MODE_REVIEW_EDIT];
let hitlApplyFeedbackTimer = null;
let hitlAutoSaveTimer = null;
const sessionSettingsSelects = new Map();
let sessionSettingsSelectDocBound = false;
function sessionSettingsSelectLabel(option) {
return option ? (option.textContent || option.label || option.value || '') : '';
}
function syncSessionSettingsSelect(select) {
const reg = sessionSettingsSelects.get(select);
if (!reg) return;
const selected = select.options[select.selectedIndex];
reg.value.textContent = sessionSettingsSelectLabel(selected);
reg.trigger.disabled = !!select.disabled;
reg.wrapper.classList.toggle('is-disabled', !!select.disabled);
reg.menu.innerHTML = '';
Array.prototype.forEach.call(select.options, function (option, index) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'session-settings-select-option';
item.setAttribute('role', 'option');
item.setAttribute('data-index', String(index));
item.setAttribute('aria-selected', option.selected ? 'true' : 'false');
item.disabled = !!option.disabled;
item.classList.toggle('is-selected', !!option.selected);
const label = document.createElement('span');
label.className = 'session-settings-select-option-label';
label.textContent = sessionSettingsSelectLabel(option);
item.appendChild(label);
reg.menu.appendChild(item);
});
}
function closeSessionSettingsSelect(select) {
const reg = sessionSettingsSelects.get(select);
if (!reg) return;
reg.wrapper.classList.remove('open');
reg.trigger.setAttribute('aria-expanded', 'false');
}
function closeAllSessionSettingsSelects() {
sessionSettingsSelects.forEach(function (_reg, select) {
closeSessionSettingsSelect(select);
});
}
function enhanceSessionSettingsSelect(select) {
if (!select || select.dataset.sessionSettingsSelect === '1') {
if (select) syncSessionSettingsSelect(select);
return;
}
const panel = select.closest && select.closest('.conversation-reasoning-card');
if (!panel) return;
select.dataset.sessionSettingsSelect = '1';
select.classList.add('session-settings-native-select');
select.tabIndex = -1;
select.setAttribute('aria-hidden', 'true');
const wrapper = document.createElement('div');
wrapper.className = 'session-settings-select';
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'session-settings-select-trigger';
trigger.setAttribute('aria-haspopup', 'listbox');
trigger.setAttribute('aria-expanded', 'false');
const value = document.createElement('span');
value.className = 'session-settings-select-value';
const caret = document.createElement('span');
caret.className = 'session-settings-select-caret';
caret.setAttribute('aria-hidden', 'true');
caret.textContent = '⌄';
trigger.appendChild(value);
trigger.appendChild(caret);
const menu = document.createElement('div');
menu.className = 'session-settings-select-menu';
menu.setAttribute('role', 'listbox');
select.parentNode.insertBefore(wrapper, select);
wrapper.appendChild(trigger);
wrapper.appendChild(menu);
wrapper.appendChild(select);
sessionSettingsSelects.set(select, { wrapper: wrapper, trigger: trigger, value: value, menu: menu });
trigger.addEventListener('click', function (event) {
event.stopPropagation();
if (select.disabled) return;
const willOpen = !wrapper.classList.contains('open');
closeAllSessionSettingsSelects();
wrapper.classList.toggle('open', willOpen);
trigger.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
});
trigger.addEventListener('keydown', function (event) {
if (select.disabled) return;
const enabled = Array.prototype.filter.call(select.options, function (option) { return !option.disabled; });
if (!enabled.length) return;
const currentOption = select.options[select.selectedIndex];
const current = Math.max(0, enabled.indexOf(currentOption));
let next = current;
if (event.key === 'ArrowDown') next = Math.min(enabled.length - 1, current + 1);
else if (event.key === 'ArrowUp') next = Math.max(0, current - 1);
else if (event.key === 'Home') next = 0;
else if (event.key === 'End') next = enabled.length - 1;
else if (event.key === 'Escape') {
closeSessionSettingsSelect(select);
return;
} else if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
wrapper.classList.add('open');
trigger.setAttribute('aria-expanded', 'true');
return;
} else {
return;
}
event.preventDefault();
const nextOption = enabled[next];
if (nextOption && select.value !== nextOption.value) {
select.value = nextOption.value;
select.dispatchEvent(new Event('change', { bubbles: true }));
}
syncSessionSettingsSelect(select);
});
menu.addEventListener('click', function (event) {
const item = event.target.closest('.session-settings-select-option');
if (!item || item.disabled) return;
event.stopPropagation();
const option = select.options[Number(item.dataset.index)];
if (option && !option.disabled && select.value !== option.value) {
select.value = option.value;
select.dispatchEvent(new Event('change', { bubbles: true }));
}
syncSessionSettingsSelect(select);
closeSessionSettingsSelect(select);
});
select.addEventListener('change', function () {
syncSessionSettingsSelect(select);
});
syncSessionSettingsSelect(select);
}
function initSessionSettingsSelects() {
const panel = document.getElementById('conversation-reasoning-body');
if (!panel) return;
panel.querySelectorAll('select').forEach(enhanceSessionSettingsSelect);
if (!sessionSettingsSelectDocBound) {
document.addEventListener('click', closeAllSessionSettingsSelects);
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape') closeAllSessionSettingsSelects();
});
sessionSettingsSelectDocBound = true;
}
}
function refreshSessionSettingsSelects() {
sessionSettingsSelects.forEach(function (_reg, select) {
syncSessionSettingsSelect(select);
});
}
function syncChatReasoningBarHeight() {
const inputBar = document.getElementById('chat-input-container');
const reasoning = document.getElementById('chat-reasoning-wrapper');
if (!inputBar || !reasoning) return;
const h = Math.ceil(inputBar.getBoundingClientRect().height || 0);
if (h > 0) {
reasoning.style.setProperty('--chat-input-bar-height', h + 'px');
}
}
function initChatReasoningBarHeightSync() {
syncChatReasoningBarHeight();
window.addEventListener('resize', syncChatReasoningBarHeight);
const inputBar = document.getElementById('chat-input-container');
if (inputBar && typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(syncChatReasoningBarHeight);
ro.observe(inputBar);
}
}
/** 非阻塞提示(与 chat-files-toast 样式共用) */
function showChatToast(message, type) {
@@ -391,7 +580,7 @@ function readHitlConfigFromForm() {
}
function updateHitlStatusUI(_cfg) {
/* 侧栏已改为「应用」按钮生效,不再用角标展示模式 */
/* 侧栏已改为自动保存,不再用角标展示模式 */
}
function applyHitlConfigToUI(cfg) {
@@ -409,6 +598,7 @@ function applyHitlConfigToUI(cfg) {
}
if (toolsEl) toolsEl.value = toolsVal;
updateHitlStatusUI(conf);
refreshSessionSettingsSelects();
}
function bindHitlSidebarModeListener() {
@@ -417,6 +607,9 @@ function bindHitlSidebarModeListener() {
modeEl.dataset.hitlModeBound = '1';
modeEl.addEventListener('change', function () {
applyHitlConfigToUI(readHitlConfigFromForm());
refreshSessionSettingsSelects();
scheduleHitlSidebarAutosave(0);
updateChatReasoningSummary();
});
}
@@ -458,7 +651,7 @@ function showHitlApplyFeedback(text, isError, partial) {
}
}
/** 侧栏人机协同:修改模式/白名单后点此写入本地、合并展示并同步服务端 */
/** 侧栏人机协同:自动写入本地、合并展示并尽量同步服务端 */
async function applyHitlSidebarConfig() {
const btn = document.getElementById('hitl-apply-btn');
showHitlApplyFeedback('', false);
@@ -486,7 +679,7 @@ async function applyHitlSidebarConfig() {
const ok = typeof window.t === 'function' ? window.t('chat.hitlApplyOkSync') : '人机协同配置已保存并同步到服务器。';
showHitlApplyFeedback(ok, false);
} else if (yamlMerged) {
const okYaml = typeof window.t === 'function' ? window.t('chat.hitlApplyOkWhitelistYaml') : '免审批工具已合并进 config.yaml 并生效。协同模式、超时等仍须选中会话后再点「应用」才会写入服务器。';
const okYaml = typeof window.t === 'function' ? window.t('chat.hitlApplyOkWhitelistYaml') : '免审批工具已合并进 config.yaml 并生效。会话配置会自动保存。';
showHitlApplyFeedback(okYaml, false);
} else {
const localOnly = typeof window.t === 'function' ? window.t('chat.hitlApplyOkLocal') : '已保存到本浏览器。';
@@ -505,6 +698,29 @@ async function applyHitlSidebarConfig() {
}
}
function scheduleHitlSidebarAutosave(delayMs) {
if (hitlAutoSaveTimer) {
clearTimeout(hitlAutoSaveTimer);
hitlAutoSaveTimer = null;
}
hitlAutoSaveTimer = setTimeout(function () {
hitlAutoSaveTimer = null;
applyHitlSidebarConfig();
}, typeof delayMs === 'number' ? delayMs : 500);
}
function bindHitlSensitiveToolsAutosaveListener() {
const toolsEl = document.getElementById('hitl-sensitive-tools');
if (!toolsEl || toolsEl.dataset.hitlAutosaveBound === '1') return;
toolsEl.dataset.hitlAutosaveBound = '1';
toolsEl.addEventListener('input', function () {
scheduleHitlSidebarAutosave(700);
});
toolsEl.addEventListener('blur', function () {
scheduleHitlSidebarAutosave(0);
});
}
/** 将 localStorage 规范为 eino_single | deep | plan_execute | supervisor */
function chatAgentModeNormalizeStored(stored, cfg) {
const pub = cfg && cfg.multi_agent ? cfg.multi_agent : null;
@@ -536,6 +752,7 @@ if (typeof window !== 'undefined') {
window.getHitlConfigForConversation = getHitlConfigForConversation;
bindHitlSidebarModeListener();
bindHitlReviewerToggleListeners();
bindHitlSensitiveToolsAutosaveListener();
window.setHitlReviewerUI = setHitlReviewerUI;
window.onHitlReviewerChanged = onHitlReviewerChanged;
window.bindHitlReviewerToggleListeners = bindHitlReviewerToggleListeners;
@@ -638,10 +855,85 @@ function syncReasoningRowVisibility(modeVal) {
if (!show) {
closeChatReasoningPanel();
} else {
syncChatReasoningBarHeight();
updateChatReasoningSummary();
}
}
function normalizeChatAIChannelId(s) {
return String(s || '').trim().toLowerCase().replace(/_/g, '-').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
function resolveChatAIChannelId(id) {
const raw = String(id || '').trim();
if (!raw) return '';
if (chatAIChannels[raw]) return raw;
const normalized = normalizeChatAIChannelId(raw);
return normalized && chatAIChannelIdByNormalizedId[normalized] ? chatAIChannelIdByNormalizedId[normalized] : '';
}
function populateChatAIChannelSelect(ai) {
const select = document.getElementById('chat-ai-channel-select');
if (!select) return;
const cfg = ai && typeof ai === 'object' ? ai : {};
chatAIChannels = cfg.channels && typeof cfg.channels === 'object' ? cfg.channels : {};
chatAIChannelIdByNormalizedId = {};
Object.keys(chatAIChannels).forEach(function (id) {
const normalized = normalizeChatAIChannelId(id);
if (normalized && !chatAIChannelIdByNormalizedId[normalized]) {
chatAIChannelIdByNormalizedId[normalized] = id;
}
});
chatDefaultAIChannel = resolveChatAIChannelId(cfg.default_channel || '');
select.innerHTML = '';
const fallbackOpt = document.createElement('option');
fallbackOpt.value = '';
fallbackOpt.textContent = typeof window.t === 'function' ? window.t('chat.aiChannelDefault') : '跟随默认通道';
select.appendChild(fallbackOpt);
Object.keys(chatAIChannels).sort().forEach(function (id) {
const ch = chatAIChannels[id] || {};
const opt = document.createElement('option');
opt.value = id;
opt.textContent = (ch.name || id) + (ch.model ? ' · ' + ch.model : '');
select.appendChild(opt);
});
let stored = '';
try { stored = localStorage.getItem(AI_CHANNEL_STORAGE_KEY) || ''; } catch (e) {}
stored = resolveChatAIChannelId(stored);
select.value = stored || '';
refreshSessionSettingsSelects();
updateChatReasoningSummary();
}
function selectedChatAIChannelId() {
const select = document.getElementById('chat-ai-channel-select');
return resolveChatAIChannelId(select ? select.value : '');
}
function currentChatAIChannelLabel() {
const id = selectedChatAIChannelId() || chatDefaultAIChannel;
const ch = id ? chatAIChannels[id] : null;
if (!ch) {
return typeof window.t === 'function' ? window.t('chat.aiChannelDefaultShort') : '默认通道';
}
return ch.name || id;
}
function truncateChatAIChannelSummaryLabel(label) {
const chars = Array.from(String(label || ''));
if (chars.length <= CHAT_AI_CHANNEL_SUMMARY_NAME_MAX) return chars.join('');
return chars.slice(0, CHAT_AI_CHANNEL_SUMMARY_NAME_MAX).join('') + '...';
}
function persistChatAIChannelPref() {
const id = selectedChatAIChannelId();
try {
if (id) localStorage.setItem(AI_CHANNEL_STORAGE_KEY, id);
else localStorage.removeItem(AI_CHANNEL_STORAGE_KEY);
} catch (e) {}
updateChatReasoningSummary();
}
function reasoningSummaryModeLabel(mode) {
const m = (mode || 'default').trim();
const t = (typeof window.t === 'function') ? window.t : function (k) { return k; };
@@ -662,8 +954,18 @@ function updateChatReasoningSummary() {
const effort = effEl && effEl.value ? String(effEl.value).trim() : '';
const t = (typeof window.t === 'function') ? window.t : function (k) { return k; };
const modePart = reasoningSummaryModeLabel(mode);
const effPart = effort || t('chat.reasoningSummaryDash');
el.textContent = modePart + ' / ' + effPart;
const reasoningPart = effort || modePart || t('chat.reasoningSummaryDash');
let hitlPart = '';
try {
const hitlCfg = readHitlConfigFromForm();
hitlPart = getHitlModeLabel(hitlCfg.mode);
} catch (e) {
hitlPart = '';
}
const channelPart = currentChatAIChannelLabel();
const parts = [truncateChatAIChannelSummaryLabel(channelPart), reasoningPart, hitlPart].filter(Boolean);
el.textContent = parts.join(' / ');
el.title = [channelPart, reasoningPart, hitlPart].filter(Boolean).join(' / ');
}
function closeChatReasoningPanel() {
@@ -677,6 +979,7 @@ function toggleConversationReasoningCard() {
const wrap = document.getElementById('chat-reasoning-wrapper');
const toggle = document.getElementById('conversation-reasoning-toggle');
if (!wrap || !toggle) return;
syncChatReasoningBarHeight();
wrap.classList.toggle('conversation-reasoning-collapsed');
const collapsed = wrap.classList.contains('conversation-reasoning-collapsed');
toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
@@ -711,6 +1014,7 @@ function restoreChatReasoningControlsFromStorage() {
e.value = v;
}
}
refreshSessionSettingsSelects();
updateChatReasoningSummary();
} catch (err) { /* ignore */ }
}
@@ -721,6 +1025,7 @@ function persistChatReasoningPrefs() {
const elEff = document.getElementById('chat-reasoning-effort');
if (m) localStorage.setItem(REASONING_MODE_LS, m.value || 'default');
if (elEff) localStorage.setItem(REASONING_EFFORT_LS, elEff.value || '');
refreshSessionSettingsSelects();
updateChatReasoningSummary();
} catch (err) { /* ignore */ }
}
@@ -746,12 +1051,15 @@ function buildReasoningRequestPayload() {
}
if (typeof window !== 'undefined') {
window.persistChatAIChannelPref = persistChatAIChannelPref;
window.populateChatAIChannelSelect = populateChatAIChannelSelect;
window.persistChatReasoningPrefs = persistChatReasoningPrefs;
window.buildReasoningRequestPayload = buildReasoningRequestPayload;
window.closeChatReasoningPanel = closeChatReasoningPanel;
window.toggleChatReasoningPanel = toggleChatReasoningPanel;
window.toggleConversationReasoningCard = toggleConversationReasoningCard;
window.updateChatReasoningSummary = updateChatReasoningSummary;
window.refreshSessionSettingsSelects = refreshSessionSettingsSelects;
}
function closeAgentModePanel() {
@@ -826,6 +1134,7 @@ async function initChatAgentModeFromConfig() {
if (!r.ok) return;
const cfg = await r.json();
multiAgentAPIEnabled = !!(cfg.multi_agent && cfg.multi_agent.enabled);
populateChatAIChannelSelect(cfg.ai || {});
if (typeof window !== 'undefined') {
window.__csaiMultiAgentPublic = cfg.multi_agent || null;
const tw = cfg.hitl && cfg.hitl.tool_whitelist;
@@ -1042,10 +1351,20 @@ async function sendMessage() {
conversationId: currentConversationId,
role: typeof getCurrentRole === 'function' ? getCurrentRole() : ''
};
let streamConversationId = body.conversationId ? String(body.conversationId) : null;
const isStreamStillVisibleForRequest = function () {
if (!document.getElementById(progressId)) return false;
if (!streamConversationId) return currentConversationId === body.conversationId;
return currentConversationId === streamConversationId;
};
if (!currentConversationId && typeof getActiveProjectId === 'function') {
const pid = getActiveProjectId();
if (pid) body.projectId = pid;
}
const aiChannelId = selectedChatAIChannelId();
if (aiChannelId) {
body.aiChannelId = aiChannelId;
}
const hitlCfg = readHitlConfigFromForm();
if (normalizeHitlMode(hitlCfg.mode) !== HITL_MODE_OFF) {
const sensitiveTools = hitlToolsSplitToArray(hitlCfg.sensitiveTools || '');
@@ -1078,7 +1397,7 @@ async function sendMessage() {
window.CyberStrikeChatScroll.onUserSendMessage();
}
const progressElement = document.getElementById(progressId);
registerProgressTask(progressId, currentConversationId);
registerProgressTask(progressId, streamConversationId);
loadActiveTasks();
let assistantMessageId = null;
let mcpExecutionIds = [];
@@ -1105,7 +1424,7 @@ async function sendMessage() {
window.__csAgentLiveStream = {
active: true,
conversationId: currentConversationId || null,
conversationId: streamConversationId || null,
progressId: progressId
};
try {
@@ -1117,9 +1436,26 @@ async function sendMessage() {
if (eventData && eventData.type === 'done') {
streamSawDone = true;
}
const eventConvId = eventData && eventData.data && eventData.data.conversationId
? String(eventData.data.conversationId)
: '';
let justBoundConversation = false;
if (eventConvId) {
if (streamConversationId && streamConversationId !== eventConvId) {
return;
}
if (!streamConversationId && eventData.type === 'conversation') {
streamConversationId = eventConvId;
justBoundConversation = true;
}
}
if (!justBoundConversation && !isStreamStillVisibleForRequest()) {
return;
}
handleStreamEvent(eventData, progressElement, progressId,
() => assistantMessageId, (id) => { assistantMessageId = id; },
() => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; });
() => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; },
{ conversationId: streamConversationId });
};
const processSseLines = typeof processSseDataLinesYielding === 'function'
? processSseDataLinesYielding
@@ -1157,13 +1493,13 @@ async function sendMessage() {
if (typeof loadActiveTasks === 'function') {
loadActiveTasks();
}
const convId = currentConversationId || (body && body.conversationId) || null;
const convId = streamConversationId || (body && body.conversationId) || null;
let attached = false;
if (convId && typeof window.attachRunningTaskEventStream === 'function') {
window.__csAgentLiveStream = { active: false, conversationId: null, progressId: null };
attached = await window.attachRunningTaskEventStream(convId).catch(() => false);
}
if (!attached) {
if (!attached && isStreamStillVisibleForRequest()) {
const hint = typeof window.t === 'function'
? window.t('chat.streamEndedWithoutDone')
: '连接提前结束,未收到任务完成信号。任务可能仍在后端执行,请查看顶部运行中任务或刷新当前对话。';
@@ -1186,6 +1522,12 @@ async function sendMessage() {
}
} catch (error) {
if (!isStreamStillVisibleForRequest()) {
if (typeof loadActiveTasks === 'function') {
loadActiveTasks();
}
return;
}
removeMessage(progressId);
const msg = error && error.message != null ? String(error.message) : String(error);
const isNetwork = /network|fetch|Failed to fetch|aborted|AbortError|load failed|NetworkError/i.test(msg);
@@ -10201,6 +10543,8 @@ function applyCustomIcon() {
// 自定义图标输入框回车键处理
document.addEventListener('DOMContentLoaded', function() {
initSessionSettingsSelects();
initChatReasoningBarHeightSync();
const customInput = document.getElementById('custom-icon-input');
if (customInput) {
customInput.addEventListener('keydown', function(e) {
+14 -4
View File
@@ -29,6 +29,16 @@ var dashboardState = {
lastProjectSummary: null, // 最近一次项目仪表盘摘要(供 Tab 切换时重绘)
};
function dashboardProjectScopedUrl(url) {
try {
var pid = typeof getActiveProjectId === 'function' ? (getActiveProjectId() || '') : '';
if (!pid) return url;
return url + (url.indexOf('?') === -1 ? '?' : '&') + 'project_id=' + encodeURIComponent(pid);
} catch (e) {
return url;
}
}
async function refreshDashboard() {
const runningEl = document.getElementById('dashboard-running-tasks');
const vulnTotalEl = document.getElementById('dashboard-vuln-total');
@@ -143,11 +153,11 @@ async function refreshDashboard() {
// External MCP 健康度
fetchJson('/api/external-mcp/stats'),
// WebShell 已建立的连接(pentest 落地后的 foothold,对运营场景非常关键)
fetchJson('/api/webshell/connections'),
fetchJson(dashboardProjectScopedUrl('/api/webshell/connections')),
// C2 仪表盘条:监听器 / 会话 / 待处理任务(任务接口含 pending_queued_count
fetchJson('/api/c2/listeners'),
fetchJson('/api/c2/sessions?limit=500'),
fetchJson('/api/c2/tasks?page=1&page_size=1'),
fetchJson(dashboardProjectScopedUrl('/api/c2/listeners')),
fetchJson(dashboardProjectScopedUrl('/api/c2/sessions?limit=500')),
fetchJson(dashboardProjectScopedUrl('/api/c2/tasks?page=1&page_size=1')),
fetchJson('/api/projects/dashboard-summary?fact_limit=10'),
selectedSeverityStatus ? fetchJson('/api/vulnerabilities/stats?status=' + encodeURIComponent(selectedSeverityStatus)) : Promise.resolve(null)
]);
+23
View File
@@ -615,6 +615,28 @@ function reconcileHitlUiState() {
let hitlFollowRunSeq = 0;
function hitlAutoResizeTextarea(textarea) {
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = Math.max(textarea.scrollHeight, textarea.offsetHeight || 0) + 'px';
}
function bindHitlAutoResizeTextareas(root) {
const scope = root || document;
if (!scope || !scope.querySelectorAll) return;
scope.querySelectorAll('.hitl-edit-args').forEach(function (textarea) {
if (textarea.__hitlAutoResizeBound) {
hitlAutoResizeTextarea(textarea);
return;
}
textarea.__hitlAutoResizeBound = true;
hitlAutoResizeTextarea(textarea);
textarea.addEventListener('input', function () {
hitlAutoResizeTextarea(textarea);
});
});
}
/**
* 审批提交后原 SSE 已断开:轮询任务列表,运行中则拉取过程详情;任务结束后再整页加载会话以对齐终态。
*/
@@ -855,6 +877,7 @@ async function refreshHitlPending() {
} else {
container.innerHTML = workflowHtml + (workflowHtml && toolHtml ? '<div class="hitl-pending-section-divider"></div>' : '') + (toolHtml || '');
}
bindHitlAutoResizeTextareas(container);
renderHitlPendingPagination();
} catch (e) {
hitlPendingLoaded = false;
+329 -45
View File
@@ -4,6 +4,7 @@ let userInterruptModalPending = null;
let activeTaskInterval = null;
const ACTIVE_TASK_REFRESH_INTERVAL = 10000; // 10秒检查一次
const TASK_FINAL_STATUSES = new Set(['failed', 'timeout', 'cancelled', 'completed']);
const hitlInterruptToolItemMap = new Map();
/**
* 主对话 POST 流仍在读取时禁止再挂 task-events 补流否则同一事件会画两遍 HITL 是否开启无关
@@ -19,6 +20,13 @@ function syncAgentLiveStreamConversationId(cid) {
} catch (e) { /* ignore */ }
}
function setCurrentConversationIdFromStream(cid) {
currentConversationId = cid;
try {
window.currentConversationId = cid;
} catch (e) { /* ignore */ }
}
function shouldSkipTaskEventReplayAttach(conversationId) {
try {
const live = window.__csAgentLiveStream;
@@ -1984,7 +1992,32 @@ function formatEinoRunRetryTitle(data) {
// 处理流式事件
function handleStreamEvent(event, progressElement, progressId,
getAssistantId, setAssistantId, getMcpIds, setMcpIds) {
getAssistantId, setAssistantId, getMcpIds, setMcpIds, options) {
const expectedConversationId = options && options.conversationId
? String(options.conversationId)
: '';
const eventConversationId = event && event.data && event.data.conversationId
? String(event.data.conversationId)
: '';
if (expectedConversationId) {
if (eventConversationId && eventConversationId !== expectedConversationId) {
return;
}
if (
typeof window.currentConversationId === 'string' &&
window.currentConversationId &&
window.currentConversationId !== expectedConversationId
) {
return;
}
const progressNode = progressId ? document.getElementById(progressId) : null;
const progressConversationId = progressNode && progressNode.dataset
? String(progressNode.dataset.conversationId || '')
: '';
if (progressConversationId && progressConversationId !== expectedConversationId) {
return;
}
}
const streamScrollWasPinned = typeof window.captureScrollPinState === 'function'
? window.captureScrollPinState()
: (typeof window.isChatMessagesPinnedToBottom === 'function' ? window.isChatMessagesPinnedToBottom() : true);
@@ -2048,7 +2081,7 @@ function handleStreamEvent(event, progressElement, progressId,
}
// 更新当前对话ID
currentConversationId = event.data.conversationId;
setCurrentConversationIdFromStream(event.data.conversationId);
syncAgentLiveStreamConversationId(event.data.conversationId);
updateActiveConversation();
addAttackChainButton(currentConversationId);
@@ -2408,29 +2441,38 @@ function handleStreamEvent(event, progressElement, progressId,
break;
case 'hitl_interrupt':
const hitlItemId = addTimelineItem(timeline, 'warning', {
title: '🧑‍⚖️ HITL',
message: event.message,
data: event.data
});
renderInlineHitlApproval(hitlItemId, event.data || {});
const hitlTargetItem = findToolCallItemForHitl(timeline, event.data || {});
if (hitlTargetItem && hitlTargetItem.id) {
renderInlineHitlApproval(hitlTargetItem.id, event.data || {});
} else {
const hitlItemId = addTimelineItem(timeline, 'hitl_interrupt', {
title: '🧑‍⚖️ HITL',
message: event.message,
data: event.data
});
renderInlineHitlApproval(hitlItemId, event.data || {});
}
try {
window.dispatchEvent(new CustomEvent('hitl-interrupt', { detail: event.data || {} }));
} catch (e) {}
break;
case 'hitl_resumed':
addTimelineItem(timeline, 'progress', {
title: '✅ HITL',
message: event.message,
data: event.data
});
if (!resolveInlineHitlDecision(timeline, event.data || {}, 'approve', event.message)) {
addTimelineItem(timeline, 'progress', {
title: '✅ HITL',
message: event.message,
data: event.data
});
}
break;
case 'hitl_rejected':
addTimelineItem(timeline, 'error', {
title: '⛔ HITL',
message: event.message,
data: event.data
});
if (!resolveInlineHitlDecision(timeline, event.data || {}, 'reject', event.message)) {
addTimelineItem(timeline, 'error', {
title: '⛔ HITL',
message: event.message,
data: event.data
});
}
break;
case 'user_interrupt_continue': {
@@ -2801,7 +2843,7 @@ function handleStreamEvent(event, progressElement, progressId,
updateProgressConversation(progressId, responseData.conversationId);
break;
}
currentConversationId = responseData.conversationId;
setCurrentConversationIdFromStream(responseData.conversationId);
syncAgentLiveStreamConversationId(responseData.conversationId);
updateActiveConversation();
addAttackChainButton(currentConversationId);
@@ -2904,7 +2946,7 @@ function handleStreamEvent(event, progressElement, progressId,
break;
}
currentConversationId = responseData.conversationId;
setCurrentConversationIdFromStream(responseData.conversationId);
syncAgentLiveStreamConversationId(responseData.conversationId);
updateActiveConversation();
addAttackChainButton(currentConversationId);
@@ -3064,7 +3106,7 @@ function handleStreamEvent(event, progressElement, progressId,
}
// 更新对话ID
if (event.data && event.data.conversationId) {
currentConversationId = event.data.conversationId;
setCurrentConversationIdFromStream(event.data.conversationId);
syncAgentLiveStreamConversationId(event.data.conversationId);
updateActiveConversation();
addAttackChainButton(currentConversationId);
@@ -3115,6 +3157,22 @@ function handleStreamEvent(event, progressElement, progressId,
function renderInlineHitlApproval(itemId, data) {
const item = document.getElementById(itemId);
if (!item || !data || !data.interruptId) return;
if (item.classList.contains('timeline-item-tool_call')) {
const state = toolCallDetailStateByItemId.get(item.id) || {};
state.hitlData = data;
state.pending = true;
setToolCallDetailState(item, state);
if (data.interruptId) {
hitlInterruptToolItemMap.set(String(data.interruptId), item.id);
}
const existingContent = item.querySelector('.timeline-item-content.tool-call-detail-content');
if (existingContent) {
existingContent.remove();
item.classList.remove('tool-call-detail-expanded');
}
renderToolCallDetailContent(item);
return;
}
let contentEl = item.querySelector('.timeline-item-content');
if (!contentEl) {
// warning 等类型默认没有内容区域;HITL 内联审批需要可交互容器
@@ -3136,31 +3194,165 @@ function renderInlineHitlApproval(itemId, data) {
const allowEdit = mode === 'review_edit';
const argsObj = payload.argumentsObj && typeof payload.argumentsObj === 'object' ? payload.argumentsObj : {};
const argsJSON = JSON.stringify(argsObj, null, 2);
const modeLabel = mode === 'review_edit' ? '审查编辑' : '审批模式';
const panel = document.createElement('div');
panel.className = 'hitl-inline-approval';
panel.innerHTML = `
<div class="hitl-input-help"><strong>${escapeHtml(toolName)}</strong> ${escapeHtml(mode || '-')}</div>
${allowEdit
? `<div class="hitl-input-help">审查编辑参数(JSON,可选):留空表示沿用原参数。</div>
<textarea class="hitl-edit-args hitl-inline-edit" placeholder='{"command":"ls -la"}'>${escapeHtml(argsJSON === '{}' ? '' : argsJSON)}</textarea>`
: '<div class="hitl-input-help">当前模式不支持改参,仅可通过/拒绝。</div>'
panel.innerHTML = buildInlineHitlApprovalHtml(data, {
toolName: toolName,
mode: mode,
modeLabel: modeLabel,
allowEdit: allowEdit,
argsJSON: argsJSON
});
contentEl.appendChild(panel);
bindInlineHitlApproval(panel, data, { allowEdit: allowEdit });
}
function resolveInlineHitlDecision(timeline, data, decision, message) {
if (!timeline || !data || !data.interruptId) return false;
const interruptId = String(data.interruptId);
let item = null;
const mappedId = hitlInterruptToolItemMap.get(interruptId);
if (mappedId) item = document.getElementById(mappedId);
if (!item) item = findToolCallItemForHitl(timeline, data);
if (!item) {
item = timeline.querySelector('[data-hitl-interrupt-id="' + hitlEscapeAttrSelector(interruptId) + '"]');
}
if (!item) return false;
if (item.classList.contains('timeline-item-tool_call')) {
const state = toolCallDetailStateByItemId.get(item.id) || {};
state.hitlData = Object.assign({}, state.hitlData || data, {
resolved: true,
decision: decision,
decisionMessage: message || '',
comment: data.comment || (state.hitlData && state.hitlData.comment) || '',
editedArgs: data.editedArgs || data.editedArguments || (state.hitlData && (state.hitlData.editedArgs || state.hitlData.editedArguments)) || null
});
if (decision === 'approve' && state.hitlData.editedArgs && typeof state.hitlData.editedArgs === 'object') {
state.originalArgs = state.originalArgs || state.args || {};
state.args = state.hitlData.editedArgs;
state.argsEditedByHitl = true;
}
<div class="hitl-input-help">备注可选建议写审批依据</div>
<input class="hitl-config-input hitl-inline-comment" type="text" placeholder="例如:允许只读命令">
<div class="hitl-pending-actions">
state.pending = decision === 'approve';
setToolCallDetailState(item, state);
const content = item.querySelector('.timeline-item-content.tool-call-detail-content');
if (content) {
content.remove();
item.classList.remove('tool-call-detail-expanded');
}
renderToolCallDetailContent(item);
return true;
}
const panel = item.querySelector('.hitl-inline-approval');
if (panel) {
markInlineHitlDecision(panel, decision, message || '');
return true;
}
return false;
}
function findToolCallItemForHitl(timeline, data) {
if (!timeline || !data) return null;
const payload = data.payload && typeof data.payload === 'object' ? data.payload : {};
const toolCallId = String(data.toolCallId || payload.toolCallId || '').trim();
if (toolCallId) {
const byId = timeline.querySelector('[data-tool-call-id="' + hitlEscapeAttrSelector(toolCallId) + '"]');
if (byId && byId.classList.contains('timeline-item-tool_call')) return byId;
}
const toolName = String(data.toolName || payload.toolName || '').trim().toLowerCase();
if (!toolName) return null;
const shortWant = toolName.indexOf('::') >= 0 ? toolName.split('::').pop() : toolName;
const calls = timeline.querySelectorAll('.timeline-item-tool_call');
for (let i = calls.length - 1; i >= 0; i--) {
const tn = String(calls[i].dataset.toolName || '').trim().toLowerCase();
const shortTn = tn.indexOf('::') >= 0 ? tn.split('::').pop() : tn;
if (tn === toolName || tn.endsWith('::' + shortWant) || shortTn === shortWant) {
return calls[i];
}
}
return null;
}
function buildInlineHitlApprovalHtml(data, opts) {
const hasToolNameOverride = opts && Object.prototype.hasOwnProperty.call(opts, 'toolName');
const toolName = hasToolNameOverride ? String(opts.toolName || '') : (data.toolName || '-');
const mode = opts && opts.mode ? opts.mode : String(data.mode || 'approval').trim().toLowerCase();
const modeLabel = opts && opts.modeLabel ? opts.modeLabel : (mode === 'review_edit' ? '审查编辑' : '审批模式');
const allowEdit = opts && opts.allowEdit === true;
const argsJSON = opts && opts.argsJSON ? opts.argsJSON : '';
const toolBadge = toolName
? '<span class="hitl-tool-badge">' + escapeHtml(toolName) + '</span>'
: '';
if (data && data.resolved) {
const ok = data.decision === 'approve';
const text = data.decisionMessage || (ok ? '已通过,继续执行' : '已拒绝');
const comment = data.comment ? '<span class="hitl-inline-decision-comment">' + escapeHtml(data.comment) + '</span>' : '';
return `
<div class="hitl-inline-decision hitl-inline-decision--${ok ? 'approve' : 'reject'}">
<span class="hitl-inline-decision-dot" aria-hidden="true"></span>
<strong>${escapeHtml(ok ? '审批通过' : '审批拒绝')}</strong>
<span>${escapeHtml(text)}</span>
${comment}
</div>
`;
}
return `
<div class="hitl-inline-header">
<div class="hitl-inline-title">
<span class="hitl-inline-icon" aria-hidden="true">!</span>
<span>待审批</span>
</div>
<div class="hitl-inline-badges">
${toolBadge}
<span class="hitl-mode-tag hitl-mode-tag--${escapeHtml(mode || 'approval')}">${escapeHtml(modeLabel)}</span>
</div>
</div>
<div class="hitl-inline-body">
<div class="hitl-input-help hitl-inline-summary">确认上方参数后决定是否继续执行</div>
${allowEdit
? `<label class="hitl-inline-field">
<span class="hitl-context-label">参数覆盖JSON可选</span>
<textarea class="hitl-edit-args hitl-inline-edit" placeholder='{"command":"ls -la"}'>${escapeHtml(argsJSON === '{}' ? '' : argsJSON)}</textarea>
</label>`
: ''
}
<label class="hitl-inline-field">
<span class="hitl-context-label">备注可选</span>
<input class="hitl-config-input hitl-inline-comment" type="text" placeholder="例如:允许只读命令">
</label>
</div>
<div class="hitl-pending-actions hitl-inline-actions">
<div class="hitl-input-help hitl-inline-status" aria-live="polite"></div>
<button class="btn-secondary hitl-inline-reject">拒绝</button>
<button class="btn-primary hitl-inline-approve">通过</button>
</div>
<div class="hitl-input-help hitl-inline-status"></div>
`;
contentEl.appendChild(panel);
}
function autoResizeHitlTextarea(textarea) {
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = Math.max(textarea.scrollHeight, textarea.offsetHeight || 0) + 'px';
}
function bindInlineHitlApproval(panel, data, opts) {
const approveBtn = panel.querySelector('.hitl-inline-approve');
const rejectBtn = panel.querySelector('.hitl-inline-reject');
const commentInput = panel.querySelector('.hitl-inline-comment');
const editInput = panel.querySelector('.hitl-inline-edit');
const statusEl = panel.querySelector('.hitl-inline-status');
const allowEdit = opts && opts.allowEdit === true;
if (!approveBtn || !rejectBtn || !commentInput || !statusEl) return;
if (editInput) {
autoResizeHitlTextarea(editInput);
editInput.addEventListener('input', function () {
autoResizeHitlTextarea(editInput);
});
}
const setBusy = function (busy) {
approveBtn.disabled = busy;
@@ -3197,7 +3389,27 @@ function renderInlineHitlApproval(itemId, data) {
setBusy(false);
return;
}
statusEl.textContent = decision === 'approve' ? '已通过,等待执行继续...' : '已拒绝,反馈已交给模型继续迭代...';
const msg = decision === 'approve' ? '已通过,等待执行继续...' : '已拒绝,反馈已交给模型继续迭代...';
const toolItem = panel.closest('.timeline-item-tool_call');
if (toolItem && toolItem.id) {
const state = toolCallDetailStateByItemId.get(toolItem.id) || {};
state.hitlData = Object.assign({}, state.hitlData || data, {
resolved: true,
decision: decision,
decisionMessage: msg,
comment: comment,
editedArgs: editedArgs
});
if (decision === 'approve' && editedArgs && typeof editedArgs === 'object') {
state.originalArgs = state.originalArgs || state.args || {};
state.args = editedArgs;
state.argsEditedByHitl = true;
}
state.pending = decision === 'approve';
setToolCallDetailState(toolItem, state);
}
statusEl.textContent = msg;
markInlineHitlDecision(panel, decision, msg);
panel.classList.add('hitl-inline-done');
} catch (e) {
statusEl.textContent = '提交失败:' + (e && e.message ? e.message : 'unknown error');
@@ -3209,6 +3421,19 @@ function renderInlineHitlApproval(itemId, data) {
rejectBtn.onclick = function () { submit('reject'); };
}
function markInlineHitlDecision(panel, decision, message) {
if (!panel) return;
const ok = decision === 'approve';
panel.classList.add('hitl-inline-done');
panel.innerHTML = `
<div class="hitl-inline-decision hitl-inline-decision--${ok ? 'approve' : 'reject'}">
<span class="hitl-inline-decision-dot" aria-hidden="true"></span>
<strong>${escapeHtml(ok ? '审批通过' : '审批拒绝')}</strong>
<span>${escapeHtml(message || (ok ? '已通过,继续执行' : '已拒绝'))}</span>
</div>
`;
}
function renderInlineWorkflowHitlApproval(itemId, data) {
const item = document.getElementById(itemId);
if (!item || !data) return;
@@ -3228,15 +3453,28 @@ function renderInlineWorkflowHitlApproval(itemId, data) {
const panel = document.createElement('div');
panel.className = 'workflow-hitl-inline-approval hitl-inline-approval';
panel.innerHTML = `
<div class="hitl-input-help"><strong>${escapeHtml(label)}</strong> </div>
${prompt ? `<div class="hitl-input-help">${escapeHtml(prompt)}</div>` : ''}
<div class="hitl-input-help">备注可选</div>
<input class="hitl-config-input workflow-hitl-inline-comment" type="text" placeholder="审批意见">
<div class="hitl-pending-actions">
<div class="hitl-inline-header">
<div class="hitl-inline-title">
<span class="hitl-inline-icon" aria-hidden="true">!</span>
<span>工作流审批</span>
</div>
<div class="hitl-inline-badges">
<span class="hitl-tool-badge">${escapeHtml(label)}</span>
<span class="hitl-mode-tag hitl-mode-tag--approval">审批模式</span>
</div>
</div>
<div class="hitl-inline-body">
${prompt ? `<div class="hitl-inline-note">${escapeHtml(prompt)}</div>` : '<div class="hitl-inline-note">工作流暂停,等待你确认是否继续。</div>'}
<label class="hitl-inline-field">
<span class="hitl-context-label">备注可选</span>
<input class="hitl-config-input workflow-hitl-inline-comment" type="text" placeholder="审批意见">
</label>
</div>
<div class="hitl-pending-actions hitl-inline-actions">
<div class="hitl-input-help workflow-hitl-inline-status" aria-live="polite"></div>
<button class="btn-secondary workflow-hitl-inline-reject">拒绝</button>
<button class="btn-primary workflow-hitl-inline-approve">通过</button>
</div>
<div class="hitl-input-help workflow-hitl-inline-status"></div>
`;
contentEl.appendChild(panel);
@@ -3520,8 +3758,8 @@ async function restoreHitlInlineForConversation(conversationId) {
payload: payloadObj,
conversationId: item.conversationId || conversationId
};
let hitlItemEl = detailsContainer.querySelector('[data-hitl-interrupt-id="' + hitlEscapeAttrSelector(String(item.id)) + '"]');
if (!hitlItemEl && item.toolCallId) {
let hitlItemEl = null;
if (item.toolCallId) {
hitlItemEl = detailsContainer.querySelector('[data-tool-call-id="' + hitlEscapeAttrSelector(String(item.toolCallId)) + '"]');
}
if (!hitlItemEl && item.toolName) {
@@ -3538,6 +3776,9 @@ async function restoreHitlInlineForConversation(conversationId) {
}
}
}
if (!hitlItemEl) {
hitlItemEl = detailsContainer.querySelector('[data-hitl-interrupt-id="' + hitlEscapeAttrSelector(String(item.id)) + '"]');
}
if (!hitlItemEl) continue;
renderInlineHitlApproval(hitlItemEl.id, hitlData);
}
@@ -3683,7 +3924,16 @@ async function attachRunningTaskEventStream(conversationId) {
if (eventData && eventData.type === 'done') {
replaySawDone = true;
}
handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = mergeMcpExecutionIDLists(mcpIds, ids || []); });
if (typeof window.currentConversationId === 'string' && window.currentConversationId !== conversationId) {
return;
}
const eventConvId = eventData && eventData.data && eventData.data.conversationId
? String(eventData.data.conversationId)
: '';
if (eventConvId && eventConvId !== conversationId) {
return;
}
handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = mergeMcpExecutionIDLists(mcpIds, ids || []); }, { conversationId: conversationId });
};
while (true) {
const chunk = await reader.read();
@@ -3842,7 +4092,7 @@ function buildToolResultSectionHtml(data, opts) {
};
const execResultLabel = _t('timeline.executionResult');
const execIdLabel = _t('timeline.executionId');
const waitingLabel = _t('timeline.running');
const waitingLabel = opts.pendingText || _t('timeline.running');
if (opts.pending) {
return (
'<div class="tool-result-section pending">' +
@@ -3982,21 +4232,55 @@ async function renderToolCallDetailContent(item) {
buildToolResultSectionHtml(state.resultData, { rawText: state.rawText }) +
'</div>';
} else if (state.pending !== false) {
let pendingOpts = { pending: true };
if (state.hitlData && state.hitlData.interruptId) {
pendingOpts = {
pending: true,
pendingText: state.hitlData.resolved ? '已通过,等待执行结果' : '等待审批,通过后执行'
};
}
resultBlock = '<div class="tool-details tool-result-slot">' +
buildToolResultSectionHtml({}, { pending: true }) +
buildToolResultSectionHtml({}, pendingOpts) +
'</div>';
}
const paramsLabel = typeof window.t === 'function' ? window.t('timeline.params') : '参数:';
const hitlEditedArgsLabel = state.argsEditedByHitl
? '<span class="tool-args-hitl-edited">已按 HITL 改参执行</span>'
: '';
const argsBlock = state.hideArgs ? '' :
'<div class="tool-arg-section">' +
'<strong data-i18n="timeline.params">' + escapeHtml(paramsLabel) + '</strong>' +
hitlEditedArgsLabel +
'<pre class="tool-args">' + escapeHtml(JSON.stringify(args, null, 2)) + '</pre>' +
'</div>';
content.innerHTML = '<div class="tool-details">' + argsBlock + resultBlock + '</div>';
let hitlBlock = '';
if (state.hitlData && state.hitlData.interruptId) {
const hitlPayload = state.hitlData.payload && typeof state.hitlData.payload === 'object' ? state.hitlData.payload : {};
let hitlMode = String(state.hitlData.mode || '').trim().toLowerCase();
if (hitlMode === 'feedback' || hitlMode === 'followup') hitlMode = 'approval';
const hitlAllowEdit = hitlMode === 'review_edit';
const hitlArgsObj = hitlPayload.argumentsObj && typeof hitlPayload.argumentsObj === 'object' ? hitlPayload.argumentsObj : args;
hitlBlock = '<div class="hitl-inline-approval hitl-inline-approval--merged">' +
buildInlineHitlApprovalHtml(state.hitlData, {
toolName: '',
mode: hitlMode,
modeLabel: hitlMode === 'review_edit' ? '审查编辑' : '审批模式',
allowEdit: hitlAllowEdit,
argsJSON: JSON.stringify(hitlArgsObj || {}, null, 2)
}) +
'</div>';
}
content.innerHTML = '<div class="tool-details">' + argsBlock + resultBlock + hitlBlock + '</div>';
item.appendChild(content);
item.classList.add('tool-call-detail-expanded');
updateToolDetailToggleLabel(item);
const hitlPanel = content.querySelector('.hitl-inline-approval');
if (hitlPanel && state.hitlData) {
let bindMode = String(state.hitlData.mode || '').trim().toLowerCase();
if (bindMode === 'feedback' || bindMode === 'followup') bindMode = 'approval';
bindInlineHitlApproval(hitlPanel, state.hitlData, { allowEdit: bindMode === 'review_edit' });
}
}
if (typeof document !== 'undefined' && !document.__cyberStrikeToolCallDetailToggleBound) {
+6 -1
View File
@@ -596,8 +596,12 @@ function prefetchProjectsForChat() {
ensureProjectsLoaded().catch(() => {});
}
/** 新对话时默认不绑定项目;用户需主动选择后才写入共享黑板 */
/** 新对话沿用用户最近选择的项目;没有选择时才保持未绑定。 */
async function ensureDefaultActiveProjectForNewChat() {
const id = getActiveProjectId();
if (!id) return '';
const project = await fetchProjectSummary(id).catch(() => null);
if (project && project.id && project.status !== 'archived') return project.id;
setActiveProjectId('');
return '';
}
@@ -988,6 +992,7 @@ function updateProjectStats(stats) {
async function selectProject(id) {
currentProjectId = id;
if (id) setActiveProjectId(id);
projectAssetsPagination.page = 1;
const searchEl = document.getElementById('project-facts-search');
const catEl = document.getElementById('project-facts-filter-category');
+546 -50
View File
@@ -1,5 +1,9 @@
// 设置相关功能
let currentConfig = null;
let selectedAIChannelId = '';
const AI_CHANNEL_PROBE_CONCURRENCY = 2;
const selectedAIChannelBulkIds = new Set();
const aiChannelProbeResults = {};
let allTools = [];
let alwaysVisibleToolNames = new Set();
let alwaysVisibleBuiltinToolNames = new Set();
@@ -668,39 +672,10 @@ async function loadConfig(loadTools = true, options = {}) {
});
}
// 填充OpenAI配置
const providerEl = document.getElementById('openai-provider');
if (providerEl) {
providerEl.value = currentConfig.openai.provider || 'openai';
}
document.getElementById('openai-api-key').value = currentConfig.openai.api_key || '';
document.getElementById('openai-base-url').value = currentConfig.openai.base_url || '';
document.getElementById('openai-model').value = currentConfig.openai.model || '';
const maxTokensEl = document.getElementById('openai-max-total-tokens');
if (maxTokensEl) {
maxTokensEl.value = currentConfig.openai.max_total_tokens || 120000;
}
const orm = currentConfig.openai && currentConfig.openai.reasoning ? currentConfig.openai.reasoning : {};
const orModeEl = document.getElementById('openai-reasoning-mode');
if (orModeEl) {
const mv = (orm.mode || 'auto').toString().trim().toLowerCase();
orModeEl.value = ['auto', 'on', 'off'].includes(mv) ? mv : 'auto';
}
const orEffEl = document.getElementById('openai-reasoning-effort');
if (orEffEl) {
const ev = (orm.effort || '').toString().trim().toLowerCase();
orEffEl.value = ['', 'low', 'medium', 'high', 'max', 'xhigh'].includes(ev) ? ev : '';
}
const orProfEl = document.getElementById('openai-reasoning-profile');
if (orProfEl) {
const pv = (orm.profile || 'auto').toString().trim().toLowerCase();
const ok = ['auto', 'deepseek_compat', 'openai_compat', 'output_config_effort'];
orProfEl.value = ok.includes(pv) ? pv : 'auto';
}
const orAllowEl = document.getElementById('openai-reasoning-allow-client');
if (orAllowEl) {
orAllowEl.checked = orm.allow_client_reasoning !== false;
}
currentConfig.ai = ensureAIConfigShape(currentConfig);
selectedAIChannelId = currentConfig.ai.default_channel;
renderAIChannelSelect();
writeAIChannelToMainForm(selectedAIChannelId);
fillVisionConfigFromCurrent(currentConfig.vision || {});
initModelListControls();
@@ -1897,7 +1872,14 @@ async function applySettings() {
};
const wecomAgentIdVal = document.getElementById('robot-wecom-agent-id')?.value.trim();
const prevOpenai = (currentConfig && currentConfig.openai) ? currentConfig.openai : {};
if (!currentConfig) currentConfig = {};
currentConfig.ai = ensureAIConfigShape(currentConfig);
const activeChannelId = normalizeAIChannelId(selectedAIChannelId || currentConfig.ai.default_channel || 'default');
currentConfig.ai.channels[activeChannelId] = readAIChannelFromMainForm(activeChannelId);
currentConfig.ai.default_channel = activeChannelId;
renderAIChannelSelect();
const activeChannel = currentConfig.ai.channels[activeChannelId] || {};
const prevOpenai = activeChannel;
const prevRobots = (currentConfig && currentConfig.robots) ? currentConfig.robots : {};
const prevHitl = (currentConfig && currentConfig.hitl) ? currentConfig.hitl : {};
const hitlRetentionRaw = document.getElementById('hitl-retention-days')?.value;
@@ -1909,21 +1891,7 @@ async function applySettings() {
return String(s || '').split(/[\n,]/).map(v => v.trim()).filter(Boolean);
};
const config = {
openai: {
...prevOpenai,
provider: provider,
api_key: apiKey,
base_url: baseUrl,
model: model,
max_total_tokens: parseInt(document.getElementById('openai-max-total-tokens')?.value) || 120000,
reasoning: {
...(prevOpenai.reasoning || {}),
mode: document.getElementById('openai-reasoning-mode')?.value || 'auto',
effort: (document.getElementById('openai-reasoning-effort')?.value || '').trim(),
profile: document.getElementById('openai-reasoning-profile')?.value || 'auto',
allow_client_reasoning: document.getElementById('openai-reasoning-allow-client')?.checked !== false
}
},
ai: currentConfig.ai,
vision: visionPayload,
fofa: {
api_key: document.getElementById('fofa-api-key')?.value.trim() || '',
@@ -2438,6 +2406,534 @@ function enhanceModelPickSelect(selectId) {
syncModelPickDropdown(selectId);
}
function normalizeAIChannelId(name) {
const raw = String(name || '').trim().toLowerCase().replace(/_/g, '-');
const id = raw.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
return id || 'default';
}
function escapeAIChannelHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function ensureAIConfigShape(cfg) {
const ai = cfg && cfg.ai && typeof cfg.ai === 'object' ? cfg.ai : {};
const channels = ai.channels && typeof ai.channels === 'object' ? { ...ai.channels } : {};
let def = normalizeAIChannelId(ai.default_channel || '');
if (!channels[def]) {
const oa = (cfg && cfg.openai) ? cfg.openai : {};
channels[def] = {
name: def === 'default' ? 'Default' : def,
provider: oa.provider || 'openai',
api_key: oa.api_key || '',
base_url: oa.base_url || '',
model: oa.model || '',
max_total_tokens: oa.max_total_tokens || 120000,
max_completion_tokens: oa.max_completion_tokens || 0,
reasoning: oa.reasoning || {}
};
}
return { default_channel: def, channels };
}
function readAIChannelFromMainForm(id) {
const prev = currentConfig?.ai?.channels?.[id] || {};
const maxCompletionTokens = parseInt(document.getElementById('openai-max-completion-tokens')?.value, 10) || 16384;
return {
...prev,
name: (document.getElementById('ai-channel-name')?.value || '').trim() || prev.name || id,
provider: document.getElementById('openai-provider')?.value || 'openai',
api_key: document.getElementById('openai-api-key')?.value.trim() || '',
base_url: document.getElementById('openai-base-url')?.value.trim() || '',
model: document.getElementById('openai-model')?.value.trim() || '',
max_total_tokens: parseInt(document.getElementById('openai-max-total-tokens')?.value, 10) || 120000,
max_completion_tokens: maxCompletionTokens,
reasoning: {
...(prev.reasoning || {}),
mode: document.getElementById('openai-reasoning-mode')?.value || 'auto',
effort: (document.getElementById('openai-reasoning-effort')?.value || '').trim(),
profile: document.getElementById('openai-reasoning-profile')?.value || 'auto',
allow_client_reasoning: document.getElementById('openai-reasoning-allow-client')?.checked !== false
}
};
}
function writeAIChannelToMainForm(id) {
const ai = ensureAIConfigShape(currentConfig || {});
const ch = ai.channels[id] || ai.channels[ai.default_channel] || {};
selectedAIChannelId = id || ai.default_channel;
const nameEl = document.getElementById('ai-channel-name');
if (nameEl) nameEl.value = ch.name || selectedAIChannelId;
const providerEl = document.getElementById('openai-provider');
if (providerEl) {
const provider = (ch.provider === 'openai' || !ch.provider) ? 'openai_compatible' : ch.provider;
providerEl.value = provider;
}
const keyEl = document.getElementById('openai-api-key');
if (keyEl) keyEl.value = ch.api_key || '';
const baseEl = document.getElementById('openai-base-url');
if (baseEl) baseEl.value = ch.base_url || '';
const modelEl = document.getElementById('openai-model');
if (modelEl) modelEl.value = ch.model || '';
const maxTokensEl = document.getElementById('openai-max-total-tokens');
if (maxTokensEl) maxTokensEl.value = ch.max_total_tokens || 120000;
const maxCompletionTokensEl = document.getElementById('openai-max-completion-tokens');
if (maxCompletionTokensEl) maxCompletionTokensEl.value = ch.max_completion_tokens || 16384;
const r = ch.reasoning || {};
const modeEl = document.getElementById('openai-reasoning-mode');
if (modeEl) modeEl.value = ['auto', 'on', 'off'].includes(String(r.mode || '').toLowerCase()) ? String(r.mode).toLowerCase() : 'auto';
const effEl = document.getElementById('openai-reasoning-effort');
if (effEl) effEl.value = ['', 'low', 'medium', 'high', 'max', 'xhigh'].includes(String(r.effort || '').toLowerCase()) ? String(r.effort || '').toLowerCase() : '';
const profileEl = document.getElementById('openai-reasoning-profile');
if (profileEl) profileEl.value = ['auto', 'deepseek_compat', 'openai_compat', 'output_config_effort'].includes(String(r.profile || '').toLowerCase()) ? String(r.profile || '').toLowerCase() : 'auto';
const allowEl = document.getElementById('openai-reasoning-allow-client');
if (allowEl) allowEl.checked = r.allow_client_reasoning !== false;
syncModelListFetchButtons();
}
function renderAIChannelSelect() {
if (!currentConfig) return;
currentConfig.ai = ensureAIConfigShape(currentConfig);
const select = document.getElementById('ai-channel-select');
if (!select) return;
select.innerHTML = '';
const ids = Object.keys(currentConfig.ai.channels || {}).sort();
ids.forEach((id) => {
const ch = currentConfig.ai.channels[id] || {};
const opt = document.createElement('option');
opt.value = id;
const marker = id === currentConfig.ai.default_channel ? ' *' : '';
opt.textContent = `${ch.name || id}${marker} · ${ch.model || '-'}`;
select.appendChild(opt);
});
selectedAIChannelId = selectedAIChannelId && currentConfig.ai.channels[selectedAIChannelId]
? selectedAIChannelId
: currentConfig.ai.default_channel;
select.value = selectedAIChannelId;
renderAIChannelList(ids);
updateAIChannelEditorChrome(selectedAIChannelId);
const countLabel = typeof window.t === 'function'
? window.t('settingsBasic.aiChannelCount').replace('{count}', String(ids.length))
: `已保存 ${ids.length} 个通道`;
showAIChannelSaveHint(countLabel, true);
}
function channelHostLabel(baseUrl) {
const raw = String(baseUrl || '').trim();
if (!raw) return '-';
try {
return new URL(raw).host || raw;
} catch (e) {
return raw.replace(/^https?:\/\//, '').split('/')[0] || raw;
}
}
function renderAIChannelList(ids) {
const list = document.getElementById('ai-channel-list');
if (!list || !currentConfig?.ai?.channels) return;
list.innerHTML = '';
(ids || Object.keys(currentConfig.ai.channels).sort()).forEach((id) => {
const ch = currentConfig.ai.channels[id] || {};
const isDefault = id === currentConfig.ai.default_channel;
const isComplete = !validateSelectedAIChannelPayload(ch);
const probe = aiChannelProbeResults[id] || null;
const item = document.createElement('div');
item.className = 'ai-channel-list-item' + (id === selectedAIChannelId ? ' active' : '') + (selectedAIChannelBulkIds.has(id) ? ' checked' : '');
item.setAttribute('role', 'button');
item.setAttribute('tabindex', '0');
item.setAttribute('aria-current', id === selectedAIChannelId ? 'true' : 'false');
item.onclick = () => selectAIChannelForEditing(id);
item.onkeydown = (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
selectAIChannelForEditing(id);
}
};
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'ai-channel-bulk-check';
checkbox.checked = selectedAIChannelBulkIds.has(id);
checkbox.setAttribute('aria-label', `选择 ${ch.name || id}`);
checkbox.onclick = (event) => {
event.stopPropagation();
if (checkbox.checked) {
selectedAIChannelBulkIds.add(id);
} else {
selectedAIChannelBulkIds.delete(id);
}
item.classList.toggle('checked', checkbox.checked);
};
const defaultBadge = isDefault ? `<span class="ai-channel-badge">${escapeAIChannelHtml(settingsT('settingsBasic.aiChannelDefaultBadge', '默认'))}</span>` : '';
let statusText = isComplete
? settingsT('settingsBasic.aiChannelReady', '可用')
: settingsT('settingsBasic.aiChannelDraft', '待完善');
let statusClass = isComplete ? 'ready' : 'draft';
if (probe) {
statusText = probe.message || statusText;
statusClass = probe.status || statusClass;
}
const body = document.createElement('div');
body.className = 'ai-channel-card-body';
body.innerHTML = `
<div class="ai-channel-list-main">
<span class="ai-channel-status-dot ${statusClass}" aria-hidden="true"></span>
<strong title="${escapeAIChannelHtml(ch.name || id)}">${escapeAIChannelHtml(ch.name || id)}</strong>
${defaultBadge}
</div>
<div class="ai-channel-list-meta" title="${escapeAIChannelHtml(ch.model || '-')} · ${escapeAIChannelHtml(channelHostLabel(ch.base_url))}">${escapeAIChannelHtml(ch.model || '-')} · ${escapeAIChannelHtml(channelHostLabel(ch.base_url))}</div>
<div class="ai-channel-list-foot">
<span class="ai-channel-status-label ${statusClass}" title="${escapeAIChannelHtml(statusText)}">${escapeAIChannelHtml(statusText)}</span>
<span title="${escapeAIChannelHtml(id)}">${escapeAIChannelHtml(id)}</span>
</div>
`;
item.appendChild(checkbox);
item.appendChild(body);
list.appendChild(item);
});
}
function showAIChannelSaveHint(message, ok) {
const el = document.getElementById('ai-channel-save-hint');
if (!el) return;
el.textContent = message;
el.classList.toggle('is-error', ok === false);
el.classList.toggle('is-success', ok !== false);
}
function updateAIChannelEditorChrome(id) {
const ai = ensureAIConfigShape(currentConfig || {});
const channelId = normalizeAIChannelId(id || ai.default_channel || 'default');
const ch = ai.channels[channelId] || {};
const title = document.getElementById('ai-channel-editor-title');
const meta = document.getElementById('ai-channel-editor-meta');
if (title) title.textContent = ch.name || channelId;
if (meta) {
const parts = [
channelId === ai.default_channel
? settingsT('settingsBasic.aiChannelDefaultMeta', '默认通道')
: settingsT('settingsBasic.aiChannelCustomMeta', '自定义通道'),
ch.provider === 'claude' ? 'Claude' : settingsT('settingsBasic.aiChannelOpenAICompat', 'OpenAI 兼容'),
ch.model || settingsT('settingsBasic.aiChannelModelMissing', '未填写模型'),
channelHostLabel(ch.base_url)
].filter(Boolean);
meta.textContent = parts.join(' / ');
}
}
function validateSelectedAIChannelPayload(ch) {
const missing = [];
if (!String(ch.base_url || '').trim()) missing.push('Base URL');
if (!String(ch.api_key || '').trim()) missing.push('API Key');
if (!String(ch.model || '').trim()) missing.push('模型');
if (missing.length) {
return missing.join(', ');
}
return '';
}
async function persistAIChannelsToServer(successMessage, options = {}) {
if (typeof requirePermission === 'function' && !requirePermission('config:write')) return false;
if (!currentConfig) currentConfig = {};
currentConfig.ai = ensureAIConfigShape(currentConfig);
const id = normalizeAIChannelId(selectedAIChannelId || currentConfig.ai.default_channel || 'default');
const channelPayload = readAIChannelFromMainForm(id);
currentConfig.ai.channels[id] = channelPayload;
selectedAIChannelId = id;
const missing = validateSelectedAIChannelPayload(channelPayload);
if (missing) {
showAIChannelSaveHint(`请填写:${missing}`, false);
alert(`请填写:${missing}`);
return false;
}
renderAIChannelSelect();
showAIChannelSaveHint('正在保存通道...', true);
try {
const shouldMergeLatest = options.mergeLatest !== false;
const latestResponse = shouldMergeLatest ? await apiFetch('/api/config') : null;
if (latestResponse && latestResponse.ok) {
const latest = await latestResponse.json();
const latestAI = ensureAIConfigShape(latest || {});
currentConfig.ai.channels = {
...(latestAI.channels || {}),
...(currentConfig.ai.channels || {}),
[id]: channelPayload
};
if (!currentConfig.ai.default_channel) {
currentConfig.ai.default_channel = latestAI.default_channel || id;
}
}
const updateResponse = await apiFetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ai: currentConfig.ai })
});
if (!updateResponse.ok) {
const error = await updateResponse.json().catch(() => ({}));
throw new Error(error.error || '保存通道失败');
}
const applyResponse = await apiFetch('/api/config/apply', { method: 'POST' });
if (!applyResponse.ok) {
const error = await applyResponse.json().catch(() => ({}));
throw new Error(error.error || '应用通道失败');
}
const response = await apiFetch('/api/config');
if (response.ok) {
currentConfig = await response.json();
currentConfig.ai = ensureAIConfigShape(currentConfig);
selectedAIChannelId = currentConfig.ai.channels[id] ? id : currentConfig.ai.default_channel;
renderAIChannelSelect();
writeAIChannelToMainForm(selectedAIChannelId);
if (typeof populateChatAIChannelSelect === 'function') {
populateChatAIChannelSelect(currentConfig.ai);
}
}
showAIChannelSaveHint(successMessage || '通道已保存', true);
return true;
} catch (error) {
showAIChannelSaveHint(error.message || '保存通道失败', false);
alert(error.message || '保存通道失败');
return false;
}
}
async function persistAIConfigOnlyToServer(successMessage) {
if (typeof requirePermission === 'function' && !requirePermission('config:write')) return false;
if (!currentConfig) return false;
currentConfig.ai = ensureAIConfigShape(currentConfig);
showAIChannelSaveHint('正在保存通道...', true);
try {
const updateResponse = await apiFetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ai: currentConfig.ai })
});
if (!updateResponse.ok) {
const error = await updateResponse.json().catch(() => ({}));
throw new Error(error.error || '保存通道失败');
}
const applyResponse = await apiFetch('/api/config/apply', { method: 'POST' });
if (!applyResponse.ok) {
const error = await applyResponse.json().catch(() => ({}));
throw new Error(error.error || '应用通道失败');
}
if (typeof populateChatAIChannelSelect === 'function') {
populateChatAIChannelSelect(currentConfig.ai);
}
showAIChannelSaveHint(successMessage || '通道已保存', true);
return true;
} catch (error) {
showAIChannelSaveHint(error.message || '保存通道失败', false);
alert(error.message || '保存通道失败');
return false;
}
}
async function saveSelectedAIChannel() {
await persistAIChannelsToServer(typeof window.t === 'function' ? window.t('settingsBasic.aiChannelSaved') : '通道已保存');
}
async function setSelectedAIChannelDefault() {
if (!currentConfig) return;
currentConfig.ai = ensureAIConfigShape(currentConfig);
const id = normalizeAIChannelId(selectedAIChannelId || currentConfig.ai.default_channel || 'default');
currentConfig.ai.channels[id] = readAIChannelFromMainForm(id);
currentConfig.ai.default_channel = id;
await persistAIChannelsToServer(typeof window.t === 'function' ? window.t('settingsBasic.aiChannelDefaultSaved') : '已设为默认通道');
}
function selectAIChannelForEditing(id) {
if (!currentConfig) return;
if (selectedAIChannelId && currentConfig.ai?.channels?.[selectedAIChannelId]) {
currentConfig.ai.channels[selectedAIChannelId] = readAIChannelFromMainForm(selectedAIChannelId);
}
const next = normalizeAIChannelId(id || currentConfig.ai?.default_channel || 'default');
selectedAIChannelId = next;
writeAIChannelToMainForm(next);
renderAIChannelSelect();
}
function uniqueAIChannelId(base) {
const ai = ensureAIConfigShape(currentConfig || {});
let id = normalizeAIChannelId(base);
if (!ai.channels[id]) return id;
let i = 2;
while (ai.channels[`${id}-${i}`]) i++;
return `${id}-${i}`;
}
function createAIChannelFromForm() {
if (!currentConfig) currentConfig = {};
currentConfig.ai = ensureAIConfigShape(currentConfig);
if (selectedAIChannelId && currentConfig.ai.channels[selectedAIChannelId]) {
currentConfig.ai.channels[selectedAIChannelId] = readAIChannelFromMainForm(selectedAIChannelId);
}
const baseName = (typeof window.t === 'function' ? window.t('settingsBasic.aiChannelUntitled') : 'New Channel');
const id = uniqueAIChannelId(baseName);
currentConfig.ai.channels[id] = {
name: baseName,
provider: 'openai_compatible',
api_key: '',
base_url: '',
model: '',
max_total_tokens: 120000,
max_completion_tokens: 16384,
reasoning: { mode: 'auto', effort: '', profile: 'auto', allow_client_reasoning: true }
};
selectedAIChannelId = id;
renderAIChannelSelect();
writeAIChannelToMainForm(id);
showAIChannelSaveHint('新通道尚未保存,填写后点击「保存更改」。', true);
}
function copyAIChannelFromForm() {
if (!currentConfig) return;
currentConfig.ai = ensureAIConfigShape(currentConfig);
const source = readAIChannelFromMainForm(selectedAIChannelId || currentConfig.ai.default_channel);
const id = uniqueAIChannelId((source.name || selectedAIChannelId || 'channel') + '-copy');
currentConfig.ai.channels[id] = { ...source, name: (source.name || id) + ' Copy' };
selectedAIChannelId = id;
renderAIChannelSelect();
writeAIChannelToMainForm(id);
showAIChannelSaveHint('复制的通道尚未保存,确认后点击「保存更改」。', true);
}
function deleteSelectedAIChannel() {
if (!currentConfig) return;
currentConfig.ai = ensureAIConfigShape(currentConfig);
const ids = Object.keys(currentConfig.ai.channels || {});
if (ids.length <= 1) {
alert('至少保留一个 AI 通道');
return;
}
const id = selectedAIChannelId || currentConfig.ai.default_channel;
const ch = currentConfig.ai.channels[id] || {};
const name = ch.name || id;
const msg = typeof window.t === 'function'
? window.t('settingsBasic.aiChannelDeleteConfirm').replace('{name}', name)
: `确定删除 AI 通道「${name}」吗?`;
if (!confirm(msg)) {
return;
}
delete currentConfig.ai.channels[id];
currentConfig.ai.default_channel = Object.keys(currentConfig.ai.channels).sort()[0];
renderAIChannelSelect();
writeAIChannelToMainForm(currentConfig.ai.default_channel);
persistAIChannelsToServer('通道已删除', { mergeLatest: false });
}
function selectedOrAllAIChannelIdsForProbe() {
if (!currentConfig) return [];
currentConfig.ai = ensureAIConfigShape(currentConfig);
if (selectedAIChannelId && currentConfig.ai.channels[selectedAIChannelId]) {
currentConfig.ai.channels[selectedAIChannelId] = readAIChannelFromMainForm(selectedAIChannelId);
}
const checked = Array.from(selectedAIChannelBulkIds).filter((id) => currentConfig.ai.channels[id]);
const ids = checked.length ? checked : Object.keys(currentConfig.ai.channels || {}).sort();
return ids.filter((id) => !validateSelectedAIChannelPayload(currentConfig.ai.channels[id] || {}));
}
async function probeSelectedAIChannels() {
if (typeof requirePermission === 'function' && !requirePermission('config:write')) return;
const ids = selectedOrAllAIChannelIdsForProbe();
if (!ids.length) {
alert('没有可探活的完整通道,请先填写 Base URL、API Key 和模型');
return;
}
showAIChannelSaveHint(`正在探活 ${ids.length} 个通道...`, true);
ids.forEach((id) => {
aiChannelProbeResults[id] = { status: 'testing', message: '测试中...' };
});
renderAIChannelList();
let okCount = 0;
let nextIndex = 0;
async function probeNextAIChannel() {
const id = ids[nextIndex++];
if (!id) return;
const ch = currentConfig.ai.channels[id] || {};
try {
const response = await apiFetch('/api/config/test-openai', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: ch.provider || 'openai_compatible',
base_url: ch.base_url || '',
api_key: ch.api_key || '',
model: ch.model || ''
})
});
const result = await response.json().catch(() => ({}));
if (response.ok && result.success) {
okCount += 1;
const latency = result.latency_ms ? ` ${result.latency_ms}ms` : '';
aiChannelProbeResults[id] = { status: 'ready', message: `可用${latency}` };
} else {
aiChannelProbeResults[id] = { status: 'failed', message: (result.error || '连接失败') };
}
} catch (error) {
aiChannelProbeResults[id] = { status: 'failed', message: error.message || '测试出错' };
}
renderAIChannelList();
}
const workers = Array.from({ length: Math.min(AI_CHANNEL_PROBE_CONCURRENCY, ids.length) }, async function () {
while (nextIndex < ids.length) {
await probeNextAIChannel();
}
});
await Promise.all(workers);
showAIChannelSaveHint(`探活完成:${okCount}/${ids.length} 可用`, okCount === ids.length);
}
async function deleteCheckedAIChannels() {
if (typeof requirePermission === 'function' && !requirePermission('config:write')) return;
if (!currentConfig) return;
currentConfig.ai = ensureAIConfigShape(currentConfig);
const ids = Array.from(selectedAIChannelBulkIds).filter((id) => currentConfig.ai.channels[id]);
if (!ids.length) {
alert('请先勾选要删除的通道');
return;
}
const deletable = ids.filter((id) => id !== currentConfig.ai.default_channel);
if (!deletable.length) {
alert('默认通道不能批量删除,请先切换默认通道');
return;
}
if (Object.keys(currentConfig.ai.channels || {}).length - deletable.length < 1) {
alert('至少保留一个 AI 通道');
return;
}
const names = deletable.map((id) => currentConfig.ai.channels[id]?.name || id).join('、');
if (!confirm(`确定删除 ${deletable.length} 个 AI 通道吗?\n${names}`)) {
return;
}
deletable.forEach((id) => {
delete currentConfig.ai.channels[id];
selectedAIChannelBulkIds.delete(id);
delete aiChannelProbeResults[id];
});
if (!currentConfig.ai.channels[selectedAIChannelId]) {
selectedAIChannelId = currentConfig.ai.default_channel;
}
renderAIChannelSelect();
writeAIChannelToMainForm(selectedAIChannelId);
await persistAIConfigOnlyToServer(`已删除 ${deletable.length} 个通道`);
}
if (typeof window !== 'undefined') {
window.selectAIChannelForEditing = selectAIChannelForEditing;
window.saveSelectedAIChannel = saveSelectedAIChannel;
window.setSelectedAIChannelDefault = setSelectedAIChannelDefault;
window.createAIChannelFromForm = createAIChannelFromForm;
window.copyAIChannelFromForm = copyAIChannelFromForm;
window.deleteSelectedAIChannel = deleteSelectedAIChannel;
window.probeSelectedAIChannels = probeSelectedAIChannels;
window.deleteCheckedAIChannels = deleteCheckedAIChannels;
}
function initModelListControls() {
const providerEl = document.getElementById('openai-provider');
if (providerEl && !providerEl.dataset.modelListBound) {
@@ -2941,7 +3437,7 @@ async function saveToolsConfig() {
// 构建只包含工具配置的配置对象
const config = {
openai: currentConfig.openai || {},
ai: ensureAIConfigShape(currentConfig || {}),
agent: currentConfig.agent || {},
multi_agent: {
enabled: currentConfig?.multi_agent?.enabled === true,
+210 -3
View File
@@ -622,7 +622,8 @@ function getWebshellConnections() {
if (typeof apiFetch === 'undefined') {
return Promise.resolve([]);
}
return apiFetch('/api/webshell/connections', { method: 'GET' })
var url = '/api/webshell/connections';
return apiFetch(url, { method: 'GET' })
.then(function (r) { return r.json(); })
.then(function (list) { return Array.isArray(list) ? list : []; })
.catch(function (e) {
@@ -631,11 +632,205 @@ function getWebshellConnections() {
});
}
function webshellConnectionProjectId(conn) {
return (conn && (conn.project_id || conn.projectId) || '').trim();
}
function webshellProjectOptionsHtml(selectedId) {
var selected = String(selectedId || '').trim();
var html = '<option value="">' + escapeHtml(wsT('assets.unboundProject') || '暂不绑定') + '</option>';
var entries = [];
try {
if (typeof projectNameById !== 'undefined') entries = Object.entries(projectNameById);
} catch (e) {}
entries.sort(function (a, b) {
return String(a[1] || '').localeCompare(String(b[1] || ''), undefined, { sensitivity: 'base' });
});
entries.forEach(function (entry) {
var id = entry[0];
var name = entry[1] || id;
if (!id) return;
html += '<option value="' + escapeHtml(id) + '"' + (id === selected ? ' selected' : '') + '>' + escapeHtml(name) + '</option>';
});
if (selected && !entries.some(function (entry) { return entry[0] === selected; })) {
html += '<option value="' + escapeHtml(selected) + '" selected>' + escapeHtml(selected) + '</option>';
}
return html;
}
var webshellFormSelectMap = {};
var webshellFormSelectDocBound = false;
var WEBSHELL_FORM_SELECT_CARET = '<span class="webshell-form-select-caret" aria-hidden="true"></span>';
function closeAllWebshellFormSelects() {
Object.keys(webshellFormSelectMap).forEach(function (id) {
var reg = webshellFormSelectMap[id];
if (!reg || !reg.wrapper) return;
reg.wrapper.classList.remove('open');
if (reg.trigger) reg.trigger.setAttribute('aria-expanded', 'false');
});
}
function syncWebshellFormSelect(select) {
if (!select || !select.id) return;
var reg = webshellFormSelectMap[select.id];
if (!reg) return;
var dropdown = reg.dropdown;
var trigger = reg.trigger;
var valueSpan = trigger.querySelector('.webshell-form-select-value');
dropdown.innerHTML = '';
Array.prototype.forEach.call(select.options, function (opt) {
var item = document.createElement('button');
item.type = 'button';
item.className = 'webshell-form-select-option';
item.setAttribute('role', 'option');
item.setAttribute('data-value', opt.value);
item.setAttribute('aria-selected', opt.value === select.value ? 'true' : 'false');
if (opt.value === select.value) item.classList.add('is-selected');
var check = document.createElement('span');
check.className = 'webshell-form-select-check';
check.textContent = '✓';
check.setAttribute('aria-hidden', 'true');
var label = document.createElement('span');
label.className = 'webshell-form-select-label';
label.textContent = opt.textContent;
item.appendChild(check);
item.appendChild(label);
dropdown.appendChild(item);
});
var selectedOpt = select.options[select.selectedIndex];
if (valueSpan) valueSpan.textContent = selectedOpt ? selectedOpt.textContent : '';
trigger.disabled = !!select.disabled;
reg.wrapper.classList.toggle('is-disabled', !!select.disabled);
}
function enhanceWebshellFormSelect(select) {
if (!select || !select.id) return;
var existing = webshellFormSelectMap[select.id];
if (existing && existing.select !== select) delete webshellFormSelectMap[select.id];
if (select.dataset.webshellFormCustom === '1') {
syncWebshellFormSelect(select);
return;
}
select.dataset.webshellFormCustom = '1';
select.classList.add('webshell-form-native-select');
select.tabIndex = -1;
select.setAttribute('aria-hidden', 'true');
var wrapper = document.createElement('div');
wrapper.className = 'webshell-form-select-ui';
var trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'webshell-form-select-trigger';
trigger.setAttribute('aria-haspopup', 'listbox');
trigger.setAttribute('aria-expanded', 'false');
var valueSpan = document.createElement('span');
valueSpan.className = 'webshell-form-select-value';
trigger.appendChild(valueSpan);
trigger.insertAdjacentHTML('beforeend', WEBSHELL_FORM_SELECT_CARET);
var dropdown = document.createElement('div');
dropdown.className = 'webshell-form-select-dropdown';
dropdown.setAttribute('role', 'listbox');
var parent = select.parentNode;
parent.insertBefore(wrapper, select);
wrapper.appendChild(trigger);
wrapper.appendChild(dropdown);
wrapper.appendChild(select);
webshellFormSelectMap[select.id] = { wrapper: wrapper, trigger: trigger, dropdown: dropdown, select: select };
trigger.addEventListener('click', function (e) {
e.stopPropagation();
if (select.disabled) return;
var open = wrapper.classList.contains('open');
closeAllWebshellFormSelects();
if (!open) {
wrapper.classList.add('open');
trigger.setAttribute('aria-expanded', 'true');
}
});
dropdown.addEventListener('click', function (e) {
var item = e.target.closest('.webshell-form-select-option');
if (!item) return;
e.stopPropagation();
var value = item.getAttribute('data-value');
if (value === null) return;
if (select.value !== value) {
select.value = value;
select.dispatchEvent(new Event('change', { bubbles: true }));
}
wrapper.classList.remove('open');
trigger.setAttribute('aria-expanded', 'false');
syncWebshellFormSelect(select);
});
select.addEventListener('change', function () {
syncWebshellFormSelect(select);
});
syncWebshellFormSelect(select);
}
function refreshWebshellFormSelects(root) {
var container = root || document.getElementById('webshell-modal');
if (!container) return;
Object.keys(webshellFormSelectMap).forEach(function (id) {
if (!document.getElementById(id)) delete webshellFormSelectMap[id];
});
container.querySelectorAll('select').forEach(enhanceWebshellFormSelect);
if (!webshellFormSelectDocBound) {
webshellFormSelectDocBound = true;
document.addEventListener('click', closeAllWebshellFormSelects);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeAllWebshellFormSelects();
});
}
}
function populateWebshellProjectSelect(selectedId) {
var sel = document.getElementById('webshell-project-id');
if (!sel) return Promise.resolve();
var selected = String(selectedId || '').trim();
sel.innerHTML = webshellProjectOptionsHtml(selected);
sel.value = selected;
syncWebshellFormSelect(sel);
var loadPromise = Promise.resolve([]);
if (typeof ensureProjectsLoaded === 'function') {
loadPromise = ensureProjectsLoaded();
} else if (typeof fetchAllProjects === 'function') {
loadPromise = fetchAllProjects(false).then(function (list) {
if (typeof rebuildProjectNameMap === 'function') rebuildProjectNameMap(list || []);
return list || [];
});
}
return loadPromise.then(function () {
sel.innerHTML = webshellProjectOptionsHtml(selected);
sel.value = selected;
syncWebshellFormSelect(sel);
}).catch(function (e) {
console.warn('加载 WebShell 项目选项失败', e);
});
}
// 从服务端刷新连接列表并重绘侧栏
function refreshWebshellConnectionsFromServer() {
return getWebshellConnections().then(function (list) {
webshellConnections = list;
renderWebshellList();
if (typeof ensureProjectsLoaded === 'function') {
ensureProjectsLoaded().then(function () {
renderWebshellList();
}).catch(function () {});
}
return list;
});
}
@@ -4636,11 +4831,19 @@ function showAddWebshellModal() {
if (osSelEl) osSelEl.value = 'auto';
var encSelEl = document.getElementById('webshell-encoding');
if (encSelEl) encSelEl.value = 'auto';
var defaultProjectId = '';
try {
defaultProjectId = typeof getActiveProjectId === 'function' ? (getActiveProjectId() || '') : '';
} catch (e) {}
populateWebshellProjectSelect(defaultProjectId);
document.getElementById('webshell-remark').value = '';
var titleEl = document.getElementById('webshell-modal-title');
if (titleEl) titleEl.textContent = wsT('webshell.addConnection');
var modal = document.getElementById('webshell-modal');
if (modal) openAppModal(modal);
if (modal) {
openAppModal(modal);
refreshWebshellFormSelects(modal);
}
}
// 打开编辑连接弹窗(预填当前连接信息)
@@ -4662,7 +4865,9 @@ function showEditWebshellModal(connId) {
if (osEditEl) osEditEl.value = normalizeWebshellOS(conn.os);
var encEditEl = document.getElementById('webshell-encoding');
if (encEditEl) encEditEl.value = normalizeWebshellEncoding(conn.encoding);
populateWebshellProjectSelect(webshellConnectionProjectId(conn));
document.getElementById('webshell-remark').value = conn.remark || '';
refreshWebshellFormSelects(document.getElementById('webshell-modal'));
document.getElementById('webshell-url')?.focus();
});
}
@@ -4961,7 +5166,9 @@ function saveWebshellConnection() {
var editIdEl = document.getElementById('webshell-edit-id');
var editId = editIdEl ? editIdEl.value.trim() : '';
var body = { url: url, password: password, type: type, method: method === 'get' ? 'get' : 'post', cmd_param: cmdParam, encoding: encoding, os: osTag, remark: remark || url };
var projectId = (document.getElementById('webshell-project-id') || {}).value || '';
if (projectId && typeof projectId.trim === 'function') projectId = projectId.trim(); else projectId = '';
var body = { url: url, password: password, type: type, method: method === 'get' ? 'get' : 'post', cmd_param: cmdParam, encoding: encoding, os: osTag, remark: remark || url, project_id: projectId };
if (typeof apiFetch === 'undefined') return;
var reqUrl = editId ? ('/api/webshell/connections/' + encodeURIComponent(editId)) : '/api/webshell/connections';