/* AI Worker 项目管理平台 - 前端 SPA(V1) */ 'use strict'; const $ = (sel, el = document) => el.querySelector(sel); const $$ = (sel, el = document) => [...el.querySelectorAll(sel)]; const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const fmtTime = ts => ts ? new Date(ts * 1000).toLocaleString('zh-CN', {hour12:false}) : '—'; const fmtMoney = v => (v ?? 0) >= 0.01 ? '¥' + Number(v).toFixed(2) : '¥' + Number(v ?? 0).toFixed(4); const STATUS = { todo: {label:'待执行', cls:'st-todo'}, running:{label:'执行中', cls:'st-running'}, review:{label:'待审核', cls:'st-review'}, done:{label:'已完成', cls:'st-done'}, rejected:{label:'已打回', cls:'st-rejected'}, failed:{label:'失败', cls:'st-failed'}, cancelled:{label:'已取消', cls:'st-cancelled'} }; const PRI = {high:'高', medium:'中', low:'低'}; const KANBAN_ORDER = ['todo','running','review','done','rejected','failed']; const ALERT_LEVEL = {info:'ℹ️', warn:'⚠️', critical:'🚨'}; /* ---------- API ---------- */ async function api(path, opts = {}) { const res = await fetch(path, { headers: {'Content-Type': 'application/json'}, ...opts, body: opts.body ? JSON.stringify(opts.body) : undefined }); let data = {}; try { data = await res.json(); } catch (e) {} if (res.status === 401) { showLogin(); throw new Error('未登录'); } if (!res.ok || data.ok === false) throw new Error(data.error || `请求失败(${res.status})`); return data; } /* ---------- Toast ---------- */ function toast(msg, type = '') { const el = document.createElement('div'); el.className = 'toast ' + type; el.textContent = msg; $('#toast-wrap').appendChild(el); setTimeout(() => el.remove(), 3200); } /* ---------- Login ---------- */ function showLogin() { $('#login-mask').style.display = 'flex'; } function hideLogin() { $('#login-mask').style.display = 'none'; } $('#login-btn').addEventListener('click', async () => { try { await api('/api/login', {method:'POST', body:{username: $('#login-user').value, password: $('#login-pwd').value}}); hideLogin(); $('#logout-btn').style.display = 'block'; router(); } catch (e) { $('#login-err').textContent = e.message; } }); $('#login-pwd').addEventListener('keydown', e => { if (e.key === 'Enter') $('#login-btn').click(); }); $('#logout-btn').addEventListener('click', async e => { e.preventDefault(); await api('/api/logout', {method:'POST'}); location.hash = '#/dashboard'; location.reload(); }); /* ---------- Modal ---------- */ function openModal(html) { $('#modal-box').innerHTML = html; $('#modal-mask').style.display = 'flex'; $$('#modal-box [data-close]').forEach(b => b.addEventListener('click', closeModal)); } function closeModal() { $('#modal-mask').style.display = 'none'; $('#modal-box').innerHTML = ''; } $('#modal-mask').addEventListener('click', e => { if (e.target.id === 'modal-mask') closeModal(); }); /* ---------- 告警角标 ---------- */ async function refreshAlertBadge() { try { const r = await api('/api/alerts/unread_count'); const b = $('#alert-badge'); if (r.count > 0) { b.textContent = r.count; b.style.display = 'inline'; } else b.style.display = 'none'; } catch (e) {} } setInterval(refreshAlertBadge, 30000); /* ---------- Router ---------- */ const routes = { 'dashboard': pageDashboard, 'projects': pageProjects, 'project': pageProject, 'workers': pageWorkers, 'reports': pageReports, 'logs': pageLogs, 'alerts': pageAlerts, 'api': pageApiTokens, 'settings': pageSettings, 'agents': pageAgents, 'eval': pageEval, 'templates': pageTemplates, 'enterprise': pageEnterprise }; function router() { const hash = location.hash.replace(/^#\//, '') || 'dashboard'; const parts = hash.split('/'); const name = parts[0]; const fn = routes[name] || pageDashboard; const navMap = {project:'projects', api:'api', settings:'settings', alerts:'alerts'}; $$('#sidebar nav a').forEach(a => a.classList.toggle('active', a.dataset.route === (navMap[name] || name))); $('#main').innerHTML = '
加载中…
'; fn(parts.slice(1)).catch(e => { $('#main').innerHTML = `
${esc(e.message)}
`; }); } window.addEventListener('hashchange', router); /* ---------- 仪表盘 ---------- */ async function pageDashboard() { const s = (await api('/api/stats')).data; const total = Object.values(s.by_status).reduce((a, b) => a + b, 0); const pct = n => total ? Math.round(n / total * 100) : 0; $('#main').innerHTML = `

仪表盘AI 虚拟团队运营总览

项目
${s.projects}
进行中 ${s.by_status.done ?? 0} 个已完成任务
任务总数
${s.tasks}
完成率 ${pct(s.by_status.done ?? 0)}% · 待审核 ${s.by_status.review ?? 0}
AI Worker
${s.workers}
虚拟员工档案数
累计成本
${fmtMoney(s.total_cost)}
${(s.total_tokens/1e6).toFixed(2)}M tokens 消耗
质量指标
一次通过率
${s.one_pass_rate ?? '—'}%(验收一次通过)
返工次数
${s.rework_count} 次打回
执行失败
${s.by_status.failed ?? 0} 个任务
近 7 天成本趋势(元)
${s.daily_cost.map(d => `
${d.day} ${d.cost.toFixed(4)}
`).join('')}
任务状态分布
${Object.entries(STATUS).filter(([k]) => s.by_status[k]).map(([k, v]) => `
${v.label} ${s.by_status[k]} 个 ${pct(s.by_status[k])}%
`).join('') || '
暂无数据
'}
最近任务动态
${s.recent.map(t => ``).join('') || ''}
项目任务状态更新时间
${esc(t.project_name || '—')}${esc(t.title)} ${STATUS[t.status].label}${fmtTime(t.updated_at)}
暂无任务,去 创建项目
`; } /* ---------- 项目列表 ---------- */ async function pageProjects() { const list = (await api('/api/projects')).data; $('#main').innerHTML = `

项目立项 → 规划 → 执行 → 验收 → 归档

${list.map(p => `
${esc(p.name)}${{active:'进行中', planning:'规划中', done:'已完成', archived:'已归档'}[p.status]}
${esc(p.objective || p.description || '暂无描述')}
任务 ${p.task_count} · 完成 ${p.done_count} ${p.budget_limit ? '预算 ¥' + p.budget_limit : ''}
`).join('') || '
还没有项目,点右上角新建
'}
`; } function openProjectModal(p = {}) { openModal(`

${p.id ? '编辑项目' : '新建项目'}

`); $('#pm-save').addEventListener('click', async () => { const body = { name: $('#pm-name').value.trim(), objective: $('#pm-objective').value, acceptance_criteria: $('#pm-accept').value, description: $('#pm-desc').value, status: $('#pm-status').value, budget_limit: parseFloat($('#pm-budget').value || 0) }; if (!body.name) return toast('请填写项目名称', 'err'); try { await api(p.id ? `/api/projects/${p.id}` : '/api/projects', {method: p.id ? 'PUT' : 'POST', body}); toast('已保存', 'ok'); closeModal(); router(); } catch (e) { toast(e.message, 'err'); } }); } /* ---------- 项目详情(看板 / DAG / 知识库) ---------- */ let projCtx = null; // {pid, p, tasks, workers, tab} async function pageProject([pid, tab]) { const p = (await api(`/api/projects/${pid}`)).data; const tasks = (await api(`/api/tasks?project_id=${pid}`)).data; const workers = (await api('/api/workers')).data; projCtx = {pid, p, tasks, workers, tab: tab || 'kanban'}; renderProjectShell(); if (projCtx.tab === 'dag') renderDag(); else if (projCtx.tab === 'kb') renderKb(); else renderKanban(); } function renderProjectShell() { const {p, pid, tab} = projCtx; $('#main').innerHTML = `
← 返回

${esc(p.name)}${esc(p.objective || '')}

看板 DAG 工作流 知识库 RAG
`; } async function refreshProj() { projCtx.tasks = (await api(`/api/tasks?project_id=${projCtx.pid}`)).data; if (projCtx.tab === 'dag') renderDag(); else renderKanban(); } /* --- 看板 --- */ function renderKanban() { const {tasks} = projCtx; const by = {}; tasks.forEach(t => { (by[t.status] = by[t.status] || []).push(t); }); $('#tab-body').innerHTML = `
${KANBAN_ORDER.map(s => `
${STATUS[s].label} 0
`).join('')}
`; KANBAN_ORDER.forEach(s => { const list = by[s] || []; $('#cnt-' + s).textContent = list.length; $('#kanban .kb-body[data-drop="' + s + '"]').innerHTML = list.map(t => `
${esc(t.title)}
●${PRI[t.priority]} ${t.worker_id ? `Worker#${t.worker_id}` : '自动路由'} ${t.depends_on && t.depends_on.length ? `依赖×${t.depends_on.length}` : ''} ${t.rejection_count ? `打回×${t.rejection_count}` : ''} ${t.status === 'running' ? '' : ''}
${t.status === 'done' ? '
✅ 已交付 v' + t.output_version + '
' : ''}
`).join('') || '
'; }); bindKanbanDnD(); } function bindKanbanDnD() { $$('.kb-body').forEach(bd => { bd.addEventListener('dragover', e => { e.preventDefault(); bd.classList.add('drag-over'); }); bd.addEventListener('dragleave', () => bd.classList.remove('drag-over')); bd.addEventListener('drop', e => { e.preventDefault(); bd.classList.remove('drag-over'); const tid = parseInt(e.dataTransfer.getData('text/plain')); moveTask(tid, bd.dataset.drop); }); }); $$('.kb-card[draggable="true"]').forEach(c => { c.addEventListener('dragstart', e => { e.dataTransfer.setData('text/plain', c.dataset.id); c.classList.add('dragging'); }); c.addEventListener('dragend', () => c.classList.remove('dragging')); }); } async function moveTask(tid, status) { const t = projCtx.tasks.find(x => x.id === tid); if (!t || t.status === status) return; if (['running', 'review'].includes(status)) return toast('不能直接拖到该状态', 'err'); if (status === 'todo' && !['rejected', 'failed', 'done'].includes(t.status)) return; if (status === 'done') return toast('完成请走审核流(审核通过)', 'err'); await api(`/api/tasks/${tid}`, {method:'PUT', body:{status}}); toast('状态已更新', 'ok'); refreshProj(); } /* --- DAG 工作流(可交互画布:拖节点 / 滚轮缩放 / 拖画布平移) --- */ let dagState = null; // {pid, pos:{id:{x,y}}, scale, tx, ty, drag:null, moved:false} function dagLayout(tasks, idMap) { // 拓扑分层:最长路径 const layer = {}; const visit = id => { if (layer[id] !== undefined) return layer[id]; const t = idMap[id]; let l = 0; (t.depends_on || []).forEach(d => { if (idMap[d]) l = Math.max(l, visit(d) + 1); }); layer[id] = l; return l; }; tasks.forEach(t => visit(t.id)); const layers = {}; tasks.forEach(t => { (layers[layer[t.id]] = layers[layer[t.id]] || []).push(t.id); }); const W = 260, H = 110, PAD = 60; const width = Math.max(2, Object.keys(layers).length) * W + PAD; const height = Math.max(...tasks.map(t => layer[t.id] === undefined ? 0 : layer[t.id]), 0) * H + PAD + 60; const pos = {}; Object.entries(layers).forEach(([l, ids]) => { const x = PAD + parseInt(l) * W; const step = Math.min(H - 60, Math.max(70, (height - 2 * PAD) / (ids.length + 1))); ids.forEach((id, i) => { pos[id] = {x, y: PAD + (i + 1) * step}; }); }); return {width, height, pos}; } function dagSize() { const {tasks} = projCtx; const idMap = {}; tasks.forEach(t => { idMap[t.id] = t; }); return dagLayout(tasks, idMap); } function dagFitView() { // 画布适配视口:基于节点实际包围盒缩放并居中,让全部节点可见 const s = dagState; const box = $('#dag-box'); if (!box || !s) return; const poss = Object.values(s.pos || {}); if (!poss.length) return; const minX = Math.min(...poss.map(p => p.x)) - 120; const maxX = Math.max(...poss.map(p => p.x)) + 120; const minY = Math.min(...poss.map(p => p.y)) - 120; const maxY = Math.max(...poss.map(p => p.y)) + 120; const cw = Math.max(200, maxX - minX), ch = Math.max(200, maxY - minY); const bw = box.clientWidth, bh = box.clientHeight; const scale = Math.max(0.25, Math.min(1, (bw - 60) / cw, (bh - 60) / ch)); s.scale = scale; // 节点包围盒中心(viewBox 坐标)对准视口中心 const cx = DAG_M + (minX + maxX) / 2; const cy = DAG_M + (minY + maxY) / 2; s.tx = bw / 2 - cx * scale; s.ty = bh / 2 - cy * scale; dagApplyView(); saveDagState(); } function dagZoomTo(scale) { // 一键回到指定比例(100% = 原始视图) const s = dagState; if (!s) return; s.scale = scale; s.tx = 0; s.ty = 0; dagApplyView(); saveDagState(); } function saveDagState() { if (!dagState) return; try { const pos = {}; Object.entries(dagState.pos).forEach(([k, v]) => { pos[k] = {x: Math.round(v.x), y: Math.round(v.y)}; }); localStorage.setItem('aw_dag_pos_' + dagState.pid, JSON.stringify(pos)); localStorage.setItem('aw_dag_view_' + dagState.pid, JSON.stringify({scale: dagState.scale, tx: Math.round(dagState.tx), ty: Math.round(dagState.ty)})); } catch (e) {} } function dagApplyView() { const s = dagState; const svg = $('#dag-svg'); if (!svg) return; svg.style.transform = `translate(${s.tx}px, ${s.ty}px) scale(${s.scale})`; svg.style.transformOrigin = '0 0'; const pct = $('#dag-zoom-pct'); if (pct) pct.textContent = Math.round(s.scale * 100) + '%'; } function drawDag() { const {tasks} = projCtx; const idMap = {}; tasks.forEach(t => { idMap[t.id] = t; }); const edges = []; tasks.forEach(t => (t.depends_on || []).forEach(d => { if (idMap[d]) edges.push({from: d, to: t.id}); })); const {width, height} = dagSize(); const colors = {todo:'#2a3550', running:'#173b6b', review:'#4a3a10', done:'#0f3d2e', rejected:'#4a1c1c', failed:'#3d1c2e', cancelled:'#333'}; const ready = tasks.filter(t => (t.status === 'todo' || t.status === 'failed') && (!t.depends_on || t.depends_on.every(d => idMap[d]?.status === 'done'))); const body = $('#tab-body'); if (!body) return; const NW = 140, NHW = 70; // 节点宽 / 半宽 const M = DAG_M; // svg 盒边距:节点平移/缩放后仍在盒内可命中 const dagText = t => { const raw = `#${t.id} ${t.title}`; return raw.length > 13 ? raw.slice(0, 13) + '…' : raw; }; body.innerHTML = `
共 ${tasks.length} 个节点 · ${edges.length} 条依赖边${ready.length ? ` · ${ready.length} 个任务就绪` : ''} · 拖节点调位 / 滚轮缩放 / 拖空白平移 / 双击复位 / 拖节点到左下角🗑️删除
🔍 100%
🗑️ 删除区0
${edges.map(e => { const a = dagState.pos[e.from], b = dagState.pos[e.to]; if (!a || !b) return ''; const mx = (a.x + b.x) / 2; const act = idMap[e.from].status === 'done'; return ``; }).join('')} ${tasks.map(t => { const p = dagState.pos[t.id]; const col = colors[t.status]; return ` ${esc(dagText(t))} ${STATUS[t.status].label}${t.depends_on?.length ? ' · 前置' + t.depends_on.length : ''} `; }).join('')}
💡 节点点击查看详情;拖动节点可自定义布局(自动记忆);前置任务完成后下游自动触发。执行整个工作流 = 派发所有就绪任务(含失败重试)。
`; dagApplyView(); bindDagEvents(); dagTrashCount(); } let dagBound = false; // window 级监听只绑定一次 let dagDrag = null; // 模块级拖拽状态(box 重绑后 window handler 仍可访问) const DAG_M = 3000; // svg 盒边距:节点平移/缩放后仍在盒内可命中 function bindDagEvents() { const box = $('#dag-box'); if (!box) return; const s = dagState; const svgEl = () => $('#dag-svg'); // box 级监听:每次 DOM 重建后重新绑定(box 是新元素) box.addEventListener('mousedown', e => { if (e.button !== 0) return; const nodeEl = e.target.closest('.dag-node'); if (nodeEl) { const id = Number(nodeEl.dataset.id); const p = s.pos[id]; dagDrag = {type: 'node', id, sx: e.clientX, sy: e.clientY, ox: p.x, oy: p.y, moved: false}; nodeEl.style.cursor = 'grabbing'; const sv = svgEl(); if (sv) sv.style.cursor = 'grabbing'; } else { dagDrag = {type: 'pan', sx: e.clientX, sy: e.clientY, ox: s.tx, oy: s.ty, moved: false}; const sv = svgEl(); if (sv) sv.style.cursor = 'grabbing'; } e.preventDefault(); }); // 滚轮缩放(以鼠标为锚点) box.addEventListener('wheel', e => { e.preventDefault(); const rect = box.getBoundingClientRect(); const mx = e.clientX - rect.left, my = e.clientY - rect.top; const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12; const ns = Math.min(3, Math.max(0.25, s.scale * factor)); const cx = (mx - s.tx) / s.scale, cy = (my - s.ty) / s.scale; s.scale = ns; s.tx = mx - cx * ns; s.ty = my - cy * ns; dagApplyView(); saveDagState(); }, {passive: false}); // 双击空白复位视图 box.addEventListener('dblclick', e => { if (e.target.closest('.dag-node')) return; s.scale = 1; s.tx = 0; s.ty = 0; dagApplyView(); saveDagState(); }); // 点击节点打开详情(拖动超过阈值则不触发) box.addEventListener('click', e => { const nodeEl = e.target.closest('.dag-node'); if (!nodeEl) return; if (dagState.suppressClick) { dagState.suppressClick = false; return; } openTaskDetail(Number(nodeEl.dataset.id)); }); // window 级监听:只绑定一次(handler 内现查 DOM,兼容画布重建) if (dagBound) return; dagBound = true; window.addEventListener('mousemove', e => { if (!dagDrag) return; const st = dagState; if (!st) return; const dx = e.clientX - dagDrag.sx, dy = e.clientY - dagDrag.sy; if (!dagDrag.moved && Math.abs(dx) + Math.abs(dy) < 3) return; dagDrag.moved = true; const trashEl = $('#dag-trash'); if (trashEl) { const tb = trashEl.getBoundingClientRect(); trashEl.classList.toggle('hover', e.clientX >= tb.left && e.clientX <= tb.right && e.clientY >= tb.top && e.clientY <= tb.bottom); } if (dagDrag.type === 'node') { const p = st.pos[dagDrag.id]; if (p) { p.x = dagDrag.ox + dx / st.scale; p.y = dagDrag.oy + dy / st.scale; } } else { st.tx = dagDrag.ox + dx; st.ty = dagDrag.oy + dy; } dagApplyView(); if (dagDrag.type === 'node') { const svgEl2 = $('#dag-svg'); if (!svgEl2) return; svgEl2.querySelectorAll('.dag-node').forEach(g => { const nid = Number(g.dataset.id); const pp = st.pos[nid]; if (!pp) return; g.querySelector('rect').setAttribute('x', pp.x - 70); g.querySelector('rect').setAttribute('y', pp.y - 24); const ts = g.querySelectorAll('text'); ts[0].setAttribute('x', pp.x); ts[0].setAttribute('y', pp.y - 2); ts[1].setAttribute('x', pp.x); ts[1].setAttribute('y', pp.y + 15); }); const touched = new Set([dagDrag.id]); projCtx.tasks.forEach(t => (t.depends_on || []).forEach(d => { if (d === dagDrag.id || t.id === dagDrag.id) { touched.add(d); touched.add(t.id); } })); touched.forEach(tid => { const a = st.pos[tid]; if (!a) return; projCtx.tasks.forEach(t => { if (t.id !== tid) return; (t.depends_on || []).forEach(d => { const b = st.pos[d]; if (!b) return; const path = svgEl2.querySelector(`path[data-e="${d}-${tid}"]`); if (!path) return; const mx = (b.x + a.x) / 2; path.setAttribute('d', `M${b.x + 70},${b.y} C${mx},${b.y} ${mx},${a.y} ${a.x - 70},${a.y}`); }); }); }); } }); window.addEventListener('mouseup', e => { if (!dagDrag) return; const wasNode = dagDrag.type === 'node'; const trashEl = $('#dag-trash'); let dropped = false; if (wasNode && dagDrag.moved && trashEl) { const tb = trashEl.getBoundingClientRect(); if (e.clientX >= tb.left && e.clientX <= tb.right && e.clientY >= tb.top && e.clientY <= tb.bottom) { dropped = true; dagTrashNode(dagDrag.id); } } const sv = $('#dag-svg'); if (wasNode) { const nodeEl = sv ? sv.querySelector('.dag-node[data-id="' + dagDrag.id + '"]') : null; if (nodeEl) nodeEl.style.cursor = 'grab'; } if (sv) sv.style.cursor = ''; if (trashEl) trashEl.classList.remove('hover'); if (dagDrag.moved && !dropped) { dagState.suppressClick = true; setTimeout(() => { if (dagState) dagState.suppressClick = false; }, 400); saveDagState(); } dagDrag = null; }); } /* --- DAG 回收站:软删除 / 恢复 / 彻底删除 --- */ async function dagTrashCount() { try { const r = await api(`/api/tasks/trash?project_id=${projCtx.pid}`); const el = $('#dag-trash-cnt'); if (el) el.textContent = r.data.length; } catch (e) {} } async function dagTrashNode(taskId) { try { const t = projCtx.tasks.find(x => x.id === taskId); await api(`/api/tasks/${taskId}/trash`, {method: 'POST'}); toast(`🗑️ 任务「${t?.title || taskId}」已移入回收站`, 'ok'); // 重新拉取项目数据并重绘画布 const tasks = (await api(`/api/tasks?project_id=${projCtx.pid}`)).data; projCtx.tasks = tasks; const idMap = {}; tasks.forEach(t => { idMap[t.id] = t; }); const {pos} = dagLayout(tasks, idMap); Object.entries(dagState.pos).forEach(([k, v]) => { if (pos[k]) pos[k] = v; }); dagState.pos = pos; drawDag(); } catch (e) { toast('删除失败:' + e.message, 'err'); } } async function openDagTrash() { const r = await api(`/api/tasks/trash?project_id=${projCtx.pid}`); const rows = r.data; openModal(`

🗑️ 回收站 ${rows.length} 个已删除任务

被删除的任务都在这里,可恢复或彻底删除(不可恢复)。
${rows.map(t => ` `).join('') || ''}
ID标题状态删除时间操作
#${t.id} ${esc(t.title)} ${STATUS[t.status]?.label || t.status} ${fmtTime(t.deleted_at)}
回收站是空的
`); } async function dagRestoreTask(taskId) { try { await api(`/api/tasks/${taskId}/restore`, {method: 'POST'}); toast('♻️ 已恢复', 'ok'); const tasks = (await api(`/api/tasks?project_id=${projCtx.pid}`)).data; projCtx.tasks = tasks; const idMap = {}; tasks.forEach(t => { idMap[t.id] = t; }); const {pos} = dagLayout(tasks, idMap); Object.entries(dagState.pos).forEach(([k, v]) => { if (pos[k]) pos[k] = v; }); dagState.pos = pos; drawDag(); closeModal(); } catch (e) { toast('恢复失败:' + e.message, 'err'); } } async function dagHardDelete(taskId) { if (!confirm('彻底删除后不可恢复,确定?')) return; try { await api(`/api/tasks/${taskId}?hard=1`, {method: 'DELETE'}); toast('已彻底删除', 'ok'); closeModal(); openDagTrash(); dagTrashCount(); } catch (e) { toast('删除失败:' + e.message, 'err'); } } function renderDag() { const {tasks, pid} = projCtx; const idMap = {}; tasks.forEach(t => { idMap[t.id] = t; }); const {pos} = dagLayout(tasks, idMap); // 恢复已保存布局 / 视图 let savedPos = null, savedView = null; try { savedPos = JSON.parse(localStorage.getItem('aw_dag_pos_' + pid) || 'null'); savedView = JSON.parse(localStorage.getItem('aw_dag_view_' + pid) || 'null'); } catch (e) {} if (savedPos) Object.entries(savedPos).forEach(([k, v]) => { if (pos[k]) pos[k] = v; }); dagState = { pid, pos, scale: savedView?.scale || 1, tx: savedView?.tx || 0, ty: savedView?.ty || 0, drag: null, moved: false, }; const body2 = $('#tab-body'); body2.innerHTML = ''; drawDag(); // 节点是否全部在视口内?任一越界则自动适配(防止节点被平移到视口外无法操作) const box = $('#dag-box'); const bb = box ? box.getBoundingClientRect() : null; let allVisible = !bb; if (bb) { allVisible = tasks.every(t => { const p = dagState.pos[t.id]; if (!p) return true; const vx = bb.left + dagState.tx + (DAG_M + p.x) * dagState.scale; const vy = bb.top + dagState.ty + (DAG_M + p.y) * dagState.scale; return vx < bb.right && vx > bb.left && vy < bb.bottom && vy > bb.top; }); } if (!allVisible || !savedView) dagFitView(); } async function runWorkflow() { const r = await api(`/api/projects/${projCtx.pid}/workflow/run`, {method:'POST'}); toast(`🚀 已派发 ${r.started.length} 个任务${r.blocked.length ? `,${r.blocked.length} 个因依赖阻塞` : ''}`, 'ok'); setTimeout(refreshProj, 1500); } /* --- 知识库 RAG --- */ async function renderKb() { const docs = (await api(`/api/projects/${projCtx.pid}/documents`)).data; $('#tab-body').innerHTML = `
文档共 ${docs.length} 篇 · 任务执行时自动检索注入上下文
文档列表
${docs.map(d => `
${esc(d.name)} ${d.chunks} 块 ${d.content.length} 字 · ${fmtTime(d.created_at)}
`).join('') || '
暂无文档,添加产品资料/知识手册后 AI Worker 干活时会自动参考
'}
`; } async function kbSearch() { const q = $('#kb-q').value.trim(); if (!q) return; const r = await api(`/api/projects/${projCtx.pid}/search?q=${encodeURIComponent(q)}`); $('#kb-results').innerHTML = r.data.length ? r.data.map(h => `
📄 命中 文档#${h.document_id} · 块${h.chunk_id} · 得分 ${h.score}
${esc(h.content.slice(0, 160))}${h.content.length > 160 ? '…' : ''}
`).join('') : '
无命中(知识库为空或关键词不匹配)
'; } function openDocModal(docId) { (docId ? api(`/api/documents/${docId}`) : Promise.resolve({data: {}})).then(async r => { const doc = docId ? r.data : {}; openModal(`

${docId ? '编辑文档' : '添加文档'}

`); $('#dm-file').addEventListener('change', e => { const f = e.target.files[0]; if (!f) return; const rd = new FileReader(); rd.onload = () => { $('#dm-content').value = rd.result; if (!$('#dm-name').value) $('#dm-name').value = f.name; }; rd.readAsText(f); }); $('#dm-save').addEventListener('click', async () => { const name = $('#dm-name').value.trim(), content = $('#dm-content').value; if (!name || !content.trim()) return toast('名称和内容必填', 'err'); const rr = await api(docId ? `/api/documents/${docId}` : `/api/projects/${projCtx.pid}/documents`, {method: docId ? 'PUT' : 'POST', body: docId ? {name, content} : {name, content}}); toast(docId ? '已保存' : `已添加,分块 ${rr.chunks} 块`, 'ok'); closeModal(); renderKb(); }); }); } async function delDoc(docId) { if (!confirm('确认删除该文档及其分块?')) return; await api(`/api/documents/${docId}`, {method:'DELETE'}); toast('已删除', 'ok'); renderKb(); } /* --- 新建任务(含依赖) --- */ function openTaskModal(editTask) { const t = editTask || {}; const {tasks, workers, pid} = projCtx || {tasks: [], workers: []}; const candidates = tasks.filter(x => x.status !== 'running' && x.id !== t.id); const deps = t.depends_on || []; openModal(`

${t.id ? '编辑任务' : '新建任务'}

${candidates.length ? candidates.map(c => ` `).join('') : '暂无其他任务可选'}
`); $('#tm-save').addEventListener('click', async () => { const title = $('#tm-title').value.trim(); if (!title) return toast('请填写任务标题', 'err'); const body = { title, description: $('#tm-desc').value, worker_id: $('#tm-worker').value ? parseInt($('#tm-worker').value) : null, priority: $('#tm-pri').value, deadline: $('#tm-deadline').value, review_required: $('#tm-review').value === '1', depends_on: $$('#tm-deps input:checked').map(x => parseInt(x.value)) }; try { if (t.id) { await api(`/api/tasks/${t.id}`, {method:'PUT', body}); toast('已保存', 'ok'); closeModal(); refreshProj(); } else { const r = await api('/api/tasks', {method:'POST', body:{...body, project_id: projCtx.pid}}); toast('任务已创建', 'ok'); closeModal(); refreshProj(); if (confirm('任务已创建,立即派给 AI Worker 执行?')) runTask(r.id); } } catch (e) { toast(e.message, 'err'); } }); } async function runTask(tid) { try { await api(`/api/tasks/${tid}/run`, {method:'POST'}); toast('🚀 已派活,AI Worker 开始执行', 'ok'); refreshProj(); } catch (e) { toast(e.message, 'err'); } } /* ---------- 任务详情(含审核) ---------- */ async function openTaskDetail(tid) { const t = (await api(`/api/tasks/${tid}`)).data; const workers = projCtx?.workers || (await api('/api/workers')).data; const w = t.worker || (t.worker_id ? {id: t.worker_id, name: `#${t.worker_id}`} : null); const deps = (t.depends_on || []).map(d => projCtx?.tasks.find(x => x.id === d)).filter(Boolean); openModal(`

${esc(t.title)}${STATUS[t.status].label}

执行 Worker
${w ? `${esc(w.name)} ${esc(w.provider)}/${esc(w.model)}` : '未指派(自动路由)'}
优先级
●${PRI[t.priority]} · 返工 ${t.rejection_count} 次
前置任务
${deps.length ? deps.map(d => `#${d.id} ${esc(d.title).slice(0,12)}(${STATUS[d.status].label})`).join('') : '无'}
创建/更新
${fmtTime(t.created_at)} / ${fmtTime(t.updated_at)}
截止
${t.deadline || '—'}
任务指令
${esc(t.description || t.title)}
${t.output_text ? `
产出物 v${t.output_version}(${t.output_text.length} 字)
${esc(t.output_text)}
` : ''} ${t.error ? `
错误信息
${esc(t.error)}
` : ''}
执行日志
${t.logs.map(logLine).join('') || '
暂无日志
'}
`); } function logLine(l) { const icons = {info:'ℹ️', success:'✅', warn:'⚠️', error:'❌'}; return `
${fmtTime(l.created_at)}${icons[l.level] || ''} ${esc(l.message)}
`; } async function reviewTask(tid, action) { if (action === 'reject') { const reason = prompt('请输入打回原因(将退回重做):'); if (reason === null) return; if (!reason.trim()) return toast('打回必须填写原因', 'err'); await api(`/api/tasks/${tid}/review`, {method:'POST', body:{action, reason: reason.trim()}}); } else { await api(`/api/tasks/${tid}/review`, {method:'POST', body:{action}}); } toast(action === 'approve' ? '✅ 验收通过,任务完成' : '任务已打回,等待返工', 'ok'); closeModal(); refreshProj(); } async function cancelTask(tid) { if (!confirm('确认取消该任务?')) return; await api(`/api/tasks/${tid}/cancel`, {method:'POST'}); toast('已取消', 'ok'); closeModal(); refreshProj(); } async function reopenTask(tid) { await api(`/api/tasks/${tid}`, {method:'PUT', body:{status:'todo'}}); toast('任务已重新打开', 'ok'); closeModal(); refreshProj(); } async function delTask(tid) { if (!confirm('删除后任务将移入回收站(DAG 画布左下角🗑️可恢复),确定?')) return; await api(`/api/tasks/${tid}`, {method:'DELETE'}); toast('已移入回收站', 'ok'); closeModal(); refreshProj(); } /* ---------- AI 规划 WBS ---------- */ function openWbsModal() { const {p} = projCtx; openModal(`

🧠 AI 辅助规划(WBS 任务分解)

`); $('#wb-gen').addEventListener('click', async () => { const goal = $('#wb-goal').value.trim(); if (!goal) return toast('请填写项目目标', 'err'); $('#wb-result').innerHTML = '
AI 正在拆解任务(约 10-30 秒)…
'; try { const r = await api(`/api/projects/${projCtx.pid}/wbs/generate`, {method:'POST', body:{goal}}); let wbs = r.data; $('#wb-result').innerHTML = `
生成 ${wbs.length} 个任务(可编辑后导入)· 花费 ¥${r.cost.toFixed(4)} / ${r.usage} tokens
${wbs.map((t, i) => `
${i+1}
依赖任务:${wbs.map((_, j) => ``).join('')}
`).join('')} `; $('#wb-import').addEventListener('click', async () => { const tasks = wbs.map((t, i) => ({ title: $(`[data-f="title"][data-i="${i}"]`).value.trim() || t.title, description: $(`[data-f="desc"][data-i="${i}"]`).value, depends_on: $$(`[data-dep="${i}"]:checked`).map(x => parseInt(x.value)) })); const rr = await api(`/api/projects/${projCtx.pid}/wbs/import`, {method:'POST', body:{tasks, worker_id: projCtx.workers[0]?.id || null}}); toast(`✅ 已导入 ${rr.created} 个任务(含依赖关系)`, 'ok'); closeModal(); refreshProj(); router(); }); } catch (e) { $('#wb-result').innerHTML = `
❌ ${esc(e.message)}
`; } }); } /* ---------- Worker 管理 ---------- */ async function pageWorkers() { const [ws, ps] = await Promise.all([api('/api/workers'), api('/api/providers')]); $('#main').innerHTML = `

AI Worker 管理模型 + 角色提示词 + 工具权限 + 成本上限 = 虚拟员工档案

${ws.data.map(w => ` `).join('') || ''}
ID名称模型角色提示词温度/上限本月成本成本上限状态操作
#${w.id} ${esc(w.name)}
${esc(w.description || '')}
${esc(w.provider)} ${esc(w.model)} ${esc((w.system_prompt || '—').slice(0, 40))} ${w.temperature} / ${w.max_tokens} ${fmtMoney(w.month_cost)} ${w.task_cost_limit ? '单任务 ¥' + w.task_cost_limit : '—'}
${w.monthly_cost_limit ? '月 ¥' + w.monthly_cost_limit : ''}
${w.status === 'enabled' ? '启用' : '停用'}
还没有 Worker,注册第一个虚拟员工吧
`; } async function testWorker(wid) { toast('正在测试连通性…'); try { const r = await api(`/api/workers/${wid}/test`, {method:'POST'}); toast(`✅ 连通正常:${r.data.latency_ms}ms,回复「${r.data.reply}」,成本 ¥${r.data.cost}`, 'ok'); } catch (e) { toast('❌ ' + e.message, 'err'); } } async function visionTest(wid) { openModal(`

🖼️ 视觉能力测试

多模态智能体:输入图片 URL 与问题,验证图像理解能力。示例:
https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/640px-Cat03.jpg
`); $('#vt-go').addEventListener('click', async () => { const url = $('#vt-url').value.trim(); if (!url) return toast('请填写图片 URL', 'err'); $('#vt-out').innerHTML = '
视觉分析中(多模态调用约 10-60 秒)…
'; try { const r = await api(`/api/workers/${wid}/vision_test`, {method:'POST', body: {image_url: url, question: $('#vt-q').value}}); $('#vt-out').innerHTML = `
${esc(r.data.text)}
tokens ${r.data.total_tokens} · 成本 ${fmtMoney(r.data.cost)} · 延迟 ${r.data.latency_ms ?? '—'}ms
`; } catch (e) { $('#vt-out').innerHTML = `
${esc(e.message)}
`; } }); } async function delWorker(wid) { if (!confirm('确认删除该 Worker?(历史任务将解除指派)')) return; await api(`/api/workers/${wid}`, {method:'DELETE'}); toast('已删除', 'ok'); router(); } function openWorkerModal(w, providers) { w = w || {}; const sel = (providerId) => providers.find(p => p.id === providerId); openModal(`

${w.id ? '编辑 Worker' : '注册 Worker'}

`); window.onProviderChange = () => { const p = providers.find(x => x.id === $('#wm-provider').value); if (!p) return; const base = $('#wm-base'); if (!base.value || base.placeholder === base.value) base.value = p.base_url; base.placeholder = p.base_url; const model = $('#wm-model'); if (p.models.length && !model.value) model.value = p.models[0]; }; $('#wm-save').addEventListener('click', async () => { const body = { name: $('#wm-name').value.trim(), provider: $('#wm-provider').value, model: $('#wm-model').value.trim(), base_url: $('#wm-base').value.trim(), api_key: $('#wm-key').value.trim(), system_prompt: $('#wm-prompt').value, description: $('#wm-desc').value.trim(), temperature: parseFloat($('#wm-temp').value || 0.7), max_tokens: parseInt($('#wm-max').value || 2000), task_cost_limit: parseFloat($('#wm-tasklimit').value || 0), monthly_cost_limit: parseFloat($('#wm-monthlimit').value || 0), status: $('#wm-status').value }; if (!body.name || !body.provider || !body.model) return toast('名称/供应商/模型必填', 'err'); try { await api(w.id ? `/api/workers/${w.id}` : '/api/workers', {method: w.id ? 'PUT' : 'POST', body}); toast('已保存', 'ok'); closeModal(); router(); } catch (e) { toast(e.message, 'err'); } }); } /* ---------- 成本报表 ---------- */ async function pageReports() { const group = location.hash.includes('group=') ? location.hash.split('group=')[1] : 'project'; const r = (await api('/api/reports/cost?group=' + group)).data; const s = (await api('/api/stats')).data; const head = {project:['项目','调用次数','Tokens','成本'], worker:['Worker','模型','调用次数','Tokens','成本'], model:['供应商/模型','调用次数','Tokens','成本']}[group]; $('#main').innerHTML = `

成本报表按项目 × 任务 × Worker × 模型 多维核算

按项目 按 Worker 按模型 累计成本 ${fmtMoney(s.total_cost)} · ${(s.total_tokens/1e6).toFixed(2)}M tokens
${head.map(h => ``).join('')} ${r.map(x => { const cost = x.cost || 0, tokens = x.tokens || 0; const maxCost = Math.max(...r.map(v => v.cost || 0), 0.001); return `${group === 'project' ? `` : group === 'worker' ? `` : ``} `; }).join('') || ''}
${h}
${esc(x.project_name || '—')}${esc(x.worker_name || '—')}${esc(x.provider || '')} ${esc(x.model || '')}${esc(x.provider || '')} ${esc(x.model || '')}${x.runs}${(tokens/1e6).toFixed(3)}M ${fmtMoney(cost)}
暂无成本数据,先派几个任务吧
`; } /* ---------- 运行日志 ---------- */ async function pageLogs() { const logs = (await api('/api/logs?limit=200')).data; $('#main').innerHTML = `

运行日志全链路可追溯,每个结论都能溯源

${logs.map(l => `
${fmtTime(l.created_at)} [任务#${l.task_id} ${esc(l.task_title || '')}] ${esc(l.message)}
`).join('') || '
暂无日志
'}
`; } /* ---------- 告警中心 ---------- */ async function pageAlerts() { const alerts = (await api('/api/alerts?limit=200')).data; $('#main').innerHTML = `

告警中心任务失败 · 预算超标 · Worker 异常 · 成本拦截

${alerts.map(a => `
${ALERT_LEVEL[a.level] || 'ℹ️'} ${esc(a.title)} ${a.read ? '' : '未读'}
${esc(a.detail)}
${fmtTime(a.created_at)} · ${esc(a.type)}
`).join('') || '
暂无告警 🎉
'}
`; refreshAlertBadge(); } async function readAlert(id) { await api(`/api/alerts/${id}/read`, {method:'POST'}); pageAlerts(); } async function readAllAlerts() { await api('/api/alerts/read_all', {method:'POST'}); toast('已全部标记为已读', 'ok'); pageAlerts(); } /* ---------- 开放 API ---------- */ async function pageApiTokens() { const tokens = (await api('/api/tokens')).data; $('#main').innerHTML = `

开放 API外部系统通过 Bearer Token 调平台能力

已生成的 Token
${tokens.map(t => ``).join('') || ''}
名称创建时间最近使用操作
${esc(t.name)}${fmtTime(t.created_at)} ${t.last_used_at ? fmtTime(t.last_used_at) : '从未使用'}
暂无 Token
调用示例
POST /api/tasks 创建任务
curl -X POST http://<host>:16071/api/tasks \\
  -H "Authorization: Bearer <TOKEN>" \\
  -H "Content-Type: application/json" \\
  -d '{"project_id":1,"title":"生成周报","description":"…"}'
# 响应: {"ok":true,"id":42}
POST /api/tasks/42/run 派活执行
curl -X POST http://<host>:16071/api/tasks/42/run \\
  -H "Authorization: Bearer <TOKEN>"
GET /api/tasks/42 查结果(status=review 时人工审核)
POST /api/tasks/42/review 审核
curl -X POST http://<host>:16071/api/tasks/42/review \\
  -H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json" \\
  -d '{"action":"approve"}'   # 或 {"action":"reject","reason":"…"}
`; } async function createToken() { const name = prompt('Token 名称(用途说明):') || 'default'; const r = await api('/api/tokens', {method:'POST', body:{name}}); alert('✅ 生成成功(只显示一次,请立即复制保存):\n\n' + r.token); pageApiTokens(); } async function delToken(id) { if (!confirm('删除后使用该 Token 的调用将失效,确认?')) return; await api(`/api/tokens/${id}`, {method:'DELETE'}); toast('已删除', 'ok'); pageApiTokens(); } /* ---------- 通知设置 ---------- */ async function pageSettings() { const [chans, evs, st] = await Promise.all([api('/api/channels'), api('/api/events'), api('/api/settings')]); $('#main').innerHTML = `

通知与设置飞书 / 企微群机器人 · 邮件 · 预算告警阈值

通知渠道
${chans.data.map(c => ``).join('') || ''}
名称类型订阅事件状态操作
${esc(c.name)} ${c.type === 'feishu' ? '飞书' : c.type === 'wecom' ? '企微' : '邮件'} ${(c.events || []).map(e => `${(evs.data.find(x => x.id === e) || {}).name || e}`).join('') || '—'} ${c.enabled ? '启用' : '停用'}
暂无渠道,添加飞书/企微机器人后任务事件自动推送
💡 飞书群机器人:群设置 → 群机器人 → 添加自定义机器人,复制 Webhook 地址(以 https://open.feishu.cn/open-apis/bot/v2/hook/ 开头)。企微同理(https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=…)。
预算告警
告警会写入告警中心并推送到订阅了「预算告警」事件的渠道。
邮件通知
${st.data.email_configured ? '✅ 已配置 SMTP(config.py EMAIL)' : '⚠️ 未配置 SMTP(config.py 的 EMAIL 填写 host/user/password 后重启生效)'}
`; } async function saveSettings() { await api('/api/settings', {method:'PUT', body:{budget_alert_ratio: parseFloat($('#st-ratio').value)}}); toast('已保存', 'ok'); } async function testChannel(id) { toast('正在发送测试消息…'); try { const r = await api(`/api/channels/${id}/test`, {method:'POST'}); r.ok ? toast('✅ 测试消息已送达', 'ok') : toast('❌ ' + r.msg, 'err'); } catch (e) { toast('❌ ' + e.message, 'err'); } } async function delChannel(id) { if (!confirm('确认删除该渠道?')) return; await api(`/api/channels/${id}`, {method:'DELETE'}); toast('已删除', 'ok'); pageSettings(); } function openChannelModal(c, events) { c = c || {}; openModal(`

${c.id ? '编辑通知渠道' : '添加通知渠道'}

${events.map(e => ``).join('')}
`); const toggle = () => { const isMail = $('#cm-type').value === 'email'; $('#cm-webhook-wrap').style.display = isMail ? 'none' : ''; $('#cm-email-wrap').style.display = isMail ? '' : 'none'; }; $('#cm-type').addEventListener('change', toggle); toggle(); $('#cm-save').addEventListener('click', async () => { const body = { name: $('#cm-name').value.trim(), type: $('#cm-type').value, webhook: $('#cm-webhook').value.trim(), email: $('#cm-email').value.trim(), events: $$('#modal-box .check-group input:checked').map(x => x.value), enabled: $('#cm-enabled').checked ? 1 : 0 }; if (!body.name || (body.type !== 'email' && !body.webhook) || (body.type === 'email' && !body.email)) return toast('请填写完整', 'err'); await api(c.id ? `/api/channels/${c.id}` : '/api/channels', {method: c.id ? 'PUT' : 'POST', body}); toast('已保存', 'ok'); closeModal(); pageSettings(); }); } /* ---------- V2 · 多 Agent 协作 ---------- */ let agentsCtx = {modes: [], workers: [], runs: []}; async function pageAgents() { const [modes, workers, runs] = await Promise.all([ api('/api/agents/modes'), api('/api/workers'), api('/api/agents/runs') ]); agentsCtx = {modes: modes.data, workers: workers.data, runs: runs.data}; renderAgents(); } function renderAgents() { const {modes, workers, runs} = agentsCtx; const wOpts = workers.map(w => ``).join(''); // 各模式专属参数定义(m: 适用模式列表) const paramRows = [ {id: 'ag-workers', m: 'all', html: ``}, {id: 'ag-rounds', m: 'review', html: ``}, {id: 'ag-pass', m: 'review', html: ``}, {id: 'ag-debate-rounds', m: 'debate', html: ``}, {id: 'ag-stances', m: 'debate', html: ``}, {id: 'ag-context', m: 'all', html: ``}, ]; const modeHints = { supervisor: '💡 第 1 个 Agent 担任主管(负责拆解与最终合成),其余 Agent 并行执行子任务。', review: '💡 第 1 个 Agent 为执行者,第 2 个为评审(只选 1 个时由同一 Agent 自评)。低于分数线自动返工,最多重试所选轮次。', debate: '💡 除最后 1 个 Agent 为裁判外,其余均为辩手;立场按顺序分配,裁判综合裁决输出共识。', }; $('#main').innerHTML = `

多 Agent 协作主管 / 评审 / 辩论 三种团队协作模式

${modes.map(m => `
${m.icon}
${esc(m.name)}
${esc(m.desc)}
${m.id === 'supervisor' ? '拆解+委派+合成' : m.id === 'review' ? '产出+评审+返工' : '立论+质询+裁决'}
`).join('')}
⚡ 发起协作运行
${paramRows.map(r => r.html).join('')}
${modeHints.supervisor}
📋 运行记录
${runs.map(r => ` `).join('') || ''}
ID模式标题状态过程摘要成本时间操作
#${r.id}${esc(r.mode)} ${esc(r.title || r.topic.slice(0, 30))} ${({done:'✅ 完成',running:'⏳ 执行中',failed:'❌ 失败',cancelled:'已取消'})[r.status] || r.status} ${esc(r.summary || '—')} ${fmtMoney(r.cost)} ${fmtTime(r.created_at)}
暂无运行记录
`; // 模式切换:动态显示/隐藏专属参数 const applyMode = mode => { $$('.form-grid [data-modes]').forEach(el => { const ms = el.dataset.modes.split(','); el.style.display = (ms.includes('all') || ms.includes(mode)) ? '' : 'none'; }); $('#ag-hint').textContent = modeHints[mode] || ''; $$('.mode-card').forEach(c => c.style.outline = c.dataset.mode === mode ? '2px solid var(--accent)' : ''); }; $('#ag-mode').addEventListener('change', e => applyMode(e.target.value)); $$('.mode-card').forEach(c => c.addEventListener('click', () => { $('#ag-mode').value = c.dataset.mode; applyMode(c.dataset.mode); })); applyMode($('#ag-mode').value); $('#ag-run').addEventListener('click', async () => { const mode = $('#ag-mode').value; const topic = $('#ag-topic').value.trim(); if (!topic) return toast('请填写任务主题', 'err'); const workerIds = [...$('#ag-workers').selectedOptions].map(o => Number(o.value)); if (!workerIds.length) return toast('请至少选择一个 Agent', 'err'); const params = {workers: workerIds, context: $('#ag-context').value}; if (mode === 'review') { params.rounds = Number($('#ag-rounds').value) || 2; params.pass_score = Number($('#ag-pass').value) || 70; } if (mode === 'debate') { params.rounds = Number($('#ag-debate-rounds').value) || 1; params.stances = $('#ag-stances').value.split(',').map(s => s.trim()).filter(Boolean); } try { await api('/api/agents/runs', {method: 'POST', body: {mode, title: $('#ag-title').value.trim(), topic, params}}); toast('协作已启动 🚀', 'ok'); pageAgents(); } catch (e) { toast(e.message, 'err'); } }); } async function viewAgentRun(id) { const d = (await api(`/api/agents/runs/${id}`)).data; openModal(`

🤝 协作运行 #${d.id} · ${esc(d.mode)}

状态
${d.status}
主题
${esc(d.topic)}
摘要
${esc(d.summary || '—')}
成本 / tokens
${fmtMoney(d.cost)} / ${d.total_tokens}
执行过程
${(d.steps || []).map(s => `
[${esc(s.role)} / ${esc(s.stage)}] ${esc(s.worker_name || '')}
${esc(s.content)}
`).join('') || '
暂无步骤
'}
最终产出
${esc(d.result || '(执行中…)')}
`); } /* ---------- V2 · 自动评估 ---------- */ async function pageEval() { const [datasets, runs, board, workers] = await Promise.all([ api('/api/eval/datasets'), api('/api/eval/runs'), api('/api/eval/leaderboard'), api('/api/workers') ]); const wOpts = workers.data.map(w => ``).join(''); $('#main').innerHTML = `

自动评估体系eval 数据集沉淀 · LLM 评委打分 · Worker 排行榜

🏆 Worker 评估排行榜
${board.data.map((b, i) => ` `).join('') || ''}
排名Worker模型评估次数平均分累计成本
${i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : '#' + (i+1)} ${esc(b.worker_name)}${esc(b.model)}${b.runs} ${b.avg_score}/100${fmtMoney(b.cost)}
暂无评估数据,先跑一次评估吧
📚 数据集
${datasets.data.map(ds => ` `).join('') || ''}
名称用例最佳分操作
${esc(ds.name)}${ds.is_builtin ? ' 内置' : ''} ${ds.case_count}${ds.best_score != null ? ds.best_score : '—'}
暂无数据集
🔄 评估运行记录
${runs.data.map(r => ` `).join('') || ''}
ID数据集Worker状态得分进度操作
#${r.id}${esc(r.dataset_name)}${esc(r.worker_name)} ${({done:'✅ 完成',running:'⏳ 进行中',failed:'❌ 失败'})[r.status] || r.status} ${r.status === 'done' ? r.score : '—'} ${r.cases_done}/${r.cases_total}
暂无运行记录
💡 评估 = Worker 逐条作答 → LLM 评委按 rubric 打分。沉淀功能会把已验收通过的任务产出自动转为评测用例。
⚡ 发起评估
`; $('#ev-run').addEventListener('click', async () => { try { await api('/api/eval/runs', {method: 'POST', body: {dataset_id: Number($('#ev-ds').value), worker_id: Number($('#ev-w').value)}}); toast('评估已启动', 'ok'); pageEval(); } catch (e) { toast(e.message, 'err'); } }); } function evalCreateDataset() { openModal(`

新建评估数据集

`); $('#ed-save').addEventListener('click', async () => { try { await api('/api/eval/datasets', {method: 'POST', body: {name: $('#ed-name').value.trim(), description: $('#ed-desc').value, rubric: $('#ed-rubric').value}}); toast('已创建', 'ok'); closeModal(); pageEval(); } catch (e) { toast(e.message, 'err'); } }); } async function evalOpenDataset(id) { const ds = (await api(`/api/eval/datasets/${id}`)).data; openModal(`

📚 ${esc(ds.name)} ${ds.case_count || ds.cases.length} 用例

${esc(ds.description)}
评分标准
${esc(ds.rubric || '(未设置,使用默认)')}
用例列表
${(ds.cases || []).map(c => `
Q${c.id}: ${esc(c.input.slice(0, 120))}
期望:${esc((c.expected || '—').slice(0, 120))}
`).join('') || '
暂无用例
'}
添加用例
`); $('#ec-add').addEventListener('click', async () => { try { await api(`/api/eval/datasets/${id}/cases`, {method: 'POST', body: {input: $('#ec-input').value, expected: $('#ec-expected').value}}); toast('已添加', 'ok'); closeModal(); evalOpenDataset(id); } catch (e) { toast(e.message, 'err'); } }); } async function evalDelCase(cid) { if (!confirm('确定删除该用例?')) return; await api(`/api/eval/cases/${cid}`, {method: 'DELETE'}); toast('已删除', 'ok'); closeModal(); pageEval(); } async function evalRunDataset(id) { openModal(`

🚀 评估数据集 #${id}

将用选定 Worker 逐条作答并由 LLM 评委打分,每条用例约需 1~2 分钟。
`); } async function evalSink() { try { const r = await api('/api/eval/datasets/0/sink', {method: 'POST'}); toast(`已沉淀 ${r.sunk} 条任务产出`, 'ok'); pageEval(); } catch (e) { toast(e.message, 'err'); } } async function viewEvalRun(id) { const d = (await api(`/api/eval/runs/${id}`)).data; openModal(`

🎯 评估运行 #${d.id} · ${esc(d.dataset_name)}

Worker
${esc(d.worker_name)}
平均分
${d.status === 'done' ? d.score : '…'}/100
进度
${d.cases_done}/${d.cases_total}
成本
${fmtMoney(d.cost)}
逐条结果
${(d.results || []).map(r => `
用例 #${r.case_id} 得分 ${r.score} · ${r.latency_ms}ms · ${fmtMoney(r.cost)}
输入:${esc(r.input || '')}
评委意见
${esc(r.judgment)}
执行输出
${esc(r.output)}
`).join('') || '
暂无结果
'} `); } /* ---------- V2 · 模板市场 ---------- */ async function pageTemplates() { const [tpls, projects, workers] = await Promise.all([ api('/api/templates'), api('/api/projects'), api('/api/workers') ]); const typeMap = {task: '📝 任务模板', project: '📁 项目模板', team: '👥 团队模板'}; const projOpts = projects.data.map(p => ``).join(''); const wOpts = workers.data.map(w => ``).join(''); $('#main').innerHTML = `

模板市场任务 / 项目 / Agent 团队 一键复用

${['task', 'project', 'team'].map(t => `
${typeMap[t]}
${tpls.data.filter(x => x.type === t).map(x => `
${esc(x.name)} ${x.is_builtin ? '内置' : ''}
${esc(x.description)}
使用 ${x.usage_count} 次${x.tags.map(tg => ` · ${esc(tg)}`).join('')}
${x.is_builtin ? '' : ``}
`).join('') || '
暂无模板
'}
`).join('')} `; window._tplProjOpts = projOpts; window._tplWorkerOpts = wOpts; } function tplCreate() { openModal(`

新建模板

`); $('#tc-save').addEventListener('click', async () => { let content; try { content = JSON.parse($('#tc-content').value); } catch (e) { return toast('JSON 格式错误', 'err'); } try { await api('/api/templates', {method: 'POST', body: { type: $('#tc-type').value, name: $('#tc-name').value.trim(), description: $('#tc-desc').value, content, tags: $('#tc-tags').value.split(',').map(s => s.trim()).filter(Boolean) }}); toast('已创建', 'ok'); closeModal(); pageTemplates(); } catch (e) { toast(e.message, 'err'); } }); } async function tplApply(id, type) { const t = (await api(`/api/templates/${id}`)).data; let extra = ''; if (type === 'task') extra = ` `; if (type === 'team') extra = ` `; const placeholders = Object.keys(t.content || {}).filter(k => typeof t.content[k] === 'string' && t.content[k].includes('{')).map(k => ``).join(''); openModal(`

使用模板:${esc(t.name)}

${esc(t.description)}
${extra}${placeholders} `); $('#ta-go').addEventListener('click', async () => { const variables = {}; Object.keys(t.content || {}).filter(k => typeof t.content[k] === 'string' && t.content[k].includes('{')).forEach(k => variables[k] = $(`#ta-var-${k}`)?.value || ''); const body = {variables, worker_id: $('#ta-worker')?.value ? Number($('#ta-worker').value) : null, project_id: $('#ta-project')?.value ? Number($('#ta-project').value) : null, provider: $('#ta-provider')?.value, model: $('#ta-model')?.value}; try { const r = await api(`/api/templates/${id}/apply`, {method: 'POST', body}); toast(r.kind === 'project' ? `已创建项目 #${r.id}(${r.task_ids.length} 个任务)` : r.kind === 'team' ? `已创建 ${r.ids.length} 个 Worker` : `已创建任务 #${r.id}`, 'ok'); closeModal(); if (r.kind === 'project') location.hash = `#/project/${r.id}/kanban`; else pageTemplates(); } catch (e) { toast(e.message, 'err'); } }); } async function tplDelete(id) { if (!confirm('确定删除该模板?')) return; await api(`/api/templates/${id}`, {method: 'DELETE'}); toast('已删除', 'ok'); pageTemplates(); } async function tplRestore() { await api('/api/templates/builtin/restore', {method: 'POST'}); toast('内置模板已就绪', 'ok'); pageTemplates(); } /* ---------- V2 · 企业版 ---------- */ let entTab = 'users'; async function pageEnterprise() { const [users, audit, sso, comp] = await Promise.all([ api('/api/enterprise/users'), api('/api/enterprise/audit?limit=100'), api('/api/enterprise/sso'), api('/api/enterprise/compliance') ]); $('#main').innerHTML = `

企业版私有化 · SSO · 审计 · 合规

👥 用户与权限 🔐 SSO 单点登录 📜 审计日志 ⚖️ 合规中心
`; window._entData = {users: users.data, audit: audit.data, sso: sso.data, comp: comp.data}; entRender(); } function entSwitch(tab) { entTab = tab; entRender(); } function entRender() { const d = window._entData || {}; const box = $('#ent-body'); if (entTab === 'users') { box.innerHTML = `
👥 用户列表(RBAC)
${(d.users || []).map(u => ` `).join('')}
用户名显示名角色来源状态最近登录操作
${esc(u.username)}${esc(u.display_name)} ${({admin:'👑 管理员',member:'成员',auditor:'审计员'})[u.role] || u.role} ${({local:'本地',oidc:'OIDC',ldap:'LDAP'})[u.source] || u.source} ${u.status === 'active' ? '✅ 启用' : '🚫 停用'} ${fmtTime(u.last_login_at)}
💡 角色说明:管理员=全部权限;成员=看板/任务操作;审计员=只读审计。V1 口令 admin123 兼容登录为 admin。
`; } else if (entTab === 'sso') { const s = d.sso || {}; box.innerHTML = `
🔐 OIDC(推荐:飞书/钉钉/企业微信/Okta)
🔑 LDAP / AD
连通性测试
SSO 登录入口
登录页可对接企业统一身份;私有化部署时 SSO 保证账号体系受企业管控。回调地址需与 IdP 侧配置一致。
`; $('#so-save').addEventListener('click', async () => { await api('/api/enterprise/sso', {method: 'PUT', body: { oidc_enabled: $('#so-oidc-on').checked ? '1' : '0', oidc_name: $('#so-oidc-name').value, oidc_discovery_url: $('#so-oidc-disc').value.trim(), oidc_client_id: $('#so-oidc-cid').value.trim(), oidc_client_secret: $('#so-oidc-secret').value.trim(), oidc_redirect_uri: $('#so-oidc-redirect').value.trim(), oidc_admin_group: $('#so-oidc-admingroup').value.trim(), sso_auto_provision: $('#so-oidc-auto').checked ? '1' : '0', ldap_enabled: $('#so-ldap-on').checked ? '1' : '0', ldap_url: $('#so-ldap-url').value.trim(), ldap_base_dn: $('#so-ldap-base').value.trim(), ldap_bind_dn: $('#so-ldap-bind').value.trim(), ldap_bind_password: $('#so-ldap-bindpwd').value.trim(), ldap_user_filter: $('#so-ldap-filter').value.trim() }}); toast('SSO 配置已保存', 'ok'); pageEnterprise(); }); $('#so-ldap-test').addEventListener('click', async () => { try { const r = await api('/api/enterprise/sso/ldap/test', {method: 'POST', body: {username: $('#so-ldap-testuser').value, password: $('#so-ldap-testpwd').value}}); toast('✅ LDAP 认证成功:' + (r.data.display || r.data.username), 'ok'); } catch (e) { toast(e.message, 'err'); } }); $('#so-oidc-login').addEventListener('click', async () => { try { const r = await api('/api/enterprise/sso/oidc/login', {method: 'POST'}); location.href = r.redirect; } catch (e) { toast(e.message, 'err'); } }); } else if (entTab === 'audit') { box.innerHTML = `
📜 审计日志
${(d.audit || []).map(a => ``).join('') || ''}
时间操作者动作目标详情IP
${fmtTime(a.created_at)}${esc(a.actor)}${esc(a.action)} ${esc(a.target)}${esc(a.detail)}${esc(a.ip)}
暂无审计记录
登录/登出、协作运行、评估、数据导出等关键操作自动留痕,满足等保审计要求。
`; } else { const c = d.comp || {}; box.innerHTML = `
🗄️ 数据保留策略
到期自动清理审计日志、协作运行、评估结果等历史数据。
🛡️ PII 脱敏
📤 数据导出(GDPR / 个人信息保护法)
导出包含项目/任务/协作运行/评估/用户/审计全量数据,供数据可携带权与监管审计使用。
📊 存量统计
审计记录
${c.audit_count} 条
协作运行
${c.agent_runs_count} 次
评估运行
${c.eval_runs_count} 次
数据使用同意凭证
${esc(c.consent_token || '—')}
`; $('#co-ret-save').addEventListener('click', async () => { await api('/api/enterprise/compliance', {method: 'POST', body: {retention_days: Number($('#co-ret').value), apply_retention: true}}); toast('已保存并清理', 'ok'); pageEnterprise(); }); $('#co-mask-save').addEventListener('click', async () => { await api('/api/enterprise/compliance', {method: 'POST', body: {mask_pii: $('#co-mask').checked}}); toast('已保存', 'ok'); pageEnterprise(); }); } } function entAddUser() { openModal(`

新建用户

`); $('#eu-save').addEventListener('click', async () => { try { await api('/api/enterprise/users', {method: 'POST', body: { username: $('#eu-name').value.trim(), display_name: $('#eu-display').value, role: $('#eu-role').value, source: $('#eu-source').value, password: $('#eu-pwd').value }}); toast('已创建', 'ok'); closeModal(); pageEnterprise(); } catch (e) { toast(e.message, 'err'); } }); } function entEditUser(id) { const u = (window._entData?.users || []).find(x => x.id === id); if (!u) return; openModal(`

编辑用户 ${esc(u.username)}

`); $('#eu2-save').addEventListener('click', async () => { await api(`/api/enterprise/users/${id}`, {method: 'PUT', body: { display_name: $('#eu2-display').value, role: $('#eu2-role').value, status: $('#eu2-status').value, password: $('#eu2-pwd').value }}); toast('已保存', 'ok'); closeModal(); pageEnterprise(); }); $('#eu2-del').addEventListener('click', async () => { if (!confirm('确定删除该用户?')) return; await api(`/api/enterprise/users/${id}`, {method: 'DELETE'}); toast('已删除', 'ok'); closeModal(); pageEnterprise(); }); } /* ---------- 启动 ---------- */ (async function init() { try { const me = await api('/api/me'); if (!me.authed) { showLogin(); $('#main').innerHTML = '
请先登录
'; return; } $('#logout-btn').style.display = 'block'; } catch (e) { showLogin(); return; } try { await api('/api/health'); } catch (e) { $('#conn-state').textContent = '● 服务异常'; $('#conn-state').className = 'conn err'; } refreshAlertBadge(); router(); })();