diff --git a/docs/reports/A48.md b/docs/reports/A48.md index 486bd89..fdfd7c6 100644 --- a/docs/reports/A48.md +++ b/docs/reports/A48.md @@ -65,3 +65,9 @@ HubSnapshot: generation, seq, timestamp, profiles_by_provider, all_profiles, rea ## 2026-08-31 — оркестратор сверху по запросу владельца Владелец явно запросил изменить сохранённые координаты: оркестратор (430,60), первый ряд субагентов — два кодера и ревьюер на y=250; остальные 9 субагентов в трёх колонках ниже. Раскладка применена через штатную команду и сохранена `save_workflow`. После перезагрузки координаты подтверждены по DOM. Связи, модели и настройки агентов не менялись. Начальный вид клиента центрирует выбранного агента по горизонтали. Новый снимок: `docs/screenshots/a48/after/overview-orchestrator-top.png`; предыдущие сравнительные снимки показывают состояние до этого запроса. Проверка синтаксиса workflow.js прошла. + +## 2026-08-31 — разводка и цвета стрелок + +По запросу владельца изменено только отображение существующих связей. Нисходящая связь выходит снизу и входит сверху; переходы между соседними карточками идут напрямую, обратные связи — по отдельным нижним дорожкам, возврат к оркестратору — снаружи справа. Углы скруглены. SUCCESS/REVIEW_PASSED зелёные, REVIEW_FAILED/ERROR красные пунктирные, NEXT/ALWAYS золотые; наконечники и подписи используют соответствующий цвет. Невидимый hit-path больше не перекрашивает видимый путь. Длинные подписи переносятся в межузловом промежутке. Fit учитывает внешние линии. + +`node tests/test_workflow_routes_a48.js`: шесть связей текущей раскладки не пересекают 13 карточек, направление портов и возвратные дорожки проверены. Оба прежних JS-набора проходят. В браузере подтверждены разные stroke/marker, прозрачность шести hit-path, отсутствие пересечений подписей с узлами и ошибок консоли. Новый снимок: `after/overview-colored-arrows.png`. Старые снимки в галерее остаются историческим сравнением. Топология и настройки workflow не менялись. Полный произвольный граф с препятствиями не проверен; маршрутизация рассчитана на текущую раскладку по рядам. diff --git a/docs/screenshots/a48/after/overview-colored-arrows.png b/docs/screenshots/a48/after/overview-colored-arrows.png new file mode 100644 index 0000000..9f7852b Binary files /dev/null and b/docs/screenshots/a48/after/overview-colored-arrows.png differ diff --git a/docs/screenshots/a48/index.html b/docs/screenshots/a48/index.html index 00dcb63..9461bcd 100644 --- a/docs/screenshots/a48/index.html +++ b/docs/screenshots/a48/index.html @@ -1,4 +1,4 @@ A48 — визуальное сравнение -

A48 · Сравнение с макетами

Клиент проверен с живыми данными; визуальное согласование остаётся за владельцем. «До»: хаб 81a58f6. «После»: клиент A48 с тем же защищённым API. 21 снимок до и 21 после. Аккаунтов 0, поэтому заполненные карточки и выбор реальной модели не проверены.

База кода: 80aab00. Контрольная ширина снимков: 1280 px. Макеты имеют разные исходные размеры; изображения показаны полностью, без подмены данных.

Обзор 1536×1024 · Граф после перетаскивания и вписывания · Адаптивность 760 px

+

A48 · Сравнение с макетами

Клиент проверен с живыми данными; визуальное согласование остаётся за владельцем. «До»: хаб 81a58f6. «После»: клиент A48 с тем же защищённым API. 21 снимок до и 21 после. Аккаунтов 0, поэтому заполненные карточки и выбор реальной модели не проверены.

База кода: 80aab00. Контрольная ширина снимков: 1280 px. Макеты имеют разные исходные размеры; изображения показаны полностью, без подмены данных.

Обзор 1536×1024 · Граф после перетаскивания и вписывания · Адаптивность 760 px

Последняя доработка: оркестратор сверху и цветные стрелки

diff --git a/src/antigravity_provider/router/web/static/index.html b/src/antigravity_provider/router/web/static/index.html index 0f44855..1c75406 100644 --- a/src/antigravity_provider/router/web/static/index.html +++ b/src/antigravity_provider/router/web/static/index.html @@ -128,7 +128,7 @@
- +
@@ -136,7 +136,7 @@
diff --git a/src/antigravity_provider/router/web/static/workflow.css b/src/antigravity_provider/router/web/static/workflow.css index a512d66..32badd7 100644 --- a/src/antigravity_provider/router/web/static/workflow.css +++ b/src/antigravity_provider/router/web/static/workflow.css @@ -188,3 +188,16 @@ body[data-workflow-mode="edit"] .workflow-toolbar { display:flex; justify-conten .workflow-inspector .inspector-section { padding:10px; margin:8px 0; } .workflow-inspector .inspector-section p { line-height:1.4; margin:6px 0; } .workflow-inspector dl { gap:6px 10px; margin:8px 0; } +/* Keep the interaction hit area invisible: it must not paint over colored links. */ +.workflow-edges .workflow-edge-hit { stroke:transparent; stroke-width:16; fill:none; marker-end:none; } +.edge-success { --edge-color:var(--status-healthy); } +.edge-return { --edge-color:var(--status-error); } +.edge-next { --edge-color:var(--accent); } +.workflow-edges .workflow-link { stroke:var(--edge-color); stroke-width:1.6; stroke-linecap:round; stroke-linejoin:round; marker-end:revert-layer; } +.workflow-edges .workflow-link.edge-success { marker-end:url(#wf-arrow-success); } +.workflow-edges .workflow-link.edge-return { marker-end:url(#wf-arrow-return); stroke-dasharray:5 5; } +.workflow-edges .workflow-link.edge-next { marker-end:url(#wf-arrow-next); } +.workflow-edges marker path { fill:var(--edge-color); stroke:none; marker-end:none; } +.edge-label-group .workflow-edge-label { fill:var(--edge-color); font-size:10px; } +.edge-label-group .workflow-label-bg { fill:var(--canvas-bg); stroke:none; } +.edge-legend.next { color:var(--accent); } diff --git a/src/antigravity_provider/router/web/static/workflow.js b/src/antigravity_provider/router/web/static/workflow.js index 04160da..9495d01 100644 --- a/src/antigravity_provider/router/web/static/workflow.js +++ b/src/antigravity_provider/router/web/static/workflow.js @@ -326,33 +326,76 @@ function finishConnection(event) { openEdgeDialog({ id: `edge-${Date.now()}`, source, target, condition: 'SUCCESS', label: '' }, true); } +function roundedWorkflowPath(points, radius = 18) { + let path = `M ${points[0].x} ${points[0].y}`; + for (let i = 1; i < points.length - 1; i++) { + const prev = points[i-1], corner = points[i], next = points[i+1]; + const before = Math.hypot(corner.x-prev.x,corner.y-prev.y); + const after = Math.hypot(next.x-corner.x,next.y-corner.y); + const r = Math.min(radius,before/2,after/2); + if (!r) continue; + const x = corner.x+(prev.x-corner.x)*r/before, y = corner.y+(prev.y-corner.y)*r/before; + const nx = corner.x+(next.x-corner.x)*r/after, ny = corner.y+(next.y-corner.y)*r/after; + path += ` L ${x} ${y} Q ${corner.x} ${corner.y} ${nx} ${ny}`; + } + const end = points.at(-1); + return path + ` L ${end.x} ${end.y}`; +} + +function workflowEdgeRoute(source, target, lane = 0) { + const sx = source.x+source.width/2, tx = target.x+target.width/2; + const sy = source.y+source.height/2, ty = target.y+target.height/2; + // Downward branches leave the bottom and enter the top of the next row. + if (target.y >= source.y+source.height+32) { + const y = (source.y+source.height+target.y)/2; + return {points:[{x:sx,y:source.y+source.height+5},{x:sx,y},{x:tx,y},{x:tx,y:target.y-7}],x:(sx+tx)/2,y:y-10,width:Math.max(100,Math.abs(tx-sx)-24)}; + } + // Forward transitions between peers use the open horizontal corridor. + if (Math.abs(sy-ty)<40 && target.x >= source.x+source.width+24) { + const x1=source.x+source.width+5, x2=target.x-7; + return {points:[{x:x1,y:sy},{x:x2,y:ty}],x:(x1+x2)/2,y:Math.min(sy,ty)-16,width:Math.max(50,x2-x1-10)}; + } + // Feedback between peers runs below their row, separate from forward arrows. + if (Math.abs(sy-ty)<40) { + const y=Math.max(source.y+source.height,target.y+target.height)+38+lane*28; + return {points:[{x:sx,y:source.y+source.height+5},{x:sx,y},{x:tx,y},{x:tx,y:target.y+target.height+7}],x:(sx+tx)/2,y:y+14,width:Math.max(90,Math.abs(tx-sx)-30)}; + } + // Upward return travels outside both nodes and enters from the right. + const x=Math.max(source.x+source.width,target.x+target.width)+48+lane*24; + return {points:[{x:source.x+source.width+5,y:sy},{x,y:sy},{x,y:ty},{x:target.x+target.width+7,y:ty}],x:(x+target.x+target.width)/2,y:ty-14,width:Math.max(80,x-target.x-target.width-24)}; +} + +function workflowLabelLines(text, width) { + const limit=Math.max(8,Math.floor(width/5.7)); + const words=String(text).split(/\s+/), lines=[]; + let line=''; + for (const word of words) { + if (line && (line+' '+word).length>limit) { lines.push(line); line=''; } + line+=(line?' ':'')+word; + } + if(line) lines.push(line); + return lines; +} + function drawWorkflowEdges() { const svg = document.getElementById('workflow-edges'); const layer = document.getElementById('workflow-edge-layer'); if (!svg || !layer) return; - const parts = []; - const labels = []; - const occupiedLabels = []; + const parts = [], labels = []; + let feedbackLane=0; workflowUi.draftEdges.forEach((edge) => { const source = document.querySelector(`.workflow-node[data-agent-id="${CSS.escape(edge.source)}"]`); const target = document.querySelector(`.workflow-node[data-agent-id="${CSS.escape(edge.target)}"]`); if (!source || !target) return; - const x1 = source.offsetLeft + source.offsetWidth; - const y1 = source.offsetTop + source.offsetHeight / 2; - const x2 = target.offsetLeft; - const y2 = target.offsetTop + target.offsetHeight / 2; - const klass = String(edge.condition || '').toLowerCase(); - const label = edge.label || edge.condition; - const labelWidth = Math.max(70, String(label).length * 6 + 16); - const labelX = (x1 + x2) / 2; - let labelY = Math.min(source.offsetTop, target.offsetTop) - 30; - const boxes = [...document.querySelectorAll('.workflow-node')].map(node => ({left:node.offsetLeft,top:node.offsetTop,width:node.offsetWidth,height:node.offsetHeight})); - const overlaps = box => labelX + labelWidth/2 > box.left - 6 && labelX - labelWidth/2 < box.left + box.width + 6 && labelY + 12 > box.top - 6 && labelY - 12 < box.top + box.height + 6; - while ([...boxes,...occupiedLabels].some(overlaps)) labelY -= 30; - occupiedLabels.push({left:labelX-labelWidth/2,top:labelY-12,width:labelWidth,height:24}); - const path = `M ${x1} ${y1} C ${x1+28} ${y1}, ${x1+28} ${labelY}, ${x1} ${labelY} L ${x2} ${labelY} C ${x2-28} ${labelY}, ${x2-28} ${y2}, ${x2} ${y2}`; - labels.push(`${wfEscape(label)}`); - parts.push(``); + const rect=node=>({x:node.offsetLeft,y:node.offsetTop,width:node.offsetWidth,height:node.offsetHeight}); + const condition=String(edge.condition || '').toUpperCase(); + const tone=['REVIEW_FAILED','ERROR'].includes(condition)?'return':['SUCCESS','REVIEW_PASSED'].includes(condition)?'success':'next'; + const route=workflowEdgeRoute(rect(source),rect(target),tone==='return'?feedbackLane++:0); + const path=roundedWorkflowPath(route.points); + const lines=workflowLabelLines(edge.label || condition,Math.min(220,route.width)); + const width=Math.max(...lines.map(line=>line.length))*5.7+12, height=lines.length*13+6; + labels.push(`${wfEscape(edge.label || condition)} · ${wfEscape(condition)}${lines.map((line,i)=>`${wfEscape(line)}`).join('')}`); + parts.push(``); }); layer.innerHTML = parts.join(''); document.getElementById('workflow-label-layer').innerHTML = labels.join(''); @@ -424,6 +467,11 @@ function fitWorkflowGraph() { maxX = Math.max(maxX, x + Number(label.getAttribute('width'))); maxY = Math.max(maxY, y + Number(label.getAttribute('height'))); }); + document.querySelectorAll('.workflow-link').forEach(path => { + const box = path.getBBox(); + minX = Math.min(minX, box.x); minY = Math.min(minY, box.y); + maxX = Math.max(maxX, box.x+box.width); maxY = Math.max(maxY, box.y+box.height); + }); const width = maxX - minX || 200; const height = maxY - minY || 120; const pad = 60; diff --git a/tests/test_workflow_routes_a48.js b/tests/test_workflow_routes_a48.js new file mode 100644 index 0000000..dae82ff --- /dev/null +++ b/tests/test_workflow_routes_a48.js @@ -0,0 +1,27 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const context = vm.createContext({document:{addEventListener(){}},console}); +vm.runInContext(fs.readFileSync('src/antigravity_provider/router/web/static/workflow.js','utf8'),context); +const nodes=[{x:430,y:60,width:250,height:116},...Array.from({length:12},(_,i)=>({x:70+i%3*360,y:250+Math.floor(i/3)*240,width:250,height:116}))]; +const edges=[[0,1,0],[1,2,0],[2,1,0],[2,3,0],[3,2,1],[3,0,0]]; +for(const [source,target,lane] of edges){ + const route=context.workflowEdgeRoute(nodes[source],nodes[target],lane); + for(let i=1;in.x&&a.xn.y&&Math.min(a.y,b.y)n.y&&a.yn.x&&Math.min(a.x,b.x)${target} crosses a card`); + } + } + assert(!context.roundedWorkflowPath(route.points).includes('NaN')); +} +const down=context.workflowEdgeRoute(nodes[0],nodes[1]); +assert.equal(down.points[0].x,nodes[0].x+nodes[0].width/2); +assert.equal(down.points.at(-1).y,nodes[1].y-7); +const feedback=context.workflowEdgeRoute(nodes[2],nodes[1]); +assert(feedback.points[1].y>nodes[2].y+nodes[2].height); +console.log('A48 routes: downward ports, separate feedback lanes, six routes avoid all 13 cards');