From d4fbb852a55fd7afcaee23f0612f2794b315f454 Mon Sep 17 00:00:00 2001 From: roberttwang Date: Wed, 14 Oct 2026 11:17:05 +0800 Subject: [PATCH] web: redesign the workspace and let room bots work as a crew MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The home chat acts as the PM: once a requirement is clear it offers to hand the work to an SOP (fixed process, gated steps) or to a group-chat bot crew (open-ended work). The SOP picker now lists the platform templates too. Rooms follow the group-chat bot model. "@LoopForge" answers a question from the room's context or starts a crew mission; the captain splits the work across role bots, hands each bot's output to the next one, and reports in the room. People steer by talking: replying to a bot or "@某个 Bot" adds a step, replying to a mission adds a requirement, any message unblocks a stuck step, and plain chatter is left alone. Messages can quote an earlier one, and a pending bot reply shows as typing. A finished crew can be saved as a draft SOP. Each step runs as a hidden one-node workflow through the normal dispatch path, so runs, logs and approvals work unchanged; the issue is reopened between steps because each successful run closes delivery. The flow canvas pans by dragging blank space (or shift+wheel) and leaves room for the floating properties panel. Sidebar, home, flow designer, role studio, assets and rooms share one design layer (design.css) with zh/en. UI structure tests follow the new hubs (交付/构建/洞察/平台/设置). The Playwright suite still targets the old layout and is not updated yet. --- e2e/tests/06-extensions.spec.js | 10 +- .../extensions/plugins/delivery-board.css | 65 +- examples/extensions/plugins/delivery-board.js | 130 +- internal/domain/crew.go | 71 + internal/domain/room.go | 14 + internal/httpapi/assistant.go | 4 + internal/httpapi/assistant_home.go | 7 + internal/httpapi/dispatch.go | 5 + internal/httpapi/room_ask.go | 154 ++ internal/httpapi/room_crew.go | 928 ++++++++++++ internal/httpapi/room_crew_test.go | 56 + internal/httpapi/rooms.go | 21 +- internal/httpapi/server.go | 8 + internal/httpapi/share_gate.go | 27 +- internal/repo/catalog.go | 3 +- internal/repo/rooms.go | 33 + web/app.js | 94 +- web/app/access.js | 14 +- web/app/agents.js | 88 +- web/app/approvals.js | 48 +- web/app/assets.js | 17 +- web/app/flows.js | 23 +- web/app/homechat.js | 213 ++- web/app/issues.js | 23 +- web/app/onboarding.js | 8 +- web/app/runs.js | 35 +- web/app/sops.js | 1 + web/app/workbench.js | 194 +-- web/boot.js | 1 + web/design.css | 1335 +++++++++++++++++ web/embed.go | 2 +- web/homechat_test.js | 2 +- web/i18n-en.js | 11 + web/i18n.js | 115 ++ web/index.html | 206 +-- web/onboarding_test.js | 4 +- web/rooms.js | 989 ++++++++++-- web/syntax_test.go | 33 +- 38 files changed, 4507 insertions(+), 485 deletions(-) create mode 100644 internal/domain/crew.go create mode 100644 internal/httpapi/room_ask.go create mode 100644 internal/httpapi/room_crew.go create mode 100644 internal/httpapi/room_crew_test.go create mode 100644 web/boot.js create mode 100644 web/design.css create mode 100644 web/i18n-en.js create mode 100644 web/i18n.js 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 `
${list.map(s => ``).join('')}
`; + } + function card(run) { + const list = steps(run); + const done = list.filter(s => s.status === 'succeeded').length; + const current = list.find(s => ['running', 'waiting', 'failed'].includes(s.status)); + const num = run.executionContext?.issueNumber; + const wall = run.timing?.wallMs || (new Date(run.updatedAt) - new Date(run.createdAt)); + return ``; + } + function trend(runs) { + const today = new Date(); today.setHours(0, 0, 0, 0); + const days = Array.from({ length: 14 }, (_, i) => ({ t: today.getTime() - (13 - i) * DAY, ok: 0, other: 0 })); + for (const run of runs) { + const t = new Date(run.createdAt).getTime(); + const d = days.find(x => t >= x.t && t < x.t + DAY); + if (d) run.status === 'succeeded' ? d.ok++ : d.other++; + } + const max = Math.max(1, ...days.map(d => d.ok + d.other)); + const bars = days.map((d, i) => { + const x = i * 14 + 2, hOk = (d.ok / max) * 44, hOther = (d.other / max) * 44; + return ``; + }).join(''); + return `${bars}`; + } registerView({ id: 'delivery-board', - title: '交付看板', + title: '大盘', icon: '▦', async render(el, ctx) { - el.innerHTML = '
加载中…
'; + el.innerHTML = `
`; const query = ctx.project ? `?projectId=${encodeURIComponent(ctx.project.id)}` : ''; let runs; try { - runs = await api(`/api/runs${query}`, { silent: true }); + const [list, workflows] = await Promise.all([ + api(`/api/runs${query}`, { silent: true }), + api('/api/workflows', { silent: true }).catch(() => []), + ]); + runs = list; + workflowNodes = new Map((workflows || []).map(w => [w.id, w.nodes || []])); } catch (e) { - el.innerHTML = `
加载失败:${escapeHTML(e.message)}
`; + el.innerHTML = `
${L('加载失败', 'Failed to load')}:${escapeHTML(e.message)}
`; return; } - const counts = {}; - for (const run of runs) counts[run.status] = (counts[run.status] || 0) + 1; - const tiles = Object.entries(labels).map(([status, label]) => - `
${counts[status] || 0}${label}
`).join(''); - const recent = runs.slice(0, 10).map(run => - `
  • ${escapeHTML(run.id)} ${escapeHTML(run.workflowSnapshot?.name || run.workflowId)} · ${escapeHTML(labels[run.status] || run.status)}
  • `).join(''); - el.innerHTML = ` -
    -

    ${escapeHTML(ctx.project?.name || '全部项目')} · 共 ${runs.length} 次运行

    -
    ${tiles}
    -
      ${recent || '
    • 还没有运行记录
    • '}
    -
    `; + const week = runs.filter(r => Date.now() - new Date(r.createdAt) < 7 * DAY); + const delivered = week.filter(r => r.status === 'succeeded'); + const finished = runs.filter(r => ['succeeded', 'failed'].includes(r.status)); + const rate = finished.length ? Math.round((finished.filter(r => r.status === 'succeeded').length / finished.length) * 100) : null; + const lead = delivered.length ? delivered.reduce((s, r) => s + (r.timing?.wallMs || 0), 0) / delivered.length : 0; + const waiting = runs.filter(r => r.status === 'waiting'); + const waitMs = waiting.reduce((s, r) => s + (r.timing?.waitingMs || 0), 0); + const stat = (label, value, sub, tone = '') => `
    ${label}${value}${sub}
    `; + const cols = columns.map(col => { + const items = runs.filter(r => col.statuses.includes(r.status)); + return `
    +
    ${L(col.zh, col.en)}${items.length}
    +
    ${items.map(card).join('') || `
    ${col.id === 'waiting' ? L('没有卡在你这里的事', 'Nothing waiting on you') : '—'}
    `}
    +
    `; + }).join(''); + el.innerHTML = `
    +
    +

    ${escapeHTML(ctx.project?.name || L('全部项目', 'All projects'))}

    ${L('交付看板', 'Delivery board')}

    ${L('每一次 SOP 运行都是一张卡:从开跑、等你拍板,到交付。', 'Every SOP run is a card — from kickoff, through your sign-off, to delivery.')}

    +
    ${L('近 14 天运行', 'Runs · last 14 days')}${trend(runs)}
    +
    +
    + ${stat(L('本周交付', 'Delivered this week'), delivered.length, L(`共 ${week.length} 次运行`, `${week.length} runs total`), 'is-ok')} + ${stat(L('成功率', 'Success rate'), rate === null ? '—' : `${rate}%`, L(`${finished.length} 次已结束`, `${finished.length} finished`))} + ${stat(L('平均交付时长', 'Avg lead time'), fmtDuration(lead), L('开跑 → 交付', 'kickoff → delivered'))} + ${stat(L('等你确认', 'Waiting on you'), waiting.length, waiting.length ? L(`累计等待 ${fmtDuration(waitMs)}`, `${fmtDuration(waitMs)} waiting`) : L('全部清空', 'All clear'), waiting.length ? 'is-wait' : '')} +
    +
    ${cols}
    +
    `; + el.querySelectorAll('[data-run]').forEach(btn => btn.addEventListener('click', () => { location.hash = `#/runs/${btn.dataset.run}`; })); }, }); })(); diff --git a/internal/domain/crew.go b/internal/domain/crew.go new file mode 100644 index 0000000..a4544e2 --- /dev/null +++ b/internal/domain/crew.go @@ -0,0 +1,71 @@ +package domain + +import "time" + +// WorkflowStatusCrew marks the hidden single-role workflow behind one crew +// step. Catalog listings skip it. +const WorkflowStatusCrew = "crew" + +// CrewMission is open-ended work handed to the bots of a room instead of a +// fixed SOP. The LoopForge captain plans which roles take which steps; every +// step runs as its own single-role run, reports back in the room and hands +// its result to the next bot. People steer it by talking in the room. +type CrewMission struct { + ID string `json:"id"` + Goal string `json:"goal"` + Detail string `json:"detail,omitempty"` + // Status: planning, proposed, running, waiting, done, failed, stopped. + Status string `json:"status"` + Planner string `json:"planner,omitempty"` // ai, rules + IssueID string `json:"issueId,omitempty"` + IssueNumber int `json:"issueNumber,omitempty"` + Tasks []CrewTask `json:"tasks"` + Notes []CrewNote `json:"notes,omitempty"` + Question string `json:"question,omitempty"` + QuestionAgent string `json:"questionAgent,omitempty"` + AutoStart bool `json:"autoStart,omitempty"` + StartedBy int64 `json:"startedBy,omitempty"` + StartedByName string `json:"startedByName,omitempty"` + Source string `json:"source,omitempty"` // room, home + SavedWorkflow string `json:"savedWorkflowId,omitempty"` + Error string `json:"error,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// CrewTask is one bot's step. Status: pending, running, succeeded, failed, +// skipped. +type CrewTask struct { + ID string `json:"id"` + AgentID string `json:"agentId"` + AgentName string `json:"agentName"` + Title string `json:"title"` + Instruction string `json:"instruction,omitempty"` + Status string `json:"status"` + AddedBy string `json:"addedBy,omitempty"` + WorkflowID string `json:"workflowId,omitempty"` + RunID string `json:"runId,omitempty"` + Summary string `json:"summary,omitempty"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` + StartedAt time.Time `json:"startedAt,omitempty"` + FinishedAt time.Time `json:"finishedAt,omitempty"` +} + +// CrewNote is something a person said in the room while the mission ran; +// the next bot to start receives every note not yet handed over. +type CrewNote struct { + Author string `json:"author"` + Body string `json:"body"` + At time.Time `json:"at"` + Consumed bool `json:"consumed,omitempty"` +} + +// CrewMissionActive reports whether the captain still has work to drive. +func CrewMissionActive(status string) bool { + switch status { + case "planning", "proposed", "running", "waiting": + return true + } + return false +} diff --git a/internal/domain/room.go b/internal/domain/room.go index 14f2a25..eb75646 100644 --- a/internal/domain/room.go +++ b/internal/domain/room.go @@ -77,6 +77,18 @@ type ChatMessage struct { // CanReject is view-only: the waiting step has somewhere to send work back. CanReject bool `json:"canReject,omitempty"` + // Crew is the mission state on a "crew" message; CrewID ties a + // "crew_post" (a bot or the captain speaking) back to its mission. + Crew *CrewMission `json:"crew,omitempty"` + CrewID string `json:"crewId,omitempty"` + // ReplyTo quotes an earlier message; author and text are copied so the + // quote survives the original being trimmed out of the window. + ReplyTo string `json:"replyTo,omitempty"` + ReplyAuthor string `json:"replyAuthor,omitempty"` + ReplyText string `json:"replyText,omitempty"` + // Pending marks a bot reply that is still being written. + Pending bool `json:"pending,omitempty"` + CreatedAt time.Time `json:"createdAt"` } @@ -87,4 +99,6 @@ const ( ChatKindProgress = "progress" ChatKindApproval = "approval" ChatKindDecision = "decision" + ChatKindCrew = "crew" + ChatKindCrewPost = "crew_post" ) diff --git a/internal/httpapi/assistant.go b/internal/httpapi/assistant.go index f10bc39..6a69aa2 100644 --- a/internal/httpapi/assistant.go +++ b/internal/httpapi/assistant.go @@ -50,6 +50,10 @@ type assistantLaunch struct { Description string `json:"description,omitempty"` WorkflowID string `json:"workflowId,omitempty"` IssueID string `json:"issueId,omitempty"` + // Mode is the PM's pick: "sop" runs a fixed SOP, "crew" hands open-ended + // work to the room's bots. The user can switch it on the card. + Mode string `json:"mode,omitempty"` + ModeReason string `json:"modeReason,omitempty"` } type assistantRequest struct { ProjectID string `json:"projectId"` diff --git a/internal/httpapi/assistant_home.go b/internal/httpapi/assistant_home.go index d0d64ba..b6d1798 100644 --- a/internal/httpapi/assistant_home.go +++ b/internal/httpapi/assistant_home.go @@ -15,6 +15,7 @@ const assistantSceneHome = "home" const assistantHomeScenePrompt = `你现在是这个项目的 PM 助理,在平台首页和用户对话。用户在这里提需求、问进度、处理待确认,也让你帮忙画图、出文档。 - 用户提出新需求或改动时,先弄清楚再开跑:用只读工具查已有需求(避免重复)和代码(有 GitHub 工具时读相关文件),把用户必须拍板的点放进 questions(每轮最多 3 个,选择题给 2~4 个具体选项)。message 里先用一两句话说明你查到了什么、为什么要问。 - 需求说清后提供 launch:新需求给 title 和 description(description 写完整的澄清结论:目标、范围、验收标准、涉及文件),workflowId 从「可用 SOP」里选最合适的。不要用 drafts 去建需求或派发,开跑卡片由用户确认。 +- launch 还要给 mode,决定交给谁做:有严格流程、并且「可用 SOP」里有对得上的(标准交付、修 bug、发布等),mode 填 "sop" 并给 workflowId;没有固定流程的开放任务(调研、排查、出方案、做原型、跨角色临时协作),或者没有对得上的 SOP,mode 填 "crew",交给协作群里的 Bot 团队自由协作,不用给 workflowId。用 modeReason 一句话说明为什么这样选。 - 用户 @ 了已有需求时,围绕那条需求回答;要跑它就在 launch 里填 issueId。 - 问进度、问运行结果时,用 runs_list / runs_get / issues_get 查真实数据后回答,说清卡在哪一步、需要用户做什么。 - 用户要图(流程图、架构图、时序图)时,在 files 里给 .svg 文件(完整 SVG 源码,中文用 sans-serif 字体,尺寸不超过 1200x900),或者在 message 里写 mermaid 代码块;要文档、表格时在 files 里给 .md / .csv。 @@ -169,6 +170,12 @@ func (h *assistantHandlers) resolveAssistantLaunch(projectID string, launch *ass launch.WorkflowID = "" } } + if launch.Mode != "crew" && launch.Mode != "sop" { + launch.Mode = "sop" + if launch.WorkflowID == "" && launch.IssueID == "" { + launch.Mode = "crew" + } + } if utf8.RuneCountInString(launch.Title) > 200 { launch.Title = string([]rune(launch.Title)[:200]) } diff --git a/internal/httpapi/dispatch.go b/internal/httpapi/dispatch.go index e961d6d..b5fe95c 100644 --- a/internal/httpapi/dispatch.go +++ b/internal/httpapi/dispatch.go @@ -15,6 +15,8 @@ type dispatchRequest struct { IssueID, WorkflowID, ScheduleJobID, TriggerOrigin string Rerun bool TriggerUserID int64 + // ExtraInput is appended to the issue input, e.g. a crew step's brief. + ExtraInput string } func dispatchIssue(d *deps, req dispatchRequest) (domain.Task, error) { @@ -166,6 +168,9 @@ func dispatchIssue(d *deps, req dispatchRequest) (domain.Task, error) { } input += "\n- SOP deliverables (fix-plan.md and other reports not tied to one repository) go to the workspace root, never inside a repository directory\n- do not write to any host absolute path" } + if extra := strings.TrimSpace(req.ExtraInput); extra != "" { + input += "\n\n" + extra + } input = appendSOPHandoff(issue, project, workflowID, input) taskID, dispatchMode := primaryTaskID(issue.ID, workflowID), "primary" if scheduled { diff --git a/internal/httpapi/room_ask.go b/internal/httpapi/room_ask.go new file mode 100644 index 0000000..646eae3 --- /dev/null +++ b/internal/httpapi/room_ask.go @@ -0,0 +1,154 @@ +package httpapi + +import ( + "context" + "fmt" + "regexp" + "strings" + "time" + + "github.com/Tencent/LoopForge/internal/domain" +) + +var ( + crewStatusAsk = regexp.MustCompile(`进度|状态|做到哪|卡在哪|卡住|在跑什么|谁在做|做完了吗|好了吗`) + crewTaskVerb = regexp.MustCompile(`实现|开发|修复|修一下|修掉|写一|写个|加一|加个|新增|改成|改一下|重构|调研|整理|出一份|出个|设计一|搭一|部署|上线|补一|做一|做个|帮我做|跑一下|排查`) + crewAskWord = regexp.MustCompile(`(?s)^(什么|怎么|为什么|如何|是否|能不能|能否|可不可以|谁|哪|多少|有没有|解释|总结|概括|翻译|介绍|说说|讲讲|你好|嗨|hi|hello)|[??吗呢]\s*$`) +) + +// crewWantsAnswer tells a question for the captain apart from work for the +// team. Status questions always get an answer; anything that reads like a +// task goes to the team even when it ends with a question mark. +func crewWantsAnswer(text string) bool { + t := strings.ToLower(strings.TrimSpace(text)) + if t == "" { + return false + } + if crewStatusAsk.MatchString(t) { + return true + } + if crewTaskVerb.MatchString(t) { + return false + } + return crewAskWord.MatchString(t) || len([]rune(t)) <= 6 +} + +func roomQuoteAuthor(m domain.ChatMessage) string { + if m.Kind == domain.ChatKindCrewPost && m.AgentID == "" { + return crewCaptain + } + name := strings.TrimSpace(m.Author) + if i := strings.Index(name, "("); i > 0 && strings.HasSuffix(name, ")") { + name = name[i+1 : len(name)-1] + } + return strings.TrimPrefix(name, "演示 · ") +} + +func roomQuoteText(m domain.ChatMessage) string { + switch { + case m.Kind == domain.ChatKindCrew && m.Crew != nil: + return "@LoopForge " + m.Crew.Goal + case m.Kind == "dispatch": + return fmt.Sprintf("派活:%s → %s", firstNonEmpty(m.IssueTitle, m.Body), firstNonEmpty(m.WorkflowName, m.TargetName)) + } + return m.Body +} + +// answerInRoom posts a pending captain reply right away so everyone sees the +// bot is on it, then fills it in. +func (h *roomsHandlers) answerInRoom(room domain.ChatRoom, said domain.ChatMessage, question string) { + reply := domain.ChatMessage{ + ID: newChatID("msg"), RoomID: room.ID, Author: crewCaptain, Kind: domain.ChatKindCrewPost, Pending: true, + ReplyTo: said.ID, ReplyAuthor: roomQuoteAuthor(said), ReplyText: roomTrim(said.Body, 200), CreatedAt: time.Now().UTC(), + } + if err := h.repo.PutMessage(reply); err != nil { + return + } + go func() { + reply.Body = h.crewAnswer(room, said, question) + reply.Pending = false + _ = h.repo.PutMessage(reply) + }() +} + +func (h *roomsHandlers) crewAnswer(room domain.ChatRoom, said domain.ChatMessage, question string) string { + status := h.roomStatusDigest(room) + user, ok := h.repo.User(said.UserID) + if !ok || strings.TrimSpace(user.CodeBuddyAPIKey) == "" { + if crewStatusAsk.MatchString(question) { + return status + } + return "直接回答问题要用模型,你还没配 Key:去「设置 · 个人设置」配好就行。\n现在我能做的:\n- 「@LoopForge + 要做的事」我来拉 Bot 组队干活\n- 问「进度」我汇报群里的任务\n\n" + status + } + var history []string + for _, m := range h.repo.Messages(room.ID, 40) { + if m.ID == said.ID || m.Pending { + continue + } + if text := strings.TrimSpace(roomQuoteText(m)); text != "" { + history = append(history, roomQuoteAuthor(m)+":"+roomTrim(text, 240)) + } + } + quote := "无" + if said.ReplyTo != "" { + quote = said.ReplyAuthor + ":" + said.ReplyText + } + prompt := fmt.Sprintf(crewAnswerPrompt, status, strings.Join(history, "\n"), quote, roomQuoteAuthor(said), question) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + raw, err := generateAssistantReply(ctx, prompt, user) + if text := strings.TrimSpace(raw); err == nil && text != "" { + return roomTrim(text, 2000) + } + return "这次没答上来(模型没有返回)。可以稍后再问,或者「@LoopForge + 要做的事」让 Bot 团队去做。" +} + +// roomStatusDigest is the captain's report on work started in this room. +func (h *roomsHandlers) roomStatusDigest(room domain.ChatRoom) string { + label := map[string]string{"planning": "在排分工", "proposed": "等你点开工", "running": "在做", "waiting": "卡住了,等人回复", "done": "做完了", "failed": "没做成", "stopped": "已停止"} + var lines []string + missions := h.repo.CrewMessages(room.ID) + for i := len(missions) - 1; i >= 0 && len(lines) < 5; i-- { + m := missions[i].Crew + done := 0 + current := "" + for _, t := range m.Tasks { + switch t.Status { + case "succeeded", "skipped": + done++ + case "running": + current = "," + t.AgentName + " 正在「" + t.Title + "」" + } + } + line := fmt.Sprintf("- #%d %s:%s(%d/%d 步%s)", m.IssueNumber, roomTrim(m.Goal, 40), firstNonEmpty(label[m.Status], m.Status), done, len(m.Tasks), current) + if m.Status == "waiting" && m.Question != "" { + line += "\n 问题:" + roomTrim(m.Question, 80) + } + lines = append(lines, line) + } + for _, m := range h.repo.Messages(room.ID, 200) { + if m.Kind == "dispatch" && m.RunID != "" && m.RunFinal == "" && !roomRunTerminal(m.RunStatus) { + lines = append(lines, fmt.Sprintf("- SOP「%s」· #%d %s:%s", firstNonEmpty(m.WorkflowName, m.TargetName), m.IssueNumber, roomTrim(m.IssueTitle, 40), firstNonEmpty(m.RunStatus, "排队中"))) + } + } + if len(lines) == 0 { + return "群里现在没有进行中的任务。" + } + return "群里的任务:\n" + strings.Join(lines, "\n") +} + +const crewAnswerPrompt = `你是群聊里的 Bot「LoopForge」,这个项目的 PM 兼 Bot 团队队长。像同事在群里回话一样回答:中文、口语、简短(一般不超过 8 行),可以用 Markdown 列表。 +只根据下面的群任务、群聊记录和引用的消息回答;不知道就直说,不要编。 +如果对方其实是想让人动手干活,告诉他用「@LoopForge + 要做的事」交给 Bot 团队,或 @ 一条 SOP 按流程跑。 + +## 群任务 +%s + +## 最近群聊 +%s + +## 引用的消息 +%s + +## 提问(来自 %s) +%s` diff --git a/internal/httpapi/room_crew.go b/internal/httpapi/room_crew.go new file mode 100644 index 0000000..b8b4a3a --- /dev/null +++ b/internal/httpapi/room_crew.go @@ -0,0 +1,928 @@ +package httpapi + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "regexp" + "strings" + "time" + + "github.com/Tencent/LoopForge/internal/domain" + "github.com/gin-gonic/gin" +) + +// A crew mission runs at most this many bot steps, including steps people add +// by @-ing a bot while it runs. +const crewMaxTasks = 8 + +const crewCaptain = "LoopForge" + +type crewRequest struct { + Goal string `json:"goal"` + Detail string `json:"detail"` + AutoStart bool `json:"autoStart"` + Source string `json:"source"` +} + +type crewTaskInput struct { + ID string `json:"id"` + AgentID string `json:"agentId"` + Title string `json:"title"` + Instruction string `json:"instruction"` +} + +type crewActionRequest struct { + Action string `json:"action"` // start, stop, retry, skip, plan, sop + Tasks []crewTaskInput `json:"tasks"` +} + +// createCrew hands open-ended work to the room's bots. The captain plans in +// the background; the mission card shows "planning" until the plan lands. +func (h *roomsHandlers) createCrew(c *gin.Context) { + room, ok := h.repo.Room(c.Param("id")) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "room not found"}) + return + } + if !h.roomAccess(c, room.ProjectID, true) { + return + } + var req crewRequest + if !decode(c, &req) { + return + } + userID, author := h.roomActor(c) + msg, err := h.startCrewMission(room, userID, author, req) + if err != nil { + respond(c, err) + return + } + c.JSON(http.StatusCreated, msg) +} + +func (h *roomsHandlers) startCrewMission(room domain.ChatRoom, userID int64, author string, req crewRequest) (domain.ChatMessage, error) { + goal := strings.TrimSpace(req.Goal) + detail := strings.TrimSpace(req.Detail) + if goal == "" { + return domain.ChatMessage{}, HTTPError{http.StatusBadRequest, "说一下要做什么"} + } + if len([]rune(goal)) > 200 { + goal = string([]rune(goal)[:200]) + } + if len([]rune(detail)) > 8000 { + return domain.ChatMessage{}, HTTPError{http.StatusBadRequest, "描述过长"} + } + if len(h.crewRoster(room.ProjectID, userID)) == 0 { + return domain.ChatMessage{}, HTTPError{http.StatusBadRequest, "这个项目还没有能干活的角色 Bot,先去「构建 · 角色」建一个"} + } + issue := domain.Issue{ + ID: newChatID("iss"), ProjectID: room.ProjectID, Title: goal, Description: detail, + Status: "todo", Priority: "medium", Kind: "issue", Source: "local", + } + if userID > 0 { + issue.AssigneeUserID = userID + } + issue.AppendOperation(domain.IssueOperation{Actor: author, ActorUserID: userID, Action: "create", Detail: "交给协作群「" + room.Name + "」的 Bot 团队自由协作"}) + if err := h.repo.PutIssue(issue); err != nil { + return domain.ChatMessage{}, err + } + stored, _ := h.repo.Issue(issue.ID) + source := req.Source + if source != "home" && source != "chat" { + source = "room" + } + now := time.Now().UTC() + mission := domain.CrewMission{ + ID: newChatID("crew"), Goal: goal, Detail: detail, Status: "planning", + IssueID: stored.ID, IssueNumber: stored.Number, AutoStart: req.AutoStart, + StartedBy: userID, StartedByName: author, Source: source, CreatedAt: now, UpdatedAt: now, + } + msg := domain.ChatMessage{ + ID: mission.ID, RoomID: room.ID, UserID: userID, Author: author, Kind: domain.ChatKindCrew, + Body: goal, IssueID: stored.ID, IssueNumber: stored.Number, IssueTitle: stored.Title, + Crew: &mission, CreatedAt: now, + } + if err := h.repo.PutMessage(msg); err != nil { + return domain.ChatMessage{}, err + } + h.touchRoom(room) + go h.planCrew(room, msg.ID) + return msg, nil +} + +// crewRoster lists the roles a crew may use: visible to the project and +// runnable by this user (the mock executor always is; real CLIs need a key, +// the engine falls back between built-in CLIs). +func (h *roomsHandlers) crewRoster(projectID string, userID int64) []domain.AgentDefinition { + hasKey := false + if user, ok := h.repo.User(userID); ok { + hasKey = strings.TrimSpace(user.CodeBuddyAPIKey) != "" || strings.TrimSpace(user.ClaudeCodeAPIKey) != "" || strings.TrimSpace(user.CodexAPIKey) != "" + } + var out []domain.AgentDefinition + for _, a := range h.repo.Agents() { + if !catalogVisible(a.ProjectID, a.Shared, projectID) { + continue + } + if a.ExecutorID != "mock" && !hasKey { + continue + } + out = append(out, a) + } + return out +} + +func crewAgentName(a domain.AgentDefinition) string { + name := strings.TrimSpace(a.Name) + for _, prefix := range []string{"演示 · ", "演示·", "演示 "} { + name = strings.TrimPrefix(name, prefix) + } + if name == "" { + name = a.ID + } + return name +} + +var crewRoleKinds = []struct { + kind string + re *regexp.Regexp + title string + brief string +}{ + {"analyze", regexp.MustCompile(`(?i)分析|需求|拆解|方案|analy|requirement|prd|shape|plan`), "拆解需求、定方案", "先把目标拆成可执行的要点,列出范围、风险和验收标准,给出建议方案。"}, + {"build", regexp.MustCompile(`(?i)开发|实现|编码|修复|develop|implement|build|code|engineer`), "动手实现", "按前面的方案完成实现,说明改了什么、为什么这样改。"}, + {"review", regexp.MustCompile(`(?i)审查|评审|review|audit`), "独立审查", "独立审查前面的产出,指出问题和风险,给出是否可以交付的结论。"}, + {"test", regexp.MustCompile(`(?i)测试|验证|test|verify|qa`), "测试验证", "设计并执行验证,列出覆盖的场景和结果。"}, + {"docs", regexp.MustCompile(`(?i)文档|说明|doc|write`), "整理文档", "把结论整理成可以直接给人看的文档。"}, +} + +// crewRoleScore rates how well a role fits a kind: its name says the most, +// the id a little less, the description only breaks ties. +func crewRoleScore(a domain.AgentDefinition, k int) int { + re := crewRoleKinds[k].re + score := 0 + if re.MatchString(crewAgentName(a)) { + score += 4 + } + if re.MatchString(a.ID) { + score += 2 + } + if re.MatchString(a.Description) { + score++ + } + return score +} + +// crewPlanRules is the captain's plan when no model is available: walk the +// kinds in analyze → build → review → test → docs order and give each to the +// best-fitting role not used yet. Docs only join when the goal asks for them. +func crewPlanRules(goal string, roster []domain.AgentDefinition) []domain.CrewTask { + used := map[string]bool{} + var tasks []domain.CrewTask + wantsDocs := regexp.MustCompile(`(?i)文档|说明|doc`).MatchString(goal) + for k := range crewRoleKinds { + if crewRoleKinds[k].kind == "docs" && !wantsDocs { + continue + } + best, bestScore := -1, 0 + for i, a := range roster { + if used[a.ID] { + continue + } + score := crewRoleScore(a, k) * 2 + if score > 0 && a.ExecutorID == "mock" { + score++ + } + if score > bestScore { + best, bestScore = i, score + } + } + if best < 0 { + continue + } + a := roster[best] + used[a.ID] = true + tasks = append(tasks, domain.CrewTask{AgentID: a.ID, AgentName: crewAgentName(a), Title: crewRoleKinds[k].title, Instruction: crewRoleKinds[k].brief, AddedBy: crewCaptain}) + if len(tasks) == 4 { + break + } + } + if len(tasks) == 0 { + for i, a := range roster { + if i == 2 { + break + } + tasks = append(tasks, domain.CrewTask{AgentID: a.ID, AgentName: crewAgentName(a), Title: map[bool]string{true: "先做一版", false: "接着完善"}[i == 0], Instruction: "围绕目标完成你职责范围内的部分。", AddedBy: crewCaptain}) + } + } + return tasks +} + +const crewPlanPrompt = `你是 LoopForge 协作群里的队长 Bot。用户把一件没有固定流程的事交给了群里的 Bot 团队。 +请从下面的角色里挑人,排出 2 到 5 步的分工,每一步由一个角色完成,前一步的产出会交给后一步。 +只输出一个 JSON 对象,不要任何其它文字: +{"tasks":[{"agentId":"角色 id","title":"这一步做什么(10 字以内)","instruction":"给这个 Bot 的具体要求(1-3 句)"}]} + +目标:%s +补充说明:%s + +可用角色: +%s` + +func (h *roomsHandlers) crewPlanAI(goal, detail string, roster []domain.AgentDefinition, userID int64) ([]domain.CrewTask, error) { + user, ok := h.repo.User(userID) + if !ok || strings.TrimSpace(user.CodeBuddyAPIKey) == "" { + return nil, fmt.Errorf("no model key") + } + byID := map[string]domain.AgentDefinition{} + var lines []string + for _, a := range roster { + byID[a.ID] = a + lines = append(lines, fmt.Sprintf("- %s:%s。%s", a.ID, crewAgentName(a), roomTrim(a.Description, 120))) + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + raw, err := generateAssistantReply(ctx, fmt.Sprintf(crewPlanPrompt, goal, firstNonEmpty(detail, "无"), strings.Join(lines, "\n")), user) + if err != nil { + return nil, err + } + start, end := strings.Index(raw, "{"), strings.LastIndex(raw, "}") + if start < 0 || end <= start { + return nil, fmt.Errorf("plan is not JSON") + } + var plan struct { + Tasks []crewTaskInput `json:"tasks"` + } + if err := json.Unmarshal([]byte(raw[start:end+1]), &plan); err != nil { + return nil, err + } + var tasks []domain.CrewTask + for _, t := range plan.Tasks { + a, ok := byID[strings.TrimSpace(t.AgentID)] + if !ok || strings.TrimSpace(t.Title) == "" { + continue + } + tasks = append(tasks, domain.CrewTask{AgentID: a.ID, AgentName: crewAgentName(a), Title: roomTrim(strings.TrimSpace(t.Title), 40), Instruction: roomTrim(strings.TrimSpace(t.Instruction), 600), AddedBy: crewCaptain}) + if len(tasks) == 5 { + break + } + } + if len(tasks) == 0 { + return nil, fmt.Errorf("empty plan") + } + return tasks, nil +} + +func (h *roomsHandlers) planCrew(room domain.ChatRoom, msgID string) { + msg, ok := h.repo.Message(msgID) + if !ok || msg.Crew == nil { + return + } + m := msg.Crew + roster := h.crewRoster(room.ProjectID, m.StartedBy) + tasks, err := h.crewPlanAI(m.Goal, m.Detail, roster, m.StartedBy) + planner := "ai" + if err != nil { + tasks, planner = crewPlanRules(m.Goal+" "+m.Detail, roster), "rules" + } + h.crewMu.Lock() + defer h.crewMu.Unlock() + msg, ok = h.repo.Message(msgID) + if !ok || msg.Crew == nil || msg.Crew.Status != "planning" { + return + } + m = msg.Crew + for i := range tasks { + tasks[i].ID = fmt.Sprintf("t%d", i+1) + tasks[i].Status = "pending" + } + m.Tasks, m.Planner = tasks, planner + if len(tasks) == 0 { + m.Status, m.Error = "failed", "没有可用的角色 Bot" + h.saveCrew(msg) + return + } + m.Status = "proposed" + if m.AutoStart { + m.Status = "running" + } + h.saveCrew(msg) + names := make([]string, len(tasks)) + for i, t := range tasks { + names[i] = t.AgentName + "「" + t.Title + "」" + } + lead := "收到。我拉了 " + fmt.Sprint(len(crewDistinctAgents(tasks))) + " 个 Bot 一起做:" + strings.Join(names, " → ") + "。" + if m.AutoStart { + lead += "已经开工,做完一步我会在群里交接。" + } else { + lead += "分工可以先改,确认后点「开工」。" + } + h.crewSay(msg, crewCaptain, "", lead, "", "") + if m.AutoStart { + h.ensureCrewLoop() + } +} + +func crewDistinctAgents(tasks []domain.CrewTask) map[string]bool { + out := map[string]bool{} + for _, t := range tasks { + out[t.AgentID] = true + } + return out +} + +func (h *roomsHandlers) saveCrew(msg domain.ChatMessage) { + msg.Crew.UpdatedAt = time.Now().UTC() + _ = h.repo.PutMessage(msg) + if room, ok := h.repo.Room(msg.RoomID); ok { + h.touchRoom(room) + } +} + +// crewSay posts a line in the room as a bot (agentID set) or the captain. +// handoffTo / handoffTitle mark "handing over to X for Y". +func (h *roomsHandlers) crewSay(mission domain.ChatMessage, author, agentID, body, handoffTo, handoffTitle string) { + post := domain.ChatMessage{ + ID: newChatID("msg"), RoomID: mission.RoomID, Author: author, AgentID: agentID, + Kind: domain.ChatKindCrewPost, Body: body, CrewID: mission.ID, + IssueID: mission.IssueID, IssueNumber: mission.IssueNumber, IssueTitle: mission.IssueTitle, + TargetName: handoffTo, NodeName: handoffTitle, CreatedAt: time.Now().UTC(), + } + _ = h.repo.PutMessage(post) +} + +// ensureCrewLoop runs the captain while any mission is active. It stops after +// a few idle ticks; every create, action and room poll restarts it. +func (h *roomsHandlers) ensureCrewLoop() { + if !h.crewLoopOn.CompareAndSwap(false, true) { + return + } + go func() { + defer h.crewLoopOn.Store(false) + idle := 0 + for idle < 5 { + time.Sleep(3 * time.Second) + if h.tickCrews() == 0 { + idle++ + } else { + idle = 0 + } + } + }() +} + +func (h *roomsHandlers) tickCrews() int { + active := 0 + for _, msg := range h.repo.CrewMessages("") { + if !domain.CrewMissionActive(msg.Crew.Status) { + continue + } + active++ + if msg.Crew.Status == "running" { + h.advanceCrew(msg.ID) + } + } + return active +} + +var crewQuestionRE = regexp.MustCompile(`(?m)^\s*(?:[-*>]\s*)?(?:\*\*)?(?:需要确认|需要你确认|NEED_CONFIRM)(?:\*\*)?\s*[::]\s*(.+)$`) + +func crewQuestion(output string) string { + if m := crewQuestionRE.FindStringSubmatch(output); len(m) > 1 { + return strings.TrimSpace(m[1]) + } + return "" +} + +// crewSummary keeps a bot's headline plus a few points for the room post. +func crewSummary(output string) string { + var lines []string + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "```") || strings.HasPrefix(line, "|") { + continue + } + line = strings.TrimSpace(strings.TrimLeft(line, "#>")) + line = strings.ReplaceAll(line, "**", "") + if line == "" { + continue + } + lines = append(lines, line) + if len(lines) == 6 { + break + } + } + return roomTrim(strings.Join(lines, "\n"), 700) +} + +func (h *roomsHandlers) advanceCrew(msgID string) { + h.crewMu.Lock() + defer h.crewMu.Unlock() + msg, ok := h.repo.Message(msgID) + if !ok || msg.Crew == nil || msg.Crew.Status != "running" { + return + } + m := msg.Crew + changed := false + busy := false + for i := range m.Tasks { + t := &m.Tasks[i] + if t.Status != "running" { + continue + } + run, found := h.repo.Run(t.RunID) + if !found || !roomRunTerminal(run.Status) { + busy = true + continue + } + changed = true + t.FinishedAt = time.Now().UTC() + node := run.Nodes["work"] + if run.Status == "succeeded" { + output := "" + if node != nil { + output = node.Output + } + t.Status, t.Output, t.Summary = "succeeded", roomTrim(output, 6000), crewSummary(output) + if q := crewQuestion(output); q != "" { + m.Status, m.Question, m.QuestionAgent = "waiting", q, t.AgentID + h.crewSay(msg, t.AgentName, t.AgentID, firstNonEmpty(t.Summary, "做完了「"+t.Title+"」。"), "", "") + h.crewSay(msg, crewCaptain, "", "@"+crewMention(m.StartedByName)+" "+t.AgentName+" 需要你确认:"+q+"\n直接在群里回复,我会交回给它继续。", "", "") + continue + } + next := crewNextPending(m) + to, title := "", "" + if next != nil { + to, title = next.AgentName, next.Title + } + h.crewSay(msg, t.AgentName, t.AgentID, firstNonEmpty(t.Summary, "做完了「"+t.Title+"」。"), to, title) + continue + } + reason := run.Status + if node != nil && strings.TrimSpace(node.Error) != "" { + reason = node.Error + } + t.Status, t.Error = "failed", roomTrim(reason, 400) + m.Status, m.Question, m.QuestionAgent = "waiting", "", t.AgentID + h.crewSay(msg, crewCaptain, "", "@"+crewMention(m.StartedByName)+" "+t.AgentName+" 在「"+t.Title+"」卡住了:"+t.Error+"\n回复「重试」「跳过」或「停止」,也可以直接说怎么改,我交给它重做。", "", "") + } + if m.Status == "running" && !busy { + next := crewNextPending(m) + if next == nil { + m.Status = "done" + h.crewSay(msg, crewCaptain, "", crewWrapUp(m), "", "") + } else if err := h.startCrewTask(msg, next); err != nil { + next.Status, next.Error = "failed", roomTrim(err.Error(), 400) + m.Status, m.QuestionAgent = "waiting", next.AgentID + h.crewSay(msg, crewCaptain, "", next.AgentName+" 没能开工:"+next.Error+"\n回复「重试」「跳过」或「停止」。", "", "") + } + changed = true + } + if changed { + h.saveCrew(msg) + } +} + +func crewMention(name string) string { + if i := strings.Index(name, "("); i > 0 { + return name[:i] + } + return name +} + +func crewNextPending(m *domain.CrewMission) *domain.CrewTask { + for i := range m.Tasks { + if m.Tasks[i].Status == "pending" { + return &m.Tasks[i] + } + } + return nil +} + +func crewWrapUp(m *domain.CrewMission) string { + var lines []string + for _, t := range m.Tasks { + if t.Status != "succeeded" { + continue + } + head := strings.SplitN(t.Summary, "\n", 2)[0] + lines = append(lines, "- "+t.AgentName+"「"+t.Title+"」:"+roomTrim(head, 80)) + } + return "搞定了," + fmt.Sprint(len(lines)) + " 步都交付了:\n" + strings.Join(lines, "\n") + "\n这套分工要是好用,可以一键沉淀成 SOP,下次直接按流程跑。" +} + +// crewBrief is what one bot receives on top of the issue: its part, what +// teammates already delivered and what people said in the room. +func crewBrief(m *domain.CrewMission, t *domain.CrewTask) string { + var b strings.Builder + b.WriteString("## 群聊协作(LoopForge 队长分派)\n") + b.WriteString("你是协作群里的「" + t.AgentName + "」Bot,和其它 Bot 一起完成上面的目标。\n") + b.WriteString("你这一步:" + t.Title + "\n") + if t.Instruction != "" { + b.WriteString("具体要求:" + t.Instruction + "\n") + } + var done []string + for _, prev := range m.Tasks { + if prev.Status == "succeeded" && prev.ID != t.ID { + done = append(done, "### "+prev.AgentName+"「"+prev.Title+"」\n"+roomTrim(firstNonEmpty(prev.Output, prev.Summary), 2500)) + } + } + if len(done) > 0 { + b.WriteString("\n## 队友已经交付的\n" + strings.Join(done, "\n\n") + "\n") + } + var notes []string + for i := range m.Notes { + notes = append(notes, "- "+m.Notes[i].Author+":"+m.Notes[i].Body) + m.Notes[i].Consumed = true + } + if len(notes) > 0 { + b.WriteString("\n## 群里同事的要求(必须遵守)\n" + strings.Join(notes, "\n") + "\n") + } + b.WriteString("\n## 回复方式\n- 第一行写一句话结论,下面列要点;结果会原样发到群里给大家看。\n- 缺关键信息没法继续时,单独一行写「需要确认:你的问题」,队长会去群里问人。\n") + return b.String() +} + +func (h *roomsHandlers) startCrewTask(msg domain.ChatMessage, t *domain.CrewTask) error { + m := msg.Crew + room, ok := h.repo.Room(msg.RoomID) + if !ok { + return fmt.Errorf("room not found") + } + if _, ok := h.repo.Agent(t.AgentID); !ok { + return fmt.Errorf("角色 %s 不存在", t.AgentID) + } + // Each finished step closes the delivery loop on the issue; the mission + // is not done yet, so reopen it for the next bot. A person closing the + // issue by hand still blocks the mission. + if issue, ok := h.repo.Issue(m.IssueID); ok && issue.DeliveryLocked() && !issue.ManuallyClosed() && !issue.TrackerClosed() { + if _, err := h.repo.ReopenDeliveredIssue(issue.ID); err != nil { + return err + } + } + wf := domain.WorkflowDefinition{ + ID: "crew-" + strings.TrimPrefix(m.ID, "crew-") + "-" + t.ID + "-" + fmt.Sprint(time.Now().Unix()%100000), + Name: "群聊协作 · " + t.Title, Status: domain.WorkflowStatusCrew, ProjectID: room.ProjectID, + Description: m.Goal, SandboxProfileID: h.crewSandbox(t.AgentID), + Nodes: []domain.WorkflowNode{{ID: "work", Name: t.Title, Type: "agent", AgentID: t.AgentID, MaxAttempts: 1, Position: domain.Position{X: 240, Y: 160}}}, + } + if err := h.repo.PutWorkflow(wf); err != nil { + return err + } + task, err := dispatchIssue(h.deps, dispatchRequest{IssueID: m.IssueID, WorkflowID: wf.ID, TriggerUserID: m.StartedBy, TriggerOrigin: "crew", ExtraInput: crewBrief(m, t)}) + if err != nil { + return err + } + t.Status, t.RunID, t.WorkflowID, t.StartedAt, t.Error = "running", task.RunID, wf.ID, time.Now().UTC(), "" + return nil +} + +// crewSandbox reuses the sandbox of an SOP that already runs this role. +func (h *roomsHandlers) crewSandbox(agentID string) string { + fallback := "" + for _, wf := range h.repo.Workflows() { + for _, n := range wf.Nodes { + if n.AgentID == agentID && wf.SandboxProfileID != "" { + return wf.SandboxProfileID + } + } + if fallback == "" && wf.Status == "published" { + fallback = wf.SandboxProfileID + } + } + return firstNonEmpty(fallback, "local") +} + +// crewAction: start / stop a mission, retry or skip a stuck step, replace the +// pending part of the plan, or save the finished division of work as a SOP. +func (h *roomsHandlers) crewAction(c *gin.Context) { + room, ok := h.repo.Room(c.Param("id")) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "room not found"}) + return + } + if !h.roomAccess(c, room.ProjectID, true) { + return + } + var req crewActionRequest + if !decode(c, &req) { + return + } + _, author := h.roomActor(c) + h.crewMu.Lock() + msg, ok := h.repo.Message(c.Param("mid")) + if !ok || msg.Crew == nil || msg.RoomID != room.ID { + h.crewMu.Unlock() + c.JSON(http.StatusNotFound, gin.H{"error": "协作任务不存在"}) + return + } + if req.Action == "sop" { + h.crewMu.Unlock() + id, err := h.crewToSOP(room, msg) + if err != nil { + respond(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"workflowId": id}) + return + } + err := h.applyCrewAction(&msg, req, author, room.ProjectID) + if err == nil { + h.saveCrew(msg) + } + h.crewMu.Unlock() + if err != nil { + respond(c, err) + return + } + h.ensureCrewLoop() + c.JSON(http.StatusOK, msg) +} + +func (h *roomsHandlers) applyCrewAction(msg *domain.ChatMessage, req crewActionRequest, author, projectID string) error { + m := msg.Crew + switch req.Action { + case "start": + if m.Status != "proposed" { + return HTTPError{http.StatusConflict, "这个任务已经开工了"} + } + m.Status = "running" + if next := crewNextPending(m); next != nil { + h.crewSay(*msg, crewCaptain, "", "开工。"+next.AgentName+" 先来「"+next.Title+"」。", "", "") + } + case "stop": + if !domain.CrewMissionActive(m.Status) { + return HTTPError{http.StatusConflict, "任务已经结束"} + } + for i := range m.Tasks { + if m.Tasks[i].Status == "running" && m.Tasks[i].RunID != "" { + _ = h.engine.Cancel(m.Tasks[i].RunID) + m.Tasks[i].Status = "skipped" + } + } + m.Status, m.Question = "stopped", "" + h.crewSay(*msg, crewCaptain, "", author+" 让大家先停下。做完的部分都留在需求单里。", "", "") + case "retry", "skip": + if m.Status != "waiting" { + return HTTPError{http.StatusConflict, "现在没有卡住的步骤"} + } + h.crewResolveStuck(msg, req.Action, author, "") + case "plan": + if !domain.CrewMissionActive(m.Status) { + return HTTPError{http.StatusConflict, "任务已经结束"} + } + agents := map[string]domain.AgentDefinition{} + for _, a := range h.crewRoster(projectID, m.StartedBy) { + agents[a.ID] = a + } + kept := make([]domain.CrewTask, 0, len(m.Tasks)) + for _, t := range m.Tasks { + if t.Status != "pending" { + kept = append(kept, t) + } + } + for _, in := range req.Tasks { + a, ok := agents[strings.TrimSpace(in.AgentID)] + if !ok { + return HTTPError{http.StatusBadRequest, "角色不可用:" + in.AgentID} + } + title := roomTrim(strings.TrimSpace(in.Title), 40) + if title == "" { + title = "接着做" + } + kept = append(kept, domain.CrewTask{ID: fmt.Sprintf("t%d", len(kept)+1+int(time.Now().UnixNano()%1000)), AgentID: a.ID, AgentName: crewAgentName(a), Title: title, Instruction: roomTrim(strings.TrimSpace(in.Instruction), 600), Status: "pending", AddedBy: author}) + } + if len(kept) > crewMaxTasks { + return HTTPError{http.StatusBadRequest, fmt.Sprintf("一次协作最多 %d 步", crewMaxTasks)} + } + if crewNextPending(&domain.CrewMission{Tasks: kept}) == nil && m.Status == "proposed" { + return HTTPError{http.StatusBadRequest, "至少留一步"} + } + for i := range kept { + kept[i].ID = fmt.Sprintf("t%d", i+1) + } + m.Tasks = kept + default: + return HTTPError{http.StatusBadRequest, "unknown action"} + } + return nil +} + +// crewResolveStuck moves a waiting mission on: retry the failed step, skip +// it, or (with feedback) hand the asking bot a follow-up step. +func (h *roomsHandlers) crewResolveStuck(msg *domain.ChatMessage, action, author, feedback string) { + m := msg.Crew + var stuck *domain.CrewTask + for i := len(m.Tasks) - 1; i >= 0; i-- { + if m.Tasks[i].AgentID == m.QuestionAgent && (m.Tasks[i].Status == "failed" || m.Tasks[i].Status == "succeeded") { + stuck = &m.Tasks[i] + break + } + } + question := m.Question + m.Status, m.Question, m.QuestionAgent = "running", "", "" + if stuck == nil { + return + } + switch { + case action == "skip": + if stuck.Status == "failed" { + stuck.Status = "skipped" + } + h.crewSay(*msg, crewCaptain, "", "好,跳过「"+stuck.Title+"」,继续往下。", "", "") + case action == "retry" && stuck.Status == "failed": + stuck.Status, stuck.Error, stuck.RunID = "pending", "", "" + h.crewSay(*msg, crewCaptain, "", "让 "+stuck.AgentName+" 再试一次「"+stuck.Title+"」。", "", "") + default: + if stuck.Status == "failed" { + stuck.Status = "skipped" + } + instruction := "群里的回复:" + feedback + if question != "" { + instruction = "你之前问:" + question + "\n" + instruction + } + follow := domain.CrewTask{AgentID: stuck.AgentID, AgentName: stuck.AgentName, Title: "按反馈继续「" + roomTrim(stuck.Title, 16) + "」", Instruction: instruction, Status: "pending", AddedBy: author} + h.crewInsertNext(m, follow) + h.crewSay(*msg, crewCaptain, "", "收到,交回给 "+stuck.AgentName+" 继续。", "", "") + } +} + +// crewInsertNext puts a step right after the finished ones so it runs next. +func (h *roomsHandlers) crewInsertNext(m *domain.CrewMission, t domain.CrewTask) { + at := len(m.Tasks) + for i := range m.Tasks { + if m.Tasks[i].Status == "pending" { + at = i + break + } + } + m.Tasks = append(m.Tasks[:at], append([]domain.CrewTask{t}, m.Tasks[at:]...)...) + for i := range m.Tasks { + m.Tasks[i].ID = fmt.Sprintf("t%d", i+1) + } +} + +// crewHear speaks only when spoken to, like a group-chat bot: "@LoopForge" +// answers a question or starts a mission, "@某个 Bot" or replying to a bot's +// post adds a step for it, any message unblocks a waiting mission, and a reply +// to a mission's messages is handed to the next bot as a requirement. +// Plain chatter between people is left alone. +func (h *roomsHandlers) crewHear(room domain.ChatRoom, said domain.ChatMessage) { + body := strings.TrimSpace(said.Body) + quoted, hasQuote := domain.ChatMessage{}, false + if said.ReplyTo != "" { + quoted, hasQuote = h.repo.Message(said.ReplyTo) + } + if hasQuote && quoted.Kind == domain.ChatKindCrewPost && quoted.AgentID == "" && quoted.CrewID == "" && !strings.HasPrefix(body, "@") { + body = "@LoopForge " + body + } + lower := strings.ToLower(body) + if strings.HasPrefix(lower, "@loopforge") { + goal := strings.TrimSpace(body[len("@loopforge"):]) + if goal == "" || crewWantsAnswer(goal) { + h.answerInRoom(room, said, firstNonEmpty(goal, "你好,你能做什么?")) + return + } + { + lines := strings.SplitN(goal, "\n", 2) + detail := "" + if len(lines) > 1 { + detail = lines[1] + } + if _, err := h.startCrewMission(room, said.UserID, said.Author, crewRequest{Goal: lines[0], Detail: detail, Source: "chat"}); err != nil { + h.crewSay(domain.ChatMessage{RoomID: room.ID}, crewCaptain, "", "没能接这个活:"+err.Error(), "", "") + } + } + return + } + h.crewMu.Lock() + defer h.crewMu.Unlock() + var msg domain.ChatMessage + found := false + missionID := quoted.CrewID + if quoted.Kind == domain.ChatKindCrew { + missionID = quoted.ID + } + missions := h.repo.CrewMessages(room.ID) + for i := len(missions) - 1; i >= 0; i-- { + if domain.CrewMissionActive(missions[i].Crew.Status) && missions[i].Crew.Status != "planning" && (missionID == "" || missions[i].ID == missionID) { + msg, found = missions[i], true + break + } + } + if !found { + return + } + m := msg.Crew + if quoted.AgentID != "" && !strings.Contains(body, "@") { + if a, ok := h.repo.Agent(quoted.AgentID); ok { + body = "@" + crewAgentName(a) + " " + body + } + } + lower = strings.ToLower(body) + switch lower { + case "停止", "停", "/stop", "stop": + _ = h.applyCrewAction(&msg, crewActionRequest{Action: "stop"}, said.Author, room.ProjectID) + h.saveCrew(msg) + return + } + usable := map[string]bool{} + for _, a := range h.crewRoster(room.ProjectID, m.StartedBy) { + usable[a.ID] = true + } + for _, a := range h.repo.Agents() { + name := crewAgentName(a) + if !catalogVisible(a.ProjectID, a.Shared, room.ProjectID) || !strings.Contains(body, "@"+name) { + continue + } + if !usable[a.ID] { + h.crewSay(msg, crewCaptain, "", name+" 现在叫不动:它用的模型 CLI 还没配 Key。去「设置 · 个人设置」配好后再 @ 它。", "", "") + return + } + if len(m.Tasks) >= crewMaxTasks { + h.crewSay(msg, crewCaptain, "", fmt.Sprintf("这次协作已经排满 %d 步了,先让大家做完。", crewMaxTasks), "", "") + return + } + ask := strings.TrimSpace(strings.Replace(body, "@"+name, "", 1)) + t := domain.CrewTask{AgentID: a.ID, AgentName: name, Title: roomTrim(firstNonEmpty(strings.SplitN(ask, "\n", 2)[0], "按群里的要求补一步"), 24), Instruction: ask, Status: "pending", AddedBy: said.Author} + m.Tasks = append(m.Tasks, t) + for i := range m.Tasks { + m.Tasks[i].ID = fmt.Sprintf("t%d", i+1) + } + ack := "收到,给 " + name + " 加了一步「" + t.Title + "」,排在现有分工之后。" + if m.Status == "waiting" { + ack += "现在还卡着,回复「重试」「跳过」或直接说怎么改。" + } + h.crewSay(msg, crewCaptain, "", ack, "", "") + h.saveCrew(msg) + h.ensureCrewLoop() + return + } + if m.Status == "waiting" { + switch lower { + case "重试", "retry": + h.crewResolveStuck(&msg, "retry", said.Author, "") + case "跳过", "skip": + h.crewResolveStuck(&msg, "skip", said.Author, "") + default: + m.Notes = append(m.Notes, domain.CrewNote{Author: said.Author, Body: roomTrim(body, 1000), At: time.Now().UTC()}) + h.crewResolveStuck(&msg, "answer", said.Author, body) + } + h.saveCrew(msg) + h.ensureCrewLoop() + return + } + if missionID == "" { + return + } + m.Notes = append(m.Notes, domain.CrewNote{Author: said.Author, Body: roomTrim(body, 1000), At: time.Now().UTC()}) + if next := crewNextPending(m); next != nil { + h.crewSay(msg, crewCaptain, "", "记下了,会交给下一位 "+next.AgentName+"。", "", "") + } + h.saveCrew(msg) +} + +// crewToSOP turns the steps that delivered into a draft SOP in the same order. +func (h *roomsHandlers) crewToSOP(room domain.ChatRoom, msg domain.ChatMessage) (string, error) { + m := msg.Crew + if m.SavedWorkflow != "" { + if _, ok := h.repo.Workflow(m.SavedWorkflow); ok { + return m.SavedWorkflow, nil + } + } + var nodes []domain.WorkflowNode + var edges []domain.WorkflowEdge + sandbox := "" + for _, t := range m.Tasks { + if t.Status != "succeeded" { + continue + } + id := fmt.Sprintf("step%d", len(nodes)+1) + if len(nodes) > 0 { + edges = append(edges, domain.WorkflowEdge{ID: "e" + id, Source: nodes[len(nodes)-1].ID, Target: id, When: "success"}) + } + nodes = append(nodes, domain.WorkflowNode{ID: id, Name: t.Title, Type: "agent", AgentID: t.AgentID, MaxAttempts: 2, Position: domain.Position{X: float64(120 + 280*len(nodes)), Y: 200}}) + if sandbox == "" { + sandbox = h.crewSandbox(t.AgentID) + } + } + if len(nodes) == 0 { + return "", HTTPError{http.StatusConflict, "还没有做成的步骤,没法沉淀"} + } + wf := domain.WorkflowDefinition{ + ID: "sop-" + strings.TrimPrefix(m.ID, "crew-"), Name: roomTrim("群聊沉淀 · "+m.Goal, 40), Status: "draft", + ProjectID: room.ProjectID, SandboxProfileID: sandbox, Nodes: nodes, Edges: edges, + Description: "从协作群「" + room.Name + "」的一次自由协作沉淀:" + m.Goal, + } + if err := h.repo.PutWorkflow(wf); err != nil { + return "", err + } + h.crewMu.Lock() + if again, ok := h.repo.Message(msg.ID); ok && again.Crew != nil { + again.Crew.SavedWorkflow = wf.ID + h.saveCrew(again) + h.crewSay(again, crewCaptain, "", "已沉淀成 SOP「"+wf.Name+"」(草稿),在流程模板里可以继续调整后发布。", "", "") + } + h.crewMu.Unlock() + return wf.ID, nil +} diff --git a/internal/httpapi/room_crew_test.go b/internal/httpapi/room_crew_test.go new file mode 100644 index 0000000..3616442 --- /dev/null +++ b/internal/httpapi/room_crew_test.go @@ -0,0 +1,56 @@ +package httpapi + +import ( + "testing" + + "github.com/Tencent/LoopForge/internal/domain" +) + +func TestCrewPlanRulesSplitsRolesByName(t *testing.T) { + roster := []domain.AgentDefinition{ + {ID: "demo-developer", Name: "演示 · 开发实现", Description: "演示执行器:模拟按方案完成实现", ExecutorID: "mock"}, + {ID: "demo-analyst", Name: "演示 · 需求分析", Description: "演示执行器:模拟拆解需求并给出方案", ExecutorID: "mock"}, + {ID: "reviewer", Name: "Reviewer", Description: "Independently reviews the change", ExecutorID: "claude"}, + } + tasks := crewPlanRules("调研负责人筛选", roster) + got := []string{} + for _, task := range tasks { + got = append(got, task.AgentID) + } + want := []string{"demo-analyst", "demo-developer", "reviewer"} + if len(got) != len(want) { + t.Fatalf("plan = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("plan = %v, want %v", got, want) + } + } +} + +func TestCrewQuestionFindsAsk(t *testing.T) { + if q := crewQuestion("结论\n\n需要确认:用哪个字段当负责人?"); q != "用哪个字段当负责人?" { + t.Fatalf("question = %q", q) + } + if q := crewQuestion("全部完成"); q != "" { + t.Fatalf("unexpected question %q", q) + } +} + +func TestCrewWantsAnswerSplitsQuestionsFromWork(t *testing.T) { + for text, want := range map[string]bool{ + "现在进度怎么样": true, + "这个方案为什么要拆两步?": true, + "总结一下刚才的讨论": true, + "你好": true, + "调研一下需求列表加负责人筛选": false, + "帮我修复登录页白屏的问题吗?": false, + "给导出功能写一份发布公告": false, + "整理一下导出的已知问题": false, + "": false, + } { + if got := crewWantsAnswer(text); got != want { + t.Errorf("crewWantsAnswer(%q) = %v, want %v", text, got, want) + } + } +} diff --git a/internal/httpapi/rooms.go b/internal/httpapi/rooms.go index 2aac2b1..2d0be0c 100644 --- a/internal/httpapi/rooms.go +++ b/internal/httpapi/rooms.go @@ -5,6 +5,8 @@ import ( "net/http" "strconv" "strings" + "sync" + "sync/atomic" "time" "github.com/Tencent/LoopForge/internal/domain" @@ -15,6 +17,9 @@ import ( type roomsHandlers struct { *deps runCache roomRunCache + // crewMu serialises crew mission writes; crewLoopOn guards the captain loop. + crewMu sync.Mutex + crewLoopOn atomic.Bool } // roomMaxSOPs bounds one multi-SOP dispatch. @@ -159,10 +164,18 @@ func (h *roomsHandlers) messages(c *gin.Context) { } switch c.Request.Method { case http.MethodGet: - c.JSON(http.StatusOK, h.syncRoomRuns(room, h.repo.Messages(room.ID, 200))) + messages := h.syncRoomRuns(room, h.repo.Messages(room.ID, 200)) + for _, m := range messages { + if m.Kind == domain.ChatKindCrew && m.Crew != nil && domain.CrewMissionActive(m.Crew.Status) { + h.ensureCrewLoop() + break + } + } + c.JSON(http.StatusOK, messages) case http.MethodPost: var req struct { - Body string `json:"body"` + Body string `json:"body"` + ReplyTo string `json:"replyTo"` } if !decode(c, &req) { return @@ -178,11 +191,15 @@ func (h *roomsHandlers) messages(c *gin.Context) { } userID, author := h.roomActor(c) msg := domain.ChatMessage{ID: newChatID("msg"), RoomID: room.ID, UserID: userID, Author: author, Kind: "text", Body: body, CreatedAt: time.Now().UTC()} + if quoted, ok := h.repo.Message(strings.TrimSpace(req.ReplyTo)); ok && quoted.RoomID == room.ID { + msg.ReplyTo, msg.ReplyAuthor, msg.ReplyText = quoted.ID, roomQuoteAuthor(quoted), roomTrim(roomQuoteText(quoted), 200) + } if err := h.repo.PutMessage(msg); err != nil { respond(c, err) return } h.touchRoom(room) + h.crewHear(room, msg) c.JSON(http.StatusCreated, msg) default: c.JSON(http.StatusMethodNotAllowed, gin.H{"error": "method not allowed"}) diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index a4addeb..72af288 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -306,6 +306,14 @@ func New(s *repo.Store, e *engine.Engine, syncSvc *tracker.Service, basicUser, b g.POST("/api/rooms/:id/messages", rooms.messages) g.POST("/api/rooms/:id/issues", rooms.createIssue) g.POST("/api/rooms/:id/decisions", rooms.decide) + g.POST("/api/rooms/:id/crew", rooms.createCrew) + g.POST("/api/rooms/:id/crew/:mid", rooms.crewAction) + go func() { + time.Sleep(5 * time.Second) + if rooms.tickCrews() > 0 { + rooms.ensureCrewLoop() + } + }() g.POST("/api/chat-clarify", api.stream.chatClarify) g.GET("/api/chat-clarify", api.stream.attachChatClarify) g.POST("/api/chat-clarify/cancel", api.stream.chatClarifyCancel) diff --git a/internal/httpapi/share_gate.go b/internal/httpapi/share_gate.go index 8f8dffa..e9d2d42 100644 --- a/internal/httpapi/share_gate.go +++ b/internal/httpapi/share_gate.go @@ -273,17 +273,22 @@ f.onsubmit=async(e)=>{ return ` 外部审批 · LoopForge

    外部一次决策

    ` + htmlEsc(title) + `

    diff --git a/internal/repo/catalog.go b/internal/repo/catalog.go index b1abafc..6bb2c8e 100644 --- a/internal/repo/catalog.go +++ b/internal/repo/catalog.go @@ -389,7 +389,8 @@ func (r *CatalogRepo) Workflows() []domain.WorkflowDefinition { out := make([]domain.WorkflowDefinition, 0, len(stored)+len(generic)) for _, workflow := range stored { workflow = normalizeWorkflowDefinition(workflow) - if reserved[workflow.ID] { + // Crew steps are one-off single-role runs, not SOPs anyone picks. + if reserved[workflow.ID] || workflow.Status == domain.WorkflowStatusCrew { continue } out = append(out, workflow) diff --git a/internal/repo/rooms.go b/internal/repo/rooms.go index 52e5145..82a1bca 100644 --- a/internal/repo/rooms.go +++ b/internal/repo/rooms.go @@ -135,3 +135,36 @@ func (r *RoomsRepo) PutMessage(v domain.ChatMessage) error { "payload": mustJSON(v), "created_at": timePtr(v.CreatedAt), }) } + +// CrewMessages lists the crew mission messages of one room, or of every room +// when roomID is empty, oldest first. +func (r *RoomsRepo) CrewMessages(roomID string) []domain.ChatMessage { + q := r.DB.Table("chat_messages").Where("kind = ?", domain.ChatKindCrew).Order("created_at asc") + if roomID = strings.TrimSpace(roomID); roomID != "" { + q = q.Where("room_id = ?", roomID) + } + var rows []chatMessageRow + if err := q.Find(&rows).Error; err != nil { + return nil + } + out := make([]domain.ChatMessage, 0, len(rows)) + for _, row := range rows { + var item domain.ChatMessage + if json.Unmarshal(row.Payload, &item) != nil || item.Crew == nil { + continue + } + if item.ID == "" { + item.ID = row.ID + } + if item.RoomID == "" { + item.RoomID = row.RoomID + } + out = append(out, item) + } + return out +} + +// Message loads one chat message by id. +func (r *RoomsRepo) Message(id string) (domain.ChatMessage, bool) { + return payloadOne[domain.ChatMessage](r.DB, "chat_messages", strings.TrimSpace(id)) +} diff --git a/web/app.js b/web/app.js index b2e478a..39ba375 100644 --- a/web/app.js +++ b/web/app.js @@ -9,7 +9,7 @@ const state = { selectedAgent:null, selectedRun:null, selectedRunNode:null, runFilter:'all', runQuery:'', runWorkflowFilter:'', selectedIssue:null, projectFilter:'', projectDraft:null, requirementIssueId:null, sourceFilter:'', sopScope:'project', agentScope:'project', ruleScope:'project', skillScope:'project', dockerfileScope:'project', environmentScope:'project', sandboxScope:'project', - issueRunFilter:'', issueStatusFilter:'', issueAssigneeFilter:'', issueQuery:'', issueView:localStorage.getItem('loopforge.issueView')||'table', + issueRunFilter:'', issueStatusFilter:'', issueAssigneeFilter:'', issueQuery:'', issueView:localStorage.getItem('loopforge.issueView')||'kanban', assetType:'dockerfiles', selectedAsset:null, mcpTab:'market', mcpScope:'project', mcpMarket:[], mcpQuery:'', editingMCPId:'', poll:null, runStream:null, obsTab:'overview', obsDeliveryFilter:'all', obsTraceRunId:null, obsTraceAdapter:'live', obsScope:localStorage.getItem('loopforge.obsScope')==='project'?'project':'user', obsUserFilter:'', obsSourceFilter:'', obsUserFilterReady:false, obsTrendGrain:['day','week','month','all'].includes(localStorage.getItem('loopforge.obsTrendGrain'))?localStorage.getItem('loopforge.obsTrendGrain'):'all', obsPeriodKey:'', obsTrendMetric:['runs','issues','cost'].includes(localStorage.getItem('loopforge.obsTrendMetric'))?localStorage.getItem('loopforge.obsTrendMetric'):'tokens', obsCrossProject:null, drawerOpen:false, issueDetailTab:'overview', issueDetailData:null, propTab:'basic', uiScroll:{runId:'', nodeId:'', graphLeft:0, feedTop:0, telTop:0, feedStick:true, telStick:true}, @@ -905,7 +905,7 @@ async function boot(){ restoreRequirementComposer(); if(typeof maybeStartOnboarding==='function')maybeStartOnboarding(); } -const savedTheme=localStorage.getItem('loopforge.theme.v3')||'light';document.documentElement.dataset.theme=savedTheme; +const savedTheme=localStorage.getItem('loopforge.theme.v3')||'dark';document.documentElement.dataset.theme=savedTheme; function bind(){ prepareMCPFormHints(); @@ -921,6 +921,20 @@ function bind(){ setCapabilityNavExpanded(localStorage.getItem('loopforge.capabilityNavExpanded')==='true'); const environmentToggle=$('#environmentNavToggle');if(environmentToggle)environmentToggle.onclick=()=>setEnvironmentNavExpanded(environmentToggle.getAttribute('aria-expanded')!=='true'); setEnvironmentNavExpanded(localStorage.getItem('loopforge.environmentNavExpanded')==='true'); + $$('[data-nav-group]').forEach(toggle=>{ + const group=toggle.dataset.navGroup; + toggle.onclick=()=>{ + const open=toggle.getAttribute('aria-expanded')!=='true'; + if(open&&navIsCompact())$$('[data-nav-group]').forEach(t=>t!==toggle&&setNavGroupExpanded(t.dataset.navGroup,false)); + setNavGroupExpanded(group,open); + }; + setNavGroupExpanded(group,!navIsCompact()&&localStorage.getItem(`loopforge.navGroup.${group}`)==='true'); + }); + document.addEventListener('click',e=>{ + if(!navIsCompact()||e.target.closest('[data-nav-group]'))return; + const child=e.target.closest('[data-nav-group-body] .nav'); + if(child||!e.target.closest('[data-nav-group-body]'))$$('[data-nav-group-body]:not([hidden])').forEach(body=>{body.hidden=true;$(`[data-nav-group="${body.dataset.navGroupBody}"]`)?.setAttribute('aria-expanded','false')}); + }); $$('#accessTabs [data-access-tab]').forEach(btn=>btn.onclick=()=>setAccessTab(btn.dataset.accessTab)); $('#guideCreateToken')&&($('#guideCreateToken').onclick=()=>setAccessTab('tokens',true)); $$('[data-add]').forEach(btn=>btn.onclick=()=>addNode(btn.dataset.add)); @@ -938,7 +952,7 @@ function bind(){ $('#workflowDescription').oninput=e=>{if(!state.workflow)return;state.workflow.description=e.target.value;dirty()}; $('#saveWorkflow').onclick=saveWorkflow; $('#discardWorkflow').onclick=discardWorkflow; $('#publishWorkflow').onclick=publishWorkflow; $('#flowHistory')&&($('#flowHistory').onclick=()=>openRevisionHistory('workflow', state.workflow?.id, state.workflow?.name)); - $('#deleteNode').onclick=deleteSelected; $('#fitButton').onclick=arrange; + $('#deleteNode').onclick=deleteSelected;{const wb=$('.flow-workbench'),tg=$('#flowPropsToggle');const setProps=on=>{wb?.classList.toggle('props-hidden',!on);tg?.classList.toggle('on',on);localStorage.setItem('loopforge.flowProps',on?'1':'0');renderEdges()};setProps(localStorage.getItem('loopforge.flowProps')!=='0');tg&&(tg.onclick=()=>setProps(wb.classList.contains('props-hidden')));$('#nodes')?.addEventListener('dblclick',e=>{if(e.target.closest('.flow-node'))setProps(true)})} $('#fitButton').onclick=arrange; bindFlowResize(); $('#runButton').onclick=()=>openRunModal(state.workflow?.id); $('#flowSettingsToggle').onclick=()=>{const panel=$('#flowSettingsPanel');panel.hidden=!panel.hidden;$('#flowSettingsToggle').classList.toggle('active',!panel.hidden)}; @@ -957,9 +971,13 @@ function bind(){ const sel=$('#workflowSandbox'); if(sel)sel.focus(); }; - $$('.canvas-view-toggle [data-flow-view]').forEach(btn=>btn.onclick=()=>setFlowView(btn.dataset.flowView)); + $$('.canvas-view-toggle [data-flow-view]').forEach(btn=>btn.onclick=()=>setFlowView(btn.dataset.flowView));bindCanvasPan(); const refreshRunnersBtn=$('#refreshRunners');if(refreshRunnersBtn)refreshRunnersBtn.onclick=()=>refreshRunners(); $('#themeToggle').onclick=toggleTheme;renderThemeToggle(); + $('#langToggle')&&($('#langToggle').onclick=()=>window.lfSetLang&&window.lfSetLang(window.lfLang&&window.lfLang()==='en'?'zh':'en')); + {const spotSel='.ap-card,.db-card,.db-stat,.kanban-card,.run-card,.sop-card,.hc-sop,.room-suggest,.room-bot-run,.pa-rules li,.metric-card,.catalog-card,.agent-card,.skill-card,.mcp-card';let spotEl=null; + document.addEventListener('pointermove',e=>{const el=e.target.closest?.(spotSel)||null;if(el!==spotEl){spotEl?.classList.remove('lf-spot-on');spotEl=el;el?.classList.add('lf-spot-on')}if(el){const r=el.getBoundingClientRect();el.style.setProperty('--mx',`${e.clientX-r.left}px`);el.style.setProperty('--my',`${e.clientY-r.top}px`)}},{passive:true}); + document.addEventListener('pointerleave',()=>{spotEl?.classList.remove('lf-spot-on');spotEl=null});} const profileWrap = $('#userProfileWrap'); const userBadge = $('#userBadge'); const credentialsButton = $('#userCredentialsButton'); @@ -1197,6 +1215,12 @@ function bind(){ const panel=$('#flowSettingsPanel'); if(panel&&!panel.hidden){panel.hidden=true;$('#flowSettingsToggle')?.classList.remove('active');return} } + if(e.metaKey||e.ctrlKey||e.altKey||e.target.closest?.('input,textarea,select,[contenteditable="true"]')||$('.modal.open'))return; + if(e.key.toLowerCase()==='a'){ + const scope=state.drawerOpen?$('#issueDetailBody'):activeViewName()==='approvals'?$('#approvalList'):activeViewName()==='runs'?$('#runDetail'):null; + const approve=scope?.querySelector('[data-approve]'); + if(approve){e.preventDefault();approve.click()} + } }); // --- 工作台 / 待办验收 / 可观测与分析 / Issue 详情抽屉 --- @@ -1349,9 +1373,12 @@ function bindGlobalModals(){ } } const pluginViews=new Map(); -function viewTitles(){return {...Object.fromEntries([...pluginViews.values()].map(v=>[v.id,v.title])),chat:'首页',issues:'需求列表',rooms:'协作群聊',runs:'运行记录',metrics:'研发效能',approvals:'待我确认',sops:'流程模板',flow:'流程设计',agents:'角色定义',mcp:'MCP 管理',rules:'规则管理',skills:'技能管理',dockerfiles:'Dockerfile',environments:'环境安装脚本',sandboxes:'沙箱管理',observability:'用量分析',projects:'项目配置',businessDomains:'业务领域',platform:'平台管理',businessDomainManagement:'领域管理',runners:'执行机器',settings:'个人设置',access:'接入方式',guide:'接入指南',knowledge:'知识中心'}} +function viewTitles(){return {...Object.fromEntries([...pluginViews.values()].map(v=>[v.id,v.title])),chat:'首页',issues:'需求看板',rooms:'协作群聊',runs:'运行',metrics:'研发效能',approvals:'待我确认',sops:'流程模板',flow:'流程设计',agents:'角色',mcp:'MCP',rules:'规则',skills:'技能',dockerfiles:'Dockerfile',environments:'安装脚本',sandboxes:'沙箱',observability:'用量分析',projects:'项目',businessDomains:'业务领域',platform:'平台管理',businessDomainManagement:'领域管理',runners:'执行机器',settings:'个人设置',access:'接入方式',guide:'接入指南',knowledge:'知识中心'}} function activeViewName(){if(state.currentView)return state.currentView;const el=document.querySelector('.view.active');return el?el.id.replace(/View$/,''):''} function setCapabilityNavExpanded(expanded){const toggle=$('#capabilityNavToggle'),group=$('#capabilityNavGroup');if(!toggle||!group)return;toggle.setAttribute('aria-expanded',String(expanded));group.hidden=!expanded;localStorage.setItem('loopforge.capabilityNavExpanded',String(expanded))} +function setNavGroupExpanded(group,expanded){const toggle=$(`[data-nav-group="${group}"]`),body=$(`[data-nav-group-body="${group}"]`);if(!toggle||!body)return;toggle.setAttribute('aria-expanded',String(expanded));body.hidden=!expanded;localStorage.setItem(`loopforge.navGroup.${group}`,String(expanded))} +function navIsCompact(){return window.matchMedia('(max-width: 960px)').matches} +function syncNavGroups(name){$$('[data-nav-group-body]').forEach(body=>{const group=body.dataset.navGroupBody,owns=!!body.querySelector(`.nav[data-view="${name}"]`);$(`[data-nav-group="${group}"]`)?.classList.toggle('child-active',owns);if(!navIsCompact())setNavGroupExpanded(group,owns)})} function setEnvironmentNavExpanded(expanded){const toggle=$('#environmentNavToggle'),group=$('#environmentNavGroup');if(!toggle||!group)return;toggle.setAttribute('aria-expanded',String(expanded));group.hidden=!expanded;localStorage.setItem('loopforge.environmentNavExpanded',String(expanded))} function restoredView(){ const titles=viewTitles(); @@ -1431,7 +1458,61 @@ function persistView(name,push=false){ if(push)history.pushState(null,'',next); else history.replaceState(null,'',next); } +// 侧栏只留几个入口;入口下的页面用顶部标签(必要时再加一层子标签)切换。 +const VIEW_TABSETS=[ + {nav:'issues',label:'交付',tabs:[{view:'issues',label:'需求看板'},{view:'runs',label:'运行'},{view:'approvals',label:'待我确认',count:true}]}, + {nav:'build',label:'构建',tabs:[ + {view:'sops',label:'流程',subs:[['sops','模板'],['flow','设计']]}, + {view:'agents',label:'角色'}, + {view:'skills',label:'能力',subs:[['skills','技能'],['mcp','MCP'],['rules','规则'],['knowledge','知识']]}, + {view:'sandboxes',label:'环境',subs:[['sandboxes','沙箱'],['dockerfiles','Dockerfile'],['environments','安装脚本']]}, + {view:'projects',label:'项目'}]}, + {nav:'insights',label:'洞察',tabs:[{view:'metrics',label:'效能'},{view:'observability',label:'用量'},{view:'businessDomains',label:'汇报'}],plugins:true}, + {nav:'admin',label:'平台',tabs:[{view:'platform',label:'账号与权限',when:()=>isPlatformAdmin()},{view:'businessDomainManagement',label:'业务领域'}]}, + {nav:'me',label:'设置',tabs:[{view:'settings',label:'个人设置'},{view:'runners',label:'执行机器'},{view:'guide',label:'接入指南'}]}, +]; +function tabsetTabs(set){ + const tabs=set.tabs.filter(t=>!t.when||t.when()); + if(set.plugins)[...pluginViews.values()].forEach(v=>tabs.push({view:v.id,label:v.title})); + return tabs; +} +function tabOwns(tab,name){return tab.view===name||(tab.subs||[]).some(s=>s[0]===name)} +function viewTabset(name){return VIEW_TABSETS.find(s=>tabsetTabs(s).some(t=>tabOwns(t,name)))||null} +function resolveSectionView(name){ + const set=VIEW_TABSETS.find(s=>s.nav===name&&!s.tabs.some(t=>t.view===name)); + if(!set)return name; + const last=localStorage.getItem(`loopforge.section.${name}`); + const tabs=tabsetTabs(set); + return last&&tabs.some(t=>tabOwns(t,last))?last:(tabs[0]?.view||'chat'); +} +function applyViewTabset(name,target){ + const set=viewTabset(name);if(!set)return; + localStorage.setItem(`loopforge.section.${set.nav}`,name); + $(`.platform-sidebar .nav[data-view="${set.nav}"]`)?.classList.add('active'); + const view=$(`#${target}View`);if(!view)return; + const tabs=tabsetTabs(set); + const cur=tabs.find(t=>tabOwns(t,name)); + const count=$('#approvalNavBadge')?.textContent||''; + const title=$('#pageTitle');if(title)title.innerHTML=`${set.label}/${escapeHTML(cur?.label||title.textContent)}${cur?.subs?`/${escapeHTML((cur.subs.find(s=>s[0]===name)||[])[1]||'')}`:''}`; + let pagebar=view.querySelector(':scope>.lf-pagebar'); + if(!pagebar){ + pagebar=document.createElement('div');pagebar.className='lf-pagebar'; + pagebar.innerHTML='
    '; + view.prepend(pagebar); + view.querySelectorAll(':scope>.page-heading>.heading-actions,:scope>.page-heading>button,:scope>.page-heading>.button').forEach(el=>pagebar.lastElementChild.appendChild(el)); + } + const bar=pagebar.firstElementChild; + bar.setAttribute('aria-label',set.label); + bar.innerHTML=tabs.map(t=>{const on=t===cur;const go=on?name:(t.subs?(localStorage.getItem(`loopforge.sub.${t.view}`)||t.view):t.view);return ``}).join(''); + if(cur?.subs){ + localStorage.setItem(`loopforge.sub.${cur.view}`,name); + const sub=document.createElement('div');sub.className='lf-subtabs'; + sub.innerHTML=cur.subs.map(([v,l])=>``).join(''); + bar.appendChild(sub); + } +} function switchView(name){ + name=resolveSectionView(name); const titles=viewTitles(); if(name==='access')name='guide'; if(name==='assets')name='dockerfiles'; @@ -1452,9 +1533,12 @@ function switchView(name){ if(['mcp','rules','skills'].includes(name))setCapabilityNavExpanded(true); $('#environmentNavToggle')?.classList.toggle('child-active',['dockerfiles','environments','sandboxes'].includes(name)); if(['dockerfiles','environments','sandboxes'].includes(name))setEnvironmentNavExpanded(true); + syncNavGroups(name); + document.body.dataset.page=name; $$('.view').forEach(v=>v.classList.remove('active')); $(`#${target}View`).classList.add('active'); $('#pageTitle').textContent=titles[name]||name; + applyViewTabset(name,target); showDirectRunButton(name==='flow'); closeIssueDrawer(); // 仅真实用户切换时压栈:boot 首次进入(prevView 为空)与同视图重复调用 diff --git a/web/app/access.js b/web/app/access.js index 1e38e9c..fb2875f 100644 --- a/web/app/access.js +++ b/web/app/access.js @@ -273,7 +273,17 @@ function commandCatalog(){ const runs=(state.data.runs||[]).map(r=>({kind:'运行',title:runIssueTitle(r),meta:`${runDisplayStatus(r)} · ${r.workflowSnapshot?.name||r.workflowId}`,action:()=>openRunFromId(r.id)})); const sops=catalogWorkflows().map(w=>({kind:'SOP',title:w.name,meta:`${w.status==='published'?'已发布':'草稿'} · ${w.nodes?.length||0} 个节点`,action:()=>{state.workflow=clone(w);state.selectedNode=null;switchView('flow')}})); const agents=(state.data.agents||[]).map(a=>({kind:'Agent',title:a.name,meta:a.description||a.id,action:()=>{switchView('agents');openAgentWizard(a)}})); - const actions=[{kind:'操作',title:'新建 SOP',meta:'创建流程草稿',action:openSOPWizard}]; + const waiting=typeof approvalItems==='function'?approvalItems():[]; + const approve=waiting.slice(0,3).map(r=>{const nodeId=waitingNodeId(r),node=(r.workflowSnapshot?.nodes||(state.data.workflows||[]).find(w=>w.id===r.workflowId)?.nodes||[]).find(n=>n.id===nodeId);return {kind:'审批',title:`通过:${runIssueTitle(r)}`,meta:`停在「${node?.name||nodeId||'确认'}」· 继续下游`,action:()=>nodeId?approveRun(r.id,nodeId):openRunFromId(r.id)}}); + const actions=[ + ...approve, + {kind:'操作',title:'新建需求',meta:'写清楚要做什么,选 SOP 开跑',action:()=>openRequirementWizard()}, + {kind:'操作',title:'问助手',meta:'回首页和平台助手对话',action:()=>{switchView('chat');setTimeout(()=>$('#homeChatInput')?.focus(),60)}}, + ...(typeof quickstartDemoAvailable==='function'&&quickstartDemoAvailable()?[{kind:'操作',title:'跑一遍演示',meta:'约 30 秒,不调模型,停在「确认方案」等你',action:()=>startDemoRequirement(null)}]:[]), + {kind:'操作',title:'新建 SOP',meta:'创建流程草稿',action:openSOPWizard}, + {kind:'操作',title:'切换深色 / 亮色',meta:'当前:'+(document.documentElement.dataset.theme==='light'?'亮色':'深色'),action:toggleTheme}, + {kind:'操作',title:'Switch to English / 切换到中文',meta:'中文 ⇄ English',action:()=>window.lfSetLang&&window.lfSetLang(window.lfLang&&window.lfLang()==='en'?'zh':'en')}, + ]; return [...actions,...pages,...issues,...runs,...sops,...agents]; } function openCommandPalette(){const host=$('#commandPalette');host.hidden=false;$('#commandSearch').value='';renderCommandResults();requestAnimationFrame(()=>$('#commandSearch').focus())} @@ -291,7 +301,7 @@ function highlightCommandSelection(){ } function renderCommandResults(){ const q=$('#commandSearch').value.trim().toLowerCase(); - commandMatches=commandCatalog().filter(x=>!q||`${x.kind} ${x.title} ${x.meta}`.toLowerCase().includes(q)).slice(0,12); + commandMatches=commandCatalog().filter(x=>!q||`${x.kind} ${x.title} ${x.meta}`.toLowerCase().includes(q)).slice(0,q?12:9); commandSelectedIndex=0; $('#commandResults').innerHTML=commandMatches.length ?commandMatches.map((x,i)=>``).join('') diff --git a/web/app/agents.js b/web/app/agents.js index 5ebfda0..de8b61e 100644 --- a/web/app/agents.js +++ b/web/app/agents.js @@ -9,7 +9,11 @@ function renderAgents(){ const used=new Map();(state.data.workflows||[]).forEach(w=>(w.nodes||[]).forEach(n=>{if(n.agentId)used.set(n.agentId,(used.get(n.agentId)||0)+1)})); $('#agentCatalogStats').innerHTML=`${agents.length} 个${state.agentScope==='shared'?'共享':'项目'}角色${agents.reduce((n,a)=>n+(a.skills?.length||0),0)} 项技能绑定${agents.reduce((n,a)=>n+(a.mcpServers?.length||0),0)} 个 MCP 连接`; const empty=state.agentScope==='shared'?'还没有共享给当前项目的角色。':'当前项目还没有角色。可以新建,或从共享角色复制后改。'; - list.innerHTML=agents.map((a,index)=>{const initials=(a.name||'AI').replace(/[^A-Za-z\u4e00-\u9fa5]/g,'').slice(0,2),tone=['blue','purple','green','amber'][index%4];const exec=(state.data.executors||[]).find(x=>x.id===a.executorId);return`
    ${escapeHTML(initials)}

    ${escapeHTML(a.name)}

    ${escapeHTML(a.id)}
    v${a.version||1}

    ${escapeHTML(a.description||'尚未填写职责说明')}

    ${escapeHTML(a.model||exec?.model||exec?.name||'未选模型')}${a.loop?.enabled?`${a.loop.maxIterations||1} 轮 Loop`:'单轮'}${a.artifactOutputs?.length||0} 产物${a.verification?.length||0} 门禁${a.hooks?.length||0} Hook
    R${a.rules?.length||0} 规则S${a.skills?.length||0} 技能M${a.mcpServers?.length||0} MCP
    用于 ${used.get(a.id)||0} 个流程节点${shareBadge(a,'agent')}${catalogOwned(a.projectId)?``:''}
    `}).join('')||`
    AI${empty}

    调整搜索,或新建一个角色。

    `; + const hue=t=>{let h=0;for(const c of String(t||''))h=(h*31+c.charCodeAt(0))%360;return h}; + list.innerHTML=agents.map(a=>{const initials=(a.name||'AI').replace(/[^A-Za-z\u4e00-\u9fa5]/g,'').slice(0,2)||'AI';const exec=(state.data.executors||[]).find(x=>x.id===a.executorId);const n=used.get(a.id)||0; + const meta=[`${a.skills?.length||0} 技能`,`${a.mcpServers?.length||0} MCP`,n?`用在 ${n} 个步骤`:'还没用在流程里'].join(' · '); + return`
    ${escapeHTML(initials)}

    ${escapeHTML(a.name)}

    ${escapeHTML(exec?.name||a.executorId||'未选 CLI')}${a.model?` · ${escapeHTML(a.model)}`:''}${a.loop?.enabled?` · ${a.loop.maxIterations||1} 轮`:''}
    ${a.shared?'共享':''}

    ${escapeHTML(a.description||'尚未填写职责说明')}

    ${escapeHTML(meta)}${shareBadge(a,'agent')}${catalogOwned(a.projectId)?``:''}编辑 ›
    `}).join('')+(state.agentScope==='shared'?'':``)||`
    AI${empty}

    调整搜索,或新建一个角色。

    `; + $('#agentNewCard')&&($('#agentNewCard').onclick=()=>openAgentWizard()); list.querySelectorAll('[data-share-agent]').forEach(btn=>btn.onclick=e=>{e.stopPropagation();toggleShare('agent',btn.dataset.shareAgent)}); list.querySelectorAll('[data-clone-agent]').forEach(btn=>btn.onclick=e=>{e.stopPropagation();cloneAgent(btn.dataset.cloneAgent)}); list.querySelectorAll('[data-delete-agent]').forEach(btn=>btn.onclick=e=>{e.stopPropagation();if(btn.disabled)return;deleteAgent(btn.dataset.deleteAgent)}); @@ -135,7 +139,7 @@ function openAgentWizard(agent=null,initialStep=1){ const open=a=>{ const presetHost=$('#agentPresetHost');if(presetHost)presetHost.dataset.ready=''; selectAgent(a||{id:'',name:'',version:1,projectId:state.projectFilter||'',shared:false,description:'',instructions:'',taskPrompt:{},rules:[],skills:[],mcpServers:[],hooks:[],loop:{enabled:false,maxIterations:3,onExhausted:'fail'},interventions:[],artifactOutputs:[],verification:[]},false); - setAgentWizardStep(initialStep);$('#agentModal').classList.add('open'); + bindAgentStudio();$('#agentForm').classList.remove('is-dirty');$('#agentForm').classList.toggle('is-creating',!agent);setAgentStudioMode(initialStep>1?'full':(localStorage.getItem('loopforge.agentMode')||'simple'),false);if($('#agentAIInput'))$('#agentAIInput').value='';$('#agentModal').classList.add('open');setAgentWizardStep(initialStep,initialStep>1); if(catalogGeneric(state.workflow))loadMCPMarket().then(()=>{const current=workflowAgent(state.selectedAgent)||state.agentDraft||a;if(current)renderAgentAssetsPanel(current)}); }; if(agent?.id && !agent.instructions){ @@ -145,32 +149,80 @@ function openAgentWizard(agent=null,initialStep=1){ open(agent); } function applyAgentPreset(id){const p=agentPresets.find(x=>x.id===id),f=$('#agentForm');if(!p)return;f.elements.id.value=f.elements.id.value||`${id}-${Math.random().toString(36).slice(2,6)}`;f.elements.name.value=p.title;f.elements.description.value=p.desc;f.elements.instructions.value=p.instructions;$$('[data-agent-preset]').forEach(b=>b.classList.toggle('active',b.dataset.agentPreset===id));renderAgentSummary();toast(`已套用「${p.name}」模板,可直接保存或继续调整`)} -function setAgentWizardStep(step){ +function setAgentWizardStep(step,scroll=true){ const f=$('#agentForm'); - if(step>1&&state.agentWizardStep===1&&(!f.elements.id.value.trim()||!f.elements.name.value.trim()))return toast('选择模板或填写标识和名称'); state.agentWizardStep=Math.max(1,Math.min(4,step)); - f.querySelectorAll('.wizard-pane').forEach(p=>p.classList.toggle('active',Number(p.dataset.step)===state.agentWizardStep)); - f.querySelectorAll('[data-wizard-steps="agent"] [data-step-target]').forEach(b=>{ - const n=Number(b.dataset.stepTarget); - b.classList.toggle('active',n===state.agentWizardStep); - b.classList.toggle('done',np.classList.add('active')); + f.querySelectorAll('[data-wizard-steps="agent"] [data-step-target]').forEach(b=>b.classList.toggle('active',Number(b.dataset.stepTarget)===state.agentWizardStep)); + const main=f.querySelector('.as-main'),pane=f.querySelector(`.wizard-pane[data-step="${state.agentWizardStep}"]`); + if(scroll&&main&&pane){state.agentScrollLock=Date.now();main.scrollTo({top:state.agentWizardStep===1?0:pane.offsetTop-main.offsetTop-8,behavior:'smooth'})} const save=$('#agentSave'); const foreign=agentWizardForeign(agentWizardSubject()); save.hidden=foreign; save.textContent='保存'; - save.classList.toggle('primary',state.agentWizardStep===4); - save.classList.toggle('ghost',state.agentWizardStep!==4); + save.classList.add('primary');save.classList.remove('ghost'); const cloneBtn=$('#agentClone'); if(cloneBtn)cloneBtn.hidden=catalogGeneric(state.workflow)||!foreign; - $('#agentStepHint').textContent=`${state.agentWizardStep} / 4`; - if(state.agentWizardStep===3&&state.selectedAgent){const a=agentWizardSubject();renderAgentGovernance(a)} + if(state.agentWizardStep===3&&state.selectedAgent&&scroll){const a=agentWizardSubject();renderAgentGovernance(a)} renderAgentSummary(); } -async function saveAgent(e){e.preventDefault();const f=e.currentTarget;const existing=state.data.agents.find(a=>a.id===f.elements.id.value);if(!catalogGeneric(state.workflow)&&existing&&catalogForeign(existing.projectId))return toast('这是其他项目的共享角色,请先复制到本项目再改');const checked=name=>[...f.querySelectorAll(`input[name="${name}"]:checked`)].map(x=>x.value);const draft=readAgentDraft(f);const artifacts=draft.artifactOutputs.map((x,i)=>({name:x.name||`artifact-${i+1}`,path:x.path||'result.md',mediaType:x.path?.endsWith('.json')?'application/json':(x.mediaType||'text/markdown'),required:!!x.required})),verification=draft.verification.map((gate,i)=>({id:gate.id||`gate-${i+1}`,name:gate.name||`门禁 ${i+1}`,mode:'command',stage:'after_execution',commands:(gate.commands||[]).map(c=>String(c||'').trim()).filter(Boolean),required:true,onFailure:['loop','reject','fail','human'].includes(gate.onFailure)?gate.onFailure:'loop',executionScope:gate.executionScope==='repositories'?'repositories':'run'})).filter(g=>g.commands.length),hooks=(draft.hooks||[]).map((h,i)=>({id:String(h.id||`hook-${i+1}`).trim(),name:String(h.name||`Hook ${i+1}`).trim(),event:h.event||'PreToolUse',matcher:String(h.matcher||'').trim(),language:h.language==='python'?'python':'shell',script:String(h.script||'').trim(),timeoutSec:Number(h.timeoutSec)||10,executionScope:h.executionScope==='repositories'?'repositories':'run'})).filter(h=>h.script),interventions=[];if(f.elements.interventionAfter?.checked)interventions.push({id:'human-review',name:'人工结果复核',stage:'after_execution',required:true});if(f.elements.interventionPublish?.checked)interventions.push({id:'publish-approval',name:'产物发布确认',stage:'before_publish',required:true});const a={id:f.elements.id.value.trim(),name:f.elements.name.value.trim(),version:(existing?.version||0)+1,projectId:existing?(existing.projectId||state.projectFilter||''):(state.projectFilter||''),shared:!!f.elements.shared?.checked,description:f.elements.description.value,instructions:f.elements.instructions.value,taskPrompt:draft.taskPrompt,executorId:f.elements.executorId?.value||'',model:agentEffectiveModel(f),effort:f.elements.effort?.value||'',rules:checked('rules'),skills:checked('skills'),mcpServers:checked('mcpServers'),platformMcp:!!f.elements.platformMcp?.checked,hooks,loop:{enabled:!!f.elements.loopEnabled?.checked,maxIterations:Number(f.elements.loopMaxIterations?.value)||1,exitCondition:'all_gates_pass',onExhausted:f.elements.loopOnExhausted?.value||'fail',feedbackPrompt:f.elements.loopFeedbackPrompt?.value||''},interventions,artifactOutputs:artifacts,verification};if(!a.id||!a.name)return toast('标识和名称不能为空');if(catalogGeneric(state.workflow)){if(!isPlatformAdmin())return toast('只有平台管理员可以编辑通用模板角色');const bundled=workflowAgent(a.id);if(!bundled)return toast('该角色不属于当前通用模板');a.projectId='';a.shared=false;a.rules=bundled.rules||[];a.skills=bundled.skills||[];a.mcpServers=f.querySelectorAll('input[name="mcpServers"]').length?checked('mcpServers'):(bundled.mcpServers||[]);a.hooks=Array.isArray(bundled.hooks)?bundled.hooks:a.hooks;a.version=bundled.version||0;try{const saved=await api(`/api/workflows/${encodeURIComponent(state.workflow.id)}/agents`,{method:'PUT',body:JSON.stringify(a)});const list=state.genericTemplateAgents||[];const gi=list.findIndex(x=>x.id===saved.id);if(gi>=0)list[gi]=saved;else list.push(saved);state.genericTemplateAgents=list;state.selectedAgent=saved.id;state.agentDraft=null;$('#agentModal').classList.remove('open');renderCanvas();renderProperties();toast('通用模板角色已保存,所有项目下次运行生效');}catch(err){toast(err.message)}return;}try{await api('/api/agents',{method:'PUT',body:JSON.stringify(a)});const i=state.data.agents.findIndex(x=>x.id===a.id);if(i>=0)state.data.agents[i]=a;else state.data.agents.push(a);if(a.shared){(state.data.rules||[]).forEach(item=>{if(a.rules.includes(item.id)&&item.projectId===a.projectId)item.shared=true});(state.data.skills||[]).forEach(item=>{if(a.skills.includes(item.id)&&item.projectId===a.projectId)item.shared=true})}state.selectedAgent=a.id;state.agentDraft=null;$('#agentModal').classList.remove('open');renderAgents();renderCanvas();toast('Agent 新版本已保存') }catch(err){toast(err.message)}} +const AGENT_DRAFT_RULES=[ + [/安全|漏洞|权限|security/i,'reviewer','安全审查员'], + [/评审|审查|review|\bcr\b/i,'reviewer','代码审查员'], + [/测试|用例|回归|\bqa\b|test/i,'tester','测试工程师'], + [/发布|上线|合并|交付|checks?|pipeline/i,'coordinator','交付协调员'], + [/分析|根因|调研|方案|拆解|需求/i,'developer','需求分析师'], + [/文档|readme|说明书|doc/i,'developer','文档工程师'], + [/前端|页面|样式|\bui\b|组件/i,'developer','前端工程师'], + [/后端|接口|\bapi\b|数据库|服务端/i,'developer','后端工程师'], +]; +function agentSlug(text){const ascii=String(text||'').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'');return ascii.length>=3?ascii.slice(0,40):''} +function draftAgentFromText(text){ + const t=String(text||'').trim();if(!t)return toast('先用一句话说说这个角色要干什么'); + const rule=AGENT_DRAFT_RULES.find(r=>r[0].test(t))||[null,'developer','研发工程师']; + const preset=agentPresets.find(p=>p.id===rule[1])||agentPresets[0]; + const f=$('#agentForm'),el=f.elements; + const readonlyHint=/不改|只读|只提|不修改|不动代码/.test(t)?'\n不得修改业务代码,只输出结论与建议。':''; + el.name.value=rule[2]; + if(!el.id.readOnly)el.id.value=`${agentSlug(preset.id)||'role'}-${Math.random().toString(36).slice(2,6)}`; + el.description.value=t; + el.instructions.value=`${preset.instructions}\n\n本角色的具体职责:${t}${readonlyHint}`; + f.classList.add('is-dirty','has-draft'); + renderAgentSummary(); + f.querySelector('.as-draft-flash')?.classList.remove('as-draft-flash'); + requestAnimationFrame(()=>f.querySelector('.wizard-pane[data-step="1"]')?.classList.add('as-draft-flash')); + el.name.focus();el.name.select(); +} +function setAgentStudioMode(mode,remember=true){ + const f=$('#agentForm');if(!f)return; + const full=mode==='full'; + f.classList.toggle('as-simple',!full); + $$('[data-agent-mode]').forEach(b=>b.classList.toggle('active',b.dataset.agentMode===(full?'full':'simple'))); + if(remember)localStorage.setItem('loopforge.agentMode',full?'full':'simple'); + if(full&&state.selectedAgent)renderAgentGovernance(agentWizardSubject()); +} +function bindAgentStudio(){ + const f=$('#agentForm');if(!f||f.dataset.studio)return;f.dataset.studio='1'; + const main=f.querySelector('.as-main'); + main?.addEventListener('scroll',()=>{ + if(Date.now()-(state.agentScrollLock||0)<600)return; + const top=main.scrollTop+main.offsetTop+80;let cur=1; + f.querySelectorAll('.wizard-pane').forEach(p=>{if(p.offsetTop<=top)cur=Number(p.dataset.step)}); + if(main.scrollTop+main.clientHeight>=main.scrollHeight-4)cur=4; + if(cur!==state.agentWizardStep){state.agentWizardStep=cur;f.querySelectorAll('[data-wizard-steps="agent"] [data-step-target]').forEach(b=>b.classList.toggle('active',Number(b.dataset.stepTarget)===cur))} + },{passive:true}); + $('#agentCloseAlt')&&($('#agentCloseAlt').onclick=()=>f.querySelector('.agent-close')?.click()); + $$('[data-agent-mode]').forEach(b=>b.onclick=()=>setAgentStudioMode(b.dataset.agentMode)); + $('#agentAIGo')&&($('#agentAIGo').onclick=()=>draftAgentFromText($('#agentAIInput').value)); + $('#agentAIInput')?.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();draftAgentFromText(e.target.value)}}); + $$('[data-agent-example]').forEach(b=>b.onclick=()=>{$('#agentAIInput').value=b.dataset.agentExample;draftAgentFromText(b.dataset.agentExample)}); + f.elements.name?.addEventListener('input',()=>{if(!f.elements.id.readOnly&&!f.elements.id.dataset.touched){f.elements.id.value=agentSlug(f.elements.name.value)||f.elements.id.value||`role-${Math.random().toString(36).slice(2,6)}`}}); + f.elements.id?.addEventListener('input',()=>{f.elements.id.dataset.touched='1'}); + const markDirty=()=>{f.classList.add('is-dirty')}; + f.addEventListener('input',markDirty);f.addEventListener('change',markDirty); + f.addEventListener('keydown',e=>{if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='s'){e.preventDefault();if(!$('#agentSave').hidden)f.requestSubmit()}}); +} +async function saveAgent(e){e.preventDefault();const f=e.currentTarget;const existing=state.data.agents.find(a=>a.id===f.elements.id.value);if(!catalogGeneric(state.workflow)&&existing&&catalogForeign(existing.projectId))return toast('这是其他项目的共享角色,请先复制到本项目再改');const checked=name=>[...f.querySelectorAll(`input[name="${name}"]:checked`)].map(x=>x.value);const draft=readAgentDraft(f);const artifacts=draft.artifactOutputs.map((x,i)=>({name:x.name||`artifact-${i+1}`,path:x.path||'result.md',mediaType:x.path?.endsWith('.json')?'application/json':(x.mediaType||'text/markdown'),required:!!x.required})),verification=draft.verification.map((gate,i)=>({id:gate.id||`gate-${i+1}`,name:gate.name||`门禁 ${i+1}`,mode:'command',stage:'after_execution',commands:(gate.commands||[]).map(c=>String(c||'').trim()).filter(Boolean),required:true,onFailure:['loop','reject','fail','human'].includes(gate.onFailure)?gate.onFailure:'loop',executionScope:gate.executionScope==='repositories'?'repositories':'run'})).filter(g=>g.commands.length),hooks=(draft.hooks||[]).map((h,i)=>({id:String(h.id||`hook-${i+1}`).trim(),name:String(h.name||`Hook ${i+1}`).trim(),event:h.event||'PreToolUse',matcher:String(h.matcher||'').trim(),language:h.language==='python'?'python':'shell',script:String(h.script||'').trim(),timeoutSec:Number(h.timeoutSec)||10,executionScope:h.executionScope==='repositories'?'repositories':'run'})).filter(h=>h.script),interventions=[];if(f.elements.interventionAfter?.checked)interventions.push({id:'human-review',name:'人工结果复核',stage:'after_execution',required:true});if(f.elements.interventionPublish?.checked)interventions.push({id:'publish-approval',name:'产物发布确认',stage:'before_publish',required:true});const a={id:f.elements.id.value.trim(),name:f.elements.name.value.trim(),version:(existing?.version||0)+1,projectId:existing?(existing.projectId||state.projectFilter||''):(state.projectFilter||''),shared:!!f.elements.shared?.checked,description:f.elements.description.value,instructions:f.elements.instructions.value,taskPrompt:draft.taskPrompt,executorId:f.elements.executorId?.value||'',model:agentEffectiveModel(f),effort:f.elements.effort?.value||'',rules:checked('rules'),skills:checked('skills'),mcpServers:checked('mcpServers'),platformMcp:!!f.elements.platformMcp?.checked,hooks,loop:{enabled:!!f.elements.loopEnabled?.checked,maxIterations:Number(f.elements.loopMaxIterations?.value)||1,exitCondition:'all_gates_pass',onExhausted:f.elements.loopOnExhausted?.value||'fail',feedbackPrompt:f.elements.loopFeedbackPrompt?.value||''},interventions,artifactOutputs:artifacts,verification};if(!a.id||!a.name)return toast('标识和名称不能为空');if(catalogGeneric(state.workflow)){if(!isPlatformAdmin())return toast('只有平台管理员可以编辑通用模板角色');const bundled=workflowAgent(a.id);if(!bundled)return toast('该角色不属于当前通用模板');a.projectId='';a.shared=false;a.rules=bundled.rules||[];a.skills=bundled.skills||[];a.mcpServers=f.querySelectorAll('input[name="mcpServers"]').length?checked('mcpServers'):(bundled.mcpServers||[]);a.hooks=Array.isArray(bundled.hooks)?bundled.hooks:a.hooks;a.version=bundled.version||0;try{const saved=await api(`/api/workflows/${encodeURIComponent(state.workflow.id)}/agents`,{method:'PUT',body:JSON.stringify(a)});const list=state.genericTemplateAgents||[];const gi=list.findIndex(x=>x.id===saved.id);if(gi>=0)list[gi]=saved;else list.push(saved);state.genericTemplateAgents=list;state.selectedAgent=saved.id;state.agentDraft=null;$('#agentModal').classList.remove('open');renderCanvas();renderProperties();($('#agentForm')?.classList.remove('is-dirty'),toast('通用模板角色已保存,所有项目下次运行生效'));}catch(err){toast(err.message)}return;}try{await api('/api/agents',{method:'PUT',body:JSON.stringify(a)});const i=state.data.agents.findIndex(x=>x.id===a.id);if(i>=0)state.data.agents[i]=a;else state.data.agents.push(a);if(a.shared){(state.data.rules||[]).forEach(item=>{if(a.rules.includes(item.id)&&item.projectId===a.projectId)item.shared=true});(state.data.skills||[]).forEach(item=>{if(a.skills.includes(item.id)&&item.projectId===a.projectId)item.shared=true})}state.selectedAgent=a.id;state.agentDraft=null;$('#agentModal').classList.remove('open');renderAgents();renderCanvas();($('#agentForm')?.classList.remove('is-dirty'),toast('Agent 新版本已保存')) }catch(err){toast(err.message)}} async function deleteAgent(id){ const a=(state.data.agents||[]).find(x=>x.id===id); if(!a)return toast('角色不存在或已删除'); diff --git a/web/app/approvals.js b/web/app/approvals.js index cbc0d0d..2ca5402 100644 --- a/web/app/approvals.js +++ b/web/app/approvals.js @@ -19,13 +19,6 @@ function reworkTarget(node,nodes){ if(!id)return null; return {id,name:(nodes||[]).find(n=>n.id===id)?.name||id}; } -function waitingActionButtons(runId,nodeId,nr,node,nodes){ - if(turnsExhausted(nr))return ``; - const target=reworkTarget(node,nodes); - const rework=target?``:''; - const retry=node?.type==='approval'?'':``; - return `${retry}${rework}`; -} function humanGateSectionsHTML(nr,node){ if(turnsExhausted(nr)){ return `
    说明

    ${escapeHTML(nr?.error||'请确认是否重试本节点。不重试则本节点失败。')}

    `; @@ -53,9 +46,29 @@ function humanGateSectionsHTML(nr,node){ } return sections.join(''); } -function humanDecisionPanelHTML(runId,nodeId,nr,node,nodes){ +// One decision card for run detail, 待我确认 and the issue drawer. Run detail binds +// buttons by node id (runId omitted); the other two bind by run id plus data-node. +function decisionCardHTML({runId='',nodeId,nr,node,nodes,compact=false}){ + const exhausted=turnsExhausted(nr); + const attrs=key=>runId?`data-${key}="${escapeHTML(runId)}" data-node="${escapeHTML(nodeId)}"`:`data-${key}="${escapeHTML(nodeId)}"`; + const target=exhausted?null:reworkTarget(node,nodes); const field=isScriptWorkflowNode(node)?'':humanContextFieldHTML(); - return `
    ${humanGateSectionsHTML(nr,node)}
    ${field}
    ${waitingActionButtons(runId,nodeId,nr,node,nodes)}
    `; + const nodeTitle=escapeHTML(node?.name||nodeId||'当前节点'); + const title=exhausted?'对话轮次已用尽':(nr?.gateResult?.decision==='human'||node?.type==='approval'?'等你确认':'需要人工判断'); + let actions; + if(exhausted){ + actions=``; + }else{ + const reject=target + ?`` + :`未配置返工目标,不能退回`; + const retry=node?.type==='approval'?'':``; + actions=`A${retry}${reject}`; + } + return `
    ${title}步骤 · ${nodeTitle}
    ${humanGateSectionsHTML(nr,node)}
    ${field}
    ${actions}
    `; +} +function humanDecisionPanelHTML(runId,nodeId,nr,node,nodes){ + return decisionCardHTML({runId,nodeId,nr,node,nodes,compact:true}); } function failedNodeId(run){ const nodes=run?.workflowSnapshot?.nodes||[]; @@ -195,16 +208,7 @@ function pausedRunPanelHTML(run){ } function approvalCalloutHTML(node,nr,nodes){ nodes=nodes||state.activeRun?.workflowSnapshot?.nodes||[]; - const field=isScriptWorkflowNode(node)?'':humanContextFieldHTML(); - const nodeTitle=escapeHTML(node?.name||node?.id||'当前节点'); - const gate=nr?.gateResult; - const target=reworkTarget(node,nodes); - const rework=turnsExhausted(nr)?'':(target?``:''); - const buttons=turnsExhausted(nr) - ? `` - : `${node?.type==='approval'?'':``}${rework}`; - const title=turnsExhausted(nr)?'对话轮次已用尽':(gate?.decision==='human'?'等待人工确认':'需要人工判断'); - return `
    待确认${title}步骤:${nodeTitle}
    ${humanGateSectionsHTML(nr,node)}
    ${field}
    ${buttons}
    `; + return decisionCardHTML({nodeId:node?.id||'',nr,node,nodes}); } async function retryWaitingNode(runId,nodeId,fromEl){ const issue=(state.data.issues||[]).find(i=>i.id===issueIdForRun(runId))||state.issueDetailData?.issue; @@ -338,8 +342,10 @@ async function renderApprovals(background=false){ const sideButton=issue?``:``; const decision=r.pauseRequested?pausedRunPanelHTML(r):humanDecisionPanelHTML(r.id,nodeId,r.nodes?.[nodeId],node,r.workflowSnapshot?.nodes||[]); const shareBtn=r.pauseRequested?'':``; - return `
    待确认${escapeHTML(title)}${r.pauseRequested?'已暂停':'待确认'}
    ${escapeHTML(key)} | ${r.pauseRequested?'暂停于':'卡在'}「${escapeHTML(node?.name||nodeId||'确认')}」| 已等待 ${relativeTime(r.createdAt)}
    ${decision}${!r.pauseRequested?refreshRunSOPButtonHTML(r):''}${shareBtn}${sideButton}
    `; - }).join('')||'
    没有需要你拍板的需求
    '; + const steps=(r.workflowSnapshot?.nodes||[]).map(n=>{const st=r.nodes?.[n.id]?.status||'pending';return ``}).join(''); + return `
    ${escapeHTML(key)}${escapeHTML(title)}${r.pauseRequested?'已暂停':'待确认'}
    ${r.pauseRequested?'暂停于':'停在'}「${escapeHTML(node?.name||nodeId||'确认')}」· 已等待 ${relativeTime(r.createdAt)}
    ${decision}
    `; + }).join('')||'
    没有需要你拍板的事

    流程停在人工确认节点时会出现在这里,侧边栏「运行」上也会亮起角标。

    '; + $$('[data-approval-count]').forEach(el=>{el.hidden=!items.length;el.textContent=items.length||''}); host.querySelectorAll('[data-open-issue]').forEach(b=>b.onclick=()=>openIssueDetail(b.dataset.openIssue,'sop')); host.querySelectorAll('[data-open-logs]').forEach(b=>b.onclick=()=>openRunFromId(b.dataset.openLogs,{forceRuns:true})); host.querySelectorAll('[data-cancel-run]').forEach(b=>b.onclick=()=>cancelRunFlow(b.dataset.cancelRun)); diff --git a/web/app/assets.js b/web/app/assets.js index eaeee11..e2ebf28 100644 --- a/web/app/assets.js +++ b/web/app/assets.js @@ -4,11 +4,11 @@ function renderAssets(){ const dedicated=isProjectSharedAsset(state.assetType); const definitionScope=state[assetScopeKey(state.assetType)]; const copies={ - rules:['能力资产','规则管理','维护当前项目的行为约束和执行规则。','+ 新建规则'], - skills:['能力资产','技能管理','维护当前项目可供角色使用的技能和附加文件。','+ 新建技能'], - dockerfiles:['运行环境','Dockerfile','维护当前项目的容器构建定义。','+ 新建 Dockerfile'], - environments:['运行环境','环境安装脚本','维护安装到隔离执行环境中的工具与依赖。','+ 新建安装脚本'], - sandboxes:['运行环境','沙箱管理','维护当前项目的运行隔离方式及环境依赖。','+ 新建沙箱'] + rules:['能力','规则','维护当前项目的行为约束和执行规则。','+ 新建规则'], + skills:['能力','技能','维护当前项目可供角色使用的技能和附加文件。','+ 新建技能'], + dockerfiles:['环境','Dockerfile','维护当前项目的容器构建定义。','+ 新建 Dockerfile'], + environments:['环境','安装脚本','维护安装到隔离执行环境中的工具与依赖。','+ 新建安装脚本'], + sandboxes:['环境','沙箱','维护当前项目的运行隔离方式及环境依赖。','+ 新建沙箱'] }; const pageCopy=[...(copies[state.assetType]||copies.dockerfiles)];if(definitionScope==='shared')pageCopy[2]=state.assetType==='rules'?'查看其他项目共享的规则;共享规则只读,可直接绑定到本项目角色。':state.assetType==='skills'?'查看其他项目共享的技能;共享技能只读,可直接绑定到本项目角色。':'查看其他项目共享的环境资产;共享资产只读,可直接用于本项目。'; if($('#assetPageEyebrow'))$('#assetPageEyebrow').textContent=pageCopy[0];if($('#assetPageTitle'))$('#assetPageTitle').textContent=pageCopy[1];if($('#assetPageDescription'))$('#assetPageDescription').textContent=pageCopy[2];if($('#newAsset'))$('#newAsset').textContent=pageCopy[3]; @@ -21,7 +21,7 @@ function renderAssets(){ const cards=items.map(a=>{ const blocked=catalogOwned(a.projectId)?assetDeleteBlocked(a):''; const deleteBtn=dedicated&&catalogOwned(a.projectId)?``:''; - return `

    ${escapeHTML(a.name)}

    ${assetKind()}

    ${escapeHTML(a.description||'暂无说明')}

    ${assetChips(a)}${dedicated&&catalogOwned(a.projectId)?`${deleteBtn}`:dedicated?`来自 ${escapeHTML((state.data.projects||[]).find(p=>p.id===a.projectId)?.name||a.projectId||'平台')}`:''}
    `; + return `
    ${assetTileHTML(a)}

    ${escapeHTML(a.name)}

    ${a.shared&&catalogOwned(a.projectId)?'共享中':''}

    ${escapeHTML(a.description||'暂无说明')}

    ${assetChips(a)}${dedicated&&catalogOwned(a.projectId)?`${deleteBtn}`:dedicated?`来自 ${escapeHTML((state.data.projects||[]).find(p=>p.id===a.projectId)?.name||a.projectId||'平台')}`:''}
    `; }).join(''); const sharedEmpty=dedicated&&definitionScope==='shared';const shareHint=['rules','skills'].includes(state.assetType)?'请在资产所属项目中共享,或共享一个引用它的角色。':'请在资产所属项目中共享;共享 SOP 时也会自动共享其环境依赖。';const emptyState=!cards&&state.selectedAsset!=='__new__';list.closest('.agent-layout')?.classList.toggle('is-empty',emptyState); list.innerHTML=filter+(cards||(sharedEmpty?`
    其他项目还没有共享${assetKindLabel()}

    ${shareHint}

    `:`
    当前项目还没有${/^[A-Za-z]/.test(assetKindLabel())?' ':''}${assetKindLabel()}

    ${assetEmptyHint()}

    `)); @@ -83,7 +83,10 @@ function dockerfileOptions(selected){const items=(state.data.dockerfiles||[]).fi function environmentOptions(selected){const items=(state.data.environments||[]).filter(x=>catalogVisible(x.projectId,x.shared));return ``+items.map(x=>``).join('')} function syncSandboxDriverFields(select){const driver=select?.value||'local';select?.form?.querySelectorAll('[data-sandbox-drivers]').forEach(el=>{el.hidden=!el.dataset.sandboxDrivers.split(' ').includes(driver)})} function resolveAssetSaveIdentity(items,selectedID,requestedID){if(selectedID!=='__new__'){const existing=(items||[]).find(item=>item.id===selectedID);return existing?{id:existing.id,existing,created:false}:{error:'选中的资产已不存在,请刷新后重试'}}if((items||[]).some(item=>item.id===requestedID))return {error:'该标识已存在,请从左侧选择后修改'};return {id:requestedID,existing:null,created:true}} -function renderAssetForm(a){const f=$('#assetForm');if(!a){f.innerHTML='
    选择或新建资产
    ';return}const creating=state.selectedAsset==='__new__';const owned=creating||catalogOwned(a.projectId);const common=`

    ${creating?'新建':owned?'编辑':'查看'}${/^[A-Za-z]/.test(assetKind())?' ':''}${assetKind()}

    ${owned?'':'只读'}
    ${creating?'':'
    标识用于资产引用,创建后不可修改。
    '}`;let fields=''; +const ASSET_GLYPHS={rules:'',skills:'',dockerfiles:'',environments:'',sandboxes:''}; +function assetHue(text){let h=0;for(const ch of String(text||''))h=(h*31+ch.charCodeAt(0))%360;return h} +function assetTileHTML(a,big){return ``} +function renderAssetForm(a){const f=$('#assetForm');if(!a){f.innerHTML='
    选择或新建资产
    ';return}const creating=state.selectedAsset==='__new__';const owned=creating||catalogOwned(a.projectId);const common=`
    ${assetTileHTML(a,true)}
    ${creating?'新建':owned?'编辑':'查看'}${/^[A-Za-z]/.test(assetKind())?' ':''}${assetKind()}

    ${escapeHTML(creating?'未命名':(a.name||a.id))}

    ${creating?'':`${escapeHTML(a.id)}`}
    ${owned?'':'只读'}
    ${creating?'':'
    标识用于资产引用,创建后不可修改。
    '}`;let fields=''; if(state.assetType==='rules')fields=`${ruleGlobalFields(a)}`; if(state.assetType==='skills')fields=`${creating?`
    先创建技能,再上传文件夹或 zip 初始化文件。
    `:''}
    diff --git a/web/app/flows.js b/web/app/flows.js index bd39ede..7ad4dcc 100644 --- a/web/app/flows.js +++ b/web/app/flows.js @@ -226,6 +226,25 @@ function setFlowView(mode){$$('.canvas-view-toggle button').forEach(b=>b.classLi function renderThemeToggle(){const light=document.documentElement.dataset.theme==='light',button=$('#themeToggle');if(!button)return;button.querySelector('span').textContent=light?'☾':'☀';button.querySelector('b').textContent=light?'深色':'亮色';button.title=light?'切换到深色主题':'切换到亮色主题'} function toggleTheme(){const theme=document.documentElement.dataset.theme==='light'?'dark':'light';document.documentElement.dataset.theme=theme;localStorage.setItem('loopforge.theme.v3',theme);renderThemeToggle();toast(theme==='light'?'已切换亮色主题':'已切换深色主题')} function statusText(s){return ({pending:'待执行',running:'运行中',succeeded:'已完成',failed:'失败',waiting_approval:'待审批',waiting:'待确认',paused:'已暂停',cancelled:'已取消'}[s]||'')} +function bindCanvasPan(){ + const canvas=$('#canvas');if(!canvas||canvas.dataset.pan)return;canvas.dataset.pan='1'; + let pan=null; + const blank=t=>t===canvas||t.id==='nodes'||t.id==='edges'||t.classList?.contains('canvas-grid'); + canvas.addEventListener('pointerdown',e=>{ + if(!(e.button===1||(e.button===0&&blank(e.target))))return; + pan={x:e.clientX,y:e.clientY,l:canvas.scrollLeft,t:canvas.scrollTop,moved:false}; + canvas.setPointerCapture(e.pointerId);canvas.classList.add('is-panning'); + if(e.button===1)e.preventDefault(); + }); + canvas.addEventListener('pointermove',e=>{ + if(!pan)return;const dx=e.clientX-pan.x,dy=e.clientY-pan.y; + if(Math.abs(dx)+Math.abs(dy)>3)pan.moved=true; + canvas.scrollLeft=pan.l-dx;canvas.scrollTop=pan.t-dy; + }); + const end=()=>{if(!pan)return;if(pan.moved)canvas.addEventListener('click',ev=>ev.stopPropagation(),{capture:true,once:true});pan=null;canvas.classList.remove('is-panning')}; + canvas.addEventListener('pointerup',end);canvas.addEventListener('pointercancel',end); + canvas.addEventListener('wheel',e=>{if(e.shiftKey&&!e.deltaX&&e.deltaY){canvas.scrollLeft+=e.deltaY;e.preventDefault()}},{passive:false}); +} function canvasPoint(e){ const canvas=$('#canvas');if(!canvas)return {x:0,y:0}; const rect=canvas.getBoundingClientRect(); @@ -317,7 +336,8 @@ function renderEdges(){ `; let html=defs; - const size=state.workflow.nodes.reduce((acc,n)=>({w:Math.max(acc.w,n.position.x+320),h:Math.max(acc.h,n.position.y+220)}),{w:1200,h:800}); + const canvasEl=$('#canvas'),floatPad=$('.flow-workbench')?.classList.contains('props-hidden')?160:480; + const size=state.workflow.nodes.reduce((acc,n)=>({w:Math.max(acc.w,n.position.x+220+floatPad),h:Math.max(acc.h,n.position.y+320)}),{w:Math.max(1200,(canvasEl?.clientWidth||0)+240),h:Math.max(800,(canvasEl?.clientHeight||0)+200)}); svg.setAttribute('width',size.w);svg.setAttribute('height',size.h);svg.style.width=size.w+'px';svg.style.height=size.h+'px'; state.workflow.edges.forEach(e=>{ const a=state.workflow.nodes.find(n=>n.id===e.source),b=state.workflow.nodes.find(n=>n.id===e.target);if(!a||!b)return; @@ -328,6 +348,7 @@ function renderEdges(){ html+=` + diff --git a/web/app/homechat.js b/web/app/homechat.js index ef89d18..31adeb6 100644 --- a/web/app/homechat.js +++ b/web/app/homechat.js @@ -27,9 +27,18 @@ function homeChatWorkflows() { const list = []; [HOME_CHAT_QUICK, HOME_CHAT_DEMO].forEach(id => { const w = all.find(x => x.id === id); if (w) list.push(w); }); mine.forEach(w => { if (!list.some(x => x.id === w.id)) list.push(w); }); + all.filter(w => catalogGeneric(w) && w.status !== 'archived').forEach(w => { if (!list.some(x => x.id === w.id)) list.push(w); }); return list; } +function homeWorkflowOptionsHTML(workflows, selectedId) { + const opt = w => ``; + const own = workflows.filter(w => !catalogGeneric(w)); + const generic = workflows.filter(w => catalogGeneric(w)); + if (!generic.length) return own.map(opt).join(''); + return `${own.length ? `${own.map(opt).join('')}` : ''}${generic.map(opt).join('')}`; +} + function homeChatStoreKey() { return `${HOME_CHAT_STORE}:${state.currentUser?.id || 0}:${state.projectFilter || ''}`; } @@ -124,6 +133,10 @@ function renderHomeChat() { renderHomeChatMain(); renderHomeRunsPanel(); startHomeRunsPolling(); + if (!state.homeRunsPrimed) { + state.homeRunsPrimed = true; + refreshRuns(true).then(renderHomeRunsPanel).catch(() => {}); + } } function renderHomeChatMain() { @@ -143,6 +156,8 @@ function renderHomeChatMain() {
    ${chat.messages.map(homeChatMessageHTML).join('')}${chat.busy ? homeChatLiveHTML() : ''}
    ${homeChatComposerHTML()}
    `; bindHomeChat(); + bindHomeBento(main.querySelector('#hcBento')); + $('#homeSopSelect') && ($('#homeSopSelect').onchange = e => { chat.workflowId = e.target.value; persistHomeChat(); }); renderHomeMermaid(main); const thread = $('#homeChatThread'); if (thread) thread.scrollTop = thread.scrollHeight; @@ -163,30 +178,74 @@ function homeChatSubtitle() { } function homeChatHeroHTML() { - const workflows = homeChatWorkflows(); - const workflow = homeChatSelectedWorkflow(); - const keyHint = homeChatHasModelKey() ? '' : `

    还没配置 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 `
    ${homeChatHistoryHTML()}
    -
    -

    LoopForge

    -

    提需求、问进度、处理待确认、画图出文档,都在这里说。需求说清了,我按 SOP 帮你开跑。

    - ${homeChatComposerHTML()} +
    +

    ${escapeHTML(homeGreeting())},${escapeHTML(user.displayName || user.name || user.loginName || '')}

    +

    今天想交付什么?

    +
    ${homeChatComposerHTML()}
    ${keyHint}
    ${HOME_CHAT_SUGGESTIONS.map((s, i) => ``).join('')}
    - ${workflows.length ? `
    开跑默认用的 SOP
    ${cards}
    ` : '

    这个项目还没有 SOP。

    '} +
    ${homeBentoHTML()}
    `; } +function homeSopPickerHTML(disabled) { + const workflows = homeChatWorkflows(); + if (!workflows.length) return ''; + const current = homeChatSelectedWorkflow(); + return ``; +} + +function homeGreeting() { + const h = new Date().getHours(); + return h < 5 ? '夜深了' : h < 11 ? '早上好' : h < 13 ? '中午好' : h < 18 ? '下午好' : '晚上好'; +} + +function homeBentoHTML() { + const runs = homeRunsForProject(); + const active = runs.filter(r => HOME_RUN_ACTIVE.has(r.status)); + const waiting = active.filter(r => homeRunStatus(r).key === 'waiting'); + const moving = active.filter(r => homeRunStatus(r).key !== 'waiting'); + if (!waiting.length && !moving.length) return ''; + const row = (run, decide) => { + const steps = homeRunSteps(run); + const cur = steps.find(x => x.status === 'waiting') || steps.find(x => x.status === 'running') || steps.find(x => x.status === 'failed'); + const nodeId = decide ? (Object.keys(run.nodes || {}).find(id => run.nodes[id]?.status === 'waiting_approval') || '') : ''; + const quick = decide && nodeId && !run.pauseRequested && !(typeof turnsExhausted === 'function' && turnsExhausted(run.nodes[nodeId])); + return `
    + +
    ${escapeHTML(runIssueTitle(run))} + ${escapeHTML(cur ? `${decide ? '等你确认' : '正在'}「${cur.name}」` : homeRunStatus(run).label)} · ${escapeHTML(homeRunAgo(run.updatedAt || run.createdAt))}
    + ${steps.length ? `${steps.map(x => ``).join('')}` : ''} + ${decide ? (quick ? `` : '去确认 ›') : '›'} +
    `; + }; + const rows = [...waiting.map(r => row(r, true)), ...moving.map(r => row(r, false))]; + return `
    +
    ${waiting.length ? `${waiting.length} 件事等你拍板` : '正在进行'}${moving.length && waiting.length ? `· ${moving.length} 条在跑` : ''}
    + ${rows.slice(0, 4).join('')} +
    `; +} + +function bindHomeBento(root) { + if (!root) return; + root.querySelectorAll('[data-home-approve]').forEach(b => b.onclick = async e => { + e.stopPropagation(); + b.disabled = true; + await approveRun(b.dataset.homeApprove, b.dataset.node, b); + await refreshRuns(true); + renderHomeRunsPanel(); + }); + root.querySelectorAll('[data-home-goto]').forEach(b => b.onclick = e => { e.stopPropagation(); switchView(b.dataset.homeGoto); }); + root.querySelectorAll('[data-home-run]').forEach(card => { + const open = () => card.dataset.homeIssue ? openIssueDetail(card.dataset.homeIssue, 'sop') : (typeof openRunFromId === 'function' && openRunFromId(card.dataset.homeRun)); + card.onclick = open; + card.onkeydown = e => { if (e.key === 'Enter') open(); }; + }); +} + function homeChatComposerHTML() { const chat = ensureHomeChatState(); const latest = homeChatLatestReply(); @@ -209,6 +268,7 @@ function homeChatComposerHTML() { ${typeof clarifyModelPickerHTML === 'function' ? clarifyModelPickerHTML(chat.busy) : ''} + ${homeSopPickerHTML(chat.busy)} ${chat.busy ? '正在处理…' : uploading ? '附件上传中…' : 'Enter 发送 · Shift+Enter 换行'} ${chat.busy ? '' @@ -282,12 +342,24 @@ function homeChatLaunchCardHTML(m, index) { return `
    已开跑${escapeHTML(l.title || '')},进度在右侧。
    `; } const issue = l.issueId ? (state.data.issues || []).find(i => i.id === l.issueId) : null; - return `
    -
    ${issue ? '跑这条已有需求' : '需求已经说清,可以开跑'}${issue ? `按选中的 SOP 派发「${escapeHTML(homeIssueLabel(issue))}」` : '建一张需求单并立即派发'},进度在右侧看。
    + const mode = issue ? 'sop' : homeLaunchMode(l, workflows); + const crew = mode === 'crew'; + const recommended = l.mode === 'crew' || l.mode === 'sop' ? l.mode : ''; + const modes = issue ? '' : `
    + + +
    ${l.modeReason ? `

    ✦ ${escapeHTML(l.modeReason)}

    ` : ''}`; + const rooms = crew ? homeCrewRooms() : []; + const target = crew + ? `` + : ``; + return `
    +
    ${issue ? '跑这条已有需求' : '需求已经说清,交给谁做?'}${issue ? `按选中的 SOP 派发「${escapeHTML(homeIssueLabel(issue))}」,进度在右侧看。` : crew ? '队长 LoopForge 会拉角色 Bot 组队,交接和进度都在群里,你随时可以插话。' : '建一张需求单,按 SOP 一步步跑,进度在右侧看。'}
    + ${modes} ${issue ? '' : ``} - + ${target} ${l.description ? `
    ${issue ? '补充说明' : '澄清结论(会写进需求描述)'}
    ${renderMarkdown(l.description)}
    ` : ''} -
    +
    `; } @@ -315,7 +387,30 @@ function homeChatDraftHTML(d, index, di) {
    `; } +function homeLaunchMode(l, workflows) { + if (l.pickedMode) return l.pickedMode; + if (l.mode === 'crew' || l.mode === 'sop') return l.mode; + return (workflows || []).length ? 'sop' : 'crew'; +} + +// homeCrewRooms returns the project's rooms, loading them once per project. +function homeCrewRooms() { + const pid = (currentProject() || {}).id || state.projectFilter || ''; + const cache = state.homeRooms || (state.homeRooms = {}); + if (cache[pid] === undefined && pid) { + cache[pid] = null; + api('/api/rooms?projectId=' + encodeURIComponent(pid), { silent: true }) + .then(rooms => { cache[pid] = rooms || []; renderHomeChatMain(); }) + .catch(() => { cache[pid] = []; }); + } + return cache[pid] || []; +} + function homeChatLaunchedHTML(m) { + if (m.crew) { + return `

    已交给 #${escapeHTML(m.roomName || '协作群')} 的 Bot 团队:${escapeHTML(m.title || '')}。队长正在排分工,每个 Bot 做完会在群里交接,卡住了会 @ 你。

    +
    ${m.issueId ? `` : ''}
    `; + } return `

    已按「${escapeHTML(m.workflowName || m.workflowId || 'SOP')}」开跑:${escapeHTML(m.title || '')}。进度在右侧,需要你确认时会停下来等你。

    ${m.issueId ? `` : ''}
    `; } @@ -429,6 +524,16 @@ function bindHomeChat() { main.querySelectorAll('[data-home-goto]').forEach(b => b.onclick = () => switchView(b.dataset.homeGoto)); main.querySelectorAll('[data-open-created]').forEach(b => b.onclick = () => openIssueDetail(b.dataset.openCreated, 'sop')); main.querySelectorAll('[data-launch-go]').forEach(b => b.onclick = () => homeChatLaunch(b.closest('[data-launch]'))); + main.querySelectorAll('[data-launch-mode]').forEach(b => b.onclick = () => { + const m = ensureHomeChatState().messages[Number(b.closest('[data-launch]')?.dataset.launch)]; + if (!m?.launch) return; + m.launch.pickedMode = b.dataset.launchMode; + renderHomeChatMain(); + }); + main.querySelectorAll('[data-open-room]').forEach(b => b.onclick = () => { + if (typeof roomState === 'function' && b.dataset.openRoom) roomState().activeId = b.dataset.openRoom; + switchView('rooms'); + }); main.querySelectorAll('[data-draft]').forEach(b => b.onclick = () => homeChatConfirmDraft(b)); main.querySelectorAll('[data-clarify-model]').forEach(el => typeof bindClarifyModelPicker === 'function' && bindClarifyModelPicker(el)); main.querySelectorAll('.md-code-copy').forEach(b => b.onclick = () => { @@ -842,6 +947,7 @@ async function homeChatLaunch(card) { const l = m?.launch; const project = currentProject() || (state.data.projects || [])[0]; if (!l || !project) return toast('请先选择项目'); + if (!l.issueId && homeLaunchMode(l, homeChatWorkflows()) === 'crew') return homeChatLaunchCrew(card, m, project); const workflowId = card.querySelector('[data-launch-workflow]')?.value || homeChatSelectedWorkflow()?.id; const workflow = homeChatWorkflows().find(w => w.id === workflowId); if (!workflowId) return toast('这个项目还没有可用的 SOP'); @@ -882,6 +988,37 @@ async function homeChatLaunch(card) { await refreshAfterHomeWrite(); } +async function homeChatLaunchCrew(card, m, project) { + const chat = ensureHomeChatState(); + const l = m.launch; + const button = card.querySelector('[data-launch-go]'); + if (button) { button.disabled = true; button.textContent = '正在组队…'; } + const origin = { title: l.title || '', description: l.description || '', issueId: '' }; + const title = (card.querySelector('[data-launch-title]')?.value || '').trim() || l.title || homeChatTitle(); + try { + let roomId = card.querySelector('[data-launch-room]')?.value || ''; + let roomName = (homeCrewRooms().find(r => r.id === roomId) || {}).name || ''; + if (!roomId || roomId === '__new') { + const room = await api('/api/rooms', { method: 'POST', body: JSON.stringify({ projectId: project.id, name: '协作群' }) }); + roomId = room.id; + roomName = room.name; + state.homeRooms = {}; + } + const mission = await api('/api/rooms/' + encodeURIComponent(roomId) + '/crew', { method: 'POST', body: JSON.stringify({ goal: title, detail: l.description || '', autoStart: true, source: 'home' }) }); + l.done = true; + l.title = title; + l.issueId = mission.issueId || ''; + homeChatPush({ role: 'assistant', kind: 'launched', crew: true, roomId, roomName, title, issueId: mission.issueId || '' }); + homeChatNote(`用户把「${title}」交给了协作群「${roomName}」的 Bot 团队自由协作,需求 ${mission.issueId || ''}`); + homeChatMarkLaunchDone(chat.sessionId, origin, { id: mission.issueId || '', title }); + } catch (err) { + if (button) { button.disabled = false; button.textContent = '交给群聊开工'; } + return toast('没能交给群聊:' + err.message, 'err'); + } + persistHomeChat(); + renderHomeChatMain(); +} + // 开跑状态只在浏览器里会丢:写回服务端会话,从历史重新打开时卡片不会再次可点、重复建单。 async function homeChatMarkLaunchDone(sessionId, origin, issue) { if (!sessionId) return; @@ -1047,17 +1184,37 @@ function homeRunCardHTML(run) { ${steps.length ? `` : ''}
    ${escapeHTML(where)}${doneCount}/${steps.length || 0}
    ${failed?.error ? `

    ${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)}

    ` : ''}
    + ${quick ? `` : ''} + +
    `; +} + function renderHomeRunsPanel() { + const bento = $('#hcBento'); + if (bento && !bento.contains(document.activeElement)) { bento.innerHTML = homeBentoHTML(); bindHomeBento(bento); } const panel = $('#hcRuns'); if (!panel) return; const runs = homeRunsForProject(); const active = runs.filter(r => HOME_RUN_ACTIVE.has(r.status)); const dayAgo = Date.now() - 3 * 86400 * 1000; - const recent = runs.filter(r => !HOME_RUN_ACTIVE.has(r.status) && Date.parse(r.updatedAt || r.createdAt || '') > dayAgo).slice(0, 5); + const crewSeen = new Set(); + const recent = runs.filter(r => { + if (HOME_RUN_ACTIVE.has(r.status) || !(Date.parse(r.updatedAt || r.createdAt || '') > dayAgo)) return false; + if (!String(r.workflowId || '').startsWith('crew-')) return true; + if (crewSeen.has(r.issueId)) return false; + crewSeen.add(r.issueId); + return true; + }).slice(0, 5); const waiting = active.filter(r => homeRunStatus(r).key === 'waiting').length; panel.innerHTML = `
    @@ -1076,7 +1233,15 @@ function renderHomeRunsPanel() { layout.classList.toggle('runs-collapsed', collapsed); localStorage.setItem('loopforge.homeRuns.collapsed', collapsed ? '1' : '0'); }; + panel.closest('.hc-layout')?.classList.toggle('runs-idle', !active.length && !recent.length); panel.querySelector('#homeRunsRefresh').onclick = async () => { await refreshRuns(true); renderHomeRunsPanel(); }; + panel.querySelectorAll('[data-home-approve]').forEach(b => b.onclick = async e => { + e.stopPropagation(); + b.disabled = true; + await approveRun(b.dataset.homeApprove, b.dataset.node, b); + await refreshRuns(true); + renderHomeRunsPanel(); + }); panel.querySelectorAll('[data-home-goto]').forEach(b => b.onclick = () => switchView(b.dataset.homeGoto)); panel.querySelectorAll('[data-home-run]').forEach(card => { const open = () => card.dataset.homeIssue ? openIssueDetail(card.dataset.homeIssue, 'sop') : (typeof openRunFromId === 'function' && openRunFromId(card.dataset.homeRun)); diff --git a/web/app/issues.js b/web/app/issues.js index 980d6fe..c310037 100644 --- a/web/app/issues.js +++ b/web/app/issues.js @@ -120,24 +120,30 @@ function issueKanbanCard(i){ const isFailed=Boolean(run&&(['failed','cancelled'].includes(run.status)||issueHasFailedNode(run))); const needsConfirm=clarificationNeedsConfirm(i); const cardTone=needsConfirm?' needs-confirm':(isFailed?' needs-attention':''); - return `
    -
    ${escapeHTML(key)}
    + const assignee=issueAssigneeName(i)||''; + const pri=i.priority||'none'; + const bars=({urgent:4,high:3,medium:2,low:1}[pri]||0); + const ring=Math.max(0,Math.min(100,progress.pct||0)); + return `
    +
    ${escapeHTML(key)}${[1,2,3,4].map(n=>``).join('')}${assignee?`${escapeHTML(assignee.slice(0,1).toUpperCase())}`:''}

    ${issueTitleMarkup(i.title)}

    -
    - ${issuePriorityLabel(i.priority)} - ${escapeHTML(stage.label)} + ${w?`
    ${escapeHTML(w.name)}
    `:''} +
    + ${escapeHTML(stage.label)} + ${progress.total?`${progress.done}/${progress.total}`:''}
    -
    ${progress.done}/${progress.total||'—'} · ${progress.pct}%
    ${issueActionButtons(i)}
    `; } +const kanbanPrimaryStatuses=new Set(['backlog','todo','in_progress','in_review','done']); function renderIssueKanban(issues){ return `
    ${issueStatuses.map(([status])=>{ const col=issues.filter(i=>(i.status||'backlog')===status); + if(!col.length&&!kanbanPrimaryStatuses.has(status))return ''; return `
    -
    ${issueStatusLabel(status)}${col.length}
    -
    ${col.map(issueKanbanCard).join('')||'
    暂无卡片
    '}
    +
    ${issueStatusLabel(status)}${col.length}
    +
    ${col.map(issueKanbanCard).join('')||'
    拖拽卡片到这里
    '}
    `; }).join('')}
    `; } @@ -156,6 +162,7 @@ function bindIssueKanban(){ card.onclick=()=>{if(kanbanDragMoved){kanbanDragMoved=false;return}openIssueDetail(card.dataset.id)}; }); bindIssueRowActions($('#issueBoard')); + $$('[data-kanban-new]').forEach(btn=>btn.onclick=e=>{e.stopPropagation();if(typeof openRequirementWizard==='function')openRequirementWizard()}); $$('.kanban-column').forEach(col=>{ col.ondragover=e=>{e.preventDefault();e.dataTransfer.dropEffect='move';col.classList.add('drop-target')}; col.ondragleave=e=>{if(!col.contains(e.relatedTarget))col.classList.remove('drop-target')}; diff --git a/web/app/onboarding.js b/web/app/onboarding.js index 7bcdce8..e119c25 100644 --- a/web/app/onboarding.js +++ b/web/app/onboarding.js @@ -12,10 +12,10 @@ function onboardingSteps(demo) { return [ { target: '.nav[data-view="chat"]', title: '首页就是对话', body: '平台的事都可以先在首页问。它是这个项目的 PM 助理,会查需求、查运行,帮你澄清需求并按 SOP 开跑。' }, { target: '#homeChatForm', title: '在这里说', body: start }, - { target: '.nav[data-view="issues"]', title: '需求都在这里', body: '在这里新建,或从 GitHub 导入 Issue。说清楚要做什么就可以派发;没说清的地方,澄清会继续问。' }, - { target: '.nav[data-view="approvals"]', title: '要你拍板时来这里', body: '运行停下来等你决定,会出现在「待我确认」。先看清问题,再同意或退回。没有待办时,这里是空的。' }, - { target: '.nav[data-view="runs"]', title: '进度看运行记录', body: '点进一次运行,能看到停在哪一步、已经产出了什么。' }, - { target: '.nav[data-view="settings"]', ...last, finish: true }, + { target: '.nav[data-view="issues"]', title: '需求、运行、待确认都在「交付」', body: '顶部三个标签:需求看板管规划,运行看 Agent 正在哪一步,待我确认是等你拍板的节点。有东西要你确认时,这里会亮起角标。' }, + { target: '.nav[data-view="rooms"]', title: '没有固定流程的活,交给「群聊」', body: '在群里 @LoopForge 说一句要做什么,队长会拉几个 Bot 分工、交接、汇报;有问题也可以直接 @ 它问,或回复某条消息接着问。' }, + { target: '.nav[data-view="build"]', title: '流程、角色、能力、环境都在「构建」', body: 'SOP、角色、技能/MCP/规则、沙箱和项目都在这里,用顶部标签切换。日常不用动,要调整流程时再来。' }, + { target: '.nav[data-view="me"]', ...last, finish: true }, ]; } diff --git a/web/app/runs.js b/web/app/runs.js index e7bd7eb..1edc2b6 100644 --- a/web/app/runs.js +++ b/web/app/runs.js @@ -148,6 +148,20 @@ function renderRunOverview(){ const live=liveRuns(),runs=scopedRuns(state.data.runs||[]),active=live.filter(r=>r.status==='running'&&!r.pauseRequested).length,paused=runs.filter(r=>r.pauseRequested||r.status==='paused').length,waiting=live.filter(r=>r.status==='waiting'&&!r.pauseRequested).length,failed=runs.filter(r=>r.status==='failed').length,cancelled=runs.filter(r=>r.status==='cancelled').length,finished=runs.filter(r=>r.status==='succeeded'),successBase=finished.length+failed,rate=successBase?Math.round(finished.length*100/successBase):0,totalTokens=runs.reduce((n,r)=>n+Number(r.usage?.totalTokens||0),0); $('#runOverview').innerHTML=`
    进行中${active}
    已暂停${paused}
    待确认${waiting}
    已完成${finished.length}
    失败${failed}
    已取消${cancelled}
    成功率${rate}%${successBase} 次已结束
    累计 Token${formatTokens(totalTokens)}${runs.length} 次
    `; $('#runsLiveState').classList.toggle('active',active+waiting>0) + $('#runOverview').hidden=!runs.length; + $('#runsView .run-toolbar')&&($('#runsView .run-toolbar').hidden=!runs.length); + if(typeof updateApprovalBadge==='function')updateApprovalBadge(); +} +function runsFirstEmptyHTML(){ + const demo=typeof quickstartDemoAvailable==='function'&&quickstartDemoAvailable(); + return `
    还没有运行

    一次运行就是一条需求按 SOP 一步步往下走:分析、确认、实现、交付。每一步的进度、产物和日志都会在这里实时出现。

    ${demo?'':''}
    `; +} +function runLaneHTML(r,workflows){ + const defs=r.workflowSnapshot?.nodes||(workflows||[]).find(w=>w.id===r.workflowId)?.nodes||[]; + if(!defs.length){const p=runProgress(r);return `
    `} + const states=r.nodes||{}; + const glyph={succeeded:'✓',failed:'✕',cancelled:'–',skipped:'↷'}; + return `
    ${defs.map((n,i)=>{const st=states[n.id]?.status||'pending';const cls=st==='waiting'?'waiting_approval':st;return `${i?'':''}${glyph[st]||(i+1)}${escapeHTML(n.name||n.id)}`}).join('')}
    `; } function renderRuns(){ renderRunOverview();const workflows=catalogWorkflows();$('#runWorkflowFilter').innerHTML=''+workflows.map(w=>``).join(''); @@ -167,9 +181,10 @@ function renderRuns(){ const p=runProgress(r),ctx=runContext(r),sop=r.workflowSnapshot?.name||workflows.find(w=>w.id===r.workflowId)?.name||r.workflowId,title=ctx.issue?.title||sop; const isPaused=Boolean(r.pauseRequested||r.status==='paused'); const isDeletable=(!['running','waiting'].includes(r.status))||isPaused; - return `
    ${escapeHTML((ctx.project?.key||sop)).slice(0,1)}

    ${escapeHTML(title)}${r.workflowSnapshot?.generic?' 通用':''}

    ${ctx.issue?`${escapeHTML(ctx.project?.key||'ISS')}-${ctx.issue.number} · `:''}${escapeHTML(sop)} · ${relativeTime(r.createdAt)} ${taskTriggerBadge(ctx.task)}
    ${p.done}/${p.total} 步${runElapsed(r)}${runDisplayStatus(r)}${isDeletable?``:''}
    ` - }).join('')||'
    ⌕没有匹配的运行

    可到需求列表查看对应需求的执行结果。

    '; + return `
    ${escapeHTML((ctx.project?.key||sop)).slice(0,1)}

    ${escapeHTML(title)}${r.workflowSnapshot?.generic?' 通用':''}

    ${ctx.issue?`${escapeHTML(ctx.project?.key||'ISS')}-${ctx.issue.number} · `:''}${escapeHTML(sop)} · ${relativeTime(r.createdAt)} ${taskTriggerBadge(ctx.task)}
    ${p.done}/${p.total} 步${runElapsed(r)}${runDisplayStatus(r)}${isDeletable?``:''}
    ${runLaneHTML(r,workflows)}
    ` + }).join('')||(scopedRuns(state.data.runs||[]).length?'
    ⌕没有匹配的运行

    换个筛选条件,或清空搜索再看看。

    ':runsFirstEmptyHTML()); list.insertAdjacentHTML('beforeend', pagerHTML('runs', pagedRuns)); + list.querySelector('[data-runs-demo]')?.addEventListener('click',async e=>{const started=await startDemoRequirement(e.currentTarget,{stay:true});if(started?.task?.runId){await refreshRuns(true);openRunFromId(started.task.runId,{forceRuns:true})}}); list.querySelectorAll('.run-card').forEach(el=>el.onclick=(e)=>{if(e.target.closest('.run-view-btn,.run-del-btn,.pager'))return;showRunDetail(el.dataset.id)}); list.querySelectorAll('.run-view-btn').forEach(btn=>btn.onclick=(e)=>{e.stopPropagation();showRunDetail(btn.dataset.viewRun)}); list.querySelectorAll('.run-del-btn').forEach(btn=>btn.onclick=(e)=>{e.stopPropagation();deleteRun(btn.dataset.deleteRun)}); @@ -936,6 +951,7 @@ function renderRunDetail(r){ host.querySelector('.node-inspector .approval-callout')?.remove(); } const childCallout=childSOPCalloutHTML(r);if(childCallout)host.querySelector('.run-detail-header')?.insertAdjacentHTML('afterend',childCallout); + mountRunStage(host,r,nodes); if(pg.child){const note=`

    这是 SOP 节点,下面显示的是子 SOP 运行 ${escapeHTML(pg.runId.slice(-12))} 全部节点的内容。

    `;host.querySelectorAll('.trace-section>.section-heading,.worker-log-section>.section-heading').forEach(h=>h.insertAdjacentHTML('afterend',note))} const runMeta=host.querySelector('.run-header-meta');if(runMeta)runMeta.insertAdjacentHTML('beforeend',`${taskTriggerBadge(ctx.task)}Sandbox ${escapeHTML(r.sandboxId||'—')}`); const nodeSandbox=host.querySelector('.runtime-details span:first-child');if(nodeSandbox)nodeSandbox.remove(); @@ -958,6 +974,21 @@ function renderRunDetail(r){ bindRunEvalPane(r,host); if(runDetailTab==='eval')loadRunEval(r.id); } +function runStageLiveHTML(r,nodes){ + const live=nodes.filter(n=>['running','waiting_approval','failed'].includes(r.nodes?.[n.id]?.status)); + if(!live.length)return r.status==='succeeded'?'
    全部步骤已完成
    ':''; + return live.map(n=>{const nr=r.nodes[n.id],st=nr.status;const text=st==='waiting_approval'?'等你确认后继续下游':st==='failed'?String(nr.error||'执行失败').split('\n')[0].slice(0,140):String(nr.activity||'Agent 正在执行…').split('\n')[0].slice(0,140);return `
    ${escapeHTML(n.name||n.id)}${escapeHTML(text)}
    `}).join(''); +} +function mountRunStage(host,r,nodes){ + const stage=host.querySelector('.run-graph-section'),header=host.querySelector('.run-detail-header'); + if(!stage||!header)return; + stage.classList.add('run-stage',`is-${r.pauseRequested?'paused':r.status}`); + const heading=stage.querySelector('.section-heading h3');if(heading)heading.textContent='执行现场'; + const hint=stage.querySelector('.section-heading h3+span');if(hint)hint.textContent='点击步骤查看它的输出、产物和执行事件'; + stage.querySelector('.stage-live-wrap')?.remove(); + stage.insertAdjacentHTML('beforeend',`
    ${runStageLiveHTML(r,nodes)}
    `); + header.after(stage); +} function fallbackCopyText(value){const ta=document.createElement('textarea');ta.value=value;ta.setAttribute('readonly','');ta.style.cssText='position:fixed;top:-9999px;left:-9999px';document.body.appendChild(ta);ta.select();let ok=false;try{ok=document.execCommand('copy')}catch(e){ok=false}document.body.removeChild(ta);return ok} async function copyText(value,message){if(navigator.clipboard&&window.isSecureContext){try{await navigator.clipboard.writeText(value);toast(message);return}catch(e){}}toast(fallbackCopyText(value)?message:'复制失败,请手动复制')} async function downloadRunLogs(runId){ diff --git a/web/app/sops.js b/web/app/sops.js index 64087b8..b7d3d08 100644 --- a/web/app/sops.js +++ b/web/app/sops.js @@ -156,6 +156,7 @@ function renderSOPCatalog(){ const all=[...metrics.values()],totalRuns=all.reduce((n,m)=>n+m.totalRuns,0),succeeded=all.reduce((n,m)=>n+m.succeededRuns,0),failed=all.reduce((n,m)=>n+m.failedRuns,0),active=live.length,tokens=all.reduce((n,m)=>n+m.totalTokens,0); const scopeLabel=state.sopScope==='generic'?'通用':state.sopScope==='shared'?'共享':'项目'; $('#sopOverview').innerHTML=`
    本项目运行${totalRuns}${workflows.length} 条${scopeLabel} SOP
    已闭环${succeeded}${totalRuns?percent(succeeded*100/totalRuns):'暂无运行'}
    进行中 / 待审批${active}实时更新 · 不含中断残留
    失败运行${failed}${totalRuns?percent(failed*100/totalRuns):'0%'}
    Token 消耗${formatTokens(tokens)}当前项目累计
    `; + $('#sopOverview').hidden=!totalRuns; const filter=$('#sopHealthFilter')?.value||'all',sort=$('#sopSort')?.value||'recent'; if(filter!=='all')workflows=workflows.filter(w=>metricHealth(metrics.get(w.id), live.filter(r=>r.workflowId===w.id).length).key===filter); workflows.sort((a,b)=>{const x=metrics.get(a.id)||{},y=metrics.get(b.id)||{};if(sort==='runs')return (y.totalRuns||0)-(x.totalRuns||0);if(sort==='closure')return (y.closureRate||0)-(x.closureRate||0);if(sort==='failures')return (y.failedRuns||0)-(x.failedRuns||0);return new Date(y.lastRunAt||0)-new Date(x.lastRunAt||0)}); diff --git a/web/app/workbench.js b/web/app/workbench.js index 9b691cd..3d57972 100644 --- a/web/app/workbench.js +++ b/web/app/workbench.js @@ -12,10 +12,22 @@ function openRunFromId(id,opts={}){ function updateApprovalBadge(){ const n=approvalItems().length; const badge=$('#approvalNavBadge'); + $$('[data-approval-count]').forEach(el=>{el.hidden=!n;el.textContent=n||''}); + const base='LoopForge · 你的 AI 交付团队'; + document.title=n?`(${n}) 待确认 · ${base}`:base; + const prev=state.approvalCountSeen; + state.approvalCountSeen=n; + if(prev!=null&&n>prev)notifyApprovalWaiting(n); if(!badge)return; badge.hidden=!n; badge.textContent=n||''; } +function notifyApprovalWaiting(n){ + if(!document.hidden||typeof Notification==='undefined')return; + const show=()=>{try{const note=new Notification('LoopForge · 有流程等你确认',{body:`当前 ${n} 条待确认,点开处理`,tag:'loopforge-approval'});note.onclick=()=>{window.focus();switchView('approvals');note.close()}}catch(e){}}; + if(Notification.permission==='granted')show(); + else if(Notification.permission==='default')Notification.requestPermission().then(p=>{if(p==='granted')show()}).catch(()=>{}); +} function credentialClearAction(name,configured,label='API Key'){ return `
    ${configured?'当前已配置':'尚未配置'}
    `; @@ -205,97 +217,37 @@ function renderPlatformNav(){ function renderPlatformAdminPanel(){ const host=$('#platformAdminPanel');if(!host)return; if(!isPlatformAdmin()){host.innerHTML='';return} + const tab=state.platformAdminTab||'admins'; host.innerHTML=` -
    -
    -
    - -
    -
    - 平台管理员 - -- - 具有跨项目通用模板与角色编辑权 -
    -
    -
    -
    - -
    -
    - 系统内置 - -- - 系统初始预设名单,不可撤销 -
    -
    -
    -
    - -
    -
    - 界面授权 - -- - 控制台动态授予,可随时撤销 -
    -
    -
    -
    - -
    -
    - 生效范围 - 全平台通用 - 通用模板 · 共享角色 · 门禁规范 -
    -
    +
    +
    平台管理员--
    +
    系统内置--
    +
    界面授权--
    +
    生效范围全平台通用模板 · 共享角色 · 门禁
    -
    -
    -
    -
    - 平台权限 -

    谁可以改通用模板

    -

    平台管理员能直接编辑通用模板和它打包的角色,改完所有项目下次运行生效。由环境变量 LOOPFORGE_PLATFORM_ADMINS 固定的名单不能在这里撤销。

    -
    - -- 位管理员 -
    + -
    -
    -
    - - - - -
    - -
    -
    - - 授权后用户将获得全平台通用 SOP 模板及公共角色的修改与发布权限,请谨慎授予。 -
    +
    +
    +
    +

    谁可以改通用模板

    平台管理员能编辑通用 SOP 模板和它打包的角色,改完所有项目下次运行生效。LOOPFORGE_PLATFORM_ADMINS 里的名单不能在这里撤销。

    - - - -
    - -
    本地账号使用 bcrypt 存储密码。已存在的登录名会被重置为新密码;接入 SSO 时可改用受信反向代理身份头,无需在这里建号。
    -
    - +
    +
    + + +
    + +
    - - + +
    @@ -303,54 +255,50 @@ function renderPlatformAdminPanel(){
    -
    -
    -
    -
    - - - -

    权限与安全规则

    -
    -
      -
    • - 通用 SOP 模板全局编辑 -

      通用模板面向全平台所有项目共享,由平台管理员统一定义并维护,修改后各项目下次运行即刻生效。

      -
    • -
    • - 公共角色与通用门禁下发 -

      通用模板中引用的 Agent 角色、执行门禁由平台统一调优升级,项目内默认只读,保障基准质量。

      -
    • -
    • - 系统固化名单安全保护 -

      系统内置名单具备最高防锁死安全防护,无法通过页面撤销,确保生产管理连续性。

      -
    • -
    -
    +
    +

    创建或重置账号

    本地账号用 bcrypt 存储密码。登录名已存在时会重置为新密码;接入 SSO 时改用受信反向代理身份头,无需在这里建号。

    + +
    + +
    +

    注册与安全

    控制谁能进入平台,以及平台级权限的边界。

    + +
      +
    • 通用 SOP 模板全局生效

      通用模板由平台管理员统一维护,修改后各项目下次运行即生效。

    • +
    • 公共角色与门禁统一下发

      通用模板引用的 Agent 角色、执行门禁在项目内默认只读。

    • +
    • 内置名单防锁死

      环境变量里的管理员无法从页面撤销,保证总有人能管理平台。

    • +
    +
    +
    +
    `; + host.querySelectorAll('[data-pa-tab]').forEach(btn=>btn.onclick=()=>{ + state.platformAdminTab=btn.dataset.paTab; + host.querySelectorAll('[data-pa-tab]').forEach(b=>b.classList.toggle('active',b===btn)); + host.querySelectorAll('[data-pa-pane]').forEach(p=>p.hidden=p.dataset.paPane!==btn.dataset.paTab); + }); loadPlatformAdmins(host); } async function loadPlatformAdmins(host){ @@ -390,7 +338,7 @@ async function loadPlatformAdmins(host){ if(elTotal)elTotal.textContent=total; if(elLocked)elLocked.textContent=locked; if(elGranted)elGranted.textContent=granted; - if(elBadge)elBadge.textContent=`共 ${total} 位管理员`; + if(elBadge)elBadge.textContent=String(total); }; const render=()=>{ diff --git a/web/boot.js b/web/boot.js new file mode 100644 index 0000000..12ecbdb --- /dev/null +++ b/web/boot.js @@ -0,0 +1 @@ +(()=>{const d=document.documentElement;d.dataset.theme=localStorage.getItem('loopforge.theme.v3')||'dark';const lang=localStorage.getItem('loopforge.lang')==='en'?'en':'zh';d.dataset.lang=lang;d.lang=lang==='en'?'en':'zh-CN';if(lang==='en')document.write(' + @@ -30,50 +34,23 @@
    -
    +
    - +
    — 成功依赖⇠ 失败打回💡 悬停连线点击 × 可删除· 多个就绪节点自动并发
    @@ -398,12 +383,12 @@
    - - + + - + - - + + - + - - - + + + - + - - - - - - + + + + + +
    - - - + + + diff --git a/web/onboarding_test.js b/web/onboarding_test.js index 29a0ad4..7795ebb 100644 --- a/web/onboarding_test.js +++ b/web/onboarding_test.js @@ -18,10 +18,10 @@ const steps = context.onboardingSteps(false); assert.equal(steps.length, 6); assert.equal(steps[0].target, '.nav[data-view="chat"]'); assert.equal(steps[1].target, '#homeChatForm'); -assert.ok(steps.slice(2).every(step => step.target.startsWith('.nav[data-view='))); +assert.ok(steps.slice(2).every(step => /\.nav\[data-(view|nav-group)=/.test(step.target))); assert.equal(steps.at(-1).finish, true); assert.equal(steps.at(-1).next, '去填写'); -assert.equal(steps.at(-1).target, '.nav[data-view="settings"]'); +assert.equal(steps.at(-1).target, '.nav[data-view="me"]'); const demoSteps = context.onboardingSteps(true); assert.equal(demoSteps.length, 6); diff --git a/web/rooms.js b/web/rooms.js index 7939d64..c6dbeff 100644 --- a/web/rooms.js +++ b/web/rooms.js @@ -52,6 +52,38 @@ function bindRooms() { create.onclick = createRoom; $('#renameRoom').onclick = renameRoom; $('#roomModeChat').onclick = () => setRoomMode('chat'); + const shell = $('#roomsShell'); + const infoBtn = $('#toggleRoomInfo'); + const setInfo = open => { + shell?.classList.toggle('info-open', open); + infoBtn?.setAttribute('aria-pressed', String(open)); + localStorage.setItem('loopforge.roomInfo', open ? '1' : '0'); + }; + setInfo(localStorage.getItem('loopforge.roomInfo') === '1'); + if (infoBtn) infoBtn.onclick = () => { + const open = !shell.classList.contains('info-open'); + if (open && roomState().threadRun) openRoomRunThread(roomState().threadRun); + setInfo(open); + }; + document.querySelectorAll('[data-room-tool]').forEach(button => { + button.onclick = () => { + const input = $('#roomInput'); + if (!input) return; + if (button.dataset.roomTool === 'slash') { + input.value = '/'; + input.focus(); + input.setSelectionRange(1, 1); + } else { + const at = input.selectionStart ?? input.value.length; + const lead = at > 0 && !/\s$/.test(input.value.slice(0, at)) ? ' @' : '@'; + input.value = input.value.slice(0, at) + lead + input.value.slice(at); + input.focus(); + input.setSelectionRange(at + lead.length, at + lead.length); + } + syncRoomComposer(); + updateRoomMention(); + }; + }); $('#roomModeIssue').onclick = () => setRoomMode('issue'); form.onsubmit = event => { event.preventDefault(); @@ -70,6 +102,7 @@ function bindRooms() { } }); input.addEventListener('input', () => { + roomAutosize(input); syncRoomComposer(); updateRoomMention(); }); @@ -106,20 +139,31 @@ function roomTargetPicked() { function syncRoomComposer() { const rs = roomState(); + if (rs.sops.length) rs.mode = 'issue'; const issue = rs.mode === 'issue'; const names = new Map(roomWorkflowChoices().map(w => [w.id, roomWorkflowLabel(w)])); rs.sops = rs.sops.filter(id => names.has(id)); const picks = $('#roomSOPPicks'); if (picks) { - picks.innerHTML = rs.sops.map(id => ``).join(''); + picks.innerHTML = (rs.reply ? `` : '') + (rs.crew ? '' : '') + + rs.sops.map(id => ``).join(''); picks.querySelectorAll('[data-unpick]').forEach(button => { button.onclick = () => toggleRoomSOP(button.dataset.unpick, false); }); + picks.querySelector('[data-unpick-crew]')?.addEventListener('click', () => { rs.crew = false; syncRoomComposer(); }); + picks.querySelector('[data-unreply]')?.addEventListener('click', () => { rs.reply = null; syncRoomComposer(); }); } + picks?.closest('.room-composer-picks')?.classList.toggle('has-picks', !!rs.reply || rs.crew || rs.sops.length > 0); $('#roomSOP')?.closest('.room-chip')?.classList.toggle('on', rs.sops.length > 0); $('#roomComposer')?.classList.toggle('is-issue', issue); $('#roomModeChat')?.classList.toggle('on', !issue); $('#roomModeIssue')?.classList.toggle('on', issue); const input = $('#roomInput'); - if (input) input.placeholder = issue ? '第一行写 Issue 标题,下面写描述。输入 @ 选择 SOP' : '发消息,输入 @ 提醒同事'; + if (input) input.placeholder = rs.crew ? '说说要做成什么,队长会拉 Bot 分工(第一行是目标)' : issue ? (rs.sops.length ? '写下要交付什么(第一行是标题),回车就按 SOP 跑' : '先 @ 一条 SOP,再写要交付什么') : (rs.reply ? `回复 ${rs.reply.author}…` : '发消息… @LoopForge 提问或派活,@ SOP 按流程跑,/ 看命令'); + const send = $('#roomSend'); + if (send) { + send.classList.toggle('is-dispatch', !!rs.crew || (issue && rs.sops.length > 0)); + send.classList.toggle('is-crew', !!rs.crew); + send.title = rs.crew ? '组队' : issue && rs.sops.length ? '派活' : '发送'; + } const picked = roomTargetPicked(); const dispatch = $('#roomDispatch'); if (dispatch) { @@ -143,6 +187,10 @@ async function loadRooms() { const rs = roomState(); const projectId = roomProjectId(); fillRoomTargets(); + if (!rs.runsPrimed && typeof refreshRuns === 'function') { + rs.runsPrimed = true; + refreshRuns(true).then(() => { rs.threadSignature = ''; refreshRoomMessages(); }).catch(() => {}); + } if (!projectId) { renderRoomList([]); renderRoomThread(null, []); @@ -195,6 +243,7 @@ function renderRoomList(rooms) { async function openRoom(id, keepScroll) { const rs = roomState(); + if (rs.activeId !== (id || '')) { rs.ephemeral = []; rs.reply = null; } rs.activeId = id || ''; renderRoomList(rs.items || []); const room = (rs.items || []).find(item => item.id === id); @@ -227,7 +276,7 @@ if (!window.__roomsVisibilityBound) { }); } -async function refreshRoomMessages() { +async function refreshRoomMessages(forceBottom) { const rs = roomState(); const id = rs.activeId; const log = $('#roomLog'); @@ -237,7 +286,9 @@ async function refreshRoomMessages() { const messages = await api('/api/rooms/' + encodeURIComponent(id) + '/messages', {silent: true}); if (rs.activeId !== id) return; const room = (rs.items || []).find(item => item.id === id); - renderRoomThread(room, messages || [], !near); + renderRoomThread(room, messages || [], forceBottom === true ? false : !near); + const open = rs.threadRun && rs.runDetail?.[rs.threadRun]; + if (open && !roomRunTerminal(open.status)) loadRoomRunDetail(rs.threadRun); } catch {} } @@ -316,35 +367,280 @@ function fillRoomSOPList() { syncRoomComposer(); } +function roomTime(iso) { + const d = new Date(iso); + if (!iso || isNaN(d) || d.getFullYear() < 2000) return ''; + const hm = d.toTimeString().slice(0, 5); + return d.toDateString() === new Date().toDateString() ? hm : `${d.getMonth() + 1}/${d.getDate()} ${hm}`; +} + +function roomHue(name) { + return [...String(name || '')].reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 360, 7); +} + +// "admin(Administrator)" → {name: 'Administrator', handle: 'admin'} +function roomPerson(author) { + const match = String(author || '').match(/^(.*?)\((.*)\)$/); + return match ? {name: match[2] || match[1], handle: match[1]} : {name: String(author || '群成员'), handle: ''}; +} + +function roomAgentName(id, fallback) { + const agent = (state.data?.agents || []).find(a => a.id === id); + return String(agent?.name || fallback || id || 'Agent').replace(/^演示\s*·\s*/, ''); +} + +const ROOM_ICON_HUMAN = ''; +const ROOM_ICON_TOOL = ''; + +function roomFace(who, cls = '') { + const kind = who?.kind || 'person'; + if (kind === 'lf') return ``; + if (kind === 'human') return `${ROOM_ICON_HUMAN}`; + if (kind === 'tool') return `${ROOM_ICON_TOOL}`; + const name = String(who?.name || '?'); + const initial = escapeHTML([...name.replace(/^[^\p{L}\p{N}]+/u, '')][0] || '?').toUpperCase(); + return `${initial}`; +} + +function roomStepWho(step) { + if (step.type === 'approval') return {kind: 'human', name: '人工确认'}; + if (step.type === 'script') return {kind: 'tool', name: '脚本'}; + if (step.type === 'sop') return {kind: 'lf', name: '子流程'}; + return {kind: 'bot', name: roomAgentName(step.agentId, step.name)}; +} + +function roomRunGroups(messages) { + const groups = new Map(); + const order = []; + messages.forEach(m => { + if (m.kind === 'crew' && m.crew) { + order.push({crew: m}); + return; + } + if (m.runId && ['dispatch', 'progress', 'approval', 'decision'].includes(m.kind)) { + let g = groups.get(m.runId); + if (!g) { + g = {runId: m.runId, head: null, events: []}; + groups.set(m.runId, g); + order.push({run: g}); + } + if (m.kind === 'dispatch' && !g.head) g.head = m; + else g.events.push(m); + return; + } + order.push({msg: m}); + }); + return {order, groups}; +} + +const ROOM_STEP_DONE = ['succeeded', 'skipped']; + +function roomRunModel(g) { + const rs = roomState(); + const base = g.head || g.events[0] || {}; + const run = rs.runDetail?.[g.runId] || (state.data?.runs || []).find(r => r.id === g.runId) || null; + const wf = (state.data?.workflows || []).find(w => w.id === (run?.workflowId || base.workflowId)); + const defs = run?.workflowSnapshot?.nodes || wf?.nodes || []; + const pending = g.events.filter(e => e.kind === 'approval' && !e.resolved).pop() || null; + const final = g.events.filter(e => e.kind === 'progress' && !e.nodeId).pop(); + let status = run?.status || base.runFinal || base.runStatus || final?.nodeStatus || (pending ? 'waiting' : 'queued'); + if (pending && !roomRunTerminal(status)) status = 'waiting'; + const stateOf = id => { + const live = run?.nodes?.[id]?.status; + if (live) return live; + const ev = g.events.filter(e => e.nodeId === id).pop(); + if (ev) return ev.kind === 'approval' && !ev.resolved ? 'waiting_approval' : (ev.nodeStatus || 'running'); + return (base.runCurrent || []).includes(defs.find(n => n.id === id)?.name) ? 'running' : 'pending'; + }; + const steps = defs.map(n => ({id: n.id, name: n.name || n.id, type: n.type, agentId: n.agentId, status: stateOf(n.id), detail: run?.nodes?.[n.id] || null})); + const done = steps.length ? steps.filter(s => ROOM_STEP_DONE.includes(s.status)).length : (base.runCompleted || 0); + const total = steps.length || base.runTotal || 0; + const current = steps.find(s => s.status === 'waiting_approval') || steps.find(s => s.status === 'running') || null; + const updates = steps.filter(s => s.status !== 'pending').length + g.events.filter(e => e.kind === 'decision').length; + return {g, base, run, steps, pending, status, done, total, current, updates, sop: base.workflowName || roomAuthorName(base.author) || 'SOP'}; +} + +function roomRunHeadline(m) { + if (m.base.dispatchError && !m.g.runId) return {cls: 'failed', text: '派发没成功'}; + if (m.status === 'waiting') return {cls: 'waiting', text: `「${m.current?.name || m.pending?.nodeName || '确认'}」等你拍板`}; + if (m.status === 'running' || m.status === 'queued') { + if (!m.current) return {cls: 'running', text: m.status === 'queued' ? '排队中,马上开始' : '正在推进'}; + return {cls: 'running', text: `${roomStepWho(m.current).name} 正在做「${m.current.name}」`}; + } + if (m.status === 'succeeded') return {cls: 'succeeded', text: `交付完成 · ${m.total} 步全部通过`}; + if (m.status === 'failed') { + const bad = m.steps.find(s => s.status === 'failed'); + return {cls: 'failed', text: bad ? `卡在「${bad.name}」` : '运行失败'}; + } + if (m.status === 'cancelled') return {cls: 'cancelled', text: '已取消'}; + return {cls: m.status, text: roomRunStatusLabel(m.status)}; +} + +function roomChainHTML(steps, compact) { + if (!steps.length) return ''; + return `
    ${steps.map((s, i) => { + const cls = s.status === 'waiting_approval' ? 'waiting' : s.status; + const link = i ? `` : ''; + return `${link}${roomFace(roomStepWho(s))}${escapeHTML(s.name)}`; + }).join('')}
    `; +} + +function roomAskText(m) { + const at = m.steps.findIndex(s => s.id === m.pending?.nodeId); + const prev = at > 0 ? m.steps[at - 1] : null; + const next = at >= 0 ? m.steps[at + 1] : null; + const body = String(m.pending?.body || ''); + if (body && !/等待审批|可以直接在群里/.test(body)) return body; + const from = prev ? `${roomStepWho(prev).name} 已交付「${prev.name}」` : '上一步已完成'; + return next ? `${from}。批准后交给 ${roomStepWho(next).name} 继续「${next.name}」。` : `${from},批准后这条需求就交付了。`; +} + +function roomDecideButtons(e) { + const attrs = `data-run="${escapeHTML(e.runId)}" data-node="${escapeHTML(e.nodeId)}" data-name="${escapeHTML(e.nodeName || e.nodeId)}"`; + return `${e.canReject ? `` : ''}`; +} + +function roomAskHTML(m, mine) { + const b = m.base; + if (!m.g.head || b.lane) return ''; + const who = roomPerson(b.author); + const own = mine > 0 && Number(b.userId) === mine; + return `
    ${roomFace({kind: 'person', name: who.name})}
    ${escapeHTML(who.name)}${own ? '你' : ''}

    @${escapeHTML(m.sop)} ${escapeHTML(b.issueTitle || '')}

    ${b.body ? `

    ${escapeHTML(b.body)}

    ` : ''}
    `; +} + +function roomRunCardHTML(m, mine, bare) { + const b = m.base; + const head = roomRunHeadline(m); + const open = roomState().threadRun === m.g.runId; + const key = b.issueNumber ? '#' + b.issueNumber : '需求'; + const pct = m.total ? Math.round(m.done * 100 / m.total) : 0; + const ask = m.pending ? `

    ${escapeHTML(roomAskText(m))}

    ${roomDecideButtons(m.pending)}
    ` : ''; + const error = b.dispatchError ? `

    ${escapeHTML(b.dispatchError)}

    ` : ''; + const people = [...new Map(m.steps.filter(s => s.status !== 'pending').map(s => [roomStepWho(s).name, roomStepWho(s)])).values()].slice(0, 4); + const foot = m.g.runId + ? `
    ${people.map(w => roomFace(w, 'xs')).join('')}${m.updates ? m.updates + ' 条更新' : '刚开始'}${open ? '过程已展开' : '看过程'}›
    ` + : ''; + const card = `
    +
    ${escapeHTML(key)}${escapeHTML(b.issueTitle || m.sop)}${m.total ? `${m.done}/${m.total}` : ''}
    +
    ${escapeHTML(head.text)}
    + ${m.total ? `
    ` : ''} + ${roomChainHTML(m.steps, true)}${error}${ask}${foot} +
    `; + if (bare) return card; + return roomAskHTML(m, mine) + roomBotHTML(card, false, m.sop, b.lane ? '' : roomTime(m.g.events[0]?.createdAt || b.createdAt)); +} + +function roomBotHTML(inner, ephemeral, sopName, time) { + return `
    ${roomFace({kind: 'lf'})}
    ${escapeHTML(sopName || 'LoopForge')}BOT${ephemeral ? '仅你可见' : ''}${time ? `` : ''}
    ${inner}
    `; +} + +function roomQuoteHTML(m) { + if (!m.replyTo) return ''; + return ``; +} + +function roomReplyActHTML(id) { + return `
    `; +} + +function roomTextMessageHTML(message, mine) { + const own = mine > 0 && Number(message.userId) === mine; + if (message.kind === 'decision') return `
    ${escapeHTML(roomPerson(message.author).name)} ${escapeHTML(message.body || '')}
    `; + if (message.kind === 'dispatch') return roomRunCardHTML(roomRunModel({runId: '', head: message, events: []}), mine); + if (message.kind === 'crew_post') return roomCrewPostHTML(message); + const who = roomPerson(message.author); + return `
    ${roomFace({kind: 'person', name: who.name})}
    ${escapeHTML(who.name)}${own ? '你' : ''}
    ${roomQuoteHTML(message)}

    ${roomTextHTML(message.body || '')}

    ${roomReplyActHTML(message.id)}
    `; +} + +function roomDayKey(iso) { + const d = new Date(iso); + return isNaN(d) ? '' : d.toDateString(); +} + +function roomDayLabel(iso) { + const d = new Date(iso); + const today = new Date(); + const yesterday = new Date(Date.now() - 864e5); + if (d.toDateString() === today.toDateString()) return '今天'; + if (d.toDateString() === yesterday.toDateString()) return '昨天'; + return `${d.getMonth() + 1} 月 ${d.getDate()} 日`; +} + function renderRoomThread(room, messages, keepScroll) { const log = $('#roomLog'); if (!log) return; const previous = log.scrollTop; const rs = roomState(); - rs.active = messages.some(m => m.kind === 'dispatch' && m.runId && !m.runFinal && !roomRunTerminal(m.runStatus || '')); - // Polls usually return the same thread; skip the rebuild so open 产出摘要, - // text selection and scroll position survive. - const signature = (room?.id || '') + '|' + JSON.stringify(messages); - if (signature === rs.threadSignature && log.childElementCount) return; + rs.active = messages.some(m => (m.kind === 'dispatch' && m.runId && !m.runFinal && !roomRunTerminal(m.runStatus || '')) || (m.kind === 'crew' && ROOM_CREW_ACTIVE.includes(m.crew?.status)) || m.pending); + rs.lastMessages = messages; + const ephemeral = (rs.ephemeral || []).filter(e => e.roomId === undefined || e.roomId === room?.id); + const runsSig = (state.data?.runs || []).map(r => r.id + ':' + (r.revision || r.updatedAt || r.status)).join(','); + const signature = (room?.id || '') + '|' + JSON.stringify(messages) + '|' + ephemeral.map(e => e.id).join(',') + '|' + runsSig + '|' + (rs.threadRun || ''); + if (signature === rs.threadSignature && log.childElementCount) { + renderRoomThreadPane(); + return; + } rs.threadSignature = signature; + renderRoomBots(); if (!room) { log.innerHTML = `

    ${escapeHTML(roomGreeting())}

    ${roomProjectId() ? '在左侧新建或选择一个群开始协作' : '先在顶栏选择项目'}

    `; + renderRoomThreadPane(); return; } const mine = Number(state.currentUser?.id) || 0; - let prevRun = null; - const dispatchRuns = new Set(messages.filter(m => m.kind === 'dispatch' && m.runId).map(m => m.runId)); - const visible = messages.filter(message => { - if (message.kind === 'progress' && message.nodeId && message.nodeStatus === 'succeeded') return false; - if (message.kind === 'progress' && !message.nodeId && dispatchRuns.has(message.runId)) return false; - return true; + const {order, groups} = roomRunGroups(messages); + rs.runGroups = groups; + let day = ''; + const rows = order.map(item => { + const at = item.msg ? item.msg.createdAt : item.crew ? item.crew.createdAt : (item.run.head || item.run.events[0] || {}).createdAt; + const key = roomDayKey(at); + const divider = key && key !== day ? `
    ${escapeHTML(roomDayLabel(at))}
    ` : ''; + if (key) day = key; + return divider + (item.msg ? roomTextMessageHTML(item.msg, mine) : item.crew ? roomCrewHTML(item.crew, mine) : roomRunCardHTML(roomRunModel(item.run), mine)); + }); + const body = rows.join('') + ephemeral.map(e => roomBotHTML(e.html, true)).join(''); + log.innerHTML = body ? `
    ${roomFace({kind: 'lf'}, 'lg')}
    #${escapeHTML(room.name)}

    @LoopForge 提问或派活,Bot 们自己分工交接;@ 一条 SOP 按固定流程跑。悬停消息点「回复」可以接着问,/ 看命令。

    ` + body : roomGuideHTML(); + bindRoomLog(log); + if (keepScroll) log.scrollTop = previous; + else log.scrollTop = log.scrollHeight; + renderRoomThreadPane(); +} + +function roomStartReply(id) { + const m = (roomState().lastMessages || []).find(x => x.id === id); + if (!m) return; + const author = m.kind === 'crew' || (m.kind === 'crew_post' && !m.agentId) ? 'LoopForge' : m.agentId ? roomAgentName(m.agentId, m.author) : roomPerson(m.author).name; + const text = m.kind === 'crew' ? (m.crew?.goal || '') : String(m.body || ''); + roomState().reply = {id, author, text: text.replace(/\s+/g, ' ').slice(0, 60)}; + syncRoomComposer(); + $('#roomInput')?.focus(); +} + +function bindRoomLog(log) { + log.querySelectorAll('[data-rm-reply]').forEach(button => { + button.onclick = event => { event.stopPropagation(); roomStartReply(button.dataset.rmReply); }; + }); + log.querySelectorAll('[data-rm-jump]').forEach(button => { + button.onclick = event => { + event.stopPropagation(); + const target = log.querySelector(`[data-mid="${CSS.escape(button.dataset.rmJump)}"]`); + if (!target) return toast('原消息已经不在最近的记录里'); + target.scrollIntoView({block: 'center', behavior: 'smooth'}); + target.classList.add('is-flash'); + setTimeout(() => target.classList.remove('is-flash'), 1400); + }; + }); + log.querySelectorAll('[data-room-approve]').forEach(button => { + button.onclick = async event => { + event.stopPropagation(); + button.disabled = true; + if (typeof approveRun === 'function') await approveRun(button.dataset.roomApprove, button.dataset.node, button); + button.textContent = '已通过'; + }; + }); + log.querySelectorAll('[data-room-suggest]').forEach(button => { + button.onclick = () => runRoomCommand(button.dataset.roomSuggest); }); - log.innerHTML = visible.map(message => { - const html = roomMessageHTML(message, mine, prevRun); - if (['dispatch', 'progress', 'approval'].includes(message.kind)) prevRun = message; - else if (message.kind !== 'decision') prevRun = null; - return html; - }).join('') || roomGuideHTML(); log.querySelector('[data-room-try-chat]')?.addEventListener('click', () => setRoomMode('chat')); log.querySelector('[data-room-try-issue]')?.addEventListener('click', () => { setRoomMode('issue'); @@ -352,16 +648,416 @@ function renderRoomThread(room, messages, keepScroll) { }); bindRoomGoSOPs(log); log.querySelectorAll('[data-open-issue]').forEach(button => { - button.onclick = () => openIssueDetail(button.dataset.openIssue); + button.onclick = event => { event.stopPropagation(); openIssueDetail(button.dataset.openIssue); }; }); log.querySelectorAll('[data-open-run]').forEach(button => { - button.onclick = () => { location.hash = '#/runs/' + encodeURIComponent(button.dataset.openRun); }; + button.onclick = event => { event.stopPropagation(); location.hash = '#/runs/' + encodeURIComponent(button.dataset.openRun); }; }); log.querySelectorAll('[data-decide]').forEach(button => { - button.onclick = () => decideRoomStep(button.dataset.decide, button.dataset.run, button.dataset.node, button.dataset.name); + button.onclick = event => { + event.stopPropagation(); + decideRoomStep(button.dataset.decide, button.dataset.run, button.dataset.node, button.dataset.name); + }; }); - if (keepScroll) log.scrollTop = previous; - else log.scrollTop = log.scrollHeight; + log.querySelectorAll('[data-rm-more]').forEach(button => { + button.onclick = () => { + const p = button.previousElementSibling; + const open = p.classList.toggle('rm-clamp'); + button.textContent = open ? '展开' : '收起'; + }; + }); + log.querySelectorAll('[data-crew-act]').forEach(button => { + button.onclick = event => { + event.stopPropagation(); + roomCrewAct(button.dataset.crew, button.dataset.crewAct, button); + }; + }); + log.querySelectorAll('[data-open-crew]').forEach(card => { + const open = () => openRoomRunThread('crew:' + card.dataset.openCrew); + card.onclick = open; + card.onkeydown = event => { if (event.key === 'Enter') open(); }; + }); + log.querySelectorAll('[data-open-thread]').forEach(card => { + const open = () => openRoomRunThread(card.dataset.openThread); + card.onclick = open; + card.onkeydown = event => { if (event.key === 'Enter') open(); }; + }); +} + +async function openRoomRunThread(runId) { + const rs = roomState(); + if (runId && !runId.startsWith('crew:') && !rs.runGroups?.has(runId)) { + location.hash = '#/runs/' + encodeURIComponent(runId); + return; + } + rs.threadRun = rs.threadRun === runId ? '' : runId; + rs.threadSignature = ''; + rs.crewEdit = ''; + $('#roomsShell')?.classList.remove('info-open'); + renderRoomThread(activeRoom(), rs.lastMessages || [], true); + if (rs.threadRun && !runId.startsWith('crew:')) await loadRoomRunDetail(runId); +} + +async function loadRoomRunDetail(runId) { + const rs = roomState(); + try { + const run = await api('/api/runs/' + encodeURIComponent(runId), {silent: true}); + rs.runDetail = Object.assign(rs.runDetail || {}, {[runId]: run}); + if (rs.threadRun === runId) { + rs.threadSignature = ''; + renderRoomThread(activeRoom(), rs.lastMessages || [], true); + } + } catch {} +} + +function roomOutputSummary(text) { + const lines = String(text || '').split('\n') + .map(line => line.replace(/^\s*(#{1,6}|>|[-*+]|\d+\.)\s*/, '').replace(/\*\*|`/g, '').trim()) + .filter(Boolean); + return {title: lines[0] || '', body: lines.slice(1, 7).join('\n')}; +} + +function roomDuration(detail) { + const a = Date.parse(detail?.startedAt); + const b = Date.parse(detail?.finishedAt); + if (!(a > 0) || !(b > a)) return ''; + const s = (b - a) / 1000; + return s < 60 ? (s < 10 ? s.toFixed(1) : Math.round(s)) + ' 秒' : Math.round(s / 60) + ' 分钟'; +} + +const ROOM_STEP_VERB = {succeeded: '完成了', failed: '没做成', running: '正在做', waiting_approval: '等你确认', cancelled: '取消了', skipped: '跳过了'}; + +function roomThreadStepHTML(step, next, model) { + const who = roomStepWho(step); + const verb = ROOM_STEP_VERB[step.status] || ''; + const d = step.detail || {}; + const sum = roomOutputSummary(d.output || d.error || ''); + const meta = [roomDuration(d), Number(d.toolCalls) ? `${d.toolCalls} 次工具调用` : '', Number(d.attempt) > 1 ? `第 ${d.attempt} 次尝试` : '', d.gateResult?.decision === 'pass' ? '自检通过' : ''].filter(Boolean); + const ask = step.status === 'waiting_approval' && model.pending && model.pending.nodeId === step.id + ? `

    ${escapeHTML(roomAskText(model))}

    ${roomDecideButtons(model.pending)}
    ` + : ''; + const decisions = model.g.events.filter(e => e.kind === 'decision' && e.nodeId === step.id) + .map(e => `
    ${escapeHTML(roomPerson(e.author).name)} ${escapeHTML(e.body || '')}
    `).join(''); + const output = sum.title ? `
    ${escapeHTML(sum.title)}${sum.body ? `

    ${escapeHTML(sum.body)}

    ` : ''}
    ` : ''; + const hand = next && ROOM_STEP_DONE.includes(step.status) + ? `
    交给${roomFace(roomStepWho(next), 'xs')}${escapeHTML(roomStepWho(next).name)}·「${escapeHTML(next.name)}」
    ` + : ''; + return `
    +
    ${roomFace(who)}
    +
    +
    ${escapeHTML(who.name)}${escapeHTML(verb)}「${escapeHTML(step.name)}」
    + ${output}${meta.length ? `${escapeHTML(meta.join(' · '))}` : ''}${ask}${decisions}${hand} +
    +
    `; +} + +function renderRoomThreadPane() { + const pane = $('#roomThread'); + const shell = $('#roomsShell'); + if (!pane) return; + const rs = roomState(); + if (rs.threadRun?.startsWith('crew:')) { + const crew = (rs.lastMessages || []).find(m => m.id === rs.threadRun.slice(5) && m.crew); + shell?.classList.toggle('thread-open', !!crew); + pane.hidden = !crew; + if (crew) renderRoomCrewPane(pane, crew); + return; + } + const group = rs.threadRun ? rs.runGroups?.get(rs.threadRun) : null; + shell?.classList.toggle('thread-open', !!group); + pane.hidden = !group; + if (!group) return; + const m = roomRunModel(group); + const b = m.base; + const head = roomRunHeadline(m); + const started = m.steps.filter(s => s.status !== 'pending'); + const later = m.steps.filter(s => s.status === 'pending'); + const mine = Number(state.currentUser?.id) || 0; + const who = roomPerson(b.author); + const html = `
    执行过程 · ${escapeHTML(m.sop)}${escapeHTML((b.issueNumber ? '#' + b.issueNumber + ' ' : '') + (b.issueTitle || m.sop))}
    +
    + ${b.issueTitle ? `
    ${roomFace({kind: 'person', name: who.name})}
    ${escapeHTML(who.name)}

    @${escapeHTML(m.sop)} ${escapeHTML(b.issueTitle)}

    ${b.body ? `

    ${escapeHTML(b.body)}

    ` : ''}
    ` : ''} +
    ${escapeHTML(head.text)}${m.total ? `${m.done}/${m.total}` : ''}
    +
    ${started.map(s => roomThreadStepHTML(s, m.steps[m.steps.indexOf(s) + 1], m)).join('') || '

    Bot 正在接活,第一步开始后这里会实时更新。

    '}
    + ${later.length ? `
    接下来${later.map(s => `${roomFace(roomStepWho(s), 'xs')}${escapeHTML(s.name)}`).join('→')}
    ` : ''} +
    +
    ${b.issueId ? `` : ''}
    `; + if (pane.dataset.sig === html) return; + const scroll = pane.querySelector('.rt-scroll')?.scrollTop || 0; + pane.dataset.sig = html; + pane.innerHTML = html; + pane.querySelector('.rt-scroll').scrollTop = scroll; + pane.querySelector('[data-close-thread]').onclick = () => openRoomRunThread(rs.threadRun); + bindRoomLog(pane); +} + +function roomBotsInProject() { + const used = new Set(); + roomWorkflowChoices().forEach(w => (w.nodes || []).forEach(n => n.agentId && used.add(n.agentId))); + const agents = (state.data?.agents || []).filter(a => used.has(a.id)); + return (agents.length ? agents : (state.data?.agents || [])).slice(0, 8); +} + +function renderRoomBots() { + const host = $('#roomBots'); + if (!host) return; + const runs = typeof roomRunsInProject === 'function' ? roomRunsInProject() : []; + const busy = new Map(); + let waiting = 0; + runs.filter(r => ['running', 'waiting', 'queued'].includes(r.status)).forEach(r => { + const defs = r.workflowSnapshot?.nodes || []; + defs.forEach(n => { + const st = r.nodes?.[n.id]?.status; + if (st === 'running' && n.agentId) busy.set(n.agentId, n.name || n.id); + if (st === 'waiting_approval') waiting++; + }); + }); + const bots = roomBotsInProject(); + const row = (face, name, sub, live, attrs = '') => ``; + host.innerHTML = `
    Bot 队伍 ${bots.length + 1}
    ` + + row(roomFace({kind: 'lf'}), 'LoopForge', waiting ? `${waiting} 件事等你拍板` : '派活、交接、汇报', waiting > 0, 'data-room-bot-help') + + bots.map(a => row(roomFace({kind: 'bot', name: roomAgentName(a.id, a.name)}), roomAgentName(a.id, a.name), busy.has(a.id) ? `正在做「${busy.get(a.id)}」` : (a.description || '空闲').split('\n')[0].slice(0, 24), busy.has(a.id), `data-room-bot="${escapeHTML(a.id)}"`)).join(''); + host.querySelector('[data-room-bot-help]')?.addEventListener('click', () => runRoomCommand('help')); + host.querySelectorAll('[data-room-bot]').forEach(button => { + button.onclick = () => { + const agent = (state.data?.agents || []).find(a => a.id === button.dataset.roomBot); + if (agent && typeof openAgentWizard === 'function') openAgentWizard(agent); + }; + }); + const faces = $('#roomFaces'); + const room = activeRoom(); + if (faces) { + const members = (room?.members || []).map(m => roomFace({kind: 'person', name: roomPerson(m.name).name}, 'sm')); + const botFaces = [roomFace({kind: 'lf'}, 'sm')].concat(bots.slice(0, 3).map(a => roomFace({kind: 'bot', name: roomAgentName(a.id, a.name)}, 'sm'))); + faces.innerHTML = room ? `${members.slice(0, 3).join('')}${botFaces.join('')}${(room.members || []).length} 人 · ${bots.length + 1} Bot` : ''; + } +} + +const ROOM_CREW_ACTIVE = ['planning', 'proposed', 'running', 'waiting']; + +function roomCrewSteps(c) { + return (c.tasks || []).map(t => ({id: t.id, name: t.title, type: 'agent', agentId: t.agentId, status: t.status, task: t})); +} + +function roomCrewHeadline(c) { + const tasks = c.tasks || []; + const cur = tasks.find(t => t.status === 'running'); + const name = id => roomAgentName(id, (tasks.find(t => t.agentId === id) || {}).agentName); + switch (c.status) { + case 'planning': return {cls: 'running', text: '队长正在排分工…'}; + case 'proposed': return {cls: 'waiting', text: `分工排好了:${new Set(tasks.map(t => t.agentId)).size} 个 Bot、${tasks.length} 步,等你开工`}; + case 'running': return {cls: 'running', text: cur ? `${roomAgentName(cur.agentId, cur.agentName)} 正在做「${cur.title}」` : '交接中…'}; + case 'waiting': { + if (c.question) return {cls: 'waiting', text: `${name(c.questionAgent)} 在等你回答`}; + const bad = [...tasks].reverse().find(t => t.status === 'failed'); + return {cls: 'waiting', text: bad ? `卡在「${bad.title}」,等你决定` : '等你决定'}; + } + case 'done': return {cls: 'succeeded', text: `交付完成 · ${tasks.filter(t => t.status === 'succeeded').length} 步`}; + case 'stopped': return {cls: 'cancelled', text: '已停止'}; + default: return {cls: 'failed', text: c.error || '没能组队'}; + } +} + +function roomCrewActionsHTML(msg, inPane) { + const c = msg.crew; + const at = `data-crew="${escapeHTML(msg.id)}"`; + const btn = (act, label, primary) => ``; + const stuck = c.status === 'waiting' && !c.question; + const parts = []; + if (c.status === 'proposed') parts.push(btn('start', '开工', true), btn('edit', '改分工')); + if (stuck) parts.push(btn('retry', '重试', true), btn('skip', '跳过')); + if (c.status === 'running' || c.status === 'waiting') { + if (inPane) parts.push(btn('edit', '改后面的分工')); + parts.push(btn('stop', '停止')); + } + if (c.status === 'done') parts.push(c.savedWorkflowId ? btn('open-sop', '打开沉淀的 SOP') : btn('sop', '沉淀为 SOP', true)); + return parts.length ? `
    ${parts.join('')}
    ` : ''; +} + +function roomCrewHTML(msg, mine) { + const c = msg.crew; + const who = roomPerson(msg.author); + const own = mine > 0 && Number(msg.userId) === mine; + const ask = c.source === 'chat' ? '' + : c.source === 'home' + ? `
    ${escapeHTML(who.name)} 在首页把这件事交给了群里的 Bot 团队
    ` + : `
    ${roomFace({kind: 'person', name: who.name})}
    ${escapeHTML(who.name)}${own ? '你' : ''}

    @LoopForge ${escapeHTML(c.goal || '')}

    ${c.detail ? `

    ${escapeHTML(c.detail)}

    ` : ''}
    `; + const head = roomCrewHeadline(c); + const tasks = c.tasks || []; + const done = tasks.filter(t => ['succeeded', 'skipped'].includes(t.status)).length; + const pct = tasks.length ? Math.round(done * 100 / tasks.length) : 0; + const open = roomState().threadRun === 'crew:' + msg.id; + const question = c.status === 'waiting' + ? `

    ${escapeHTML(c.question ? c.question + '(直接在群里回复,队长会交回给它)' : '这一步没做成。可以重试、跳过,或者直接在群里说怎么改。')}

    ${roomCrewActionsHTML(msg)}
    ` + : ''; + const actions = c.status === 'waiting' ? '' : roomCrewActionsHTML(msg); + const faces = [...new Map(tasks.map(t => [t.agentId, {kind: 'bot', name: roomAgentName(t.agentId, t.agentName)}])).values()].slice(0, 5); + const card = `
    +
    ${msg.issueNumber ? '#' + msg.issueNumber : '协作'}${escapeHTML(c.goal || '')}${tasks.length ? `${done}/${tasks.length}` : ''}
    +
    ${escapeHTML(head.text)}
    + ${tasks.length ? `
    ` : ''} + ${roomChainHTML(roomCrewSteps(c), true)}${question}${actions ? `
    ${actions}
    ` : ''} +
    ${faces.map(w => roomFace(w, 'xs')).join('')}自由协作${c.planner === 'ai' ? ' · AI 排的分工' : ''}${open ? '过程已展开' : '看过程'}›
    +
    `; + return ask + roomBotHTML(card, false, 'LoopForge', roomTime(msg.createdAt)).replace('BOT', 'BOT队长').replace('
    交给${roomFace({kind: 'bot', name: m.targetName}, 'xs')}${escapeHTML(m.targetName)}·「${escapeHTML(m.nodeName || '')}」
    ` : ''; + const stale = m.pending && Date.now() - Date.parse(m.createdAt || '') > 180000; + const text = m.pending + ? (stale ? '

    这次没答上来,可以再问一次。

    ' : '

    正在思考…

    ') + : `

    ${roomTextHTML(body)}

    ${long ? '' : ''}`; + return `
    ${face}
    ${escapeHTML(name)}BOT${lf || !m.crewId ? '' : ``}
    ${roomQuoteHTML(m)}${text}${hand}
    ${m.pending ? '' : roomReplyActHTML(m.id)}
    `; +} + +async function roomCrewAct(id, act, button) { + const rs = roomState(); + const msg = (rs.lastMessages || []).find(m => m.id === id); + if (!msg) return; + if (act === 'edit') { + rs.threadRun = 'crew:' + id; + rs.crewEdit = id; + rs.threadSignature = ''; + renderRoomThread(activeRoom(), rs.lastMessages || [], true); + return; + } + if (act === 'open-sop') return roomOpenSavedSOP(msg.crew.savedWorkflowId); + if (act === 'stop' && typeof afConfirm === 'function' && !(await afConfirm({title: '让 Bot 们停下?', message: '正在做的那一步会被取消,做完的部分保留在需求单里。', confirmText: '停止'}))) return; + if (button) button.disabled = true; + try { + const result = await api('/api/rooms/' + encodeURIComponent(rs.activeId) + '/crew/' + encodeURIComponent(id), {method: 'POST', body: JSON.stringify({action: act})}); + if (act === 'sop' && result.workflowId) { + toast('已沉淀成 SOP 草稿,可以在流程设计里调整后发布'); + await roomRememberWorkflow(result.workflowId); + } + await refreshRoomMessages(true); + } catch (error) { + toast(error.message); + if (button) button.disabled = false; + } +} + +async function roomRememberWorkflow(id) { + try { + const wf = await api('/api/workflows/' + encodeURIComponent(id), {silent: true}); + const list = state.data.workflows || (state.data.workflows = []); + const at = list.findIndex(w => w.id === id); + if (at >= 0) list[at] = wf; + else list.unshift(wf); + return wf; + } catch { + return null; + } +} + +async function roomOpenSavedSOP(id) { + if (!id) return; + if (!(state.data.workflows || []).some(w => w.id === id)) await roomRememberWorkflow(id); + location.hash = '#/flow/' + encodeURIComponent(id); +} + +function roomCrewEditorHTML(msg) { + const c = msg.crew; + const bots = roomBotsInProject(); + const pending = (c.tasks || []).filter(t => t.status === 'pending'); + const row = (t, i) => `
    + + + + +
    `; + return `
    改分工${c.status === 'proposed' ? '开工前随便改' : '只改还没开始的步骤'}
    +
    ${pending.map(row).join('') || '

    后面没有排队的步骤了。

    '}
    + +
    `; +} + +function bindRoomCrewEditor(pane, msg) { + const rs = roomState(); + const list = pane.querySelector('.rce-list'); + if (!list) return; + pane.querySelectorAll('[data-rce-del]').forEach(b => { b.onclick = () => b.closest('.rce-row').remove(); }); + pane.querySelector('[data-rce-add]').onclick = () => { + const bots = roomBotsInProject(); + const wrap = document.createElement('div'); + wrap.innerHTML = roomCrewEditorHTML({crew: {status: msg.crew.status, tasks: [{agentId: bots[0]?.id, title: '', status: 'pending'}]}}); + const row = wrap.querySelector('.rce-row'); + list.querySelector('.rt-empty')?.remove(); + list.appendChild(row); + row.querySelector('[data-rce-del]').onclick = () => row.remove(); + row.querySelector('[data-rce-title]').focus(); + }; + pane.querySelector('[data-rce-cancel]').onclick = () => { + rs.crewEdit = ''; + pane.dataset.sig = ''; + renderRoomThreadPane(); + }; + pane.querySelector('[data-rce-save]').onclick = async event => { + const tasks = [...list.querySelectorAll('.rce-row')].map(r => ({agentId: r.querySelector('[data-rce-agent]').value, title: r.querySelector('[data-rce-title]').value.trim(), instruction: r.querySelector('[data-rce-ins]').value.trim()})); + event.currentTarget.disabled = true; + try { + await api('/api/rooms/' + encodeURIComponent(rs.activeId) + '/crew/' + encodeURIComponent(msg.id), {method: 'POST', body: JSON.stringify({action: 'plan', tasks})}); + rs.crewEdit = ''; + toast('分工已更新'); + await refreshRoomMessages(true); + } catch (error) { + toast(error.message); + event.currentTarget.disabled = false; + } + }; +} + +function renderRoomCrewPane(pane, msg) { + const rs = roomState(); + const c = msg.crew; + const tasks = c.tasks || []; + const head = roomCrewHeadline(c); + const steps = roomCrewSteps(c); + const started = steps.filter(s => s.status !== 'pending'); + const later = steps.filter(s => s.status === 'pending'); + const editing = rs.crewEdit === msg.id && ROOM_CREW_ACTIVE.includes(c.status); + const stepHTML = (s, i) => { + const t = s.task; + const who = roomStepWho(s); + const sum = roomOutputSummary(t.output || t.summary || t.error || ''); + const verb = {succeeded: '完成了', failed: '没做成', running: '正在做', skipped: '跳过了'}[t.status] || ''; + const next = steps[steps.indexOf(s) + 1]; + const meta = [roomDuration({startedAt: t.startedAt, finishedAt: t.finishedAt}), t.addedBy && t.addedBy !== 'LoopForge' ? `${roomPerson(t.addedBy).name} 在群里加的` : ''].filter(Boolean); + return `
    +
    ${roomFace(who)}
    +
    +
    ${escapeHTML(who.name)}${escapeHTML(verb)}「${escapeHTML(t.title)}」
    + ${t.instruction ? `要求:${escapeHTML(t.instruction)}` : ''} + ${sum.title ? `
    ${escapeHTML(sum.title)}${sum.body ? `

    ${escapeHTML(sum.body)}

    ` : ''}
    ` : ''} + ${meta.length ? `${escapeHTML(meta.join(' · '))}` : ''} + ${t.runId ? `` : ''} + ${next && t.status === 'succeeded' ? `
    交给${roomFace(roomStepWho(next), 'xs')}${escapeHTML(roomStepWho(next).name)}·「${escapeHTML(next.name)}」
    ` : ''} +
    `; + }; + const notes = (c.notes || []).length ? `
    群里的要求 · 已带给 Bot${c.notes.map(n => `

    ${escapeHTML(roomPerson(n.author).name)}${escapeHTML(n.body)}

    `).join('')}
    ` : ''; + const html = `
    执行过程 · 自由协作${c.planner === 'ai' ? ' · AI 排的分工' : ''}${escapeHTML((msg.issueNumber ? '#' + msg.issueNumber + ' ' : '') + (c.goal || ''))}
    +
    +
    ${escapeHTML(head.text)}${tasks.length ? `${tasks.filter(t => t.status === 'succeeded').length}/${tasks.length}` : ''}
    + ${c.question && c.status === 'waiting' ? `

    ${escapeHTML(c.question)}

    直接在群里回复,队长会交回给它继续。
    ` : ''} + ${editing ? roomCrewEditorHTML(msg) : `
    ${started.map(stepHTML).join('') || `

    ${c.status === 'planning' ? '队长正在看有哪些 Bot 能干这个活…' : '还没开工。'}

    `}
    + ${later.length ? `
    接下来${later.map(s => `${roomFace(roomStepWho(s), 'xs')}${escapeHTML(s.name)}`).join('→')}
    ` : ''}`} + ${notes} +
    +
    ${editing ? '' : roomCrewActionsHTML(msg, true).replace('rc-actions', 'rc-actions rt-foot-actions')}${msg.issueId ? `` : ''}
    `; + if (pane.dataset.sig === html) return; + const scroll = pane.querySelector('.rt-scroll')?.scrollTop || 0; + pane.dataset.sig = html; + pane.innerHTML = html; + pane.querySelector('.rt-scroll').scrollTop = scroll; + pane.querySelector('[data-close-thread]').onclick = () => openRoomRunThread(rs.threadRun); + bindRoomLog(pane); + if (editing) bindRoomCrewEditor(pane, msg); } function roomRunStatusLabel(status) { @@ -376,68 +1072,6 @@ function roomAuthorName(name) { return roomIsSOPAuthor(name) ? String(name).slice(4, -1) : String(name || ''); } -function roomAvatar(name) { - if (roomIsSOPAuthor(name)) return 'SOP'; - return `${escapeHTML(String(name || '?').slice(0, 1))}`; -} - -function roomDispatchHTML(message, author) { - const failed = message.dispatchError ? `

    派发未成功:${escapeHTML(message.dispatchError)}

    ` : ''; - const key = message.issueNumber ? '#' + message.issueNumber + ' ' : ''; - let run = ''; - if (message.runId) { - const total = message.runTotal || 0; - const done = message.runCompleted || 0; - const pct = total ? Math.round(done * 100 / total) : 0; - const status = message.runStatus || message.runFinal || 'queued'; - const current = (message.runCurrent || []).length ? '正在:' + message.runCurrent.join('、') : ''; - run = `
    ${escapeHTML(message.workflowName || 'SOP')}${escapeHTML(roomRunStatusLabel(status))}${total ? ` · ${done}/${total} 步` : ''}
    ${current ? `${escapeHTML(current)}` : ''}
    `; - } else if (!message.dispatchError && !message.workflowId) { - run = '当时只指定了角色,没有启动 SOP'; - } - const head = message.lane ? `${author} 并行派发` : `${author} 建了 Issue`; - const actions = `
    ${message.runId ? `` : ''}
    `; - return `
    ${head}
    ${escapeHTML(key + (message.issueTitle || 'Issue'))}${message.lane ? '' : `

    派给 ${escapeHTML(message.targetName || '未指定')}

    `}${message.body ? `

    ${escapeHTML(message.body)}

    ` : ''}${failed}${run}${actions}
    `; -} - -function roomProgressHTML(message, prev) { - const status = message.nodeStatus || ''; - const icon = status === 'succeeded' ? '✓' : status === 'failed' ? '✗' : status === 'cancelled' ? '■' : '•'; - const lines = String(message.body || '').split('\n'); - const head = lines.shift(); - const rest = lines.join('\n').trim(); - const detail = rest ? `
    产出摘要

    ${escapeHTML(rest)}

    ` : ''; - const final = !message.nodeId; - // Consecutive steps of one run already sit under its card; repeat the - // issue / SOP only when the thread switches to another run or chat. - const sameRun = prev && prev.runId && prev.runId === message.runId; - const sop = roomIsSOPAuthor(message.author); - const where = sameRun ? '' : [message.issueNumber ? '#' + message.issueNumber : '', sop ? '' : message.workflowName || ''].filter(Boolean).join(' · '); - return `
    ${roomAvatar(message.author)}
    ${escapeHTML(roomAuthorName(message.author) || 'SOP')}${where ? `${escapeHTML(where)}` : ''}

    ${icon}${escapeHTML(head)}

    ${detail}
    `; -} - -function roomApprovalHTML(message) { - const attrs = `data-run="${escapeHTML(message.runId)}" data-node="${escapeHTML(message.nodeId)}" data-name="${escapeHTML(message.nodeName || message.nodeId)}"`; - if (message.resolved) { - return `
    ${escapeHTML(message.nodeName || '审批')} 已处理 ·
    `; - } - const actions = `
    ${message.canReject ? `` : ''}
    `; - const where = [message.issueNumber ? '#' + message.issueNumber : '', message.issueTitle || ''].filter(Boolean).join(' '); - return `
    ${escapeHTML(roomAuthorName(message.author) || 'SOP')} · ${escapeHTML(where)}
    ${message.resolved ? '✓' : '⏸'} ${escapeHTML(message.nodeName || '')} ${message.resolved ? '审批已处理' : '等待审批'}${message.resolved ? '' : `

    ${escapeHTML(message.body || '')}

    `}${actions}
    `; -} - -function roomMessageHTML(message, mine, prev) { - const own = mine > 0 && Number(message.userId) === mine; - const author = escapeHTML(message.author || '群成员'); - switch (message.kind) { - case 'dispatch': return roomDispatchHTML(message, author); - case 'progress': return roomProgressHTML(message, prev); - case 'approval': return roomApprovalHTML(message); - case 'decision': return `
    ${author} ${escapeHTML(message.body || '')}
    `; - } - return `
    ${author}

    ${roomTextHTML(message.body || '')}

    `; -} - // roomTextHTML escapes a chat line and highlights @mentions; mentions of the // current user get an extra class so they stand out. function roomTextHTML(text) { @@ -465,13 +1099,16 @@ function bindRoomGoSOPs(host) { function roomGuideHTML() { const noSOP = !roomWorkflowChoices().length; return `
    +

    ${escapeHTML(roomGreeting())}

    -

    在这里聊天,或者把一条需求派给 SOP。角色写在流程里,不用再选。

    -
      -
    1. 聊天回车发送,Shift+Enter 换行。@ 用来提醒同事。
    2. -
    3. 建单切到「建单」,选一条或多条 SOP,第一行写标题,回车就派发。
    4. -
    -
    ${noSOP ? '' : ''}
    +

    在群里聊需求,@LoopForge 选一条 SOP 就开跑。进度、待确认和交付都会回到这个群。

    +
    + + + + +
    + ${noSOP ? '' : ''}
    `; } @@ -491,13 +1128,21 @@ function roomMentionItems(query, targetsOnly) { const q = String(query || '').trim().toLowerCase(); const hit = (...values) => !q || values.some(value => String(value || '').toLowerCase().includes(q)); const items = []; - const wantSOP = targetsOnly || roomState().mode === 'issue'; + if (!targetsOnly && hit('loopforge', '组队', '协作', '群聊', 'crew')) { + const bots = roomBotsInProject(); + items.push({kind: 'crew', id: 'crew', name: 'LoopForge · 组队来做', sub: '没有固定流程的活:队长拉 Bot 分工,做完一步交接一步', bots}); + } + const wantSOP = true; if (wantSOP) { roomWorkflowChoices().forEach(w => { - if (hit(w.name, w.id)) items.push({kind: 'sop', id: w.id, name: roomWorkflowLabel(w), sub: (w.nodes || []).length ? w.nodes.length + ' 个步骤,派发后立即运行' : '派发后立即运行'}); + if (hit(w.name, w.id)) items.push({kind: 'sop', id: w.id, name: roomWorkflowLabel(w), steps: w.nodes || [], sub: (w.nodes || []).length ? w.nodes.map(n => n.name || n.id).join(' → ') : '派发后立即运行'}); }); } if (!targetsOnly) { + roomBotsInProject().forEach(a => { + const name = roomAgentName(a.id, a.name); + if (hit(name, a.id)) items.push({kind: 'bot', id: a.id, name, sub: '协作进行中 @ 它,就给它加一步'}); + }); (activeRoom()?.members || []).forEach(m => { if (hit(m.name)) items.push({kind: 'member', id: String(m.userId), name: m.name || '用户 ' + m.userId, sub: roomMemberRoleLabel(m.role)}); }); @@ -524,9 +1169,38 @@ function closeRoomMention() { if (box) box.hidden = true; } +const ROOM_COMMANDS = [ + {id: 'sop', name: '/sop', sub: '选一条 SOP,把这条需求派出去'}, + {id: 'status', name: '/status', sub: '现在有哪些在跑、卡在哪'}, + {id: 'approve', name: '/approve', sub: '列出等我拍板的步骤,一键通过'}, + {id: 'help', name: '/help', sub: '看看 LoopForge 在群里能做什么'}, +]; +function roomSlashQuery(input) { + const match = String(input?.value || '').match(/^\/(\S*)$/); + return match ? match[1].toLowerCase() : null; +} +function roomAutosize(input) { + if (!input) return; + input.style.height = 'auto'; + input.style.height = Math.min(200, input.scrollHeight) + 'px'; +} + function updateRoomMention() { const input = $('#roomInput'); if (!input) return; + const slash = roomSlashQuery(input); + if (slash !== null) { + const ms = roomMentionState(); + ms.open = true; + ms.targetsOnly = false; + ms.start = -1; + ms.slash = true; + ms.items = ROOM_COMMANDS.filter(c => c.id.startsWith(slash) || !slash).map(c => ({...c, kind: 'cmd'})); + ms.index = Math.min(ms.index || 0, Math.max(0, ms.items.length - 1)); + renderRoomMention(slash); + return; + } + roomMentionState().slash = false; const token = roomMentionToken(input); if (token && (!token.query || roomMentionItems(token.query, false).length)) openRoomMention({token}); else if (!roomMentionState().targetsOnly) closeRoomMention(); @@ -536,14 +1210,20 @@ function renderRoomMention(query) { const box = $('#roomMention'); const ms = roomMentionState(); if (!box) return; - const groups = [['sop', 'SOP · 建单并运行'], ['member', '成员 · 提醒']]; - let html = ms.targetsOnly ? '
    选这条 Issue 要跑的 SOP
    ' : ''; + const groups = [['cmd', '命令'], ['crew', '自由协作 · 交给群聊'], ['sop', '按 SOP 跑 · 固定流程'], ['bot', '角色 Bot'], ['member', '提醒群成员']]; + const icon = item => item.kind === 'crew' + ? `${roomFace({kind: 'lf'}, 'sm')}${(item.bots || []).slice(0, 3).map(a => roomFace({kind: 'bot', name: roomAgentName(a.id, a.name)}, 'sm')).join('')}` + : item.kind === 'bot' ? `${roomFace({kind: 'bot', name: item.name}, 'sm')}` + : item.kind === 'sop' + ? `${[...new Map((item.steps || []).map(n => [roomStepWho(n).name, roomStepWho(n)])).values()].slice(0, 4).map(w => roomFace(w, 'sm')).join('') || roomFace({kind: 'lf'}, 'sm')}` + : item.kind === 'cmd' ? `/` : roomFace({kind: 'person', name: roomPerson(item.name).name}, 'sm'); + let html = ms.targetsOnly ? '
    选这条需求要跑的 SOP
    ' : ''; groups.forEach(([kind, label]) => { const rows = ms.items.map((item, index) => ({item, index})).filter(row => row.item.kind === kind); if (!rows.length && !(kind === 'sop' && !query && !roomWorkflowChoices().length)) return; html += `
    ${escapeHTML(label)}
    `; if (!rows.length) html += roomNoSOPHint(); - html += rows.map(({item, index}) => ``).join(''); + html += rows.map(({item, index}) => ``).join(''); }); if (!ms.items.length && !html.includes('room-mention-group')) html += `
    没有匹配「${escapeHTML(query)}」的${roomState().mode === 'issue' || ms.targetsOnly ? ' SOP 或成员' : '成员'}
    `; box.innerHTML = html; @@ -584,22 +1264,35 @@ function pickRoomMention(item) { const input = $('#roomInput'); const ms = roomMentionState(); if (!item || !input) return; + if (item.kind === 'cmd') { + input.value = ''; + ms.slash = false; + closeRoomMention(); + runRoomCommand(item.id); + return; + } if (ms.start >= 0) { const caret = input.selectionStart; - const insert = item.kind === 'member' ? '@' + item.name + ' ' : ''; + const insert = item.kind === 'member' || item.kind === 'bot' ? '@' + item.name + ' ' : ''; input.value = input.value.slice(0, ms.start) + insert + input.value.slice(caret).replace(/^ /, insert ? '' : ''); const next = ms.start + insert.length; input.setSelectionRange(next, next); } if (item.kind === 'sop') { + roomState().crew = false; roomState().mode = 'issue'; toggleRoomSOP(item.id, true); } + if (item.kind === 'crew') { + const rs = roomState(); + rs.sops = []; + rs.crew = true; + rs.mode = 'chat'; + } ms.targetsOnly = false; closeRoomMention(); syncRoomComposer(); input.focus(); - if (item.kind === 'sop' && !input.value.trim()) toast('已选好 SOP,第一行写 Issue 标题,回车建单'); } async function decideRoomStep(action, runId, nodeId, name) { @@ -613,6 +1306,8 @@ async function decideRoomStep(action, runId, nodeId, name) { try { await api('/api/rooms/' + encodeURIComponent(id) + '/decisions', {method: 'POST', body: JSON.stringify({runId, nodeId, action, reason: String(reason || '').trim()})}); toast(action === 'approve' ? '已批准' : '已打回'); + if (typeof refreshRuns === 'function') await refreshRuns(true).catch(() => {}); + if (roomState().threadRun === runId) await loadRoomRunDetail(runId); await refreshRoomMessages(); } catch (error) { toast(error.message); @@ -654,10 +1349,74 @@ async function createRoom() { } function submitRoomComposer() { - if (roomState().mode === 'issue') dispatchRoomIssue(); + const raw = String($('#roomInput')?.value || '').trim(); + const cmd = raw.match(/^\/(\w+)$/); + if (cmd && ROOM_COMMANDS.some(c => c.id === cmd[1].toLowerCase())) { + $('#roomInput').value = ''; + closeRoomMention(); + runRoomCommand(cmd[1].toLowerCase()); + return; + } + const rs = roomState(); + if (rs.crew) roomCreateCrew(); + else if (rs.sops.length) dispatchRoomIssue(); + else if (rs.mode === 'issue') openRoomMention({targetsOnly: true}); else sendRoomMessage(); } +function roomBotReply(html) { + const rs = roomState(); + if (!rs.activeId) return toast('先选择一个群'); + rs.ephemeral = (rs.ephemeral || []).concat({id: Date.now() + Math.random(), html}); + rs.threadSignature = ''; + refreshRoomMessages(true); +} + +function roomRunsInProject() { + const pid = roomProjectId(); + return (state.data?.runs || []).filter(r => !pid || r.executionContext?.projectId === pid || runContext(r)?.project?.id === pid); +} + +function roomRunTitle(r) { + return runContext(r)?.issue?.title || String(r.input || '').split('\n')[0] || r.workflowSnapshot?.name || r.id; +} + +function roomRunLine(r) { + const group = roomState().runGroups?.get(r.id) || {runId: r.id, head: {issueTitle: roomRunTitle(r), issueNumber: runContext(r)?.issue?.number, workflowId: r.workflowId, workflowName: r.workflowSnapshot?.name}, events: []}; + return roomRunCardHTML(roomRunModel(group), Number(state.currentUser?.id) || 0, true); +} + +async function runRoomCommand(id) { + if (id === 'sop') { + setRoomMode('issue'); + openRoomMention({targetsOnly: true}); + return; + } + if (id === 'help') { + roomBotReply(`

    我是 LoopForge,在群里可以这样用我:

    • @LoopForge 组队:没有固定流程的活交给群里的 Bot 团队,我来排分工,每个 Bot 做完在群里交接,你随时插话、@ 某个 Bot 加活
    • @ 选一条 SOP:有严格流程的活按 SOP 一步步跑,群里只出现一张运行卡片
    • /status 看现在有哪些在跑、卡在哪一步
    • /approve 列出等你拍板的步骤,直接在群里通过
    • SOP 跑到人工确认时,我会在群里 @ 负责人
    `); + return; + } + if (typeof refreshRuns === 'function') { try { await refreshRuns(true); } catch {} } + const runs = roomRunsInProject(); + if (id === 'status') { + const live = runs.filter(r => ['running', 'queued', 'waiting', 'paused'].includes(r.status)); + if (!live.length) { + const last = runs[0]; + roomBotReply(`

    现在没有在跑的 SOP。${last ? `最近一次是「${escapeHTML(roomRunTitle(last))}」,${escapeHTML(roomRunStatusLabel(last.status))}。` : '要开一条吗?输入 @ 选 SOP。'}

    `); + return; + } + const waiting = live.filter(r => r.status === 'waiting').length; + roomBotReply(`

    现在有 ${live.length} 条在跑${waiting ? `,其中 ${waiting} 条在等人拍板` : ''}:

    ${live.slice(0, 6).map(roomRunLine).join('')}`); + return; + } + if (id === 'approve') { + const items = typeof approvalItems === 'function' ? approvalItems() : runs.filter(r => r.status === 'waiting'); + roomBotReply(items.length + ? `

    有 ${items.length} 个步骤在等你确认:

    ${items.slice(0, 6).map(roomRunLine).join('')}` + : '

    没有等你拍板的事,SOP 都在自己往前跑。

    '); + } +} + async function sendRoomMessage() { const id = roomState().activeId; const input = $('#roomInput'); @@ -665,8 +1424,9 @@ async function sendRoomMessage() { if (!id) return toast('先选择一个群'); if (!body) return; try { - await api('/api/rooms/' + encodeURIComponent(id) + '/messages', {method: 'POST', body: JSON.stringify({body})}); + await api('/api/rooms/' + encodeURIComponent(id) + '/messages', {method: 'POST', body: JSON.stringify({body, replyTo: roomState().reply?.id || ''})}); input.value = ''; + roomState().reply = null; closeRoomMention(); syncRoomComposer(); await openRoom(id); @@ -675,6 +1435,29 @@ async function sendRoomMessage() { } } +async function roomCreateCrew() { + const rs = roomState(); + const id = rs.activeId; + const input = $('#roomInput'); + const {title, description} = splitRoomIssueText(input?.value); + if (!id) return toast('先选择一个群'); + if (!title) return toast('第一行写要做成什么'); + const send = $('#roomSend'); + if (send) send.disabled = true; + try { + await api('/api/rooms/' + encodeURIComponent(id) + '/crew', {method: 'POST', body: JSON.stringify({goal: title, detail: description})}); + input.value = ''; + rs.crew = false; + closeRoomMention(); + syncRoomComposer(); + await refreshRoomMessages(true); + } catch (error) { + toast(error.message); + } finally { + if (send) send.disabled = false; + } +} + function splitRoomIssueText(text) { const lines = String(text || '').split('\n'); const title = (lines.shift() || '').trim().slice(0, 200); diff --git a/web/syntax_test.go b/web/syntax_test.go index da6c01d..c0f152d 100644 --- a/web/syntax_test.go +++ b/web/syntax_test.go @@ -1036,8 +1036,8 @@ func TestRequirementDirectCreateUI(t *testing.T) { if sendIdx < 0 || directIdx < 0 || sendIdx > directIdx { t.Fatal("直接创建 must sit to the right of 开始分析") } - if !strings.Contains(page, `nav-label">项目配置`) { - t.Fatal("sidebar must label the projects page 项目配置") + if appJS, err := readAppBundle(); err != nil || !strings.Contains(string(appJS), `{view:'projects',label:'项目'}`) { + t.Fatal("项目配置 must be reachable as the 项目 tab under 构建") } if !strings.Contains(page, `