${escapeHTML(a.name)}
${escapeHTML(a.id)}${escapeHTML(a.description||'尚未填写职责说明')}
diff --git a/e2e/tests/06-extensions.spec.js b/e2e/tests/06-extensions.spec.js index e4e2cca..f558ce6 100644 --- a/e2e/tests/06-extensions.spec.js +++ b/e2e/tests/06-extensions.spec.js @@ -9,15 +9,15 @@ test('a web plugin adds a working page that survives a reload', async ({ page }) const nav = page.locator('#pluginNavGroup .nav[data-view="delivery-board"]'); await expect(page.locator('#pluginNavGroup')).toContainText('扩展'); - await expect(nav).toContainText('交付看板'); + await expect(nav).toContainText('大盘'); await nav.click(); const view = page.locator('#delivery-boardView'); await expect(view).toBeVisible(); - await expect(page.locator('#pageTitle')).toHaveText('交付看板'); + await expect(page.locator('#pageTitle')).toHaveText('大盘'); await expect(view).toContainText('次运行'); - await expect(view.locator('.delivery-board-tile')).toHaveCount(5); - expect(await view.locator('.delivery-board-tiles').evaluate(el => getComputedStyle(el).display)).toBe('grid'); + await expect(view.locator('.db-col')).toHaveCount(5); + expect(await view.locator('.db-board').evaluate(el => getComputedStyle(el).display)).toBe('grid'); await page.reload(); await expect(view).toBeVisible(); @@ -57,7 +57,7 @@ test('custom MCP market entries are listed and searchable', async ({ page }) => const market = await page.evaluate(() => fetch('/api/mcp-market?q=kb').then(r => r.json())); expect(market.items.map(i => i.id)).toContain('team-knowledge'); - await page.locator('#capabilityNavToggle').click(); + if (await page.locator('[data-nav-group="config"]').getAttribute('aria-expanded') !== 'true') await page.locator('[data-nav-group="config"]').click(); await page.locator('.nav[data-view="mcp"]').click(); await page.locator('#mcpMarketSearch').fill('Team knowledge'); const card = page.locator('.mcp-market-card').filter({ hasText: 'Team knowledge base (example)' }); diff --git a/examples/extensions/plugins/delivery-board.css b/examples/extensions/plugins/delivery-board.css index ed20616..ca53c08 100644 --- a/examples/extensions/plugins/delivery-board.css +++ b/examples/extensions/plugins/delivery-board.css @@ -1,7 +1,58 @@ -.delivery-board { padding: 24px; } -.delivery-board h2 { margin: 0 0 16px; font-size: 18px; } -.delivery-board-tiles { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; margin-bottom: 20px; } -.delivery-board-tile { border: 1px solid var(--line, #e5e7eb); border-radius: 10px; padding: 14px; background: var(--panel, #fff); } -.delivery-board-tile b { display: block; font-size: 24px; } -.delivery-board-tile span { color: var(--muted, #6b7280); font-size: 13px; } -.delivery-board-recent { margin: 0; padding-left: 18px; line-height: 1.9; } +.db { --db-run: var(--lf-brand, #7c6cff); --db-wait: var(--lf-wait, #f5a524); --db-ok: var(--lf-ok, #22c55e); --db-bad: var(--lf-bad, #f04f5a); --db-mute: var(--lf-text-2, #8b8fa3); + padding: 28px 32px 40px; display: flex; flex-direction: column; gap: 20px; min-height: 100%; } +.db-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; } +.db-eyebrow { margin: 0 0 6px; font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--lf-text-2, #8b8fa3); } +.db-head h1 { margin: 0; font-size: 30px; font-weight: 750; letter-spacing: -.02em; background: var(--lf-title-grad); -webkit-background-clip: text; background-clip: text; color: transparent; } +.db-sub { margin: 6px 0 0; color: var(--lf-text-2, #8b8fa3); font-size: 13.5px; } +.db-trend-box { display: flex; flex-direction: column; gap: 6px; padding: 12px 14px; min-width: 240px; border: 1px solid var(--lf-line, #26272f); border-radius: 14px; background: var(--lf-surface, #111217); } +.db-trend-box > span { font-size: 11.5px; color: var(--lf-text-2, #8b8fa3); } +.db-trend { width: 100%; height: 50px; } +.db-trend .ok { fill: var(--db-ok); } +.db-trend .other { fill: var(--db-run); opacity: .75; } +.db-trend .base { fill: var(--lf-line, #26272f); } + +.db-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; } +.db-stat { position: relative; overflow: hidden; padding: 16px 18px; border: 1px solid var(--lf-line, #26272f); border-radius: 16px; background: var(--lf-surface, #111217); display: flex; flex-direction: column; gap: 4px; } +.db-stat::after { content: ""; position: absolute; inset: 0 0 auto 0; height: 1px; background: linear-gradient(90deg, transparent, rgba(255,255,255,.18), transparent); } +.db-stat span { font-size: 12px; color: var(--lf-text-2, #8b8fa3); } +.db-stat b { font-size: 30px; font-weight: 750; letter-spacing: -.02em; font-variant-numeric: tabular-nums; } +.db-stat small { font-size: 11.5px; color: var(--lf-text-3, #6b6f80); } +.db-stat.is-ok b { color: var(--db-ok); } +.db-stat.is-wait { border-color: color-mix(in srgb, var(--db-wait) 45%, transparent); background: linear-gradient(160deg, color-mix(in srgb, var(--db-wait) 14%, transparent), var(--lf-surface, #111217) 60%); } +.db-stat.is-wait b { color: var(--db-wait); } + +.db-board { display: grid; grid-template-columns: repeat(5, minmax(220px, 1fr)); gap: 14px; align-items: start; overflow-x: auto; padding-bottom: 6px; } +.db-col { border-radius: 16px; background: color-mix(in srgb, var(--lf-surface, #111217) 70%, transparent); border: 1px solid var(--lf-line, #26272f); min-height: 260px; display: flex; flex-direction: column; } +.db-col > header { display: flex; align-items: center; gap: 8px; padding: 12px 14px; font-size: 13px; font-weight: 650; border-bottom: 1px solid var(--lf-line, #26272f); } +.db-col > header i { width: 8px; height: 8px; border-radius: 50%; background: var(--tone); box-shadow: 0 0 10px var(--tone); } +.db-col > header em { margin-left: auto; font-style: normal; font-size: 11.5px; padding: 1px 8px; border-radius: 99px; background: var(--lf-surface-3, #1d1f28); color: var(--lf-text-2, #8b8fa3); } +.db-col.tone-run { --tone: var(--db-run); } .db-col.tone-wait { --tone: var(--db-wait); } .db-col.tone-ok { --tone: var(--db-ok); } .db-col.tone-bad { --tone: var(--db-bad); } .db-col.tone-mute { --tone: var(--db-mute); } +.db-col.tone-wait { border-color: color-mix(in srgb, var(--db-wait) 30%, var(--lf-line, #26272f)); } +.db-col-body { padding: 10px; display: flex; flex-direction: column; gap: 10px; } +.db-col-empty { padding: 26px 8px; text-align: center; font-size: 12px; color: var(--lf-text-3, #6b6f80); border: 1px dashed var(--lf-line, #26272f); border-radius: 12px; } + +.db-card { all: unset; box-sizing: border-box; cursor: pointer; display: flex; flex-direction: column; gap: 10px; padding: 12px 13px; border-radius: 12px; border: 1px solid var(--lf-line, #26272f); background: var(--lf-surface-2, #16171e); transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease; position: relative; } +.db-card:hover { transform: translateY(-2px); border-color: color-mix(in srgb, var(--tone) 60%, transparent); box-shadow: 0 10px 30px -12px color-mix(in srgb, var(--tone) 55%, transparent); } +.db-card:focus-visible { outline: 2px solid var(--tone); outline-offset: 2px; } +.db-card.is-waiting::before { content: ""; position: absolute; inset: -1px; border-radius: 12px; border: 1px solid var(--db-wait); opacity: .6; animation: db-pulse 2.2s ease-in-out infinite; pointer-events: none; } +.db-card-top { display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--lf-text-2, #8b8fa3); } +.db-key { font-family: var(--mono, ui-monospace, monospace); padding: 1px 6px; border-radius: 6px; background: var(--lf-surface-3, #1d1f28); color: var(--lf-text, #e8e8ef); } +.db-sop { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.db-title { font-size: 13.5px; font-weight: 650; line-height: 1.4; color: var(--lf-text, #e8e8ef); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.db-steps { display: flex; gap: 3px; } +.db-steps i { flex: 1; height: 4px; border-radius: 4px; background: var(--lf-surface-3, #1d1f28); } +.db-steps i.is-succeeded { background: var(--db-ok); } +.db-steps i.is-running { background: var(--db-run); animation: db-pulse 1.4s ease-in-out infinite; } +.db-steps i.is-waiting, .db-steps i.is-waiting_approval { background: var(--db-wait); animation: db-pulse 1.8s ease-in-out infinite; } +.db-steps i.is-failed { background: var(--db-bad); } +.db-card-foot { display: flex; justify-content: space-between; gap: 8px; font-size: 11px; color: var(--lf-text-2, #8b8fa3); } +.db-card-foot em { font-style: normal; color: var(--tone); font-weight: 600; } + +.db-loading { display: flex; gap: 6px; justify-content: center; padding: 80px 0; } +.db-loading span { width: 8px; height: 8px; border-radius: 50%; background: var(--db-run); animation: db-pulse 1s ease-in-out infinite; } +.db-loading span:nth-child(2) { animation-delay: .15s; } .db-loading span:nth-child(3) { animation-delay: .3s; } +.db-empty { padding: 60px; text-align: center; color: var(--lf-text-2, #8b8fa3); } +@keyframes db-pulse { 50% { opacity: .35; } } +@media (max-width: 1100px) { .db-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } .db-head { flex-direction: column; align-items: stretch; } } +@media (max-width: 760px) { .db { padding: 18px 14px 90px; } .db-board { grid-template-columns: repeat(5, 78vw); scroll-snap-type: x mandatory; } .db-col { scroll-snap-align: start; } } +@media (prefers-reduced-motion: reduce) { .db-card.is-waiting::before, .db-steps i, .db-loading span { animation: none; } } diff --git a/examples/extensions/plugins/delivery-board.js b/examples/extensions/plugins/delivery-board.js index 13e1364..bceb05a 100644 --- a/examples/extensions/plugins/delivery-board.js +++ b/examples/extensions/plugins/delivery-board.js @@ -1,35 +1,127 @@ -// Example web plugin: a "Delivery board" page that summarizes the current -// project's runs. Load it with LOOPFORGE_WEB_PLUGINS=examples/extensions/plugins. +// Example web plugin: a "Delivery board" page that lays the current project's +// runs out as a kanban. Load it with LOOPFORGE_WEB_PLUGINS=examples/extensions/plugins. (function () { const { registerView, api, escapeHTML } = window.LoopForge; - const labels = { running: '运行中', waiting_approval: '等待确认', succeeded: '成功', failed: '失败', cancelled: '已取消' }; + const en = () => document.documentElement.dataset.lang === 'en'; + const L = (zh, enText) => (en() ? enText : zh); + const columns = [ + { id: 'active', zh: '进行中', en: 'In progress', statuses: ['queued', 'running', 'paused'], tone: 'run' }, + { id: 'waiting', zh: '等你确认', en: 'Needs you', statuses: ['waiting', 'waiting_approval'], tone: 'wait' }, + { id: 'done', zh: '已交付', en: 'Delivered', statuses: ['succeeded'], tone: 'ok' }, + { id: 'failed', zh: '失败', en: 'Failed', statuses: ['failed'], tone: 'bad' }, + { id: 'cancelled', zh: '已取消', en: 'Cancelled', statuses: ['cancelled'], tone: 'mute' }, + ]; + const DAY = 86400000; + + function runTitle(run) { + const line = String(run.input || '').split('\n').map(s => s.trim()).find(Boolean); + return line || run.workflowSnapshot?.name || run.workflowId; + } + function fmtDuration(ms) { + if (!ms || ms < 0) return '—'; + const s = Math.round(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.round(s / 60); + if (m < 60) return `${m}m`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ${m % 60}m`; + return `${Math.floor(h / 24)}d ${h % 24}h`; + } + function ago(ts) { + const diff = Date.now() - new Date(ts).getTime(); + if (!isFinite(diff)) return ''; + if (diff < 60000) return L('刚刚', 'just now'); + return L(`${fmtDuration(diff)}前`, `${fmtDuration(diff)} ago`); + } + let workflowNodes = new Map(); + function steps(run) { + const defs = run.workflowSnapshot?.nodes || workflowNodes.get(run.workflowId) || []; + const states = run.nodes || {}; + return defs.map(n => ({ name: n.name || n.id, status: states[n.id]?.status || 'pending' })); + } + function stepStrip(list) { + return `
${escapeHTML(run.id)} ${escapeHTML(run.workflowSnapshot?.name || run.workflowId)} · ${escapeHTML(labels[run.status] || run.status)}${escapeHTML(ctx.project?.name || L('全部项目', 'All projects'))}
${L('每一次 SOP 运行都是一张卡:从开跑、等你拍板,到交付。', 'Every SOP run is a card — from kickoff, through your sign-off, to delivery.')}
外部一次决策
${escapeHTML(a.id)}${escapeHTML(a.description||'尚未填写职责说明')}
调整搜索,或新建一个角色。
${escapeHTML(a.description||'尚未填写职责说明')}
调整搜索,或新建一个角色。
${escapeHTML(nr?.error||'请确认是否重试本节点。不重试则本节点失败。')}
流程停在人工确认节点时会出现在这里,侧边栏「运行」上也会亮起角标。
${escapeHTML(a.description||'暂无说明')}
${escapeHTML(a.description||'暂无说明')}
${shareHint}
${assetEmptyHint()}
${escapeHTML(a.id)}`}还没配置 CodeBuddy Key,我暂时没法读代码、查数据、追问需求。,或(不需要 Key)。
`; - const cards = workflows.map(w => { - const steps = (w.nodes || []).length; - const tag = homeChatWorkflowTag(w); - return ``; - }).join(''); + const user = state.currentUser || {}; + const keyHint = homeChatHasModelKey() ? '' : `还没配置模型 Key · 或
`; return `提需求、问进度、处理待确认、画图出文档,都在这里说。需求说清了,我按 SOP 帮你开跑。
- ${homeChatComposerHTML()} +${escapeHTML(homeGreeting())},${escapeHTML(user.displayName || user.name || user.loginName || '')}
+这个项目还没有 SOP。
'} +✦ ${escapeHTML(l.modeReason)}
` : ''}`; + const rooms = crew ? homeCrewRooms() : []; + const target = crew + ? `` + : ``; + return `已交给 #${escapeHTML(m.roomName || '协作群')} 的 Bot 团队:${escapeHTML(m.title || '')}。队长正在排分工,每个 Bot 做完会在群里交接,卡住了会 @ 你。
+已按「${escapeHTML(m.workflowName || m.workflowId || 'SOP')}」开跑:${escapeHTML(m.title || '')}。进度在右侧,需要你确认时会停下来等你。
${escapeHTML(failed.error.split('\n')[0].slice(0, 120))}
` : ''} - ${status.key === 'waiting' ? '' : ''} + ${status.key === 'waiting' ? homeRunDecisionHTML(run) : ''} `; } +function homeRunDecisionHTML(run) { + const nodeId = Object.keys(run.nodes || {}).find(id => run.nodes[id]?.status === 'waiting_approval') || ''; + const gate = run.nodes?.[nodeId]?.gateResult; + const why = String(gate?.actionRequired || gate?.reason || '').split('\n')[0].slice(0, 90); + const quick = nodeId && !run.pauseRequested && !(typeof turnsExhausted === 'function' && turnsExhausted(run.nodes[nodeId])); + return `${why ? `${escapeHTML(why)}
` : ''}