Files
CyberStrikeAI/web/static/js/projects.js
T
RuoJi6andGitHub eb6bab574f 优化项目对话、刷新续流、实时滚动与工具状态恢复 (#245)
* feat(chat): add project-based conversation sidebar

* feat(chat): refine Codex-style conversation UI

* feat(chat): add Codex-style conversation workflow

* feat(ui): 优化对话框与项目侧边栏交互

* fix(chat): 修复暗色输入框圆角填色

* fix(chat): 恢复输入区分层错位布局

* fix(hitl): isolate reviewer state per conversation

* feat(hitl): 增加双入口审批与倒计时进度

* fix(hitl): 汇总项目审批并隔离对话状态

* fix(ui): 修复审批状态与无项目新任务

* fix(ui): 优化审批状态与对话切换性能

* fix(ui): 修复中断任务审批仍计时

* fix(ui): 修复多对话并发切换卡顿

* fix(hitl): 主动同步审批并关闭中断状态

* fix(ui): 固定项目审批汇总为绿色

* feat(ui): 同步系统模型与推理强度

* feat(ui): 完善项目侧栏预览与新建入口

* fix(ui): 防止无项目文件夹误展开

* fix(ui): 防止长历史对话滚动误触审批

* fix(ui): 修复对话操作并补充项目置顶

* fix(ui): 移除对话分组并调整项目置顶排序

* feat(ui): 优化迭代导航与审批交互

* fix(hitl): 将 write_file 加入内置免审批工具

* fix(chat): 支持回车发送与 Shift 回车换行

* fix(chat): 优化对话刷新与 Codex 风格交互

* fix(ui): 显示对话具体更新时间

* fix(ui): 优化对话刷新与项目加载

* fix(ui): 修复 Agent 审查文字裁切

* fix(chat): 修复刷新续流与多标签页同步

* fix(chat): 修复滚动跟随与中断任务终态

* fix(ui): 修复流式滚动跳动与工具状态恢复

* fix(ui): 修复刷新后流式输出停止粘底
2026-08-13 21:28:37 +08:00

4185 lines
182 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 项目管理与事实黑板
*/
let projectsCache = [];
let projectsCacheAll = [];
const PROJECTS_LIST_PAGE_SIZE_KEY = 'cyberstrike.projects_list_page_size';
let currentProjectId = null;
let currentProjectUpdatedAt = null;
let currentProjectTab = 'facts';
let currentProjectAssets = [];
const PROJECT_ASSETS_PAGE_SIZE_KEY = 'cyberstrike.project_assets_page_size';
let projectAssetsPagination = {
page: 1,
pageSize: (() => {
try {
const size = Number(localStorage.getItem(PROJECT_ASSETS_PAGE_SIZE_KEY));
return [10, 20, 50, 100].includes(size) ? size : 20;
} catch (e) {
return 20;
}
})(),
total: 0,
totalPages: 1,
};
const projectNameById = {};
let _projectsListReady = false;
let _projectsFetchPromise = null;
const PROJECT_ACTIVE_KEY = 'cyberstrike.activeProjectId';
const PROJECT_DESCRIPTION_MAX_LENGTH = 4000;
const PROJECT_NAME_MAX_LENGTH = 200;
function tp(key, opts) {
if (typeof window.t === 'function') return window.t(key, opts);
return key;
}
function tpFmt(key, fallback, opts) {
const text = tp(key, opts);
if (!text || text === key) return fallback;
return text;
}
function requireProjectWrite() {
if (typeof requirePermission !== 'function') return true;
return requirePermission(
'project:write',
tpFmt('projects.writePermissionDenied', '当前账号仅有只读权限,无法创建或修改项目'),
);
}
function requireProjectDelete() {
if (typeof requirePermission !== 'function') return true;
return requirePermission(
'project:delete',
tpFmt('projects.writePermissionDenied', '当前账号仅有只读权限,无法删除项目'),
);
}
async function notifyProjectApiFailure(response, fallbackKey, fallbackText) {
if (typeof ensureApiOk === 'function') {
return ensureApiOk(response, tpFmt(fallbackKey, fallbackText));
}
if (!response || response.ok) return true;
alert(tpFmt(fallbackKey, fallbackText));
return false;
}
const PROJECTS_FILTER_SELECT_HANDLERS = {
'project-facts-filter-category': function () { loadProjectFacts(); },
'project-facts-filter-confidence': function () { loadProjectFacts(); },
'project-graph-view': function () { loadProjectFactGraph(); },
'project-vulns-filter-severity': function () { loadProjectVulnerabilities(); },
'project-vulns-filter-status': function () { loadProjectVulnerabilities(); },
'projects-page-size-pagination': function () { changeProjectsPageSize(); }
};
const projectsFilterSelectMap = {};
let projectsFilterSelectDocBound = false;
const PROJECTS_FILTER_SELECT_CARET = '<svg class="projects-filter-select-caret" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
function closeAllProjectsFilterSelects() {
Object.keys(projectsFilterSelectMap).forEach(function (id) {
const reg = projectsFilterSelectMap[id];
if (!reg || !reg.wrapper) return;
reg.wrapper.classList.remove('open');
if (reg.trigger) reg.trigger.setAttribute('aria-expanded', 'false');
});
}
function pruneProjectsFilterSelectMap(root) {
Object.keys(projectsFilterSelectMap).forEach(function (id) {
const select = document.getElementById(id);
if (!select || (root && !root.contains(select))) {
delete projectsFilterSelectMap[id];
}
});
}
function syncProjectsFilterSelect(select) {
const reg = projectsFilterSelectMap[select.id];
if (!reg) return;
const dropdown = reg.dropdown;
const trigger = reg.trigger;
const valueSpan = trigger.querySelector('.projects-filter-select-value');
dropdown.innerHTML = '';
Array.prototype.forEach.call(select.options, function (opt) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'projects-filter-select-option';
item.setAttribute('role', 'option');
item.setAttribute('data-value', opt.value);
if (opt.value === select.value) {
item.classList.add('is-selected');
item.setAttribute('aria-selected', 'true');
} else {
item.setAttribute('aria-selected', 'false');
}
const check = document.createElement('span');
check.className = 'projects-filter-select-check';
check.setAttribute('aria-hidden', 'true');
check.textContent = '✓';
const label = document.createElement('span');
label.className = 'projects-filter-select-label';
label.textContent = opt.textContent;
item.appendChild(check);
item.appendChild(label);
dropdown.appendChild(item);
});
const 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 syncAllProjectsFilterSelects() {
Object.keys(projectsFilterSelectMap).forEach(function (id) {
const select = document.getElementById(id);
if (select) syncProjectsFilterSelect(select);
});
}
function enhanceProjectsFilterSelect(select) {
if (!select || !select.id) return;
const existing = projectsFilterSelectMap[select.id];
if (existing && existing.select !== select) {
delete projectsFilterSelectMap[select.id];
}
if (select.dataset.projectsCustomSelect === '1') {
syncProjectsFilterSelect(select);
return;
}
select.dataset.projectsCustomSelect = '1';
select.classList.add('projects-filter-native-select');
select.tabIndex = -1;
select.setAttribute('aria-hidden', 'true');
const wrapper = document.createElement('div');
wrapper.className = 'projects-filter-select-ui';
if (select.id === 'projects-page-size-pagination') {
wrapper.classList.add('projects-filter-select-ui--compact');
}
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'projects-filter-select-trigger';
trigger.setAttribute('aria-haspopup', 'listbox');
trigger.setAttribute('aria-expanded', 'false');
const valueSpan = document.createElement('span');
valueSpan.className = 'projects-filter-select-value';
trigger.appendChild(valueSpan);
trigger.insertAdjacentHTML('beforeend', PROJECTS_FILTER_SELECT_CARET);
const dropdown = document.createElement('div');
dropdown.className = 'projects-filter-select-dropdown';
dropdown.setAttribute('role', 'listbox');
const parent = select.parentNode;
parent.insertBefore(wrapper, select);
wrapper.appendChild(trigger);
wrapper.appendChild(dropdown);
wrapper.appendChild(select);
projectsFilterSelectMap[select.id] = { wrapper: wrapper, trigger: trigger, dropdown: dropdown, select: select };
trigger.addEventListener('click', function (e) {
e.stopPropagation();
if (select.disabled) return;
const open = wrapper.classList.contains('open');
closeAllProjectsFilterSelects();
if (!open) {
wrapper.classList.add('open');
trigger.setAttribute('aria-expanded', 'true');
}
});
dropdown.addEventListener('click', function (e) {
const opt = e.target.closest('.projects-filter-select-option');
if (!opt) return;
e.stopPropagation();
const val = opt.getAttribute('data-value');
if (val === null) return;
if (select.value !== val) {
select.value = val;
select.dispatchEvent(new Event('change', { bubbles: true }));
}
wrapper.classList.remove('open');
trigger.setAttribute('aria-expanded', 'false');
syncProjectsFilterSelect(select);
});
select.addEventListener('change', function () {
syncProjectsFilterSelect(select);
});
if (!select.dataset.projectsFilterBound) {
select.dataset.projectsFilterBound = '1';
const handler = PROJECTS_FILTER_SELECT_HANDLERS[select.id];
if (typeof handler === 'function') {
select.addEventListener('change', handler);
}
}
syncProjectsFilterSelect(select);
}
function refreshProjectsFilterSelects() {
const page = document.getElementById('page-projects');
if (!page) return;
pruneProjectsFilterSelectMap(page);
page.querySelectorAll('select.projects-filter-select-native, #projects-page-size-pagination').forEach(function (select) {
enhanceProjectsFilterSelect(select);
});
if (!projectsFilterSelectDocBound) {
projectsFilterSelectDocBound = true;
document.addEventListener('click', closeAllProjectsFilterSelects);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeAllProjectsFilterSelects();
});
}
}
/** 与后端 internal/project/fact_template.go 对齐 */
const FACT_ATTACK_CHAIN_BODY_TEMPLATE = `## 结论(可验证,一句话)
<勿仅写「存在漏洞」;写明类型 + 位置 + 触发条件>
## 目标与入口
- 目标: <URL / IP:Port / 主机名>
- 入口: <路径 / 接口 / 参数>
- 前置条件: <匿名 / 角色 / Cookie / 其他依赖>
## 攻击链(逐步可复现)
1. <侦察/发现>
2. <利用/触发>
3. <影响证明(读文件、RCE 回显、越权数据等)>
## Exploit / POC
### 请求
\`\`\`http
<METHOD> <path> HTTP/1.1
Host: ...
...
<body>
\`\`\`
### 响应 / 现象
<关键响应片段、状态码、差异点>
### 命令 / 脚本(如有)
\`\`\`bash
<command>
\`\`\`
## 关键证据
- <工具输出摘要 / 截图路径 / 会话或消息 ID>
## 关联
- related_vulnerability_id: <可选>
- 依赖事实: <fact_key,如 auth/session_cookie>
- 结构化关系边(自动同步;links 文本格式 type: source_fact_key:
- discovered_on: target/primary_domain
## 备注与不确定性
<待验证假设、环境差异、绕过尝试记录>`;
const FACT_ENV_BODY_TEMPLATE = `## 摘要
<该事实的核心认知>
## 细节
<端口/版本/路径/凭据特征/业务规则等>
## 来源与证据
<命令输出、响应片段、发现时间>
## 关联
- 相关 fact_key: <可选>`;
const FACT_ATTACK_CHAIN_PREFIXES = ['finding/', 'chain/', 'exploit/', 'poc/'];
const FACT_ATTACK_CHAIN_CATEGORIES = new Set(['finding', 'chain', 'exploit', 'poc', 'vuln']);
function requiresAttackChainFact(category, factKey) {
const c = (category || '').trim().toLowerCase();
if (FACT_ATTACK_CHAIN_CATEGORIES.has(c)) return true;
const key = (factKey || '').trim().toLowerCase();
return FACT_ATTACK_CHAIN_PREFIXES.some((p) => key.startsWith(p));
}
function isSparseFactBody(category, factKey, body) {
if (!requiresAttackChainFact(category, factKey)) return false;
const text = (body || '').trim();
if (!text) return true;
const lower = text.toLowerCase();
const hasSteps =
lower.includes('攻击链') ||
lower.includes('## 攻击') ||
lower.includes('## exploit') ||
lower.includes('## poc');
const hasHTTP =
lower.includes('```http') ||
lower.includes('```bash') ||
lower.includes('curl ') ||
lower.includes('get ') ||
lower.includes('post ');
const hasReq = lower.includes('请求') || lower.includes('响应') || lower.includes('payload');
return !(hasSteps || hasHTTP || hasReq);
}
function formatFactBodyBadge(f) {
if (!requiresAttackChainFact(f.category, f.fact_key)) {
const hasBody = !!(f.body || '').trim();
return `<span class="projects-fact-badge projects-fact-badge--na" title="${escapeHtml(tp('projects.factBodyEnvTitle'))}">${hasBody ? escapeHtml(tp('projects.factBodyHasDetail')) : '—'}</span>`;
}
if (isSparseFactBody(f.category, f.fact_key, f.body)) {
return `<span class="projects-fact-badge projects-fact-badge--warn" title="${escapeHtml(tp('projects.factBodySparseTitle'))}">${escapeHtml(tp('projects.factBodySparse'))}</span>`;
}
return `<span class="projects-fact-badge projects-fact-badge--ok" title="${escapeHtml(tp('projects.factBodyReproducibleTitle'))}">${escapeHtml(tp('projects.factBodyReproducible'))}</span>`;
}
function updateFactFormHints() {
const cat = document.getElementById('fact-modal-category')?.value || '';
const key = document.getElementById('fact-modal-key')?.value || '';
const body = document.getElementById('fact-modal-body')?.value || '';
const hint = document.getElementById('fact-modal-body-hint');
if (!hint) return;
if (requiresAttackChainFact(cat, key)) {
const sparse = isSparseFactBody(cat, key, body);
hint.textContent = sparse
? tp('projects.factHintAttackSparse')
: tp('projects.factHintAttackReady');
hint.classList.toggle('projects-field-hint--warn', sparse);
} else {
hint.textContent = tp('projects.factHintEnv');
hint.classList.remove('projects-field-hint--warn');
}
}
function insertFactBodyTemplate(kind) {
const ta = document.getElementById('fact-modal-body');
if (!ta) return;
const tpl = kind === 'env' ? FACT_ENV_BODY_TEMPLATE : FACT_ATTACK_CHAIN_BODY_TEMPLATE;
if (ta.value.trim() && !confirm(tp('projects.confirmOverwriteBodyTemplate'))) return;
ta.value = tpl;
updateFactFormHints();
ta.focus();
}
function getActiveProjectId() {
try {
return localStorage.getItem(PROJECT_ACTIVE_KEY) || '';
} catch (e) {
return '';
}
}
function setActiveProjectId(id) {
try {
if (id) localStorage.setItem(PROJECT_ACTIVE_KEY, id);
else localStorage.removeItem(PROJECT_ACTIVE_KEY);
} catch (e) { /* ignore */ }
}
function rebuildProjectNameMap(list) {
Object.keys(projectNameById).forEach((k) => delete projectNameById[k]);
(list || []).forEach((p) => {
if (p && p.id) projectNameById[p.id] = p.name || p.id;
});
}
function rememberProjectsInNameMap(list) {
(list || []).forEach((p) => {
if (p && p.id) projectNameById[p.id] = p.name || p.id;
});
}
/** 与后端 projectListSearchPattern 对齐:name / description / id 子串匹配(忽略大小写) */
function matchProjectSearchQuery(project, query) {
const q = String(query || '').trim().toLowerCase();
if (!q) return true;
const name = String(project.name || '').toLowerCase();
const desc = String(project.description || '').toLowerCase();
const id = String(project.id || '').toLowerCase();
return name.includes(q) || desc.includes(q) || id.includes(q);
}
function sortProjectsForPicker(projects) {
return [...projects].sort((a, b) => {
const ap = a.pinned ? 1 : 0;
const bp = b.pinned ? 1 : 0;
if (bp !== ap) return bp - ap;
const au = a.updated_at || a.updatedAt || '';
const bu = b.updated_at || b.updatedAt || '';
return String(bu).localeCompare(String(au));
});
}
/** 从已加载列表中筛选活跃项目(对话选择器 / 项目筛选下拉) */
function filterActiveProjectsLocal(projects, query) {
const list = (projects || []).filter((p) => p && p.id && p.status !== 'archived');
const q = String(query || '').trim();
const filtered = q ? list.filter((p) => matchProjectSearchQuery(p, q)) : list;
return sortProjectsForPicker(filtered);
}
async function searchActiveProjects(query, opts = {}) {
const params = new URLSearchParams();
params.set('status', opts.status || 'active');
params.set('limit', String(opts.limit ?? (String(query || '').trim() ? PROJECT_PICKER_SEARCH_LIMIT : PROJECT_PICKER_INITIAL_LIMIT)));
params.set('offset', String(opts.offset ?? 0));
const q = String(query || '').trim();
if (q) params.set('search', q);
const res = await apiFetch(`/api/projects?${params}`);
if (!res.ok) throw new Error(tp('projects.loadProjectsFailed'));
const parsed = parseProjectsListResponse(await res.json());
rememberProjectsInNameMap(parsed.items);
return parsed;
}
async function fetchProjectSummary(projectId) {
const id = String(projectId || '').trim();
if (!id) return null;
const res = await apiFetch(`/api/projects/${encodeURIComponent(id)}`);
if (!res.ok) return null;
const project = await res.json();
if (project && project.id) rememberProjectsInNameMap([project]);
return project;
}
function getProjectsListPageSize() {
try {
const saved = parseInt(localStorage.getItem(PROJECTS_LIST_PAGE_SIZE_KEY), 10);
if ([20, 50, 100].includes(saved)) return saved;
} catch (e) { /* ignore */ }
return 50;
}
let projectsListPagination = { page: 1, pageSize: getProjectsListPageSize(), total: 0 };
let projectsListSearch = '';
let _projectsListSearchDebounce = null;
function parseListTotalValue(raw, itemsLength) {
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw;
if (raw != null && raw !== '') {
const n = parseInt(String(raw), 10);
if (Number.isFinite(n) && n >= 0) return n;
}
return itemsLength;
}
function parseListOffsetValue(raw) {
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw;
if (raw != null && raw !== '') {
const n = parseInt(String(raw), 10);
if (Number.isFinite(n) && n >= 0) return n;
}
return 0;
}
function parseProjectsListResponse(data) {
if (Array.isArray(data)) {
return { items: data, total: data.length, limit: data.length, offset: 0, isLegacyArray: true };
}
const items = data.projects || data.items || [];
const arr = Array.isArray(items) ? items : [];
return {
items: arr,
total: parseListTotalValue(data.total, arr.length),
limit: parseListTotalValue(data.limit, arr.length) || arr.length,
offset: parseListOffsetValue(data.offset),
isLegacyArray: false,
};
}
async function resolveProjectsListTotal(params, parsed, pageSize, offset) {
const serverTotal = parsed.total;
// 服务端 total 明确大于当前页末尾 → 直接信任
if (!parsed.isLegacyArray && serverTotal > offset + parsed.items.length) {
return serverTotal;
}
// 不足一页 → 已是最后一页
if (parsed.items.length < pageSize) {
return Math.max(serverTotal, offset + parsed.items.length);
}
// 满页但 total 可能被误算为 items.length → 探测下一页
const probe = new URLSearchParams(params);
probe.set('offset', String(offset + pageSize));
probe.set('limit', '1');
try {
const res = await apiFetch(`/api/projects?${probe}`);
if (!res.ok) return Math.max(serverTotal, offset + parsed.items.length);
const probeParsed = parseProjectsListResponse(await res.json());
if (probeParsed.total > serverTotal) return probeParsed.total;
if (probeParsed.items.length > 0) {
return Math.max(serverTotal, offset + pageSize + 1);
}
} catch (e) { /* ignore */ }
return Math.max(serverTotal, offset + parsed.items.length);
}
async function fetchAllProjects(includeArchived) {
const showArchived = includeArchived || document.getElementById('projects-show-archived')?.checked;
let all = [];
const pageSize = 200;
let offset = 0;
let total = Infinity;
while (all.length < total) {
const params = new URLSearchParams({ limit: String(pageSize), offset: String(offset) });
if (!showArchived) params.set('status', 'active');
const res = await apiFetch(`/api/projects?${params}`);
if (!res.ok) throw new Error(tp('projects.loadProjectsFailed'));
const parsed = parseProjectsListResponse(await res.json());
all = all.concat(parsed.items);
total = parsed.total;
if (!parsed.items.length) break;
offset += parsed.items.length;
}
return all;
}
async function fetchProjectsList(includeArchived, opts = {}) {
const showArchived = includeArchived || document.getElementById('projects-show-archived')?.checked;
const page = opts.page ?? projectsListPagination.page;
const pageSize = opts.pageSize ?? getProjectsListPageSize();
const search = opts.search !== undefined ? opts.search : projectsListSearch;
projectsListSearch = search;
const offset = (page - 1) * pageSize;
const params = new URLSearchParams({ limit: String(pageSize), offset: String(offset) });
if (search) params.set('search', search);
if (!showArchived) params.set('status', 'active');
const res = await apiFetch(`/api/projects?${params}`);
if (!res.ok) throw new Error(tp('projects.loadProjectsFailed'));
const parsed = parseProjectsListResponse(await res.json());
const total = await resolveProjectsListTotal(params, parsed, pageSize, offset);
projectsCache = parsed.items;
projectsListPagination = { page, pageSize: pageSize, total };
rebuildProjectNameMap(projectsCacheAll.length ? projectsCacheAll : projectsCache);
return projectsCache;
}
/** 对话页等项目选择器:确保全量列表已拉取(去重并发请求) */
async function ensureProjectsLoaded(force) {
if (!force && _projectsListReady) return projectsCacheAll;
if (!force && _projectsFetchPromise) return _projectsFetchPromise;
_projectsFetchPromise = fetchAllProjects(false)
.then((list) => {
projectsCacheAll = list;
rebuildProjectNameMap(projectsCacheAll);
_projectsListReady = true;
if (typeof window.refreshConversationProjectFilter === 'function') {
window.refreshConversationProjectFilter();
}
return projectsCacheAll;
})
.catch((e) => {
_projectsListReady = false;
throw e;
})
.finally(() => {
_projectsFetchPromise = null;
});
return _projectsFetchPromise;
}
function isProjectsCacheReady() {
return _projectsListReady;
}
function prefetchProjectsForChat() {
const id = (resolveChatProjectSelection() || '').trim();
if (id && !projectNameById[id]) {
fetchProjectSummary(id).catch(() => {});
}
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 '';
}
function getProjectName(id) {
return projectNameById[id] || id || '';
}
function initProjectsModalEscape() {
if (window._projectsModalEscapeBound) return;
window._projectsModalEscapeBound = true;
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if (isProjectsOverlayVisible('project-modal')) closeProjectModal();
else if (isProjectsOverlayVisible('fact-modal')) closeFactModal();
else if (isProjectsOverlayVisible('fact-detail-modal')) closeFactDetailModal();
});
}
async function initProjectsPage() {
const page = document.getElementById('page-projects');
if (!page || page.style.display === 'none') return;
initProjectsModalEscape();
refreshProjectsFilterSelects();
if (typeof syncAppModalBodyLock === 'function') {
syncAppModalBodyLock();
}
updateProjectsDetailVisibility();
projectsListPagination.pageSize = getProjectsListPageSize();
renderProjectsPagination();
await loadProjectsList();
if (!currentProjectId && projectsCache.length) {
const fromHash = new URLSearchParams(window.location.hash.split('?')[1] || '').get('id');
currentProjectId = fromHash || projectsCache[0].id;
}
renderProjectsSidebar();
if (currentProjectId) {
await selectProject(currentProjectId);
}
}
async function loadProjectsList() {
_projectsListReady = false;
projectsCacheAll = [];
projectsListPagination.pageSize = getProjectsListPageSize();
await fetchProjectsList();
renderProjectsSidebar();
renderProjectsPagination();
try {
projectsCacheAll = await fetchAllProjects();
rebuildProjectNameMap(projectsCacheAll);
_projectsListReady = true;
} catch (e) {
console.warn(e);
}
if (typeof refreshChatProjectSelector === 'function') {
refreshChatProjectSelector();
}
if (typeof refreshVulnerabilityProjectFilter === 'function') {
refreshVulnerabilityProjectFilter();
}
if (typeof window.refreshAllProjectFilterSelects === 'function') {
await window.refreshAllProjectFilterSelects();
}
}
function projectInitial(name) {
const s = (name || 'P').trim();
return s ? s.charAt(0).toUpperCase() : 'P';
}
function updateProjectsDetailVisibility() {
const main = document.getElementById('projects-detail-main');
const placeholder = document.getElementById('projects-detail-placeholder');
const inner = document.getElementById('projects-detail-inner');
const show = !!currentProjectId;
if (main) main.classList.toggle('has-project', show);
if (placeholder) placeholder.hidden = show;
if (inner) inner.hidden = !show;
}
function updateProjectsListCount() {
const el = document.getElementById('projects-list-count');
if (el) el.textContent = String(projectsListPagination.total || projectsCache.length);
}
/** 事实分类 → 徽章样式(与 fact_template.go 常量对齐) */
const FACT_CATEGORY_BADGE = {
target: 'projects-category--target',
auth: 'projects-category--auth',
infra: 'projects-category--infra',
business: 'projects-category--business',
finding: 'projects-category--finding',
chain: 'projects-category--chain',
exploit: 'projects-category--exploit',
poc: 'projects-category--poc',
note: 'projects-category--note',
vuln: 'projects-category--exploit',
};
function formatCategoryBadge(category) {
const raw = (category || '').trim();
const c = raw.toLowerCase() || 'note';
const cls = FACT_CATEGORY_BADGE[c] || 'projects-category--custom';
return `<span class="projects-category ${cls}">${escapeHtml(raw || '—')}</span>`;
}
function formatConfidenceBadge(confidence) {
const c = (confidence || '').toLowerCase();
let cls = 'projects-confidence--tentative';
let label = c || '—';
if (c === 'confirmed') {
cls = 'projects-confidence--confirmed';
label = tp('projects.confidenceConfirmed');
} else if (c === 'deprecated') {
cls = 'projects-confidence--deprecated';
label = tp('projects.confidenceDeprecated');
} else if (c === 'tentative') {
label = tp('projects.confidenceTentative');
}
return `<span class="projects-confidence ${cls}">${escapeHtml(label)}</span>`;
}
function renderProjectFactActions(keyEsc, idEsc, confidence) {
const isDeprecated = (confidence || '').toLowerCase() === 'deprecated';
const toggleBtn = isDeprecated
? `<button type="button" class="projects-action-btn projects-action-btn--restore" data-fact-key="${keyEsc}" onclick="restoreProjectFactByKey(this.dataset.factKey)" title="${escapeHtml(tp('projects.restoreTitle'))}">${escapeHtml(tp('projects.restore'))}</button>`
: `<button type="button" class="projects-action-btn projects-action-btn--mute" data-fact-key="${keyEsc}" onclick="deprecateProjectFactByKey(this.dataset.factKey)" title="${escapeHtml(tp('projects.deprecateTitle'))}">${escapeHtml(tp('projects.deprecate'))}</button>`;
return `<div class="projects-table-actions">
<button type="button" class="projects-action-btn projects-action-btn--edit" data-fact-key="${keyEsc}" onclick="showEditFactModal(this.dataset.factKey)" title="${escapeHtml(tp('projects.editTitle'))}">${escapeHtml(tp('common.edit'))}</button>
<button type="button" class="projects-action-btn projects-action-btn--view" data-fact-key="${keyEsc}" onclick="viewProjectFactBody(this.dataset.factKey)" title="${escapeHtml(tp('projects.viewBodyTitle'))}">${escapeHtml(tp('projects.details'))}</button>
${toggleBtn}
<button type="button" class="projects-action-btn projects-action-btn--danger" data-fact-id="${idEsc}" onclick="deleteProjectFact(this.dataset.factId)" title="${escapeHtml(tp('projects.deleteForeverTitle'))}">${escapeHtml(tp('common.delete'))}</button>
</div>`;
}
function formatSeverityBadge(severity) {
const s = (severity || 'info').toLowerCase();
const cls = 'projects-severity--' + (['critical', 'high', 'medium', 'low', 'info'].includes(s) ? s : 'info');
return `<span class="projects-severity ${cls}">${escapeHtml(severity || '—')}</span>`;
}
function formatVulnStatusBadge(status) {
const s = (status || 'open').toLowerCase();
const labelMap = {
open: 'vulnerabilityPage.statusOpen',
confirmed: 'vulnerabilityPage.statusConfirmed',
fixed: 'vulnerabilityPage.statusFixed',
false_positive: 'vulnerabilityPage.statusFalsePositive',
ignored: 'vulnerabilityPage.statusIgnored',
};
const label = labelMap[s] ? tp(labelMap[s]) : status || '—';
const cls = ['open', 'confirmed', 'fixed', 'false_positive', 'ignored'].includes(s) ? s : 'open';
return `<span class="status-badge status-${escapeHtml(cls)}">${escapeHtml(label)}</span>`;
}
let _projectVulnsFilterDebounce = null;
function buildProjectVulnsQueryParams() {
const params = new URLSearchParams();
params.set('project_id', currentProjectId);
params.set('limit', '200');
const search = document.getElementById('project-vulns-search')?.value?.trim();
const severity = document.getElementById('project-vulns-filter-severity')?.value?.trim();
const status = document.getElementById('project-vulns-filter-status')?.value?.trim();
if (search) params.set('q', search);
if (severity) params.set('severity', severity);
if (status) params.set('status', status);
return params;
}
function projectVulnsHasActiveFilter() {
return !!(
document.getElementById('project-vulns-search')?.value?.trim() ||
document.getElementById('project-vulns-filter-severity')?.value ||
document.getElementById('project-vulns-filter-status')?.value
);
}
function debouncedLoadProjectVulnerabilities() {
if (_projectVulnsFilterDebounce) clearTimeout(_projectVulnsFilterDebounce);
_projectVulnsFilterDebounce = setTimeout(() => {
_projectVulnsFilterDebounce = null;
loadProjectVulnerabilities();
}, 280);
}
function getProjectsListFilter() {
return (document.getElementById('projects-list-search')?.value || '').trim().toLowerCase();
}
function filterProjectsList() {
if (_projectsListSearchDebounce) clearTimeout(_projectsListSearchDebounce);
_projectsListSearchDebounce = setTimeout(() => {
_projectsListSearchDebounce = null;
const q = getProjectsListFilter();
projectsListPagination.page = 1;
fetchProjectsList(undefined, { page: 1, search: q })
.then(() => {
renderProjectsSidebar();
renderProjectsPagination();
})
.catch((e) => console.warn(e));
}, 280);
}
function goProjectsPage(page) {
const totalPages = Math.max(1, Math.ceil((projectsListPagination.total || 0) / projectsListPagination.pageSize) || 1);
const next = Math.min(Math.max(1, page), totalPages);
if (next === projectsListPagination.page) return;
fetchProjectsList(undefined, { page: next })
.then(() => {
renderProjectsSidebar();
renderProjectsPagination();
const listEl = document.getElementById('projects-list');
if (listEl) listEl.scrollTop = 0;
})
.catch((e) => console.warn(e));
}
function changeProjectsPageSize() {
const sel = document.getElementById('projects-page-size-pagination');
const newSize = sel ? parseInt(sel.value, 10) : 50;
if (![20, 50, 100].includes(newSize)) return;
try {
localStorage.setItem(PROJECTS_LIST_PAGE_SIZE_KEY, String(newSize));
} catch (e) { /* ignore */ }
projectsListPagination.pageSize = newSize;
projectsListPagination.page = 1;
fetchProjectsList(undefined, { page: 1, pageSize: newSize })
.then(() => {
renderProjectsSidebar();
renderProjectsPagination();
})
.catch((e) => console.warn(e));
}
function renderProjectsPagination() {
const el = document.getElementById('projects-pagination');
if (!el) return;
const { page, pageSize, total } = projectsListPagination;
const totalPages = Math.max(1, Math.ceil(total / pageSize) || 1);
const navDisabled = total === 0 || totalPages <= 1;
el.hidden = false;
const start = total === 0 ? 0 : (page - 1) * pageSize + 1;
const end = total === 0 ? 0 : Math.min(page * pageSize, total);
const infoText = tpFmt('projects.paginationRange', `${start}-${end}/${total}`, { start, end, total });
const pageText = tpFmt('projects.paginationPage', `${page}/${totalPages}`, { page, total: totalPages });
el.innerHTML = `
<div class="sidebar-list-pagination-inner sidebar-list-pagination-inner--compact">
<span class="pagination-info">${escapeHtml(infoText)}</span>
<div class="pagination-controls">
<button type="button" class="btn-icon-pagination" onclick="goProjectsPage(${page - 1})" ${page <= 1 || navDisabled ? 'disabled' : ''} title="${escapeHtml(tp('projects.paginationPrev'))}" aria-label="${escapeHtml(tp('projects.paginationPrev'))}"></button>
<span class="pagination-page">${escapeHtml(pageText)}</span>
<button type="button" class="btn-icon-pagination" onclick="goProjectsPage(${page + 1})" ${page >= totalPages || navDisabled ? 'disabled' : ''} title="${escapeHtml(tp('projects.paginationNext'))}" aria-label="${escapeHtml(tp('projects.paginationNext'))}"></button>
</div>
<label class="pagination-page-size">
${escapeHtml(tp('projects.paginationPerPage'))}
<select id="projects-page-size-pagination" class="projects-filter-select-native">
<option value="20" ${pageSize === 20 ? 'selected' : ''}>20</option>
<option value="50" ${pageSize === 50 ? 'selected' : ''}>50</option>
<option value="100" ${pageSize === 100 ? 'selected' : ''}>100</option>
</select>
</label>
</div>`;
refreshProjectsFilterSelects();
}
function renderProjectsSidebar() {
const el = document.getElementById('projects-list');
if (!el) return;
updateProjectsListCount();
const list = projectsCache;
if (!projectsCache.length) {
const createBtn = (typeof hasPermission === 'function' && hasPermission('project:write'))
? `<button type="button" class="btn-primary btn-small projects-empty-btn" onclick="showNewProjectModal()">${escapeHtml(tp('projects.newProject'))}</button>`
: '';
el.innerHTML = `<div class="projects-empty">${escapeHtml(tp('projects.noProjects'))}${createBtn ? `<br>${createBtn}` : ''}</div>`;
updateProjectsDetailVisibility();
renderProjectsPagination();
return;
}
if (!list.length) {
el.innerHTML = `<div class="projects-empty">${escapeHtml(tp('projects.noMatchingProjects'))}</div>`;
updateProjectsDetailVisibility();
renderProjectsPagination();
return;
}
el.innerHTML = list.map((p) => {
const fullName = p.name || tp('common.untitled');
const displayName = window.formatProjectNameForDisplay(fullName);
const active = p.id === currentProjectId ? ' is-active' : '';
const archived = p.status === 'archived' ? ' is-archived' : '';
const badges = [
p.pinned ? `<span class="projects-list-item-badge">${escapeHtml(tp('projects.pinned'))}</span>` : '',
p.status === 'archived' ? `<span class="projects-list-item-badge">${escapeHtml(tp('projects.archived'))}</span>` : '',
].join('');
return `<div class="projects-list-item${active}${archived}" data-id="${escapeAttr(p.id)}" onclick="selectProject(${escapeJsStringAttr(p.id)})">
<div class="projects-list-item-body">
<div class="projects-list-item-name" title="${escapeAttr(fullName)}">${escapeHtml(displayName)}${badges}</div>
<div class="projects-list-item-meta">${formatProjectTime(p.updated_at)}</div>
</div>
<button type="button" class="projects-list-item-menu" title="${escapeHtml(tp('projects.projectActions'))}" aria-label="${escapeHtml(tp('projects.projectActions'))}" onclick="showProjectListActionMenu(event, ${escapeJsStringAttr(p.id)})">⋯</button>
</div>`;
}).join('');
updateProjectsDetailVisibility();
if (typeof applyRBACToUI === 'function') applyRBACToUI(el);
}
function clampProjectDescription(text) {
const s = (text || '').trim();
if (s.length <= PROJECT_DESCRIPTION_MAX_LENGTH) return s;
return s.slice(0, PROJECT_DESCRIPTION_MAX_LENGTH);
}
function renderProjectDetailTitle(name) {
const titleEl = document.getElementById('projects-detail-title');
if (!titleEl) return;
const text = (name || '').trim() || tp('projects.defaultProjectName');
window.applyProjectNameDisplay(titleEl, text);
}
function renderProjectDetailDesc(desc) {
const descEl = document.getElementById('projects-detail-desc');
if (!descEl) return;
const text = (desc || '').trim();
if (!text) {
descEl.hidden = true;
descEl.textContent = '';
descEl.removeAttribute('title');
return;
}
descEl.textContent = text;
descEl.title = text;
descEl.hidden = false;
}
function updateProjectStatusPill(status) {
const el = document.getElementById('projects-detail-status');
if (!el) return;
const archived = status === 'archived';
el.textContent = archived ? tp('projects.statusArchived') : tp('projects.statusActive');
el.className = 'projects-status-pill ' + (archived ? 'projects-status-pill--archived' : 'projects-status-pill--active');
}
function renderProjectDetailMeta(updatedAt) {
const metaEl = document.getElementById('projects-detail-meta');
const timeEl = document.getElementById('projects-detail-meta-time');
if (!metaEl || !timeEl) return;
const time = formatProjectTime(updatedAt);
const full = tpFmt('projects.updatedPrefix', `Updated ${time}`, { time });
timeEl.textContent = time;
metaEl.title = full;
}
function refreshProjectDetailMetaI18n() {
if (!currentProjectId) return;
let updatedAt = currentProjectUpdatedAt;
if (updatedAt == null) {
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
const p = source.find((x) => x.id === currentProjectId);
updatedAt = p?.updated_at;
}
renderProjectDetailMeta(updatedAt);
}
function updateProjectStats(stats) {
const s = stats || {};
const f = document.getElementById('project-stat-facts');
const v = document.getElementById('project-stat-vulns');
const c = document.getElementById('project-stat-conversations');
const sparse = document.getElementById('project-stat-sparse');
const fc = s.fact_count ?? s.factCount ?? 0;
const vc = s.vuln_count ?? s.vulnCount ?? 0;
const cc = s.conversation_count ?? s.conversationCount ?? 0;
const sc = s.sparse_fact_count ?? s.sparseFactCount ?? 0;
if (f) f.textContent = tpFmt('projects.statsFacts', `${fc} facts`, { count: fc });
if (v) v.textContent = tpFmt('projects.statsVulns', `${vc} vulnerabilities`, { count: vc });
if (c) c.textContent = tpFmt('projects.statsConversations', `${cc} conversations`, { count: cc });
if (sparse) {
if (sc > 0) {
sparse.hidden = false;
sparse.textContent = tpFmt('projects.statsSparse', `${sc} to complete`, { count: sc });
} else {
sparse.hidden = true;
}
}
}
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');
const confEl = document.getElementById('project-facts-filter-confidence');
const sparseEl = document.getElementById('project-facts-filter-sparse');
const vulnSearchEl = document.getElementById('project-vulns-search');
const vulnSevEl = document.getElementById('project-vulns-filter-severity');
const vulnStatusEl = document.getElementById('project-vulns-filter-status');
if (searchEl) searchEl.value = '';
if (catEl) catEl.value = '';
if (confEl) confEl.value = '';
if (sparseEl) sparseEl.checked = false;
if (vulnSearchEl) vulnSearchEl.value = '';
if (vulnSevEl) vulnSevEl.value = '';
if (vulnStatusEl) vulnStatusEl.value = '';
syncAllProjectsFilterSelects();
renderProjectsSidebar();
updateProjectsDetailVisibility();
try {
const res = await apiFetch(`/api/projects/${id}`);
if (!res.ok) throw new Error(tp('projects.projectNotFound'));
const p = await res.json();
renderProjectDetailTitle(p.name);
document.getElementById('project-edit-name').value = p.name || '';
document.getElementById('project-edit-description').value = p.description || '';
document.getElementById('project-edit-scope').value = p.scope_json || '';
const statusEl = document.getElementById('project-edit-status');
if (statusEl) statusEl.value = p.status || 'active';
syncAllProjectsFilterSelects();
const pinEl = document.getElementById('project-edit-pinned');
if (pinEl) pinEl.checked = !!p.pinned;
updateProjectStatusPill(p.status || 'active');
currentProjectUpdatedAt = p.updated_at;
renderProjectDetailMeta(currentProjectUpdatedAt);
renderProjectDetailDesc(p.description);
projectNameById[p.id] = p.name || p.id;
} catch (e) {
console.warn(e);
}
await refreshProjectHeaderStats();
switchProjectTab(currentProjectTab);
}
function switchProjectTab(tab) {
if (tab === 'assets' && typeof hasPermission === 'function' && !hasPermission('asset:read')) tab = 'facts';
currentProjectTab = tab;
['facts', 'graph', 'assets', 'conversations', 'vulns', 'settings'].forEach((t) => {
const btn = document.getElementById(`project-tab-${t}`);
const panel = document.getElementById(`project-panel-${t}`);
if (btn) btn.classList.toggle('is-active', t === tab);
if (panel) panel.hidden = t !== tab;
});
if (tab === 'facts') loadProjectFacts();
if (tab === 'graph') loadProjectFactGraph();
if (tab === 'assets') loadProjectAssets();
if (tab === 'conversations') loadProjectConversations();
if (tab === 'vulns') loadProjectVulnerabilities();
}
async function loadProjectAssets(page) {
const tbody = document.getElementById('project-assets-tbody');
const countEl = document.getElementById('project-assets-count');
if (!tbody || !currentProjectId) return;
const requestedPage = Math.max(1, Number(page || projectAssetsPagination.page || 1));
projectAssetsPagination.page = requestedPage;
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="7">${escapeHtml(tpFmt('common.loading', '加载中...'))}</td></tr>`;
const qs = new URLSearchParams({
project_id: currentProjectId,
page: String(requestedPage),
page_size: String(projectAssetsPagination.pageSize),
});
const res = await apiFetch(`/api/assets?${qs.toString()}`);
if (!res.ok) {
currentProjectAssets = [];
projectAssetsPagination.total = 0;
projectAssetsPagination.totalPages = 1;
if (countEl) countEl.textContent = '0';
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="7">${escapeHtml(tpFmt('common.loadFailed', '加载失败'))}</td></tr>`;
renderProjectAssetsPagination();
return;
}
const data = await res.json();
currentProjectAssets = data.assets || [];
projectAssetsPagination.page = Number(data.page || requestedPage);
projectAssetsPagination.total = Number(data.total || 0);
projectAssetsPagination.totalPages = Math.max(1, Number(data.total_pages || 1));
if (projectAssetsPagination.page > projectAssetsPagination.totalPages) {
return loadProjectAssets(projectAssetsPagination.totalPages);
}
if (countEl) countEl.textContent = tpFmt('projects.assetCount', `${data.total || 0} 个资产`, { count: data.total || 0 });
if (!currentProjectAssets.length) {
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="7">${escapeHtml(tpFmt('projects.noBoundAssets', '暂无绑定到此项目的资产'))}</td></tr>`;
renderProjectAssetsPagination();
return;
}
tbody.innerHTML = currentProjectAssets.map((asset, index) => {
const target = asset.host || asset.domain || asset.ip || '-';
const service = [asset.protocol, asset.port ? ':' + asset.port : ''].join('') || '-';
const fingerprint = [asset.title, asset.server].filter(Boolean).join(' · ') || '-';
const updated = asset.last_seen_at ? new Date(asset.last_seen_at).toLocaleString() : '-';
const status = asset.status === 'inactive' ? tpFmt('assets.statusInactive', '停用') : tpFmt('assets.statusActive', '活跃');
return `<tr>
<td class="cell-summary"><button type="button" class="projects-asset-target" onclick="openProjectAssetDetail(${index})" title="${escapeHtml(target)}">${escapeHtml(target)}</button></td>
<td><code>${escapeHtml(service)}</code></td>
<td class="cell-summary" title="${escapeHtml(fingerprint)}">${escapeHtml(fingerprint)}</td>
<td>${escapeHtml(asset.source || '-')}</td>
<td>${escapeHtml(updated)}</td>
<td><span class="asset-status asset-status--${escapeHtml(asset.status || 'active')}">${escapeHtml(status)}</span></td>
<td class="col-actions"><div class="projects-table-actions"><button type="button" class="projects-action-btn projects-action-btn--mute" data-require-permission="asset:write" onclick="unbindAssetFromProject(${index})" title="${escapeHtml(tp('projects.unbindProjectTitle'))}">${escapeHtml(tp('projects.unbind'))}</button></div></td>
</tr>`;
}).join('');
renderProjectAssetsPagination();
const tableWrap = document.querySelector('#project-panel-assets .projects-table-wrap');
if (tableWrap) tableWrap.scrollTop = 0;
}
function renderProjectAssetsPagination() {
const root = document.getElementById('project-assets-pagination');
if (!root) return;
const { page, pageSize, total, totalPages } = projectAssetsPagination;
const start = total === 0 ? 0 : (page - 1) * pageSize + 1;
const end = total === 0 ? 0 : Math.min(page * pageSize, total);
const atFirst = page <= 1 || total === 0;
const atLast = page >= totalPages || total === 0;
root.innerHTML = `<div class="pagination">
<div class="pagination-info">
<span>${escapeHtml(tpFmt('projects.paginationShow', `显示 ${start}-${end} / 共 ${total}`, { start, end, total }))}</span>
<label class="pagination-page-size">${escapeHtml(tpFmt('projects.paginationPerPage', '每页显示'))}
<select id="project-assets-page-size" onchange="changeProjectAssetsPageSize(this.value)">
${[10, 20, 50, 100].map(size => `<option value="${size}" ${size === pageSize ? 'selected' : ''}>${size}</option>`).join('')}
</select>
</label>
</div>
<div class="pagination-controls">
<button type="button" class="btn-secondary" onclick="loadProjectAssets(1)" ${atFirst ? 'disabled' : ''}>${escapeHtml(tpFmt('skillsPage.firstPage', '首页'))}</button>
<button type="button" class="btn-secondary" onclick="loadProjectAssets(${Math.max(1, page - 1)})" ${atFirst ? 'disabled' : ''}>${escapeHtml(tpFmt('projects.paginationPrev', '上一页'))}</button>
<span class="pagination-page">${escapeHtml(tpFmt('skillsPage.pageOf', `第 ${page} / ${totalPages} 页`, { current: page, total: totalPages }))}</span>
<button type="button" class="btn-secondary" onclick="loadProjectAssets(${Math.min(totalPages, page + 1)})" ${atLast ? 'disabled' : ''}>${escapeHtml(tpFmt('projects.paginationNext', '下一页'))}</button>
<button type="button" class="btn-secondary" onclick="loadProjectAssets(${totalPages})" ${atLast ? 'disabled' : ''}>${escapeHtml(tpFmt('skillsPage.lastPage', '尾页'))}</button>
</div>
</div>`;
}
function changeProjectAssetsPageSize(value) {
const size = Number(value);
if (![10, 20, 50, 100].includes(size)) return;
projectAssetsPagination.pageSize = size;
projectAssetsPagination.page = 1;
try {
localStorage.setItem(PROJECT_ASSETS_PAGE_SIZE_KEY, String(size));
} catch (e) { /* ignore */ }
loadProjectAssets(1);
}
function openProjectAssetDetail(index) {
const asset = currentProjectAssets[Number(index)];
if (asset && typeof window.openAssetDetailRecord === 'function') window.openAssetDetailRecord(asset);
}
async function unbindAssetFromProject(index) {
const asset = currentProjectAssets[Number(index)];
if (!asset || !asset.id || !currentProjectId) return;
const target = asset.host || asset.domain || asset.ip || asset.id;
const message = tpFmt('projects.unbindAssetConfirm', `确定将“${target}”从当前项目解绑吗?资产不会被删除。`, { target });
if (!confirm(message)) return;
try {
const res = await apiFetch('/api/assets/project-binding', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ asset_ids: [asset.id], project_id: '' })
});
if (!res.ok) throw new Error(await res.text());
if (typeof showInlineToast === 'function') {
showInlineToast(tpFmt('projects.unbindAssetDone', '已从项目解绑资产', { target }));
}
await loadProjectAssets(projectAssetsPagination.page);
await refreshProjectHeaderStats();
} catch (error) {
alert(`${tp('projects.unbindFailed')}: ${error.message || error}`);
}
}
let _selectedGraphFactKey = null;
let _selectedGraphEdgeId = null;
let _currentGraphData = null;
let _graphConnectMode = false;
let _graphConnectSource = null;
function toggleProjectFactGraphConnectMode() {
_graphConnectMode = !_graphConnectMode;
_graphConnectSource = null;
const btn = document.getElementById('project-graph-connect-btn');
if (btn) {
btn.classList.toggle('is-active', _graphConnectMode);
btn.textContent = _graphConnectMode ? tp('projects.graphConnectActive') : tp('projects.graphConnect');
btn.classList.toggle('projects-graph-action-btn--connect-active', _graphConnectMode);
}
if (typeof ProjectFactGraph !== 'undefined') {
ProjectFactGraph.setConnectMode(_graphConnectMode, handleGraphConnectNodePick);
}
}
async function handleGraphConnectNodePick(factKey) {
if (!factKey || String(factKey).startsWith('vuln:')) return;
if (!requireProjectWrite()) return;
if (!_graphConnectSource) {
_graphConnectSource = factKey;
if (typeof showNotification === 'function') {
showNotification(tpFmt('projects.graphConnectPickTarget', `已选源节点 ${factKey},请点击目标节点`, { source: factKey }), 'info');
}
return;
}
if (_graphConnectSource === factKey) return;
const edgeType = window.prompt(tp('projects.graphEdgeTypePrompt'), 'leads_to');
if (!edgeType) {
_graphConnectSource = null;
return;
}
const res = await apiFetch(`/api/projects/${currentProjectId}/fact-edges`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source_fact_key: _graphConnectSource,
target_fact_key: factKey,
edge_type: edgeType.trim(),
}),
});
_graphConnectSource = null;
if (!(await notifyProjectApiFailure(res, 'projects.graphConnectFailed', '创建边失败'))) return;
if (typeof showNotification === 'function') showNotification(tp('projects.graphConnectSuccess'), 'success');
loadProjectFactGraph();
loadProjectFacts();
}
function formatIncomingLinksForModal(links) {
if (!links || !links.length) return '';
return links
.map((e) => `${e.edge_type || e.type}: ${e.source_fact_key || e.from}`)
.join('\n');
}
async function loadProjectFactGraph() {
const container = document.getElementById('project-fact-graph-container');
const statsEl = document.getElementById('project-fact-graph-stats');
if (!container || !currentProjectId) return;
container.innerHTML = `<div class="loading-spinner">${escapeHtml(tp('common.loading'))}</div>`;
closeProjectFactGraphSidebar();
const view = document.getElementById('project-graph-view')?.value || 'path';
const hideDeprecated = document.getElementById('project-facts-filter-hide-deprecated')?.checked !== false;
const params = new URLSearchParams({ view });
if (!hideDeprecated) params.set('exclude_deprecated', '0');
try {
const res = await apiFetch(`/api/projects/${currentProjectId}/fact-graph?${params}`);
if (!res.ok) throw new Error(tp('common.loadFailed'));
const data = await res.json();
_currentGraphData = data;
if (typeof ProjectFactGraph !== 'undefined') {
ProjectFactGraph.render(container, data, {
emptyText: tp('projects.graphEmpty'),
emptyTitle: tp('projects.graphEmptyTitle'),
emptySteps: [
tp('projects.graphEmptyStep1'),
tp('projects.graphEmptyStep2'),
tp('projects.graphEmptyStep3'),
],
emptyActionLabel: tp('projects.graphEmptyCta'),
onEmptyAction: () => showAddFactModal(),
onNodeSelect: (factKey) => showProjectFactGraphNode(factKey, _currentGraphData),
onEdgeSelect: (edgeId) => showProjectFactGraphEdge(edgeId, _currentGraphData),
});
}
const nodeCount = (data.nodes || []).length;
const edgeCount = (data.edges || []).length;
if (statsEl) {
statsEl.innerHTML =
`<span class="projects-graph-stat-badge"><strong>${nodeCount}</strong> ${escapeHtml(tp('projects.graphStatsNodes'))}</span>` +
`<span class="projects-graph-stat-badge"><strong>${edgeCount}</strong> ${escapeHtml(tp('projects.graphStatsEdges'))}</span>`;
}
} catch (e) {
container.innerHTML = `<div class="error-message">${escapeHtml(e.message || tp('common.loadFailed'))}</div>`;
if (statsEl) statsEl.textContent = '';
}
}
function filterProjectFactGraph() {
const q = document.getElementById('project-graph-search')?.value || '';
if (typeof ProjectFactGraph !== 'undefined') {
ProjectFactGraph.filterBySearch(q);
}
}
function centerProjectFactGraph() {
if (typeof ProjectFactGraph !== 'undefined') ProjectFactGraph.center();
}
function closeProjectFactGraphSidebar() {
_selectedGraphFactKey = null;
_selectedGraphEdgeId = null;
if (typeof ProjectFactGraph !== 'undefined') ProjectFactGraph.clearEdgeSelection();
const sidebar = document.getElementById('project-fact-graph-sidebar');
if (sidebar) sidebar.hidden = true;
}
function isSyntheticGraphEdge(edge) {
if (!edge) return true;
const id = String(edge.id || '');
const type = String(edge.type || '');
return id.startsWith('vuln-link:') || type === 'links_vuln';
}
function getGraphEdgesForFact(factKey, graphData) {
if (!factKey || !graphData?.edges) return [];
return graphData.edges.filter((e) => e.source === factKey || e.target === factKey);
}
function renderGraphEdgesListHtml(factKey, graphData, selectedEdgeId) {
const edges = getGraphEdgesForFact(factKey, graphData);
if (!edges.length) {
return `<p class="project-fact-graph-edges-empty">${escapeHtml(tp('projects.graphEdgesEmpty'))}</p>`;
}
return edges
.map((e) => {
const isOut = e.source === factKey;
const dirLabel = isOut ? tp('projects.graphEdgeFromSelf') : tp('projects.graphEdgeToSelf');
const src = e.source || '';
const tgt = e.target || '';
const selected = e.id === selectedEdgeId ? ' is-selected' : '';
const synthetic = isSyntheticGraphEdge(e);
const deleteBtn = synthetic
? `<span class="project-fact-graph-edge-synthetic" title="${escapeHtml(tp('projects.graphEdgeSynthetic'))}">—</span>`
: `<button type="button" class="project-fact-graph-edge-delete" data-edge-id="${escapeAttr(e.id)}" onclick="event.stopPropagation(); deleteProjectFactEdge(this.dataset.edgeId)" title="${escapeAttr(tp('projects.graphDeleteEdge'))}"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M2 2l8 8M10 2l-8 8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></button>`;
return `<div class="project-fact-graph-edge-item${selected}" data-edge-id="${escapeAttr(e.id)}" onclick="focusProjectFactGraphEdge(${escapeJsStringAttr(e.id)})">
<span class="project-fact-graph-edge-dir">${escapeHtml(dirLabel)}</span>
<span class="project-fact-graph-edge-type">${escapeHtml(e.type || '')}</span>
<span class="project-fact-graph-edge-peer" title="${escapeHtml(src + ' → ' + tgt)}">${escapeHtml(src)}${escapeHtml(tgt)}</span>
${deleteBtn}
</div>`;
})
.join('');
}
function renderProjectFactGraphEdges(factKey, graphData, selectedEdgeId) {
const wrap = document.getElementById('project-fact-graph-edges-wrap');
const list = document.getElementById('project-fact-graph-edges-list');
if (!wrap || !list) return;
const edges = getGraphEdgesForFact(factKey, graphData);
wrap.hidden = false;
list.innerHTML = renderGraphEdgesListHtml(factKey, graphData, selectedEdgeId);
if (selectedEdgeId) {
const selectedEl = list.querySelector('[data-edge-id="' + String(selectedEdgeId).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"]');
if (selectedEl) selectedEl.scrollIntoView({ block: 'nearest' });
}
if (!edges.length) wrap.hidden = false;
}
function graphVulnIdFromKey(factKey) {
const key = String(factKey || '');
if (!key.startsWith('vuln:')) return null;
return key.slice(5);
}
function showProjectFactGraphNode(factKey, graphData, selectedEdgeId) {
if (!factKey) {
closeProjectFactGraphSidebar();
return;
}
_selectedGraphFactKey = factKey;
_selectedGraphEdgeId = selectedEdgeId || null;
const node = (graphData?.nodes || []).find((n) => n.fact_key === factKey || n.id === factKey);
const vulnId = graphVulnIdFromKey(factKey);
const isVulnNode = !!vulnId;
const sidebar = document.getElementById('project-fact-graph-sidebar');
const titleEl = document.getElementById('project-fact-graph-node-title');
const metaEl = document.getElementById('project-fact-graph-node-meta');
const categoryEl = document.getElementById('project-fact-graph-node-category');
const detailBtn = document.getElementById('project-fact-graph-detail-btn');
const editBtn = document.getElementById('project-fact-graph-edit-btn');
if (!sidebar || !titleEl || !metaEl) return;
titleEl.textContent = isVulnNode ? vulnId : factKey;
titleEl.title = isVulnNode ? vulnId : factKey;
if (categoryEl) {
const visualType =
typeof ProjectFactGraph !== 'undefined' && ProjectFactGraph.resolveGraphNodeType
? ProjectFactGraph.resolveGraphNodeType(node)
: node?.type || node?.category || 'note';
const theme =
typeof ProjectFactGraph !== 'undefined' && ProjectFactGraph.nodeTheme
? ProjectFactGraph.nodeTheme(visualType)
: { typeEn: String(visualType).toUpperCase(), typeLabel: visualType };
categoryEl.textContent = theme.typeEn || String(visualType).toUpperCase();
categoryEl.hidden = false;
categoryEl.className = 'project-fact-graph-node-category project-fact-graph-node-category--' + visualType;
categoryEl.title = theme.typeLabel || visualType;
}
const conf = node?.confidence || '';
const summary = (node?.summary || node?.label || '').trim();
if (summary || conf || isVulnNode) {
const parts = [];
if (summary) {
parts.push(`<span class="project-fact-graph-node-summary">${escapeHtml(summary)}</span>`);
}
if (isVulnNode) {
parts.push(
`<span class="project-fact-graph-node-vuln-hint">${escapeHtml(tp('projects.graphVulnSidebarHint'))}</span>`,
);
}
if (conf) {
parts.push(formatConfidenceBadge(conf));
}
metaEl.innerHTML = parts.join('');
} else {
metaEl.textContent = '';
}
if (detailBtn) {
detailBtn.textContent = isVulnNode ? tp('projects.viewVulnerability') : tp('projects.details');
}
if (editBtn) {
editBtn.hidden = isVulnNode;
}
renderProjectFactGraphEdges(factKey, graphData, _selectedGraphEdgeId);
if (_selectedGraphEdgeId && typeof ProjectFactGraph !== 'undefined') {
ProjectFactGraph.selectEdge(_selectedGraphEdgeId);
} else if (typeof ProjectFactGraph !== 'undefined') {
ProjectFactGraph.clearEdgeSelection();
}
sidebar.hidden = false;
}
function showProjectFactGraphEdge(edgeId, graphData) {
const edge = (graphData?.edges || []).find((e) => e.id === edgeId);
if (!edge) return;
const anchorKey = edge.source && !String(edge.source).startsWith('vuln:') ? edge.source : edge.target;
showProjectFactGraphNode(anchorKey, graphData, edgeId);
}
function focusProjectFactGraphEdge(edgeId) {
if (!edgeId || !_currentGraphData) return;
showProjectFactGraphEdge(edgeId, _currentGraphData);
}
async function deleteProjectFactEdge(edgeId) {
if (!edgeId || !currentProjectId) return;
if (!requireProjectWrite()) return;
const edge = (_currentGraphData?.edges || []).find((e) => e.id === edgeId);
if (isSyntheticGraphEdge(edge)) return;
if (!confirm(tp('projects.confirmDeleteGraphEdge'))) return;
const res = await apiFetch(`/api/projects/${currentProjectId}/fact-edges/${encodeURIComponent(edgeId)}`, {
method: 'DELETE',
});
if (!(await notifyProjectApiFailure(res, 'projects.graphEdgeDeleteFailed', '删除边失败'))) return;
if (typeof showNotification === 'function') showNotification(tp('projects.graphEdgeDeleteSuccess'), 'success');
const keepKey = _selectedGraphFactKey;
await loadProjectFactGraph();
if (keepKey) showProjectFactGraphNode(keepKey, _currentGraphData);
loadProjectFacts();
}
function openSelectedGraphFactDetail() {
if (!_selectedGraphFactKey) return;
const vulnId = graphVulnIdFromKey(_selectedGraphFactKey);
if (vulnId) {
openVulnerabilityDetail(vulnId);
return;
}
viewProjectFactBody(_selectedGraphFactKey);
}
function editSelectedGraphFact() {
if (_selectedGraphFactKey) showEditFactModal(_selectedGraphFactKey);
}
function buildProjectFactsQueryParams() {
const params = new URLSearchParams();
params.set('limit', '200');
params.set('include_link_counts', 'true');
const search = document.getElementById('project-facts-search')?.value?.trim();
const category = document.getElementById('project-facts-filter-category')?.value?.trim();
const confidence = document.getElementById('project-facts-filter-confidence')?.value?.trim();
const sparseOnly = document.getElementById('project-facts-filter-sparse')?.checked;
const hideDeprecated = document.getElementById('project-facts-filter-hide-deprecated')?.checked;
if (search) params.set('search', search);
if (category) params.set('category', category);
if (confidence) params.set('confidence', confidence);
if (sparseOnly) params.set('sparse_only', 'true');
if (hideDeprecated) params.set('exclude_deprecated', 'true');
return params;
}
function debouncedLoadProjectFacts() {
if (_projectFactsFilterDebounce) clearTimeout(_projectFactsFilterDebounce);
_projectFactsFilterDebounce = setTimeout(() => {
_projectFactsFilterDebounce = null;
loadProjectFacts();
}, 280);
}
async function loadProjectFacts() {
const tbody = document.getElementById('project-facts-tbody');
if (!tbody || !currentProjectId) return;
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="8">${escapeHtml(tp('common.loading'))}</td></tr>`;
const qs = buildProjectFactsQueryParams().toString();
const res = await apiFetch(`/api/projects/${currentProjectId}/facts?${qs}`);
if (!res.ok) {
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="8">${escapeHtml(tp('common.loadFailed'))}</td></tr>`;
return;
}
const facts = await res.json();
if (!facts.length) {
const hasFilter =
document.getElementById('project-facts-search')?.value?.trim() ||
document.getElementById('project-facts-filter-category')?.value ||
document.getElementById('project-facts-filter-confidence')?.value ||
document.getElementById('project-facts-filter-sparse')?.checked;
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="8">${
hasFilter ? tp('projects.noMatchingFacts') : tp('projects.noFacts')
}</td></tr>`;
refreshProjectHeaderStats();
return;
}
tbody.innerHTML = facts.map((f) => {
const keyEsc = escapeHtml(f.fact_key);
const idEsc = escapeHtml(f.id);
const vulnLink = f.related_vulnerability_id
? `<span class="projects-fact-vuln-link" title="${escapeHtml(tp('projects.relatedVulnIdTitle'))}">${escapeHtml(f.related_vulnerability_id.slice(0, 8))}…</span>`
: '';
const pinBadge = f.pinned
? `<span class="projects-list-item-badge" title="${escapeHtml(tp('projects.pinned'))}">${escapeHtml(tp('projects.pinned'))}</span>`
: '';
const lc = f.link_counts || {};
const linkBadge =
lc.outgoing || lc.incoming
? `<span class="projects-fact-link-badge" title="${escapeHtml(tp('projects.linkCountsTitle'))}">↑${lc.outgoing || 0}${lc.incoming || 0}</span>`
: '<span class="projects-fact-link-badge projects-fact-link-badge--empty">—</span>';
return `<tr>
<td class="cell-fact-key"><code class="projects-fact-key-chip" title="${keyEsc}">${keyEsc}</code>${pinBadge}${vulnLink}</td>
<td class="cell-fact-category">${formatCategoryBadge(f.category)}</td>
<td class="cell-summary" title="${escapeHtml(f.summary)}">${escapeHtml(f.summary)}</td>
<td class="cell-fact-links">${linkBadge}</td>
<td>${formatFactBodyBadge(f)}</td>
<td>${formatConfidenceBadge(f.confidence)}</td>
<td>${formatProjectTime(f.updated_at, f.created_at)}</td>
<td class="col-actions">${renderProjectFactActions(keyEsc, idEsc, f.confidence)}</td>
</tr>`;
}).join('');
refreshProjectHeaderStats();
}
async function refreshProjectHeaderStats() {
if (!currentProjectId) return;
try {
const res = await apiFetch(`/api/projects/${currentProjectId}/stats`);
if (!res.ok) return;
const stats = await res.json();
updateProjectStats(stats);
} catch (e) {
console.warn(e);
}
}
async function loadProjectConversations() {
const tbody = document.getElementById('project-conversations-tbody');
if (!tbody || !currentProjectId) return;
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="3">${escapeHtml(tp('common.loading'))}</td></tr>`;
const res = await apiFetch(`/api/projects/${currentProjectId}/conversations?limit=100`);
if (!res.ok) {
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="3">${escapeHtml(tp('common.loadFailed'))}</td></tr>`;
return;
}
const data = await res.json();
const items = data.conversations || [];
if (!items.length) {
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="3">${escapeHtml(tp('projects.noBoundConversations'))}</td></tr>`;
return;
}
tbody.innerHTML = items
.map((conv) => {
const id = conv.id;
const idEsc = escapeHtml(id);
const title = escapeHtml(conv.title || tp('projects.untitledConversation'));
const updated = formatProjectTime(conv.updatedAt || conv.updated_at, conv.createdAt || conv.created_at);
return `<tr>
<td class="cell-summary" title="${title}">${title}</td>
<td>${escapeHtml(updated)}</td>
<td class="col-actions">
<div class="projects-table-actions">
<button type="button" class="projects-action-btn projects-action-btn--view" data-conv-id="${idEsc}" onclick="openProjectConversation(this.dataset.convId)">${escapeHtml(tp('projects.open'))}</button>
<button type="button" class="projects-action-btn" data-conv-id="${idEsc}" onclick="promoteConversationAttackChain(this.dataset.convId)" title="${escapeHtml(tp('projects.promoteAttackChainTitle'))}">${escapeHtml(tp('projects.promoteAttackChain'))}</button>
<button type="button" class="projects-action-btn projects-action-btn--mute" data-conv-id="${idEsc}" onclick="unbindConversationFromProject(this.dataset.convId)" title="${escapeHtml(tp('projects.unbindProjectTitle'))}">${escapeHtml(tp('projects.unbind'))}</button>
</div>
</td>
</tr>`;
})
.join('');
}
function openProjectConversation(conversationId) {
if (!conversationId) return;
if (typeof switchPage === 'function') {
switchPage('chat');
}
setTimeout(() => {
if (typeof loadConversation === 'function') {
loadConversation(conversationId);
}
}, 200);
}
async function promoteConversationAttackChain(conversationId) {
if (!currentProjectId || !conversationId) return;
if (!requireProjectWrite()) return;
if (!confirm(tp('projects.confirmPromoteAttackChain'))) return;
const res = await apiFetch(
`/api/projects/${currentProjectId}/promote-attack-chain/${encodeURIComponent(conversationId)}`,
{ method: 'POST' },
);
if (!(await notifyProjectApiFailure(res, 'projects.promoteAttackChainFailed', '沉淀失败'))) return;
const data = await res.json();
if (typeof showNotification === 'function') {
showNotification(
tpFmt(
'projects.promoteAttackChainSuccess',
`已沉淀 ${data.facts_created || 0} 新 / ${data.facts_updated || 0} 更新 / ${data.edges_created || 0} 边`,
data,
),
'success',
);
}
loadProjectFacts();
if (currentProjectTab === 'graph') loadProjectFactGraph();
}
async function unbindConversationFromProject(conversationId) {
if (!conversationId || !confirm(tp('projects.confirmUnbindConversation'))) return;
const res = await apiFetch(`/api/conversations/${encodeURIComponent(conversationId)}/project`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId: '' }),
});
if (!res.ok) return alert(tp('projects.unbindFailed'));
loadProjectConversations();
refreshProjectHeaderStats();
}
let _factDetailKey = null;
let _factDetailFact = null;
let _projectFactsFilterDebounce = null;
async function viewProjectFactBody(factKey) {
document.getElementById('fact-detail-title').textContent = factKey;
document.getElementById('fact-detail-meta').textContent = '…';
document.getElementById('fact-detail-body').textContent = '';
const warnEl = document.getElementById('fact-detail-sparse-warn');
if (warnEl) {
warnEl.hidden = true;
warnEl.textContent = '';
}
const linkBtn = document.getElementById('fact-detail-link-vuln-btn');
const createBtn = document.getElementById('fact-detail-create-vuln-btn');
if (linkBtn) linkBtn.hidden = true;
if (createBtn) createBtn.hidden = true;
openProjectsOverlay('fact-detail-modal', { focus: false });
const res = await apiFetch(`/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}`);
if (!res.ok) {
closeFactDetailModal();
return alert(tp('common.loadFailed'));
}
const f = await res.json();
_factDetailKey = f.fact_key;
_factDetailFact = f;
deferModalContent(() => {
document.getElementById('fact-detail-title').textContent = `[${f.fact_key}]`;
const metaParts = [
tpFmt('projects.factMetaCategory', `Category: ${f.category}`, { value: f.category }),
tpFmt('projects.factMetaConfidence', `Confidence: ${f.confidence}`, { value: f.confidence }),
tpFmt('projects.factMetaUpdated', `Updated: ${formatProjectTime(f.updated_at, f.created_at)}`, {
time: formatProjectTime(f.updated_at, f.created_at),
}),
];
if (f.related_vulnerability_id) metaParts.push(tpFmt('projects.factMetaRelatedVuln', `Related vulnerability: ${f.related_vulnerability_id}`, { value: f.related_vulnerability_id }));
if (f.source_conversation_id) metaParts.push(tpFmt('projects.factMetaSourceConversation', `Source conversation: ${f.source_conversation_id}`, { value: f.source_conversation_id }));
document.getElementById('fact-detail-meta').textContent = metaParts.join(' · ');
document.getElementById('fact-detail-body').textContent = f.body || tp('projects.emptyBody');
if (warnEl) {
if (isSparseFactBody(f.category, f.fact_key, f.body)) {
warnEl.hidden = false;
warnEl.textContent = tp('projects.factSparseWarn');
} else {
warnEl.hidden = true;
warnEl.textContent = '';
}
}
if (linkBtn) linkBtn.hidden = false;
if (createBtn) createBtn.hidden = false;
});
}
function editFactFromDetail() {
const key = _factDetailKey;
closeFactDetailModal();
if (key) showEditFactModal(key);
}
function closeFactDetailModal() {
closeProjectsOverlay('fact-detail-modal');
_factDetailKey = null;
_factDetailFact = null;
}
async function linkFactToExistingVulnerability() {
const f = _factDetailFact;
if (!f || !currentProjectId) return;
const res = await apiFetch(`/api/vulnerabilities?project_id=${encodeURIComponent(currentProjectId)}&limit=50`);
if (!res.ok) return alert(tp('projects.loadVulnerabilityListFailed'));
const data = await res.json();
const items = data.Vulnerabilities || data.vulnerabilities || data.items || [];
if (!items.length) return alert(tp('projects.noVulnerabilitiesInProject'));
const lines = items.map((v, i) => `${i + 1}. [${v.severity}] ${v.title} (${v.id})`);
const pick = prompt(
tp('projects.promptLinkFactToVuln', {
factKey: f.fact_key,
lines: lines.join('\n'),
interpolation: { escapeValue: false },
}) || `Enter index to link fact "${f.fact_key}":\n\n${lines.join('\n')}`,
);
if (pick == null || pick === '') return;
const idx = parseInt(pick, 10) - 1;
if (Number.isNaN(idx) || idx < 0 || idx >= items.length) return alert(tp('projects.invalidIndex'));
const vulnId = items[idx].id;
const upd = await apiFetch(`/api/projects/${currentProjectId}/facts/${encodeURIComponent(f.id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fact_key: f.fact_key,
category: f.category,
summary: f.summary,
body: f.body || '',
confidence: f.confidence,
related_vulnerability_id: vulnId,
}),
});
if (!upd.ok) return alert(tp('projects.linkFailed'));
alert(tp('projects.linkSuccess'));
closeFactDetailModal();
loadProjectFacts();
}
async function createVulnerabilityFromCurrentFact() {
const f = _factDetailFact;
if (!f || !currentProjectId) return;
if (typeof requirePermission === 'function' && !requirePermission('vulnerability:write')) return;
let convId =
(f.source_conversation_id || '').trim() ||
(typeof window.currentConversationId === 'string' ? window.currentConversationId.trim() : '');
if (!convId) {
convId = prompt(tp('projects.promptConversationIdForVulnCreate'), '')?.trim() || '';
}
if (!convId) return alert(tp('projects.cancelledNoConversationId'));
const severity = inferSeverityFromFact(f);
const body = {
conversation_id: convId,
project_id: currentProjectId,
title: (f.summary || f.fact_key).slice(0, 200),
description:
tp('projects.generatedFromFact', {
factKey: f.fact_key,
interpolation: { escapeValue: false },
}) || `Generated from project fact ${f.fact_key}`,
severity,
status: 'open',
type: f.category || 'finding',
target: '',
proof: f.body || '',
impact: '',
recommendation: '',
};
const res = await apiFetch('/api/vulnerabilities', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!(await notifyProjectApiFailure(res, 'projects.createVulnerabilityFailed', '创建漏洞失败'))) return;
const vuln = await res.json();
const upd = await apiFetch(`/api/projects/${currentProjectId}/facts/${encodeURIComponent(f.id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fact_key: f.fact_key,
category: f.category,
summary: f.summary,
body: f.body || '',
confidence: f.confidence,
related_vulnerability_id: vuln.id,
}),
});
if (!(await notifyProjectApiFailure(upd, 'projects.linkFailed', '关联失败'))) return;
const createdVulnLabel = vuln.title || vuln.id;
const successMsg = tp('projects.createVulnerabilityAndLinkSuccess', {
value: createdVulnLabel,
interpolation: { escapeValue: false },
});
alert(successMsg || `Created and linked vulnerability: ${createdVulnLabel}`);
closeFactDetailModal();
loadProjectFacts();
if (currentProjectTab === 'vulns') loadProjectVulnerabilities();
}
function inferSeverityFromFact(f) {
const c = (f.category || '').toLowerCase();
const key = (f.fact_key || '').toLowerCase();
if (c === 'exploit' || c === 'poc' || key.includes('rce') || key.includes('sqli')) return 'high';
if (c === 'finding' || c === 'chain') return 'medium';
return 'medium';
}
async function deprecateProjectFactByKey(factKey) {
if (!requireProjectWrite()) return;
if (!confirm(
tp('projects.confirmDeprecateFact', {
factKey,
interpolation: { escapeValue: false },
}) || `Deprecate fact ${factKey}?`,
)) return;
const res = await apiFetch(`/api/projects/${currentProjectId}/facts/deprecate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fact_key: factKey }),
});
if (!(await notifyProjectApiFailure(res, 'projects.operationFailed', '操作失败'))) return;
loadProjectFacts();
}
async function restoreProjectFactByKey(factKey) {
if (!requireProjectWrite()) return;
if (!confirm(
tp('projects.confirmRestoreFact', {
factKey,
interpolation: { escapeValue: false },
}) || `Restore fact ${factKey}? It will re-enter the board index with tentative status.`,
)) return;
const res = await apiFetch(`/api/projects/${currentProjectId}/facts/restore`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fact_key: factKey, confidence: 'tentative' }),
});
if (!(await notifyProjectApiFailure(res, 'projects.operationFailed', '操作失败'))) return;
loadProjectFacts();
}
function openVulnerabilitiesForProject(projectId) {
const pid = projectId || currentProjectId;
if (!pid) return;
if (typeof switchPage === 'function') {
switchPage('vulnerabilities');
}
if (typeof window.setVulnerabilityProjectFilter === 'function') {
window.setVulnerabilityProjectFilter(pid);
} else {
window.location.hash = `vulnerabilities?project_id=${encodeURIComponent(pid)}`;
}
}
async function loadProjectVulnerabilities() {
const tbody = document.getElementById('project-vulns-tbody');
if (!tbody || !currentProjectId) return;
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="4">${escapeHtml(tp('common.loading'))}</td></tr>`;
const qs = buildProjectVulnsQueryParams().toString();
const res = await apiFetch(`/api/vulnerabilities?${qs}`);
if (!res.ok) {
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="4">${escapeHtml(tp('common.loadFailed'))}</td></tr>`;
return;
}
const data = await res.json();
const items = data.Vulnerabilities || data.vulnerabilities || data.items || (Array.isArray(data) ? data : []);
if (!items.length) {
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="4">${
projectVulnsHasActiveFilter() ? tp('projects.noMatchingVulns') : tp('projects.noVulnerabilityRecords')
}</td></tr>`;
refreshProjectHeaderStats();
return;
}
tbody.innerHTML = items.map((v) => {
const idEsc = escapeHtml(v.id);
return `<tr>
<td class="cell-summary" title="${escapeHtml(v.title)}">${escapeHtml(v.title)}</td>
<td>${formatSeverityBadge(v.severity)}</td>
<td>${formatVulnStatusBadge(v.status)}</td>
<td class="col-actions">
<div class="projects-table-actions">
<button type="button" class="projects-action-btn projects-action-btn--view" data-vuln-id="${idEsc}" onclick="openVulnerabilityDetail(this.dataset.vulnId)">${escapeHtml(tp('common.view'))}</button>
<button type="button" class="projects-action-btn projects-action-btn--view" data-vuln-id="${idEsc}" onclick="viewFactsForVulnerability(this.dataset.vulnId)" title="${escapeHtml(tp('projects.viewRelatedFactsTitle'))}">${escapeHtml(tp('projects.facts'))}</button>
</div>
</td>
</tr>`;
}).join('');
refreshProjectHeaderStats();
}
function openVulnerabilityDetail(vulnId) {
openVulnerabilitiesForProject(currentProjectId);
if (typeof window.setVulnerabilityIdFilter === 'function') {
setTimeout(() => window.setVulnerabilityIdFilter(vulnId), 300);
}
}
async function viewFactsForVulnerability(vulnId) {
if (!currentProjectId) return;
switchProjectTab('facts');
const searchEl = document.getElementById('project-facts-search');
const catEl = document.getElementById('project-facts-filter-category');
const confEl = document.getElementById('project-facts-filter-confidence');
const sparseEl = document.getElementById('project-facts-filter-sparse');
const hideDepEl = document.getElementById('project-facts-filter-hide-deprecated');
if (searchEl) searchEl.value = '';
if (catEl) catEl.value = '';
if (confEl) confEl.value = '';
if (sparseEl) sparseEl.checked = false;
if (hideDepEl) hideDepEl.checked = true;
const params = new URLSearchParams({ limit: '50', related_vulnerability_id: vulnId });
const res = await apiFetch(`/api/projects/${currentProjectId}/facts?${params}`);
if (!res.ok) return alert(tp('projects.loadRelatedFactsFailed'));
const facts = await res.json();
if (!facts.length) {
alert(tp('projects.noFactsForVulnerability'));
loadProjectFacts();
return;
}
if (facts.length === 1) {
viewProjectFactBody(facts[0].fact_key);
return;
}
const pick = prompt(
tp('projects.promptChooseFactByIndex', {
count: facts.length,
lines: facts.map((f, i) => `${i + 1}. ${f.fact_key}`).join('\n'),
interpolation: { escapeValue: false },
}) || `This vulnerability is linked to ${facts.length} facts. Enter index to view:\n${facts.map((f, i) => `${i + 1}. ${f.fact_key}`).join('\n')}`,
);
if (pick == null || pick === '') {
loadProjectFacts();
return;
}
const idx = parseInt(pick, 10) - 1;
if (facts[idx]) viewProjectFactBody(facts[idx].fact_key);
else loadProjectFacts();
}
function openProjectsOverlay(id, opts) {
openAppModal(id, opts);
}
function isProjectsOverlayVisible(id) {
return isAppModalOpen(id);
}
function closeProjectsOverlay(id) {
closeAppModal(id);
}
function showNewProjectModal() {
if (!requireProjectWrite()) return;
document.getElementById('project-modal-title').textContent = tp('projects.modalNewTitle');
const sub = document.getElementById('project-modal-subtitle');
if (sub) sub.textContent = tp('projects.modalNewSubtitle');
const submitBtn = document.getElementById('project-modal-submit-btn');
if (submitBtn) submitBtn.textContent = tp('projects.createProject');
document.getElementById('project-modal-name').value = '';
document.getElementById('project-modal-description').value = '';
window._projectModalEditId = null;
openProjectsOverlay('project-modal');
}
async function showEditProjectModal(projectId, options = {}) {
if (!projectId) return;
if (!requireProjectWrite()) return;
window._projectModalFromChat = false;
window._projectModalFromChatSidebar = options.fromChatSidebar === true;
window._projectModalEditId = projectId;
document.getElementById('project-modal-title').textContent = tp('projects.modalEditTitle');
const sub = document.getElementById('project-modal-subtitle');
if (sub) sub.textContent = tp('projects.modalEditSubtitle');
const submitBtn = document.getElementById('project-modal-submit-btn');
if (submitBtn) submitBtn.textContent = tp('projects.saveChanges');
const nameEl = document.getElementById('project-modal-name');
const descEl = document.getElementById('project-modal-description');
if (nameEl) nameEl.value = '';
if (descEl) descEl.value = '';
openProjectsOverlay('project-modal', { focus: false });
let p = findProjectById(projectId);
if (!p) {
try {
const res = await apiFetch(`/api/projects/${encodeURIComponent(projectId)}`);
if (!res.ok) throw new Error(tp('projects.projectNotFound'));
p = await res.json();
} catch (e) {
closeProjectModal();
alert(e.message || tp('projects.projectNotFound'));
window._projectModalEditId = null;
return;
}
}
const name = (p.name || '').slice(0, PROJECT_NAME_MAX_LENGTH);
const description = clampProjectDescription(p.description || '');
deferModalContent(() => {
if (nameEl) nameEl.value = name;
if (descEl) descEl.value = description;
nameEl?.focus();
});
}
/** 从对话区「选择项目」面板打开新建项目,创建成功后自动绑定当前对话 */
function showNewProjectModalFromChat() {
closeChatProjectPanel();
window._projectModalFromChat = true;
showNewProjectModal();
}
/** 从对话侧栏新建项目,保持当前对话的项目绑定不变。 */
function showNewProjectModalFromChatSidebar() {
if (!requireProjectWrite()) return;
window._projectModalFromChat = false;
window._projectModalFromChatSidebar = true;
showNewProjectModal();
}
async function saveProjectModal() {
if (!requireProjectWrite()) return;
const name = document.getElementById('project-modal-name').value.trim().slice(0, PROJECT_NAME_MAX_LENGTH);
if (!name) return alert(tp('projects.enterProjectName'));
const body = {
name,
description: clampProjectDescription(document.getElementById('project-modal-description').value),
};
const editId = window._projectModalEditId;
const submitBtn = document.getElementById('project-modal-submit-btn');
if (submitBtn) submitBtn.disabled = true;
try {
const res = editId
? await apiFetch(`/api/projects/${editId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
: await apiFetch('/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!(await notifyProjectApiFailure(res, 'projects.saveFailed', '保存失败'))) return;
const fromChat = !!window._projectModalFromChat;
const fromChatSidebar = !!window._projectModalFromChatSidebar;
const fromWebshellConnId = window._projectModalFromWebshellConnId || '';
window._projectModalFromChat = false;
window._projectModalFromChatSidebar = false;
window._projectModalFromWebshellConnId = '';
closeProjectModal();
const saved = await res.json();
await loadProjectsList();
if (saved.id) {
if (fromWebshellConnId && !editId) {
if (typeof applyWebshellAiProjectSelection === 'function') {
await applyWebshellAiProjectSelection(saved.id);
}
} else if (fromChat && !editId) {
await applyChatProjectSelection(saved.id);
} else if (!fromChatSidebar) {
await selectProject(saved.id);
}
}
} catch (error) {
if (typeof notifyApiError === 'function') {
notifyApiError(error?.message || tpFmt('projects.saveFailed', '保存失败'));
} else {
alert(error?.message || tpFmt('projects.saveFailed', '保存失败'));
}
} finally {
if (submitBtn) submitBtn.disabled = false;
}
}
function closeProjectModal() {
window._projectModalFromChat = false;
window._projectModalFromChatSidebar = false;
window._projectModalEditId = null;
closeProjectsOverlay('project-modal');
}
function formatProjectScopeJson() {
const el = document.getElementById('project-edit-scope');
if (!el) return;
const raw = el.value.trim();
if (!raw) return;
try {
el.value = JSON.stringify(JSON.parse(raw), null, 2);
} catch (e) {
alert(tp('projects.invalidJson') + ': ' + (e.message || String(e)));
}
}
function insertProjectScopeExample() {
const el = document.getElementById('project-edit-scope');
if (!el) return;
const example = {
targets: ['https://example.com'],
exclude: ['*.cdn.example.com'],
notes: tp('projects.scopeNoteAuthorizedWebOnly'),
};
el.value = JSON.stringify(example, null, 2);
el.focus();
}
async function saveProjectSettings() {
if (!currentProjectId || !requireProjectWrite()) return;
const scopeRaw = document.getElementById('project-edit-scope').value.trim();
if (scopeRaw) {
try {
JSON.parse(scopeRaw);
} catch (e) {
alert(tp('projects.invalidScopeJson') + ': ' + (e.message || String(e)));
return;
}
}
const body = {
name: document.getElementById('project-edit-name').value.trim(),
description: clampProjectDescription(document.getElementById('project-edit-description').value),
scope_json: scopeRaw,
status: document.getElementById('project-edit-status')?.value || 'active',
pinned: !!document.getElementById('project-edit-pinned')?.checked,
};
const res = await apiFetch(`/api/projects/${currentProjectId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!(await notifyProjectApiFailure(res, 'projects.saveFailed', '保存失败'))) return;
await loadProjectsList();
await selectProject(currentProjectId);
if (typeof notifyApiError === 'function') {
notifyApiError(tp('projects.saved'), 'success');
} else {
alert(tp('projects.saved'));
}
}
function findProjectById(projectId) {
return projectsCache.find((p) => p.id === projectId) || projectsCacheAll.find((p) => p.id === projectId);
}
let _projectListMenuTargetId = null;
let _projectListMenuSource = '';
let _projectListMenuDocClickBound = false;
function closeProjectListActionMenu() {
const menu = document.getElementById('projects-list-action-menu');
if (!menu) return;
menu.style.display = 'none';
_projectListMenuTargetId = null;
_projectListMenuSource = '';
}
function positionProjectListActionMenu(event) {
const menu = document.getElementById('projects-list-action-menu');
if (!menu) return;
menu.style.display = 'block';
menu.style.visibility = 'visible';
menu.style.opacity = '1';
void menu.offsetHeight;
const menuRect = menu.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = event.clientX;
let top = event.clientY;
if (left + menuRect.width > viewportWidth) {
left = Math.max(8, event.clientX - menuRect.width);
}
if (top + menuRect.height > viewportHeight) {
top = Math.max(8, event.clientY - menuRect.height);
}
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
}
function showProjectListActionMenu(event, projectId, source = '') {
event.stopPropagation();
event.preventDefault();
const menu = document.getElementById('projects-list-action-menu');
if (!menu) return;
if (_projectListMenuTargetId === projectId && menu.style.display === 'block') {
closeProjectListActionMenu();
return;
}
closeProjectListActionMenu();
const p = findProjectById(projectId);
if (!p) return;
_projectListMenuTargetId = projectId;
_projectListMenuSource = source;
const editText = document.getElementById('projects-list-menu-edit-text');
const pinText = document.getElementById('projects-list-menu-pin-text');
const archiveText = document.getElementById('projects-list-menu-archive-text');
const deleteText = document.getElementById('projects-list-menu-delete-text');
if (editText) {
editText.textContent = source === 'chat'
? pickerMessage(tp, 'projects.renameProject', '重命名')
: tp('projects.editProject');
}
if (pinText) {
pinText.textContent = p.pinned
? pickerMessage(tp, 'projects.unpinProjectAction', '取消置顶')
: pickerMessage(tp, 'projects.pinProjectAction', '置顶项目');
}
if (archiveText) {
archiveText.textContent = p.status === 'archived'
? tp('projects.restoreProjectActive')
: tp('projects.archiveProject');
}
if (deleteText) deleteText.textContent = tp('projects.deleteProject');
positionProjectListActionMenu(event);
if (typeof applyRBACToUI === 'function') applyRBACToUI(menu);
}
function updateCachedProjectPinnedState(projectId, pinned) {
const update = (project) => {
if (project?.id === projectId) project.pinned = !!pinned;
return project;
};
projectsCache = sortProjectsForPicker(projectsCache.map(update));
projectsCacheAll = sortProjectsForPicker(projectsCacheAll.map(update));
renderProjectsSidebar();
if (isProjectsCacheReady() && chatProjectFolderContext.ready) {
renderChatProjectFolders(projectsCacheAll);
}
}
async function toggleProjectPinnedFromListMenu() {
if (!requireProjectWrite()) return;
const projectId = _projectListMenuTargetId;
const project = findProjectById(projectId);
closeProjectListActionMenu();
if (!projectId || !project) return;
const nextPinned = !project.pinned;
const res = await apiFetch(`/api/projects/${projectId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pinned: nextPinned }),
});
if (!(await notifyProjectApiFailure(res, 'projects.operationFailed', '操作失败'))) return;
updateCachedProjectPinnedState(projectId, nextPinned);
await loadProjectsList();
}
function initProjectListActionMenu() {
if (_projectListMenuDocClickBound) return;
_projectListMenuDocClickBound = true;
document.addEventListener('click', (event) => {
const menu = document.getElementById('projects-list-action-menu');
if (!menu || menu.style.display === 'none') return;
if (menu.contains(event.target)) return;
if (event.target.closest('.projects-list-item-menu, .project-folder-menu')) return;
closeProjectListActionMenu();
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeProjectListActionMenu();
});
}
async function toggleProjectArchiveById(projectId) {
if (!requireProjectWrite()) return;
const p = findProjectById(projectId);
if (!p) return;
const cur = p.status || 'active';
const next = cur === 'archived' ? 'active' : 'archived';
if (!confirm(next === 'archived' ? tp('projects.confirmArchiveProject') : tp('projects.confirmRestoreProjectActive'))) return;
const res = await apiFetch(`/api/projects/${projectId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: next }),
});
if (!(await notifyProjectApiFailure(res, 'projects.operationFailed', '操作失败'))) return;
await loadProjectsList();
if (currentProjectId === projectId && projectsCache.some((item) => item.id === projectId)) {
await selectProject(projectId);
} else if (currentProjectId === projectId) {
currentProjectId = null;
updateProjectsDetailVisibility();
if (projectsCache.length) await selectProject(projectsCache[0].id);
}
}
async function deleteProjectById(projectId) {
if (!requireProjectDelete()) return;
if (!projectId || !confirm(tp('projects.confirmDeleteProject'))) return;
const deletedIndex = projectsCache.findIndex((p) => p.id === projectId);
const res = await apiFetch(`/api/projects/${projectId}`, { method: 'DELETE' });
if (!(await notifyProjectApiFailure(res, 'projects.deleteFailed', '删除失败'))) return;
if (getActiveProjectId() === projectId) setActiveProjectId('');
if (currentProjectId === projectId) currentProjectId = null;
await loadProjectsList();
if (projectsCache.length) {
const nextIndex = Math.min(deletedIndex >= 0 ? deletedIndex : 0, projectsCache.length - 1);
await selectProject(projectsCache[nextIndex].id);
} else {
updateProjectsDetailVisibility();
}
}
async function toggleProjectArchiveFromListMenu() {
const projectId = _projectListMenuTargetId;
closeProjectListActionMenu();
if (!projectId) return;
await toggleProjectArchiveById(projectId);
}
function editProjectFromListMenu() {
const projectId = _projectListMenuTargetId;
const fromChatSidebar = _projectListMenuSource === 'chat';
closeProjectListActionMenu();
if (!projectId) return;
showEditProjectModal(projectId, { fromChatSidebar });
}
async function deleteProjectFromListMenu() {
const projectId = _projectListMenuTargetId;
closeProjectListActionMenu();
if (!projectId) return;
await deleteProjectById(projectId);
}
async function archiveCurrentProject() {
if (!currentProjectId) return;
await toggleProjectArchiveById(currentProjectId);
}
async function deleteCurrentProject() {
if (!currentProjectId) return;
await deleteProjectById(currentProjectId);
}
function resetFactModalForm() {
window._factModalEditId = null;
const keyEl = document.getElementById('fact-modal-key');
if (keyEl) keyEl.disabled = false;
document.getElementById('fact-modal-title').textContent = tp('projects.addFact');
document.getElementById('fact-modal-submit-btn').textContent = tp('projects.saveFact');
document.getElementById('fact-modal-key').value = '';
document.getElementById('fact-modal-category').value = 'note';
document.getElementById('fact-modal-summary').value = '';
document.getElementById('fact-modal-body').value = '';
document.getElementById('fact-modal-confidence').value = 'tentative';
const pinEl = document.getElementById('fact-modal-pinned');
if (pinEl) pinEl.checked = false;
const rel = document.getElementById('fact-modal-related-vuln');
if (rel) rel.value = '';
const linksEl = document.getElementById('fact-modal-links');
if (linksEl) linksEl.value = '';
const incomingWrap = document.getElementById('fact-modal-incoming-links-wrap');
if (incomingWrap) incomingWrap.hidden = true;
updateFactFormHints();
}
function fillFactModalForm(f) {
window._factModalEditId = f.id;
document.getElementById('fact-modal-title').textContent = tp('projects.editFact');
document.getElementById('fact-modal-submit-btn').textContent = tp('projects.saveChanges');
document.getElementById('fact-modal-key').value = f.fact_key || '';
const catEl = document.getElementById('fact-modal-category');
const cat = (f.category || 'note').trim().toLowerCase();
if (catEl) {
const known = Array.from(catEl.options).some((o) => o.value === cat);
if (known) catEl.value = cat;
else {
const opt = document.createElement('option');
opt.value = f.category;
opt.textContent = tpFmt('projects.customCategoryOption', `${f.category} (custom)`, { value: f.category });
catEl.appendChild(opt);
catEl.value = f.category;
}
}
document.getElementById('fact-modal-summary').value = f.summary || '';
document.getElementById('fact-modal-body').value = f.body || '';
const conf = (f.confidence || 'tentative').toLowerCase();
const confEl = document.getElementById('fact-modal-confidence');
if (confEl) {
const allowed = ['tentative', 'confirmed', 'deprecated'];
confEl.value = allowed.includes(conf) ? conf : 'tentative';
}
const rel = document.getElementById('fact-modal-related-vuln');
if (rel) rel.value = f.related_vulnerability_id || '';
const linksEl = document.getElementById('fact-modal-links');
if (linksEl) linksEl.value = formatIncomingLinksForModal(f.incoming_links);
const pinEl = document.getElementById('fact-modal-pinned');
if (pinEl) pinEl.checked = !!f.pinned;
updateFactFormHints();
}
function showAddFactModal() {
if (!currentProjectId) return alert(tp('projects.selectProjectFirst'));
if (!requireProjectWrite()) return;
resetFactModalForm();
openProjectsOverlay('fact-modal');
}
async function showEditFactModal(factKey) {
if (!currentProjectId) return alert(tp('projects.selectProjectFirst'));
if (!requireProjectWrite()) return;
resetFactModalForm();
openProjectsOverlay('fact-modal', { focus: false });
const res = await apiFetch(
`/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}&include_links=true`,
);
if (!res.ok) {
closeFactModal();
return alert(tp('projects.loadFactFailed'));
}
const f = await res.json();
deferModalContent(() => {
fillFactModalForm(f);
document.getElementById('fact-modal-key')?.focus();
});
}
function closeFactModal() {
closeProjectsOverlay('fact-modal');
resetFactModalForm();
}
async function saveFactModal() {
if (!requireProjectWrite()) return;
const fact_key = document.getElementById('fact-modal-key').value.trim();
const summary = document.getElementById('fact-modal-summary').value.trim();
const category = document.getElementById('fact-modal-category').value.trim() || 'note';
const body = document.getElementById('fact-modal-body').value;
if (!fact_key || !summary) return alert(tp('projects.factKeySummaryRequired'));
if (isSparseFactBody(category, fact_key, body)) {
const ok = confirm(
tp('projects.confirmSaveSparseFact'),
);
if (!ok) return;
}
const payload = {
fact_key,
category,
summary,
body,
confidence: document.getElementById('fact-modal-confidence').value,
pinned: !!document.getElementById('fact-modal-pinned')?.checked,
related_vulnerability_id: document.getElementById('fact-modal-related-vuln')?.value?.trim() || '',
links_text: document.getElementById('fact-modal-links')?.value || '',
};
const editId = window._factModalEditId;
const res = editId
? await apiFetch(`/api/projects/${currentProjectId}/facts/${editId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
: await apiFetch(`/api/projects/${currentProjectId}/facts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!(await notifyProjectApiFailure(res, 'projects.saveFailed', '保存失败'))) return;
closeFactModal();
loadProjectFacts();
if (currentProjectTab === 'graph') loadProjectFactGraph();
}
async function deleteProjectFact(id) {
if (!requireProjectWrite()) return;
if (!confirm(tp('projects.confirmDeleteFact'))) return;
const res = await apiFetch(`/api/projects/${currentProjectId}/facts/${id}`, { method: 'DELETE' });
if (!(await notifyProjectApiFailure(res, 'projects.operationFailed', '操作失败'))) return;
loadProjectFacts();
if (currentProjectTab === 'graph') loadProjectFactGraph();
}
function parseProjectDate(t) {
if (t == null || t === '') return null;
if (typeof t === 'number' && Number.isFinite(t)) {
const d = new Date(t);
return isNaN(d.getTime()) || d.getFullYear() < 2000 ? null : d;
}
let s = String(t).trim();
if (!s || s.startsWith('0001-01-01')) return null;
let d = new Date(s);
if (!isNaN(d.getTime()) && d.getFullYear() >= 2000) return d;
const m = s.match(
/^(\d{4})-(\d{2})-(\d{2})[T\s](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(?:([Zz]|([+-])(\d{2}):?(\d{2}))?)?$/,
);
if (m) {
const ms = m[7] ? parseInt(String(m[7]).slice(0, 3).padEnd(3, '0'), 10) : 0;
let offMin = 0;
if (m[8] && m[9] && m[10]) {
offMin = parseInt(m[10], 10) * 60 + parseInt(m[11] || '0', 10);
if (m[9] === '-') offMin = -offMin;
}
d = new Date(
Date.UTC(
parseInt(m[1], 10),
parseInt(m[2], 10) - 1,
parseInt(m[3], 10),
parseInt(m[4], 10),
parseInt(m[5], 10),
parseInt(m[6], 10),
ms,
) - offMin * 60 * 1000,
);
if (!isNaN(d.getTime()) && d.getFullYear() >= 2000) return d;
}
return null;
}
function formatProjectTime(t, fallback) {
const d = parseProjectDate(t) || (fallback != null ? parseProjectDate(fallback) : null);
if (!d) return tp('projects.notUpdatedYet');
const now = Date.now();
const diff = now - d.getTime();
if (diff < 60000) return tp('common.justNow');
if (diff < 3600000) return tp('common.minutesAgo', { n: Math.floor(diff / 60000) });
if (diff < 86400000) return tp('common.hoursAgo', { n: Math.floor(diff / 3600000) });
if (diff < 604800000) return tp('common.daysAgo', { n: Math.floor(diff / 86400000) });
return d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function escapeHtml(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function escapeAttr(s) {
return escapeHtml(s).replace(/'/g, '&#39;');
}
function escapeJsString(text) {
return JSON.stringify(String(text == null ? '' : text));
}
function escapeJsStringAttr(text) {
return escapeAttr(escapeJsString(text));
}
function getChatProjectSelection() {
const convId = window.currentConversationId;
if (convId) {
return window._loadedConversationProjectId || '';
}
return getActiveProjectId();
}
/** 用于 UI:返回当前选中的项目 ID(有效性由 normalizeStaleChatProjectSelection 异步校验) */
function resolveChatProjectSelection() {
return getChatProjectSelection() || '';
}
let _normalizingStaleProject = false;
/** 清除 localStorage 或对话上残留的失效项目 ID */
async function normalizeStaleChatProjectSelection() {
if (_normalizingStaleProject) return;
const raw = (getChatProjectSelection() || '').trim();
if (!raw) return;
const project = await fetchProjectSummary(raw);
if (project && project.id && project.status !== 'archived') return;
_normalizingStaleProject = true;
try {
if (window.currentConversationId) {
window._loadedConversationProjectId = '';
try {
const res = await apiFetch(
`/api/conversations/${encodeURIComponent(window.currentConversationId)}/project`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId: '' }),
}
);
if (!res.ok) console.warn(tp('projects.clearStaleProjectBindingFailed'));
} catch (e) {
console.warn(e);
}
} else {
setActiveProjectId('');
}
} finally {
_normalizingStaleProject = false;
}
}
const PROJECT_PICKER_DEBOUNCE_MS = 100;
const projectPickerPanelState = {
chat: { seq: 0, timer: null },
webshell: { seq: 0, timer: null },
};
let chatProjectFolderSearchQuery = '';
let chatProjectFolderRenderSeq = 0;
let chatProjectFolderContextLoadSeq = 0;
const CHAT_PROJECT_FOLDER_PAGE_SIZE = 6;
let chatProjectFolderVisibleCount = CHAT_PROJECT_FOLDER_PAGE_SIZE;
let chatProjectFolderLastQuery = '';
const chatProjectFolderExpandedIds = new Set();
let chatProjectFolderLastSelectionId = null;
const CHAT_UNASSIGNED_PROJECT_FOLDER_ID = '__chat_unassigned_project__';
const PROJECT_FOLDER_COMPLETION_SEEN_KEY = 'cyberstrike-project-folder-completion-seen';
const chatProjectFolderContext = {
ready: false,
conversations: [],
runningIds: new Set(),
completedByConversation: new Map(),
pendingApprovalByConversation: new Map(),
};
const PROJECT_FOLDER_PREVIEW_OPEN_DELAY_MS = 160;
const PROJECT_FOLDER_PREVIEW_CLOSE_DELAY_MS = 120;
const PROJECT_APPROVAL_TICK_INTERVAL_MS = 1000;
const projectApprovalTickerEntries = new Set();
let projectApprovalTickerId = 0;
let projectFolderPreviewOpenTimer = null;
let projectFolderPreviewCloseTimer = null;
let projectFolderPreviewAnchor = null;
let projectConversationPreviewOpenTimer = null;
let projectConversationPreviewCloseTimer = null;
let projectConversationPreviewAnchor = null;
let projectConversationPreviewSuppressedUntil = 0;
function readProjectFolderCompletionSeen() {
try {
const parsed = JSON.parse(localStorage.getItem(PROJECT_FOLDER_COMPLETION_SEEN_KEY) || '{}');
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (e) {
return {};
}
}
function markProjectConversationViewed(conversationId, completedAt) {
const id = String(conversationId || '').trim();
if (!id) return;
const seen = readProjectFolderCompletionSeen();
const timestamp = completedAt || new Date().toISOString();
seen[id] = timestamp;
try {
localStorage.setItem(PROJECT_FOLDER_COMPLETION_SEEN_KEY, JSON.stringify(seen));
} catch (e) { /* ignore */ }
}
function markCurrentProjectConversationViewed() {
const conversationId = String(window.currentConversationId || '').trim();
if (!conversationId || !isProjectConversationUnread(conversationId)) return false;
const completed = chatProjectFolderContext.completedByConversation.get(conversationId);
markProjectConversationViewed(conversationId, completed?.completedAt);
if (isProjectsCacheReady() && chatProjectFolderContext.ready) {
renderChatProjectFolders(projectsCacheAll);
}
return true;
}
function initProjectConversationReadTracking() {
if (window._projectConversationReadTrackingInited) return;
const chatContainer = document.querySelector('#page-chat .chat-container');
if (!chatContainer) return;
window._projectConversationReadTrackingInited = true;
const markViewed = () => markCurrentProjectConversationViewed();
chatContainer.addEventListener('pointerdown', markViewed, { passive: true });
chatContainer.addEventListener('focusin', markViewed);
document.getElementById('chat-input')?.addEventListener('input', markViewed);
}
function isProjectConversationUnread(conversationId) {
const completed = chatProjectFolderContext.completedByConversation.get(conversationId);
if (!completed || String(completed.status || '').toLowerCase() !== 'completed') return false;
const completedAt = Date.parse(completed.completedAt || '');
if (!Number.isFinite(completedAt)) return false;
const seenAt = Date.parse(readProjectFolderCompletionSeen()[conversationId] || '');
return !Number.isFinite(seenAt) || completedAt > seenAt;
}
function getProjectApprovalTiming(details) {
if (!details || typeof details !== 'object') return { timeoutSeconds: 0, expiresAt: 0 };
let payload = details.payload;
if (typeof payload === 'string') {
try { payload = JSON.parse(payload); } catch (e) { payload = {}; }
}
if (!payload || typeof payload !== 'object') payload = {};
const approval = payload.hitlApproval && typeof payload.hitlApproval === 'object' ? payload.hitlApproval : {};
const timeout = Number(details.timeoutSeconds != null ? details.timeoutSeconds : approval.timeoutSeconds);
const timeoutSeconds = Number.isFinite(timeout) && timeout > 0 ? Math.floor(timeout) : 0;
const createdAt = Date.parse(details.createdAt || approval.createdAt || '');
let expiresAt = Date.parse(details.expiresAt || approval.expiresAt || '');
if (!Number.isFinite(expiresAt) && timeoutSeconds > 0 && Number.isFinite(createdAt)) {
expiresAt = createdAt + timeoutSeconds * 1000;
}
return { timeoutSeconds, expiresAt: Number.isFinite(expiresAt) ? expiresAt : 0 };
}
function formatProjectApprovalRemaining(milliseconds) {
const seconds = Math.max(0, Math.ceil(milliseconds / 1000));
const minutes = Math.floor(seconds / 60);
return minutes + ':' + String(seconds % 60).padStart(2, '0');
}
function registerProjectApprovalTicker(status, update) {
const entry = { status, update };
if (update() === false) return;
projectApprovalTickerEntries.add(entry);
if (projectApprovalTickerId) return;
projectApprovalTickerId = window.setInterval(() => {
projectApprovalTickerEntries.forEach((candidate) => {
if (!candidate.status.isConnected || candidate.update() === false) {
projectApprovalTickerEntries.delete(candidate);
}
});
if (!projectApprovalTickerEntries.size) {
window.clearInterval(projectApprovalTickerId);
projectApprovalTickerId = 0;
}
}, PROJECT_APPROVAL_TICK_INTERVAL_MS);
}
function bindProjectApprovalProgress(status, details) {
const timing = getProjectApprovalTiming(details);
if (!timing.timeoutSeconds || !timing.expiresAt) return;
const time = status.querySelector('.project-approval-time');
const value = status.querySelector('.project-approval-progress-value');
const update = () => {
const remaining = Math.max(0, timing.expiresAt - Date.now());
const percent = Math.max(0, Math.min(100, remaining / (timing.timeoutSeconds * 1000) * 100));
if (time) time.textContent = formatProjectApprovalRemaining(remaining);
if (value) value.style.width = `${percent.toFixed(2)}%`;
status.setAttribute('aria-valuenow', String(Math.round(percent)));
if (remaining <= 0) {
status.classList.add('is-expired');
return false;
}
return true;
};
status.setAttribute('role', 'progressbar');
status.setAttribute('aria-valuemin', '0');
status.setAttribute('aria-valuemax', '100');
registerProjectApprovalTicker(status, update);
}
const PROJECT_APPROVAL_URGENCY_CLASSES = [
'is-urgency-normal',
'is-urgency-warning',
'is-urgency-urgent',
'is-urgency-critical',
];
function projectApprovalUrgencyLevel(remainingMilliseconds, hasDeadline) {
if (!hasDeadline) return 'normal';
const remaining = Math.max(0, Number(remainingMilliseconds) || 0);
if (remaining <= 60 * 1000) return 'critical';
if (remaining <= 3 * 60 * 1000) return 'warning';
return 'normal';
}
function getProjectApprovalUrgency(details) {
const timing = getProjectApprovalTiming(details);
if (!timing.timeoutSeconds || !timing.expiresAt) {
return {
level: 'normal',
label: pickerMessage(tp, 'hitl.approvalUrgencyUnlimited', '审批不限时'),
remaining: 0,
};
}
const remaining = Math.max(0, timing.expiresAt - Date.now());
const level = projectApprovalUrgencyLevel(remaining, true);
const urgencyLabels = {
critical: pickerMessage(tp, 'hitl.approvalUrgencyWithinOne', '最早审批将在 1 分钟内到期'),
warning: pickerMessage(tp, 'hitl.approvalUrgencyOneToThree', '最早审批将在 1–3 分钟内到期'),
normal: pickerMessage(tp, 'hitl.approvalUrgencyMoreThanThree', '最早审批将在 3 分钟后到期'),
};
return {
level,
label: urgencyLabels[level],
remaining,
};
}
function bindProjectApprovalUrgency(status, details, baseLabel) {
const timing = getProjectApprovalTiming(details);
const update = () => {
const urgency = getProjectApprovalUrgency(details);
status.classList.remove(...PROJECT_APPROVAL_URGENCY_CLASSES);
status.classList.add(`is-urgency-${urgency.level}`);
status.dataset.approvalUrgency = urgency.level;
status.setAttribute('aria-label', `${baseLabel}${urgency.label}`);
status.title = `${baseLabel} · ${urgency.label}`;
return !(timing.expiresAt && urgency.remaining <= 0);
};
if (timing.timeoutSeconds && timing.expiresAt) {
registerProjectApprovalTicker(status, update);
} else {
update();
}
}
function createProjectTaskStatus(kind, details, options) {
if (!kind) return null;
const config = options && typeof options === 'object' ? options : {};
const isApprovalSummary = kind === 'approval' && config.aggregate === true;
const approvalCount = Math.max(0, Math.floor(Number(config.count) || 0));
const status = document.createElement('span');
status.className = `project-task-status project-task-status--${kind}`;
const label = isApprovalSummary
? tpFmt('hitl.waitingApprovalCount', `等待批准 ${approvalCount}`, { count: approvalCount })
: (kind === 'approval'
? pickerMessage(tp, 'hitl.waitingApprovalShort', '等待批准')
: (kind === 'running'
? pickerMessage(tp, 'tasks.statusRunning', '运行中')
: pickerMessage(tp, 'chat.completedUnread', '已完成,尚未查看')));
if (kind === 'approval') {
const timing = getProjectApprovalTiming(details);
status.innerHTML = '<span class="project-approval-label"></span>' +
(!isApprovalSummary && timing.timeoutSeconds && timing.expiresAt
? '<span class="project-approval-time"></span><span class="project-approval-progress"><span class="project-approval-progress-value"></span></span>'
: '');
status.querySelector('.project-approval-label').textContent = label;
if (isApprovalSummary) {
// 项目文件夹只汇总待审批数量,始终使用绿色;
// 紧急程度仅属于具体对话,避免多个审批让项目颜色来回跳变。
status.classList.add('project-task-status--approval-summary', 'is-urgency-normal');
status.dataset.approvalCount = String(approvalCount);
status.dataset.approvalUrgency = 'normal';
status.setAttribute('aria-label', label);
status.title = label;
} else {
bindProjectApprovalProgress(status, details);
bindProjectApprovalUrgency(status, details, label);
}
}
if (!isApprovalSummary) {
status.setAttribute('aria-label', label);
status.title = label;
}
return status;
}
function appendProjectTaskStatuses(container, kinds, detailsByKind, optionsByKind) {
const normalizedKinds = Array.from(new Set((Array.isArray(kinds) ? kinds : [kinds]).filter(Boolean)));
if (!container || !normalizedKinds.length) return;
const group = document.createElement('span');
group.className = 'project-task-status-group';
normalizedKinds.forEach((kind) => {
const status = createProjectTaskStatus(
kind,
detailsByKind && detailsByKind[kind],
optionsByKind && optionsByKind[kind]
);
if (status) group.appendChild(status);
});
if (!group.childElementCount) return null;
container.appendChild(group);
return group;
}
function getProjectFolderStatuses(conversations) {
const kinds = [];
if (conversations.some((conversation) => chatProjectFolderContext.pendingApprovalByConversation.has(conversation.id))) {
kinds.push('approval');
}
if (conversations.some((conversation) => isProjectConversationUnread(conversation.id))) {
kinds.push('unread');
}
if (conversations.some((conversation) => chatProjectFolderContext.runningIds.has(conversation.id))) {
kinds.push('running');
}
return kinds;
}
function projectFolderDisclosureMarkup(isExpanded) {
const path = isExpanded ? 'M3.5 5.5 7.5 9.5l4-4' : 'm5.5 3.5 4 4-4 4';
return `<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true"><path d="${path}" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
}
function projectFolderIconMarkup(isExpanded) {
// Codex 风格:折叠时为闭合文件夹,展开时露出向前翻开的文件夹盖。
const path = isExpanded
? 'M3.5 18V6.5a2 2 0 0 1 2-2h4l2 2h7a2 2 0 0 1 2 2v1.25M3.5 18l1.75-6.25A2 2 0 0 1 7.18 10.3H20.5l-2 7.7H3.5Z'
: 'M3.5 7a2 2 0 0 1 2-2h4l2 2H18.5a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2V7Zm0 2.5h17';
return `<svg width="19" height="19" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="${path}" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
}
function clampProjectPreviewText(value, maxLength = 220) {
const text = String(value || '').replace(/\s+/g, ' ').trim();
return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
}
function getProjectScopePreview(project) {
const raw = String(project?.scope_json || project?.scopeJson || '').trim();
if (!raw) return '';
try {
const parsed = JSON.parse(raw);
const values = [];
const appendValue = (value) => {
if (typeof value === 'string' || typeof value === 'number') {
const text = String(value).trim();
if (text) values.push(text);
} else if (Array.isArray(value)) {
value.slice(0, 4).forEach(appendValue);
}
};
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
['targets', 'target', 'domains', 'ips', 'urls', 'scope'].forEach((key) => appendValue(parsed[key]));
if (!values.length) appendValue(parsed.notes);
} else {
appendValue(parsed);
}
return clampProjectPreviewText(values.slice(0, 4).join(' · '));
} catch (e) {
return clampProjectPreviewText(raw);
}
}
function ensureProjectFolderPreview() {
let preview = document.getElementById('project-folder-preview');
if (preview) return preview;
preview = document.createElement('div');
preview.id = 'project-folder-preview';
preview.className = 'project-folder-preview';
preview.hidden = true;
preview.setAttribute('role', 'dialog');
preview.setAttribute('aria-label', pickerMessage(tp, 'chat.projectPreviewLabel', '项目信息'));
preview.innerHTML = `
<div class="project-folder-preview-header">
<span class="project-folder-preview-icon" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none"><path d="M3 6.5h6l2 2h10v9.5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
</span>
<span class="project-folder-preview-title"></span>
</div>
<div class="project-folder-preview-stats">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M21 12a8.5 8.5 0 0 1-9 8.48A8.5 8.5 0 1 1 20.48 11H21v5l-2-2" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span></span>
</div>
<div class="project-folder-preview-details">
<div class="project-folder-preview-detail project-folder-preview-description">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.7"/><path d="M12 11v5M12 8h.01" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>
<span></span>
</div>
<div class="project-folder-preview-detail project-folder-preview-scope">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="12" cy="12" r="8" stroke="currentColor" stroke-width="1.7"/><circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.7"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
<span></span>
</div>
</div>
<button type="button" class="project-folder-preview-edit" data-require-permission="project:write">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z" stroke="currentColor" stroke-width="1.7"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.83 2.83-.06-.06a1.7 1.7 0 0 0-1.88-.34 1.7 1.7 0 0 0-1.03 1.56V21h-4v-.08A1.7 1.7 0 0 0 8.95 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.55-1H3v-4h.08A1.7 1.7 0 0 0 4.6 8.95a1.7 1.7 0 0 0-.34-1.88l-.06-.06 2.83-2.83.06.06A1.7 1.7 0 0 0 8.95 4.6 1.7 1.7 0 0 0 10 3.08V3h4v.08a1.7 1.7 0 0 0 1.03 1.55 1.7 1.7 0 0 0 1.88-.34l.06-.06 2.83 2.83-.06.06A1.7 1.7 0 0 0 19.4 9c.14.61.69 1.04 1.32 1.04H21v4h-.28A1.7 1.7 0 0 0 19.4 15Z" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span></span>
</button>
`;
document.body.appendChild(preview);
preview.addEventListener('mouseenter', () => {
clearTimeout(projectFolderPreviewCloseTimer);
});
preview.addEventListener('mouseleave', scheduleHideProjectFolderPreview);
preview.querySelector('.project-folder-preview-edit')?.addEventListener('click', () => {
const projectId = preview.dataset.projectId || '';
hideProjectFolderPreview(true);
if (projectId) showEditProjectModal(projectId, { fromChatSidebar: true });
});
window.addEventListener('resize', () => hideProjectFolderPreview(true));
window.addEventListener('scroll', () => hideProjectFolderPreview(true), true);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !preview.hidden) hideProjectFolderPreview(true);
});
return preview;
}
function positionProjectFolderPreview(preview, row) {
if (!preview || !row?.isConnected) return;
const anchorRect = row.getBoundingClientRect();
const previewRect = preview.getBoundingClientRect();
const gap = 10;
const edge = 12;
let left = anchorRect.right + gap;
if (left + previewRect.width > window.innerWidth - edge) {
left = Math.max(edge, anchorRect.left - previewRect.width - gap);
}
const top = Math.min(
Math.max(edge, anchorRect.top - 10),
Math.max(edge, window.innerHeight - previewRect.height - edge)
);
preview.style.left = `${Math.round(left)}px`;
preview.style.top = `${Math.round(top)}px`;
}
function showProjectFolderPreview(project, row, conversations) {
if (!project || !row?.isConnected || window.matchMedia('(max-width: 900px), (hover: none)').matches) return;
hideProjectConversationPreview(true);
const preview = ensureProjectFolderPreview();
const isUnassigned = project._isUnassigned === true;
clearTimeout(projectFolderPreviewCloseTimer);
if (projectFolderPreviewAnchor && projectFolderPreviewAnchor !== row) {
projectFolderPreviewAnchor.querySelector('.project-folder-item')?.removeAttribute('aria-controls');
}
projectFolderPreviewAnchor = row;
const title = project.name || tp('common.untitled');
const total = conversations.length;
const active = conversations.filter((conversation) => chatProjectFolderContext.runningIds.has(conversation.id)).length;
const fallbackStats = `${total} 个任务 · ${active} 个已开启`;
const description = clampProjectPreviewText(project.description)
|| pickerMessage(tp, 'chat.projectPreviewNoDescription', '暂无项目说明');
const scope = getProjectScopePreview(project);
preview.dataset.projectId = project.id || '';
preview.classList.toggle('is-unassigned', isUnassigned);
preview.querySelector('.project-folder-preview-title').textContent = title;
preview.querySelector('.project-folder-preview-stats span').textContent = tpFmt(
'chat.projectPreviewStats',
fallbackStats,
{ total, active }
);
const descriptionRow = preview.querySelector('.project-folder-preview-description');
descriptionRow.querySelector('span').textContent = description;
descriptionRow.classList.toggle('is-empty', !String(project.description || '').trim());
const scopeRow = preview.querySelector('.project-folder-preview-scope');
scopeRow.hidden = isUnassigned || !scope;
scopeRow.querySelector('span').textContent = scope
? tpFmt('chat.projectPreviewScope', `测试范围:${scope}`, { scope })
: '';
const editButton = preview.querySelector('.project-folder-preview-edit');
editButton.hidden = isUnassigned;
editButton.querySelector('span').textContent = tpFmt(
'chat.projectPreviewEdit',
'编辑项目'
);
preview.hidden = false;
row.querySelector('.project-folder-item')?.setAttribute('aria-controls', preview.id);
positionProjectFolderPreview(preview, row);
if (typeof applyRBACToUI === 'function') applyRBACToUI(preview);
}
function scheduleShowProjectFolderPreview(project, row, conversations, immediate = false) {
clearTimeout(projectFolderPreviewOpenTimer);
clearTimeout(projectFolderPreviewCloseTimer);
projectFolderPreviewOpenTimer = setTimeout(
() => showProjectFolderPreview(project, row, conversations),
immediate ? 0 : PROJECT_FOLDER_PREVIEW_OPEN_DELAY_MS
);
}
function hideProjectFolderPreview(immediate = false) {
clearTimeout(projectFolderPreviewOpenTimer);
clearTimeout(projectFolderPreviewCloseTimer);
const close = () => {
const preview = document.getElementById('project-folder-preview');
if (preview) preview.hidden = true;
projectFolderPreviewAnchor?.querySelector('.project-folder-item')?.removeAttribute('aria-controls');
projectFolderPreviewAnchor = null;
};
if (immediate) close();
else projectFolderPreviewCloseTimer = setTimeout(close, PROJECT_FOLDER_PREVIEW_CLOSE_DELAY_MS);
}
function scheduleHideProjectFolderPreview() {
hideProjectFolderPreview(false);
}
function formatProjectConversationPreviewAge(value) {
const timestamp = Date.parse(value || '');
if (!Number.isFinite(timestamp)) return '';
const date = new Date(timestamp);
const pad = (part) => String(part).padStart(2, '0');
const parts = {
year: String(date.getFullYear()),
month: pad(date.getMonth() + 1),
day: pad(date.getDate()),
hour: pad(date.getHours()),
minute: pad(date.getMinutes()),
};
return tpFmt(
'chat.conversationPreviewDateTime',
`${parts.year}${parts.month}${parts.day}${parts.hour}:${parts.minute}`,
parts
);
}
function getProjectConversationModeLabel(conversation) {
const mode = String(conversation?.agentMode || conversation?.agent_mode || '').trim().toLowerCase();
const labels = {
eino_single: ['chat.agentModeEinoSingle', 'Eino 单代理(ADK'],
deep: ['chat.agentModeDeep', 'DeepDeepAgent'],
plan_execute: ['chat.agentModePlanExecuteLabel', 'Plan-Execute'],
supervisor: ['chat.agentModeSupervisorLabel', 'Supervisor(专家路由)'],
};
if (labels[mode]) return tpFmt(labels[mode][0], labels[mode][1]);
return clampProjectPreviewText(
conversation?.roleName || conversation?.role_name || pickerMessage(tp, 'chat.conversationPreviewDefaultMode', '默认'),
80
);
}
function ensureProjectConversationPreview() {
let preview = document.getElementById('project-conversation-preview');
if (preview) return preview;
preview = document.createElement('div');
preview.id = 'project-conversation-preview';
preview.className = 'project-conversation-preview';
preview.hidden = true;
preview.setAttribute('role', 'tooltip');
preview.innerHTML = `
<div class="project-conversation-preview-header">
<span class="project-conversation-preview-title"></span>
<span class="project-conversation-preview-age"></span>
</div>
<div class="project-conversation-preview-meta">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M3 6.5h6l2 2h10v9.5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
<span class="project-conversation-preview-project"></span>
</div>
<div class="project-conversation-preview-meta">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="6" cy="5" r="2" stroke="currentColor" stroke-width="1.7"/><circle cx="6" cy="19" r="2" stroke="currentColor" stroke-width="1.7"/><circle cx="18" cy="9" r="2" stroke="currentColor" stroke-width="1.7"/><path d="M6 7v10M8 15c5 0 3-6 8-6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
<span class="project-conversation-preview-mode"></span>
<span class="project-conversation-preview-separator" aria-hidden="true">·</span>
<span class="project-conversation-preview-status"></span>
</div>
`;
document.body.appendChild(preview);
window.addEventListener('resize', () => hideProjectConversationPreview(true));
window.addEventListener('scroll', () => hideProjectConversationPreview(true), true);
return preview;
}
function positionProjectConversationPreview(preview, row) {
if (!preview || !row?.isConnected) return;
const anchorRect = row.getBoundingClientRect();
const previewRect = preview.getBoundingClientRect();
const gap = 10;
const edge = 12;
let left = anchorRect.right + gap;
if (left + previewRect.width > window.innerWidth - edge) {
left = Math.max(edge, anchorRect.left - previewRect.width - gap);
}
const top = Math.min(
Math.max(edge, anchorRect.top - 8),
Math.max(edge, window.innerHeight - previewRect.height - edge)
);
preview.style.left = `${Math.round(left)}px`;
preview.style.top = `${Math.round(top)}px`;
}
function showProjectConversationPreview(conversation, project, row) {
if (!conversation || !row?.isConnected || window.matchMedia('(max-width: 900px), (hover: none)').matches) return;
hideProjectFolderPreview(true);
const preview = ensureProjectConversationPreview();
clearTimeout(projectConversationPreviewCloseTimer);
if (projectConversationPreviewAnchor && projectConversationPreviewAnchor !== row) {
projectConversationPreviewAnchor.querySelector('.project-conversation-item')?.removeAttribute('aria-describedby');
}
projectConversationPreviewAnchor = row;
const isRunning = chatProjectFolderContext.runningIds.has(conversation.id);
const isWaitingApproval = chatProjectFolderContext.pendingApprovalByConversation.has(conversation.id);
const completed = chatProjectFolderContext.completedByConversation.get(conversation.id);
const isUnread = !isRunning && isProjectConversationUnread(conversation.id);
const status = isWaitingApproval
? pickerMessage(tp, 'hitl.waitingApprovalShort', '等待批准')
: (isRunning
? pickerMessage(tp, 'tasks.statusRunning', '执行中')
: (isUnread
? pickerMessage(tp, 'chat.conversationPreviewUnread', '有未读更新')
: (completed
? pickerMessage(tp, 'chat.conversationPreviewViewed', '已查看')
: pickerMessage(tp, 'chat.conversationPreviewConversation', '对话'))));
const statusEl = preview.querySelector('.project-conversation-preview-status');
preview.querySelector('.project-conversation-preview-title').textContent = conversation.title
|| pickerMessage(tp, 'projects.untitledConversation', '未命名对话');
const ageEl = preview.querySelector('.project-conversation-preview-age');
ageEl.textContent = formatProjectConversationPreviewAge(
conversation.updatedAt || conversation.updated_at || conversation.createdAt || conversation.created_at
);
ageEl.hidden = !ageEl.textContent;
preview.querySelector('.project-conversation-preview-project').textContent = project?.name
|| pickerMessage(tp, 'chat.conversationPreviewNoProject', '未绑定项目');
preview.querySelector('.project-conversation-preview-mode').textContent = getProjectConversationModeLabel(conversation);
statusEl.textContent = status;
statusEl.className = 'project-conversation-preview-status'
+ (isWaitingApproval ? ' is-approval' : (isRunning ? ' is-running' : (isUnread ? ' is-unread' : '')));
preview.hidden = false;
row.querySelector('.project-conversation-item')?.setAttribute('aria-describedby', preview.id);
positionProjectConversationPreview(preview, row);
}
function scheduleShowProjectConversationPreview(conversation, project, row, immediate = false) {
if (Date.now() < projectConversationPreviewSuppressedUntil) return;
clearTimeout(projectConversationPreviewOpenTimer);
clearTimeout(projectConversationPreviewCloseTimer);
projectConversationPreviewOpenTimer = setTimeout(
() => showProjectConversationPreview(conversation, project, row),
immediate ? 0 : PROJECT_FOLDER_PREVIEW_OPEN_DELAY_MS
);
}
function hideProjectConversationPreview(immediate = false) {
clearTimeout(projectConversationPreviewOpenTimer);
clearTimeout(projectConversationPreviewCloseTimer);
const close = () => {
const preview = document.getElementById('project-conversation-preview');
if (preview) preview.hidden = true;
projectConversationPreviewAnchor?.querySelector('.project-conversation-item')?.removeAttribute('aria-describedby');
projectConversationPreviewAnchor = null;
};
if (immediate) close();
else projectConversationPreviewCloseTimer = setTimeout(close, PROJECT_FOLDER_PREVIEW_CLOSE_DELAY_MS);
}
function appendChatProjectFolderItem(list, project, expandedIds, conversations) {
const row = document.createElement('div');
const isUnassigned = project._isUnassigned === true;
const folderId = isUnassigned ? CHAT_UNASSIGNED_PROJECT_FOLDER_ID : project.id;
const isExpanded = expandedIds.has(folderId);
const statusKinds = getProjectFolderStatuses(conversations);
row.className = 'project-folder-row'
+ (isExpanded ? ' is-expanded' : '')
+ (isUnassigned ? ' is-unassigned' : '');
const button = document.createElement('button');
button.type = 'button';
button.className = 'project-folder-item';
button.dataset.projectId = project.id || '';
button.dataset.folderId = folderId;
button.setAttribute('aria-label', project.name || tp('common.untitled'));
button.setAttribute('aria-expanded', isExpanded ? 'true' : 'false');
const disclosure = document.createElement('span');
disclosure.className = 'project-folder-disclosure';
disclosure.setAttribute('aria-hidden', 'true');
disclosure.innerHTML = projectFolderDisclosureMarkup(isExpanded);
const icon = document.createElement('span');
icon.className = 'project-folder-icon';
icon.setAttribute('aria-hidden', 'true');
icon.innerHTML = projectFolderIconMarkup(isExpanded);
const title = document.createElement('span');
title.className = 'project-folder-title';
window.applyProjectNameDisplay(title, project.name, tp('common.untitled'));
const label = document.createElement('span');
label.className = 'project-folder-label';
label.appendChild(title);
if (!isUnassigned && project.pinned) {
const pinIcon = document.createElement('span');
pinIcon.className = 'project-folder-pinned';
pinIcon.textContent = '📌';
pinIcon.title = pickerMessage(tp, 'projects.pinned', '已置顶');
pinIcon.setAttribute('aria-label', pinIcon.title);
label.appendChild(pinIcon);
}
const folderApprovals = conversations
.map((conversation) => chatProjectFolderContext.pendingApprovalByConversation.get(conversation.id))
.filter(Boolean);
const folderStatusGroup = appendProjectTaskStatuses(
label,
statusKinds,
{ approval: null },
{ approval: { aggregate: true, count: folderApprovals.length } }
);
if (folderStatusGroup) folderStatusGroup.classList.add('project-task-status-group--folder');
button.appendChild(disclosure);
button.appendChild(icon);
button.appendChild(label);
button.addEventListener('click', () => {
if (isExpanded) {
chatProjectFolderExpandedIds.delete(folderId);
} else {
chatProjectFolderExpandedIds.add(folderId);
}
renderChatProjectFolders(projectsCacheAll);
});
row.addEventListener('mouseenter', () => scheduleShowProjectFolderPreview(project, row, conversations));
row.addEventListener('mouseleave', scheduleHideProjectFolderPreview);
button.addEventListener('focus', () => scheduleShowProjectFolderPreview(project, row, conversations, true));
row.addEventListener('focusout', (event) => {
const preview = document.getElementById('project-folder-preview');
if (row.contains(event.relatedTarget) || preview?.contains(event.relatedTarget)) return;
scheduleHideProjectFolderPreview();
});
const actions = document.createElement('div');
actions.className = 'project-folder-actions';
if (!isUnassigned) {
const menuButton = document.createElement('button');
menuButton.type = 'button';
menuButton.className = 'project-folder-action project-folder-menu';
menuButton.setAttribute('aria-label', tp('projects.projectActions'));
menuButton.title = menuButton.getAttribute('aria-label');
menuButton.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg>';
menuButton.addEventListener('click', (event) => {
showProjectListActionMenu(event, project.id, 'chat');
});
actions.appendChild(menuButton);
}
const newConversationButton = document.createElement('button');
newConversationButton.type = 'button';
newConversationButton.className = 'project-folder-action project-folder-new-conversation';
newConversationButton.setAttribute(
'aria-label',
isUnassigned
? pickerMessage(tp, 'chat.newUnassignedConversation', '新建无项目对话')
: pickerMessage(tp, 'chat.newConversationInProject', '在此项目中新建对话')
);
newConversationButton.title = newConversationButton.getAttribute('aria-label');
newConversationButton.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
newConversationButton.addEventListener('click', async (event) => {
event.preventDefault();
event.stopPropagation();
chatProjectFolderExpandedIds.add(folderId);
if (typeof window.startNewConversation === 'function') {
await window.startNewConversation({ projectId: isUnassigned ? '' : project.id });
}
renderChatProjectFolders(projectsCacheAll);
});
actions.appendChild(newConversationButton);
row.appendChild(button);
row.appendChild(actions);
list.appendChild(row);
}
function appendChatProjectConversationItem(list, conversation, project) {
const row = document.createElement('div');
const button = document.createElement('button');
const isSelected = window.currentConversationId === conversation.id;
const isRunning = chatProjectFolderContext.runningIds.has(conversation.id);
const isWaitingApproval = chatProjectFolderContext.pendingApprovalByConversation.has(conversation.id);
const completed = chatProjectFolderContext.completedByConversation.get(conversation.id);
const isUnread = !isRunning && isProjectConversationUnread(conversation.id);
row.className = 'project-conversation-row' + (isSelected ? ' is-selected' : '');
button.type = 'button';
button.className = 'project-conversation-item' + (isSelected ? ' is-selected' : '');
button.dataset.conversationId = conversation.id;
if (isSelected) button.setAttribute('aria-current', 'true');
const title = document.createElement('span');
title.className = 'project-conversation-title';
title.textContent = conversation.title || pickerMessage(tp, 'projects.untitledConversation', '未命名对话');
const label = document.createElement('span');
label.className = 'project-conversation-label';
label.appendChild(title);
if (conversation.pinned) {
const pinIcon = document.createElement('span');
pinIcon.className = 'conversation-item-pinned project-conversation-pinned';
pinIcon.textContent = '📌';
pinIcon.title = pickerMessage(tp, 'projects.pinned', '已置顶');
pinIcon.setAttribute('aria-label', pinIcon.title);
label.appendChild(pinIcon);
}
const statusKinds = [];
if (isWaitingApproval) statusKinds.push('approval');
if (isRunning) statusKinds.push('running');
else if (isUnread) statusKinds.push('unread');
if (statusKinds.length) appendProjectTaskStatuses(label, statusKinds, {
approval: chatProjectFolderContext.pendingApprovalByConversation.get(conversation.id)
});
button.appendChild(label);
button.addEventListener('click', async () => {
projectConversationPreviewSuppressedUntil = Date.now() + 700;
hideProjectConversationPreview(true);
selectChatProjectConversationItem(conversation.id);
if (typeof window.loadConversation === 'function') {
await window.loadConversation(conversation.id);
}
if (window.currentConversationId === conversation.id && completed) {
markProjectConversationViewed(conversation.id, completed.completedAt);
renderChatProjectFolders(projectsCacheAll);
}
});
row.addEventListener('mouseenter', () => scheduleShowProjectConversationPreview(conversation, project, row));
row.addEventListener('mouseleave', () => hideProjectConversationPreview(false));
button.addEventListener('focus', () => scheduleShowProjectConversationPreview(conversation, project, row, true));
row.addEventListener('focusout', (event) => {
if (row.contains(event.relatedTarget)) return;
hideProjectConversationPreview(false);
});
const menuButton = document.createElement('button');
menuButton.type = 'button';
menuButton.className = 'project-conversation-menu';
menuButton.setAttribute(
'aria-label',
pickerMessage(tp, 'chat.conversationActions', '对话操作')
);
menuButton.title = menuButton.getAttribute('aria-label');
menuButton.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg>';
menuButton.addEventListener('click', (event) => {
if (typeof window.openConversationContextMenuForId === 'function') {
window.openConversationContextMenuForId(event, conversation.id, conversation.title || '');
}
});
row.appendChild(button);
row.appendChild(menuButton);
list.appendChild(row);
}
function selectChatProjectConversationItem(conversationId) {
const id = String(conversationId || '').trim();
document.querySelectorAll('.project-conversation-row').forEach((row) => {
const button = row.querySelector('.project-conversation-item');
const selected = !!id && button?.dataset.conversationId === id;
row.classList.toggle('is-selected', selected);
if (!button) return;
button.classList.toggle('is-selected', selected);
if (selected) button.setAttribute('aria-current', 'true');
else button.removeAttribute('aria-current');
});
}
window.selectChatProjectConversationItem = selectChatProjectConversationItem;
async function loadChatProjectFolderContext() {
const loadSeq = ++chatProjectFolderContextLoadSeq;
const conversationsParams = new URLSearchParams({ limit: '1000', offset: '0', sort_by: 'updated_at' });
const [conversationResponse, activeResponse, completedResponse, pendingResponse] = await Promise.all([
apiFetch(`/api/conversations?${conversationsParams}`),
apiFetch('/api/agent-loop/tasks'),
apiFetch('/api/agent-loop/tasks/completed'),
apiFetch('/api/hitl/pending?page=1&pageSize=200'),
]);
if (!conversationResponse.ok) throw new Error(tp('projects.loadProjectsFailed'));
const conversationData = await conversationResponse.json();
const activeData = activeResponse.ok ? await activeResponse.json() : { tasks: [] };
const completedData = completedResponse.ok ? await completedResponse.json() : { tasks: [] };
const pendingData = pendingResponse.ok ? await pendingResponse.json() : { items: [] };
if (loadSeq !== chatProjectFolderContextLoadSeq) return false;
const conversations = Array.isArray(conversationData)
? conversationData
: (conversationData.conversations || conversationData.items || []);
chatProjectFolderContext.conversations = Array.isArray(conversations) ? conversations : [];
chatProjectFolderContext.runningIds = new Set(
(activeData.tasks || [])
.filter((task) => task && !['completed', 'failed', 'timeout', 'cancelled'].includes(String(task.status || '').toLowerCase()))
.map((task) => task.conversationId)
.filter(Boolean)
);
chatProjectFolderContext.completedByConversation = new Map();
(completedData.tasks || []).forEach((task) => {
if (task?.conversationId && !chatProjectFolderContext.completedByConversation.has(task.conversationId)) {
chatProjectFolderContext.completedByConversation.set(task.conversationId, task);
}
});
chatProjectFolderContext.pendingApprovalByConversation = new Map();
(pendingData.items || []).filter(isHumanProjectPendingApproval).forEach((item) => {
const conversationId = String(item?.conversationId || '').trim();
// pending 审批必须属于当前进程仍在运行的任务;服务重启/取消后的旧记录
// 即使在并发窗口内被读到,也不能重新显示倒计时徽标。
if (conversationId && chatProjectFolderContext.runningIds.has(conversationId) &&
!chatProjectFolderContext.pendingApprovalByConversation.has(conversationId)) {
chatProjectFolderContext.pendingApprovalByConversation.set(conversationId, item);
}
});
chatProjectFolderContext.ready = true;
return true;
}
function isHumanProjectPendingApproval(item) {
if (!item) return false;
const reviewer = String(item.reviewer || item.decidedBy || item.decided_by || '').trim().toLowerCase();
const status = String(item.status || '').trim().toLowerCase();
return reviewer !== 'audit_agent' && reviewer !== 'agent' && reviewer !== 'ai' && status !== 'audit_running';
}
function getProjectConversationSortTime(conversation) {
const value = conversation?.updatedAt || conversation?.updated_at
|| conversation?.createdAt || conversation?.created_at || '';
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : 0;
}
function sortProjectFolderConversations(conversations) {
return [...conversations].sort((a, b) => {
const pinnedDelta = Number(!!b?.pinned) - Number(!!a?.pinned);
if (pinnedDelta) return pinnedDelta;
return getProjectConversationSortTime(b) - getProjectConversationSortTime(a);
});
}
function updateChatProjectConversationPinnedState(conversationId, pinned) {
const id = String(conversationId || '').trim();
const conversation = chatProjectFolderContext.conversations.find(
(item) => String(item?.id || '').trim() === id
);
if (!conversation) return false;
conversation.pinned = !!pinned;
if (isProjectsCacheReady() && chatProjectFolderContext.ready) {
renderChatProjectFolders(projectsCacheAll);
}
return true;
}
function removeChatProjectConversation(conversationId) {
const id = String(conversationId || '').trim();
if (!id) return false;
const previousLength = chatProjectFolderContext.conversations.length;
chatProjectFolderContext.conversations = chatProjectFolderContext.conversations.filter(
(item) => String(item?.id || '').trim() !== id
);
chatProjectFolderContext.runningIds.delete(id);
chatProjectFolderContext.completedByConversation.delete(id);
chatProjectFolderContext.pendingApprovalByConversation.delete(id);
if (projectConversationPreviewAnchor?.querySelector('.project-conversation-item')?.dataset.conversationId === id) {
hideProjectConversationPreview(true);
}
if (isProjectsCacheReady() && chatProjectFolderContext.ready) {
renderChatProjectFolders(projectsCacheAll);
}
return chatProjectFolderContext.conversations.length !== previousLength;
}
function refreshChatProjectFoldersAfterAction() {
Promise.resolve().then(() => refreshChatProjectFolders()).catch((error) => {
console.warn('刷新项目对话列表失败:', error);
});
}
function resolveChatProjectFolderSelection() {
const conversationId = String(window.currentConversationId || '').trim();
if (!conversationId) return resolveChatProjectSelection();
if (!chatProjectFolderContext.ready) return null;
const conversation = chatProjectFolderContext.conversations.find(
(item) => String(item?.id || '').trim() === conversationId
);
if (!conversation) return null;
return String(conversation.projectId || conversation.project_id || '').trim();
}
function appendChatProjectFoldersLoadMore(list, remainingCount) {
if (!list || remainingCount <= 0) return;
const button = document.createElement('button');
const label = pickerMessage(tp, 'common.loadMore', '加载更多');
button.type = 'button';
button.className = 'project-folders-load-more';
button.setAttribute('aria-label', tpFmt(
'chat.projectFoldersLoadMoreRemaining',
`${label},剩余 ${remainingCount} 个项目`,
{ count: remainingCount }
));
button.innerHTML = `<span>${escapeHtml(label)}</span><span class="project-folders-load-more-count">${remainingCount}</span>`;
button.addEventListener('click', loadMoreChatProjectFolders);
list.appendChild(button);
}
function loadMoreChatProjectFolders() {
chatProjectFolderVisibleCount += CHAT_PROJECT_FOLDER_PAGE_SIZE;
if (isProjectsCacheReady() && chatProjectFolderContext.ready) {
renderChatProjectFolders(projectsCacheAll);
} else {
refreshChatProjectFolders();
}
}
function renderChatProjectFolders(projects) {
const list = document.getElementById('project-folders-list');
if (!list) return;
hideProjectFolderPreview(true);
hideProjectConversationPreview(true);
const selectedId = resolveChatProjectFolderSelection();
if (selectedId !== null && chatProjectFolderLastSelectionId !== selectedId) {
chatProjectFolderExpandedIds.add(selectedId || CHAT_UNASSIGNED_PROJECT_FOLDER_ID);
chatProjectFolderLastSelectionId = selectedId;
}
const filtered = filterActiveProjectsLocal(projects, chatProjectFolderSearchQuery);
const unassignedProject = {
id: '',
name: tp('projects.noProject'),
description: tp('projects.noProjectDescription'),
_isUnassigned: true,
};
const includeUnassigned = matchProjectSearchQuery(unassignedProject, chatProjectFolderSearchQuery);
const pinnedProjects = filtered.filter((project) => !!project.pinned);
const regularProjects = filtered.filter((project) => !project.pinned);
const folders = includeUnassigned
? [...pinnedProjects, unassignedProject, ...regularProjects]
: filtered;
const queryKey = chatProjectFolderSearchQuery.toLocaleLowerCase();
if (queryKey !== chatProjectFolderLastQuery) {
chatProjectFolderLastQuery = queryKey;
chatProjectFolderVisibleCount = CHAT_PROJECT_FOLDER_PAGE_SIZE;
}
const selectedFolderId = selectedId === null
? null
: (selectedId || CHAT_UNASSIGNED_PROJECT_FOLDER_ID);
if (selectedFolderId !== null) {
const selectedIndex = folders.findIndex((project) => (
project._isUnassigned ? CHAT_UNASSIGNED_PROJECT_FOLDER_ID : project.id
) === selectedFolderId);
if (selectedIndex >= chatProjectFolderVisibleCount) {
chatProjectFolderVisibleCount = selectedIndex + 1;
}
}
const visibleFolders = folders.slice(0, chatProjectFolderVisibleCount);
list.innerHTML = '';
if (!folders.length) {
const empty = document.createElement('div');
empty.className = 'project-folders-empty';
empty.textContent = chatProjectFolderSearchQuery
? pickerMessage(tp, 'chat.filterProjectSearchEmpty', '没有匹配的项目')
: pickerMessage(tp, 'projects.noProjects', '暂无项目');
list.appendChild(empty);
return;
}
visibleFolders.forEach((project) => {
const folderId = project._isUnassigned ? CHAT_UNASSIGNED_PROJECT_FOLDER_ID : project.id;
const conversations = sortProjectFolderConversations(
chatProjectFolderContext.conversations
.filter((conversation) => (conversation.projectId || conversation.project_id || '') === project.id)
);
appendChatProjectFolderItem(list, project, chatProjectFolderExpandedIds, conversations);
if (chatProjectFolderExpandedIds.has(folderId)) {
conversations.forEach((conversation) => appendChatProjectConversationItem(list, conversation, project));
}
});
appendChatProjectFoldersLoadMore(list, folders.length - visibleFolders.length);
}
async function refreshChatProjectFolders() {
const list = document.getElementById('project-folders-list');
if (!list) return;
const seq = ++chatProjectFolderRenderSeq;
if (!isProjectsCacheReady()) {
list.innerHTML = '';
appendChatProjectPanelMessage(list, 'project-folders-empty', pickerMessage(tp, 'common.loading', '加载中…'));
}
try {
const [projects] = await Promise.all([
ensureProjectsLoaded(),
loadChatProjectFolderContext(),
]);
if (seq !== chatProjectFolderRenderSeq) return;
renderChatProjectFolders(projects);
} catch (e) {
if (seq !== chatProjectFolderRenderSeq) return;
list.innerHTML = '';
appendChatProjectPanelMessage(
list,
'project-folders-empty',
pickerMessage(tp, 'projects.loadProjectsFailed', '加载项目失败')
);
}
}
function handleProjectFolderSearch(value) {
chatProjectFolderSearchQuery = String(value || '').trim();
const clearButton = document.getElementById('conversation-search-clear');
if (clearButton) clearButton.style.display = chatProjectFolderSearchQuery ? 'flex' : 'none';
if (isProjectsCacheReady() && chatProjectFolderContext.ready) {
renderChatProjectFolders(projectsCacheAll);
} else {
refreshChatProjectFolders();
}
}
function clearProjectFolderSearch() {
const input = document.getElementById('conversation-search-input');
if (input) input.value = '';
handleProjectFolderSearch('');
input?.focus();
}
function updateProjectFolderTaskStatuses(tasks) {
const previous = chatProjectFolderContext.runningIds;
const next = new Set(
(Array.isArray(tasks) ? tasks : [])
.filter((task) => task && !['completed', 'failed', 'timeout', 'cancelled'].includes(String(task.status || '').toLowerCase()))
.map((task) => task.conversationId)
.filter(Boolean)
);
const taskFinished = [...previous].some((conversationId) => !next.has(conversationId));
let approvalChanged = false;
chatProjectFolderContext.pendingApprovalByConversation.forEach((_details, conversationId) => {
if (next.has(conversationId)) return;
chatProjectFolderContext.pendingApprovalByConversation.delete(conversationId);
approvalChanged = true;
});
const changed = previous.size !== next.size || [...previous].some((conversationId) => !next.has(conversationId));
if (!changed && !approvalChanged) return;
chatProjectFolderContext.runningIds = next;
if (isProjectsCacheReady() && chatProjectFolderContext.ready) {
renderChatProjectFolders(projectsCacheAll);
}
if (taskFinished) refreshChatProjectFolders();
}
function setProjectConversationApprovalStatus(conversationId, pending, details) {
const id = String(conversationId || '').trim();
if (!id) return;
if (pending && isHumanProjectPendingApproval(details || {})) chatProjectFolderContext.pendingApprovalByConversation.set(id, details || { conversationId: id });
else chatProjectFolderContext.pendingApprovalByConversation.delete(id);
if (isProjectsCacheReady() && chatProjectFolderContext.ready) {
renderChatProjectFolders(projectsCacheAll);
}
}
function syncProjectConversationApprovalStatuses(items) {
const next = new Map();
(Array.isArray(items) ? items : []).forEach((details) => {
if (!isHumanProjectPendingApproval(details)) return;
const id = String(details && details.conversationId || '').trim();
if (id && chatProjectFolderContext.runningIds.has(id) && !next.has(id)) {
next.set(id, details);
}
});
let changed = next.size !== chatProjectFolderContext.pendingApprovalByConversation.size;
if (!changed) {
next.forEach((details, id) => {
const previous = chatProjectFolderContext.pendingApprovalByConversation.get(id);
const previousInterruptId = String(previous && (previous.interruptId || previous.id) || '');
const nextInterruptId = String(details && (details.interruptId || details.id) || '');
if (!previous || previousInterruptId !== nextInterruptId) changed = true;
});
}
if (!changed) return;
chatProjectFolderContext.pendingApprovalByConversation = next;
if (isProjectsCacheReady() && chatProjectFolderContext.ready) {
renderChatProjectFolders(projectsCacheAll);
}
}
window.setProjectConversationApprovalStatus = setProjectConversationApprovalStatus;
window.syncProjectConversationApprovalStatuses = syncProjectConversationApprovalStatuses;
if (!window._projectConversationActionEventsBound) {
window._projectConversationActionEventsBound = true;
document.addEventListener('conversation-pinned-changed', (event) => {
const details = event?.detail || {};
updateChatProjectConversationPinnedState(details.conversationId, details.pinned);
refreshChatProjectFoldersAfterAction();
});
document.addEventListener('conversation-deleted', (event) => {
const conversationId = event?.detail?.conversationId;
removeChatProjectConversation(conversationId);
refreshChatProjectFoldersAfterAction();
});
}
if (!window._projectApprovalStatusEventsBound) {
window._projectApprovalStatusEventsBound = true;
window.addEventListener('hitl-interrupt', (event) => {
const details = event && event.detail ? event.detail : {};
setProjectConversationApprovalStatus(details.conversationId || window.currentConversationId || '', true, details);
});
window.addEventListener('hitl-resolved', (event) => {
const details = event && event.detail ? event.detail : {};
setProjectConversationApprovalStatus(details.conversationId || window.currentConversationId || '', false);
});
}
function appendChatProjectPanelItem(list, project, selectedId, onSelect, tFn) {
const t = tFn || tp;
const isNone = !project.id;
const isSelected = isNone ? !selectedId : selectedId === project.id;
const fullDesc = isNone
? (project.description || '')
: (project.description || '').trim() || t('projects.sharedFactBoard');
const desc = isNone
? (project.description || '')
: fullDesc.slice(0, 80);
const fullName = project.name || t('common.untitled');
const displayName = window.formatProjectNameForDisplay(fullName);
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'role-selection-item-main' + (isSelected ? ' selected' : '');
btn.setAttribute('role', 'option');
btn.setAttribute('aria-label', fullName);
btn.setAttribute('data-selection-detail', fullDesc);
btn.onclick = () => onSelect(project.id || '');
btn.innerHTML = `
<div class="role-selection-item-icon-main">${isNone ? '—' : '📁'}</div>
<div class="role-selection-item-content-main">
<div class="role-selection-item-name-main" title="${escapeAttr(fullName)}">${escapeHtml(displayName)}</div>
<div class="role-selection-item-description-main">${escapeHtml(desc)}</div>
</div>
${isSelected ? '<div class="role-selection-checkmark-main">✓</div>' : ''}
`;
list.appendChild(btn);
}
function appendChatProjectPanelMessage(list, className, text) {
const el = document.createElement('div');
el.className = className;
el.textContent = text;
list.appendChild(el);
return el;
}
function pickerMessage(t, key, fallback) {
const value = t(key);
if (!value || value === key) return fallback;
return value;
}
async function renderProjectPickerPanel(panelKey, config) {
const state = projectPickerPanelState[panelKey];
const list = document.getElementById(config.listId);
if (!list || !state) return;
const query = (document.getElementById(config.searchInputId)?.value || '').trim();
const seq = ++state.seq;
const selectedId = config.getSelectedId();
const t = config.t || tp;
const renderPinned = () => {
appendChatProjectPanelItem(
list,
{
id: '',
name: t('projects.noProject'),
description: t('projects.noProjectDescription'),
},
selectedId,
config.onSelect,
t
);
};
const needsFetch = !isProjectsCacheReady();
let loadingEl = null;
if (needsFetch) {
list.innerHTML = '';
renderPinned();
loadingEl = appendChatProjectPanelMessage(
list,
'chat-project-panel-loading',
pickerMessage(t, 'common.loading', '加载中…')
);
}
try {
const all = await ensureProjectsLoaded();
if (seq !== state.seq) return;
list.innerHTML = '';
renderPinned();
const projects = filterActiveProjectsLocal(all, query);
projects.forEach((p) => {
appendChatProjectPanelItem(list, p, selectedId, config.onSelect, t);
});
if (query && projects.length === 0) {
appendChatProjectPanelMessage(
list,
'chat-project-panel-empty',
pickerMessage(t, 'chat.filterProjectSearchEmpty', '没有匹配的项目')
);
}
} catch (e) {
if (seq !== state.seq) return;
list.innerHTML = '';
renderPinned();
appendChatProjectPanelMessage(
list,
'chat-project-panel-empty',
pickerMessage(t, 'chat.filterProjectSearchFailed', '加载项目失败,请重试')
);
} finally {
if (loadingEl && loadingEl.parentNode) loadingEl.remove();
}
}
function initProjectPickerPanelSearch(panelKey, searchInputId, onSearch) {
const input = document.getElementById(searchInputId);
if (!input || input.dataset.pickerBound === panelKey) return;
input.dataset.pickerBound = panelKey;
input.addEventListener('input', onSearch);
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (panelKey === 'chat' && typeof closeChatProjectPanel === 'function') {
closeChatProjectPanel();
} else if (panelKey === 'webshell' && typeof wsCloseProjectPanel === 'function') {
wsCloseProjectPanel();
}
}
});
}
function clearProjectPickerPanelSearch(panelKey, searchInputId) {
const state = projectPickerPanelState[panelKey];
if (!state) return;
state.seq += 1;
if (state.timer) {
clearTimeout(state.timer);
state.timer = null;
}
const input = document.getElementById(searchInputId);
if (input) input.value = '';
}
function scheduleProjectPickerPanelSearch(panelKey, loadFn) {
const state = projectPickerPanelState[panelKey];
if (!state) return;
if (state.timer) clearTimeout(state.timer);
state.timer = setTimeout(() => {
state.timer = null;
loadFn();
}, PROJECT_PICKER_DEBOUNCE_MS);
}
async function loadChatProjectPanelList() {
await renderProjectPickerPanel('chat', {
listId: 'chat-project-list',
searchInputId: 'chat-project-search',
getSelectedId: resolveChatProjectSelection,
onSelect: (projectId) => selectChatProject(projectId),
});
}
async function ensureChatProjectButtonLabel() {
const id = (resolveChatProjectSelection() || '').trim();
if (id && !projectNameById[id]) {
await fetchProjectSummary(id);
}
updateChatProjectButtonLabel();
}
function updateChatProjectButtonLabel() {
const textEl = document.getElementById('chat-project-text');
if (!textEl) return;
const id = resolveChatProjectSelection();
window.applyProjectNameDisplay(
textEl,
id && projectNameById[id] ? projectNameById[id] : tp('projects.noProject')
);
if (typeof window.refreshChatWelcomeEmptyState === 'function') {
window.refreshChatWelcomeEmptyState();
}
}
async function renderChatProjectPanel() {
initProjectPickerPanelSearch('chat', 'chat-project-search', () => {
scheduleProjectPickerPanelSearch('chat', () => loadChatProjectPanelList());
});
clearProjectPickerPanelSearch('chat', 'chat-project-search');
await loadChatProjectPanelList();
const panel = document.getElementById('chat-project-panel');
if (panel && typeof applyRBACToUI === 'function') applyRBACToUI(panel);
requestAnimationFrame(() => document.getElementById('chat-project-search')?.focus());
}
function closeChatProjectPanel() {
const panel = document.getElementById('chat-project-panel');
const btn = document.getElementById('chat-project-btn');
if (panel) panel.style.display = 'none';
if (btn) {
btn.classList.remove('active');
btn.setAttribute('aria-expanded', 'false');
}
clearProjectPickerPanelSearch('chat', 'chat-project-search');
}
async function toggleChatProjectPanel() {
const panel = document.getElementById('chat-project-panel');
const btn = document.getElementById('chat-project-btn');
if (!panel) return;
const isHidden = panel.style.display === 'none' || !panel.style.display;
if (!isHidden) {
closeChatProjectPanel();
return;
}
if (typeof closeRoleSelectionPanel === 'function') closeRoleSelectionPanel();
if (typeof closeAgentModePanel === 'function') closeAgentModePanel();
if (typeof closeChatReasoningPanel === 'function') closeChatReasoningPanel();
panel.style.display = 'flex';
if (btn) {
btn.classList.add('active');
btn.setAttribute('aria-expanded', 'true');
}
await renderChatProjectPanel();
}
async function selectChatProject(projectId) {
closeChatProjectPanel();
await applyChatProjectSelection(projectId || '');
}
async function applyChatProjectSelection(projectId) {
const prev = getChatProjectSelection();
if (projectId === prev) {
updateChatProjectButtonLabel();
await refreshChatProjectFolders();
return;
}
if (window.currentConversationId) {
try {
const res = await apiFetch(`/api/conversations/${encodeURIComponent(window.currentConversationId)}/project`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || res.statusText);
}
window._loadedConversationProjectId = projectId;
if (typeof showNotification === 'function') {
showNotification(projectId ? tp('projects.projectBound') : tp('projects.projectUnbound'), 'success');
}
} catch (e) {
console.error(e);
alert(tp('projects.updateProjectBindingFailed') + ': ' + (e.message || e));
updateChatProjectButtonLabel();
return;
}
} else {
setActiveProjectId(projectId);
}
updateChatProjectButtonLabel();
await refreshChatProjectFolders();
if (typeof window.onConversationProjectBindingChanged === 'function') {
window.onConversationProjectBindingChanged(projectId);
}
}
/** 对话页项目选择器:同步按钮文案;若浮层已打开则刷新列表 */
async function refreshChatProjectSelector(options = {}) {
if (!document.getElementById('chat-project-btn')) return;
try {
await normalizeStaleChatProjectSelection();
await ensureChatProjectButtonLabel();
} catch (e) {
console.warn(e);
}
if (options.renderFolders !== false) {
const reloadFolders = options.reloadFolders !== false || !isProjectsCacheReady() || !chatProjectFolderContext.ready;
if (reloadFolders) await refreshChatProjectFolders();
else renderChatProjectFolders(projectsCacheAll);
}
const panel = document.getElementById('chat-project-panel');
if (panel && panel.style.display === 'flex') {
await loadChatProjectPanelList();
}
}
async function onChatProjectChange() {
/* 兼容旧调用;新 UI 使用 selectChatProject */
await applyChatProjectSelection(getChatProjectSelection());
}
function initChatProjectSelector() {
if (window._chatProjectSelectorInited) return;
window._chatProjectSelectorInited = true;
if (!window._projectsLanguageListenerBound) {
window._projectsLanguageListenerBound = true;
document.addEventListener('languagechange', () => {
renderProjectsSidebar();
renderProjectsPagination();
syncAllProjectsFilterSelects();
updateChatProjectButtonLabel();
refreshChatProjectFolders();
const panel = document.getElementById('chat-project-panel');
if (panel && panel.style.display === 'flex') loadChatProjectPanelList();
if (currentProjectId) {
refreshProjectDetailMetaI18n();
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
const p = source.find((x) => x.id === currentProjectId);
if (p) updateProjectStatusPill(p.status || 'active');
refreshProjectHeaderStats().catch(() => {});
switchProjectTab(currentProjectTab || 'facts');
}
});
}
refreshChatProjectSelector().catch(() => {});
document.addEventListener('click', (e) => {
const panel = document.getElementById('chat-project-panel');
const wrapper = document.querySelector('.project-selector-wrapper');
if (!panel || panel.style.display === 'none' || !panel.style.display) return;
if (!wrapper?.contains(e.target)) {
closeChatProjectPanel();
}
});
}
function initProjectGraphFooterWheelScroll() {
const footer = document.querySelector('.project-fact-graph-footer');
if (!footer || footer.dataset.wheelScrollBound === 'true') return;
footer.dataset.wheelScrollBound = 'true';
footer.addEventListener('wheel', (event) => {
if (footer.scrollWidth <= footer.clientWidth || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;
const maxScrollLeft = footer.scrollWidth - footer.clientWidth;
const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, footer.scrollLeft + event.deltaY));
if (nextScrollLeft === footer.scrollLeft) return;
footer.scrollLeft = nextScrollLeft;
event.preventDefault();
}, { passive: false });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
initChatProjectSelector();
initProjectConversationReadTracking();
initProjectListActionMenu();
initProjectGraphFooterWheelScroll();
refreshProjectsFilterSelects();
});
} else {
initChatProjectSelector();
initProjectConversationReadTracking();
initProjectListActionMenu();
initProjectGraphFooterWheelScroll();
refreshProjectsFilterSelects();
}
window.initProjectsPage = initProjectsPage;
window.showNewProjectModal = showNewProjectModal;
window.showEditProjectModal = showEditProjectModal;
window.showNewProjectModalFromChat = showNewProjectModalFromChat;
window.showNewProjectModalFromChatSidebar = showNewProjectModalFromChatSidebar;
window.saveProjectModal = saveProjectModal;
window.closeProjectModal = closeProjectModal;
window.selectProject = selectProject;
window.switchProjectTab = switchProjectTab;
window.showAddFactModal = showAddFactModal;
window.showEditFactModal = showEditFactModal;
window.editFactFromDetail = editFactFromDetail;
window.saveFactModal = saveFactModal;
window.closeFactModal = closeFactModal;
window.closeFactDetailModal = closeFactDetailModal;
window.saveProjectSettings = saveProjectSettings;
window.archiveCurrentProject = archiveCurrentProject;
window.deleteCurrentProject = deleteCurrentProject;
window.showProjectListActionMenu = showProjectListActionMenu;
window.editProjectFromListMenu = editProjectFromListMenu;
window.toggleProjectPinnedFromListMenu = toggleProjectPinnedFromListMenu;
window.toggleProjectArchiveFromListMenu = toggleProjectArchiveFromListMenu;
window.deleteProjectFromListMenu = deleteProjectFromListMenu;
window.refreshChatProjectSelector = refreshChatProjectSelector;
window.onChatProjectChange = onChatProjectChange;
window.toggleChatProjectPanel = toggleChatProjectPanel;
window.closeChatProjectPanel = closeChatProjectPanel;
window.selectChatProject = selectChatProject;
window.renderProjectPickerPanel = renderProjectPickerPanel;
window.initProjectPickerPanelSearch = initProjectPickerPanelSearch;
window.clearProjectPickerPanelSearch = clearProjectPickerPanelSearch;
window.scheduleProjectPickerPanelSearch = scheduleProjectPickerPanelSearch;
window.loadChatProjectPanelList = loadChatProjectPanelList;
window.refreshChatProjectFolders = refreshChatProjectFolders;
window.handleProjectFolderSearch = handleProjectFolderSearch;
window.clearProjectFolderSearch = clearProjectFolderSearch;
window.loadMoreChatProjectFolders = loadMoreChatProjectFolders;
window.updateProjectFolderTaskStatuses = updateProjectFolderTaskStatuses;
window.markCurrentProjectConversationViewed = markCurrentProjectConversationViewed;
window.prefetchProjectsForChat = prefetchProjectsForChat;
window.ensureDefaultActiveProjectForNewChat = ensureDefaultActiveProjectForNewChat;
window.getActiveProjectId = getActiveProjectId;
window.getProjectName = getProjectName;
window.viewProjectFactBody = viewProjectFactBody;
window.insertFactBodyTemplate = insertFactBodyTemplate;
window.updateFactFormHints = updateFactFormHints;
window.deprecateProjectFactByKey = deprecateProjectFactByKey;
window.restoreProjectFactByKey = restoreProjectFactByKey;
window.openVulnerabilitiesForProject = openVulnerabilitiesForProject;
window.openVulnerabilityDetail = openVulnerabilityDetail;
window.filterProjectsList = filterProjectsList;
window.goProjectsPage = goProjectsPage;
window.changeProjectsPageSize = changeProjectsPageSize;
window.parseProjectsListResponse = parseProjectsListResponse;
window.fetchAllProjects = fetchAllProjects;
window.debouncedLoadProjectFacts = debouncedLoadProjectFacts;
window.debouncedLoadProjectVulnerabilities = debouncedLoadProjectVulnerabilities;
window.loadProjectVulnerabilities = loadProjectVulnerabilities;
window.linkFactToExistingVulnerability = linkFactToExistingVulnerability;
window.createVulnerabilityFromCurrentFact = createVulnerabilityFromCurrentFact;
window.viewFactsForVulnerability = viewFactsForVulnerability;
window.openProjectConversation = openProjectConversation;
window.unbindConversationFromProject = unbindConversationFromProject;
window.loadProjectConversations = loadProjectConversations;
window.loadProjectAssets = loadProjectAssets;
window.openProjectAssetDetail = openProjectAssetDetail;
window.unbindAssetFromProject = unbindAssetFromProject;
window.loadProjectFactGraph = loadProjectFactGraph;
window.filterProjectFactGraph = filterProjectFactGraph;
window.centerProjectFactGraph = centerProjectFactGraph;
window.closeProjectFactGraphSidebar = closeProjectFactGraphSidebar;
window.openSelectedGraphFactDetail = openSelectedGraphFactDetail;
window.editSelectedGraphFact = editSelectedGraphFact;
window.promoteConversationAttackChain = promoteConversationAttackChain;
window.deleteProjectFactEdge = deleteProjectFactEdge;
window.focusProjectFactGraphEdge = focusProjectFactGraphEdge;
window.toggleProjectFactGraphConnectMode = toggleProjectFactGraphConnectMode;
window.rebuildProjectNameMap = rebuildProjectNameMap;
window.rememberProjectsInNameMap = rememberProjectsInNameMap;
window.searchActiveProjects = searchActiveProjects;
window.filterActiveProjectsLocal = filterActiveProjectsLocal;
window.fetchProjectSummary = fetchProjectSummary;
window.projectNameById = projectNameById;
window.ensureProjectsLoaded = ensureProjectsLoaded;
window.isProjectsCacheReady = isProjectsCacheReady;