huabu/templates/board.html

629 lines
27 KiB
HTML
Raw Permalink Normal View History

2026-02-07 00:17:23 +08:00
{% extends "base.html" %}
{% from "components/node_macro.html" import render_node %}
{% block content %}
<div class="noir-layout" x-data="{
aiOpen: false,
zoom: 1,
panX: 0,
panY: 0,
isPanning: false,
startX: 0,
startY: 0,
spaceDown: false
}" @keydown.window="if($event.code === 'Space') spaceDown = true"
@keyup.window="if($event.code === 'Space') spaceDown = false">
<!-- 顶部统一操作栏 -->
<div class="unified-toolbar">
<button class="btn-queue" onclick="queueWorkflow()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
Queue Prompt (启动任务)
</button>
<div class="divider" style="width: 1px; background: var(--border-color); margin: 0 4px;"></div>
<button class="tool-btn" title="清理画布" onclick="clearCanvas()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
</svg>
</button>
</div>
<!-- 悬浮品牌 -->
<div class="floating-brand">
<h1 class="brand-logo">NoirFlow</h1>
<p class="brand-tagline">暗流 · 节点生成系统</p>
</div>
<!-- 右键菜单 -->
<div id="context-menu" class="context-menu" @click.away="closeContext()" style="display: none;">
<div class="section-title" style="padding: 4px 12px; margin-bottom: 4px; font-size: 10px; opacity: 0.5;">新建节点
</div>
{% for config in node_configs %}
<div class="context-menu-item" @click="createNodeAtMouse('{{ config.id }}')">
<span class="icon"></span>
<span class="label">{{ config.name }}</span>
</div>
{% endfor %}
<div style="height: 1px; background: var(--border-color); margin: 4px 8px;"></div>
<div class="context-menu-item" @click="clearCanvas(); closeContext();">
<span class="icon">🗑</span>
<span class="label">清空画布</span>
</div>
</div>
<!-- AI 助手悬浮按钮 -->
<div class="ai-toggle-btn" @click="aiOpen = !aiOpen">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
</div>
<div class="ai-modal" x-show="aiOpen" x-transition x-cloak>
<div class="sidebar-header"
style="padding: 16px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
<h3 class="section-title" style="margin: 0;">AI 助手</h3>
<button @click="aiOpen = false"
style="background:none; border:none; color:var(--text-secondary); cursor:pointer; font-size: 20px;">×</button>
</div>
<div class="sidebar-section chat-section"
style="padding: 16px; flex: 1; display: flex; flex-direction: column;">
<div id="chat-messages" class="chat-box" style="flex: 1; overflow-y: auto;">
<div class="msg system">你好!我是 NoirFlow 助手。您可以问我如何构建工作流。</div>
</div>
<div class="chat-input-wrapper" style="margin-top: 12px;">
<div class="chat-input-wrapper" style="margin-top: 12px;">
<form onsubmit="handleChatSubmit(event)">
<input type="text" name="message" placeholder="与 AI 对话..." class="chat-input"
autocomplete="off">
<button type="submit" class="chat-send">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
</svg>
</button>
</form>
</div>
2026-02-07 00:17:23 +08:00
</div>
</div>
</div>
<!-- 主画布 -->
<main class="canvas-area" id="canvas" @contextmenu.prevent="showContext($event)" @wheel.passive="handleZoom($event)"
@mousedown="startPan($event)" @mousemove="doPan($event)" @mouseup="stopPan()" @mouseleave="stopPan()">
<div id="canvas-transform" :style="`transform: translate(${panX}px, ${panY}px) scale(${zoom})`"
style="transform-origin: 0 0; width: 100%; height: 100%;">
<div class="canvas-grid"></div>
<!-- 连线层 -->
<svg id="connection-svg" class="connection-layer">
<defs>
<marker id="arrowhead-image" markerWidth="5" markerHeight="5" refX="4" refY="2.5" orient="auto">
<polygon points="0 0, 5 2.5, 0 5" fill="#4ade80" />
</marker>
<marker id="arrowhead-text" markerWidth="5" markerHeight="5" refX="4" refY="2.5" orient="auto">
<polygon points="0 0, 5 2.5, 0 5" fill="#38bdf8" />
</marker>
<marker id="arrowhead-file" markerWidth="5" markerHeight="5" refX="4" refY="2.5" orient="auto">
<polygon points="0 0, 5 2.5, 0 5" fill="#facc15" />
</marker>
<marker id="arrowhead-default" markerWidth="5" markerHeight="5" refX="4" refY="2.5" orient="auto">
<polygon points="0 0, 5 2.5, 0 5" fill="#ffffff" />
</marker>
</defs>
</svg>
<div id="node-container">
<!-- 初始预览节点 -->
</div>
</div>
<!-- 顶级拖拽预览层 (不受缩放和平移影响,使用屏幕坐标) -->
<svg id="drag-layer"
style="position:fixed; top:0; left:0; width:100vw; height:100vh; z-index:9999; pointer-events:none; fill:none;"></svg>
</main>
</div>
<script>
// NoirConnect 工业级连线引擎:全局状态
window.connections = [];
window.selectedNodeId = null;
let tempPathEl = null;
window.currentZoom = 1;
// --- 核心坐标投影工具 (工业级方案) ---
function projectToCanvas(screenX, screenY) {
const svg = document.getElementById('connection-svg');
if (!svg) return { x: screenX, y: screenY };
try {
const pt = svg.createSVGPoint();
pt.x = screenX;
pt.y = screenY;
const ctm = svg.getScreenCTM();
if (ctm) {
const localPt = pt.matrixTransform(ctm.inverse());
return { x: localPt.x, y: localPt.y };
}
} catch (e) {
console.error("CTM Error:", e);
}
// 极致降级方案:手动算 (仅在 CTM 失效时)
const rect = svg.getBoundingClientRect();
const alpine = getAlpineData();
const zoom = alpine.zoom || 1;
return {
x: (screenX - rect.left) / zoom,
y: (screenY - rect.top) / zoom
};
}
function getAlpineData() {
const el = document.querySelector('.noir-layout');
if (!el) return {};
if (window.Alpine) return Alpine.$data(el);
return el.__x ? el.__x.$data : {};
}
// --- 画布操作逻辑 (Zoom & Pan) ---
let lastMouseX = 0, lastMouseY = 0;
let spaceDown = false;
window.addEventListener('keydown', (e) => { if (e.code === 'Space') spaceDown = true; });
window.addEventListener('keyup', (e) => { if (e.code === 'Space') spaceDown = false; });
function handleZoom(e) {
const alpine = getAlpineData();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
const currentZoom = alpine.zoom || 1;
const newZoom = Math.min(Math.max(0.1, currentZoom * delta), 3);
alpine.zoom = newZoom;
window.currentZoom = newZoom;
}
function startPan(e) {
const alpine = getAlpineData();
if (e.button === 1 || (e.button === 0 && (alpine.spaceDown || spaceDown))) {
alpine.isPanning = true;
alpine.startX = e.clientX - (alpine.panX || 0);
alpine.startY = e.clientY - (alpine.panY || 0);
document.body.style.cursor = 'grabbing';
e.preventDefault();
}
}
function doPan(e) {
const alpine = getAlpineData();
if (alpine.isPanning) {
alpine.panX = e.clientX - (alpine.startX || 0);
alpine.panY = e.clientY - (alpine.startY || 0);
}
}
function stopPan() {
const alpine = getAlpineData();
if (alpine) alpine.isPanning = false;
document.body.style.cursor = 'default';
}
// --- 右键菜单逻辑 ---
let menuX = 0, menuY = 0;
function showContext(e) {
const menu = document.getElementById('context-menu');
menu.style.display = 'block';
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
const alpine = getAlpineData();
const canvas = document.getElementById('canvas');
const rect = canvas.getBoundingClientRect();
menuX = (e.clientX - rect.left - (alpine.panX || 0)) / (alpine.zoom || 1);
menuY = (e.clientY - rect.top - (alpine.panY || 0)) / (alpine.zoom || 1);
}
function closeContext() {
const menu = document.getElementById('context-menu');
if (menu) menu.style.display = 'none';
}
function createNodeAtMouse(typeId) {
const params = new URLSearchParams({ type: typeId, x: Math.round(menuX), y: Math.round(menuY) });
fetch(`/api/create_node?${params.toString()}`, { method: 'POST' })
.then(res => res.text())
.then(html => {
const wrapper = document.createElement('div');
wrapper.innerHTML = html.trim();
const newNode = wrapper.firstElementChild;
document.getElementById('node-container').appendChild(newNode);
htmx.process(newNode);
closeContext();
});
}
// --- 统一执行逻辑 (级联触发) ---
2026-02-07 00:17:23 +08:00
window.queueWorkflow = function () {
console.log("[NoirFlow] Starting workflow...");
// 1. 找到所有“没有上游依赖”或者“生成器类型”的节点作为起点
// 简单起见,我们先触发所有 generator 和 input 类型的节点
2026-02-07 00:17:23 +08:00
const nodes = document.querySelectorAll('.node-box');
nodes.forEach(node => {
const nodeType = node.getAttribute('data-node-type');
// 只触发生产者节点
if (['nano_banana', 'image_upload', 'text_input', 'sys_dict_api'].includes(nodeType)) {
triggerNodeExecution(node.id);
2026-02-07 00:17:23 +08:00
}
});
};
function triggerNodeExecution(nodeId) {
console.log(`[NoirFlow] Executing node: ${nodeId}`);
const inputs = collectNodeInputs(nodeId);
// 视觉反馈:显示加载状态
const contentEl = document.getElementById(`content-${nodeId}`);
if (contentEl) contentEl.innerHTML = '<div class="loader-ripple"></div><span style="font-size:12px;color:#666">Running...</span>';
htmx.ajax('POST', `/api/run_node/${nodeId}`, {
target: `#content-${nodeId}`,
values: inputs
});
}
// 监听 HTMX 请求完成,触发下游节点
document.body.addEventListener('htmx:afterOnLoad', function (evt) {
const target = evt.detail.target;
if (target.id && target.id.startsWith('content-')) {
const nodeId = target.closest('.node-box').id;
console.log(`[NoirFlow] Node ${nodeId} finished. Checking downstream...`);
// 查找以此节点为起点的所有连线
const outgoingConns = window.connections.filter(c => {
const sEl = document.getElementById(c.startSlotId);
return sEl && sEl.closest('.node-box').id === nodeId;
});
// 触发下游节点
outgoingConns.forEach(conn => {
const endSlot = document.getElementById(conn.endSlotId);
if (endSlot) {
const nextNode = endSlot.closest('.node-box');
if (nextNode) {
// 稍微延迟一下,确保 DOM 更新完毕
setTimeout(() => triggerNodeExecution(nextNode.id), 100);
}
}
});
}
});
2026-02-07 00:17:23 +08:00
function collectNodeInputs(nodeId) {
const node = document.getElementById(nodeId);
const inputs = {};
// 1. 收集自身控件的值
2026-02-07 00:17:23 +08:00
node.querySelectorAll('.node-input-field').forEach(field => {
if (field.type === 'radio' && !field.checked) return;
2026-02-07 00:17:23 +08:00
inputs[field.name] = field.value;
});
// 1.5 收集可能存在的已有结果 (防止 Upload 节点重置)
const existingResult = node.querySelector('.result-container input[type="hidden"]');
if (existingResult) {
inputs[existingResult.name] = existingResult.value;
}
// 2. 收集连线传入的值 (优先级高于控件值)
const inputSlots = node.querySelectorAll('.slot[data-slot-type="input"]');
inputSlots.forEach(slot => {
const paramName = slot.dataset.slotName;
// 找谁连到了这个 slot
const conn = window.connections.find(c => c.endSlotId === slot.id);
if (conn) {
const sourceSlot = document.getElementById(conn.startSlotId);
if (sourceSlot) {
const sourceNode = sourceSlot.closest('.node-box');
// 从 sourceNode 获取最新的输出数据
const resultEl = sourceNode.querySelector('.result-container');
if (resultEl && resultEl.dataset.outputValue) {
console.log(`[NoirFlow] 传递数据: ${sourceNode.id} -> ${nodeId} [${paramName}=${resultEl.dataset.outputValue}]`);
inputs[paramName] = resultEl.dataset.outputValue;
}
}
}
});
2026-02-07 00:17:23 +08:00
return inputs;
}
// --- 连线引擎 ---
window.updateLines = () => {
window.connections.forEach(conn => {
updatePathPosition(conn, conn.startSlotId, conn.endSlotId);
});
};
function updatePathPosition(conn, startId, endId) {
const sEl = document.getElementById(startId);
const eEl = document.getElementById(endId);
if (!sEl || !eEl) return;
const sDot = sEl.querySelector('.slot-dot') || sEl;
const eDot = eEl.querySelector('.slot-dot') || eEl;
const sRect = sDot.getBoundingClientRect();
const eRect = eDot.getBoundingClientRect();
const p1 = projectToCanvas(sRect.left + sRect.width / 2, sRect.top + sRect.height / 2);
const p2 = projectToCanvas(eRect.left + eRect.width / 2, eRect.top + eRect.height / 2);
if (isNaN(p1.x) || isNaN(p1.y) || isNaN(p2.x) || isNaN(p2.y)) return;
const dx = Math.abs(p1.x - p2.x) * 0.5;
const d = `M ${p1.x} ${p1.y} C ${p1.x + dx} ${p1.y}, ${p2.x - dx} ${p2.y}, ${p2.x} ${p2.y}`;
conn.pathEl.setAttribute('d', d);
if (conn.hitEl) conn.hitEl.setAttribute('d', d);
}
function createConnection(sId, eId) {
const sEl = document.getElementById(sId);
const eEl = document.getElementById(eId);
// 智能类型兼容性检查
const t1 = sEl.dataset.dataType || 'any';
const t2 = eEl.dataset.dataType || 'any';
const isCompatible = (t1 === t2) ||
((t1 === 'text' || t1 === 'string') && (t2 === 'text' || t2 === 'string'));
if (!isCompatible) {
console.warn(`[NoirFlow] 类型不匹配: ${t1} vs ${t2}`);
return;
}
const outId = sEl.dataset.slotType === 'output' ? sId : eId;
const inId = sEl.dataset.slotType === 'output' ? eId : sId;
if (window.connections.find(c => c.startSlotId === outId && c.endSlotId === inId)) return;
const activeType = sEl.dataset.slotType === 'output' ? t1 : t2;
const colors = { 'image': '#4ade80', 'text': '#38bdf8', 'string': '#38bdf8', 'dict': '#a855f7', 'file': '#facc15' };
const activeColor = colors[activeType] || '#ffffff';
const hit = document.createElementNS('http://www.w3.org/2000/svg', 'path');
hit.setAttribute('class', 'connection-hit-area');
hit.style.cssText = 'fill:none; stroke:transparent; stroke-width:15px; cursor:pointer; pointer-events:stroke;';
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('class', 'connection-path');
const markerMap = { 'image': 'arrowhead-image', 'text': 'arrowhead-text', 'string': 'arrowhead-text', 'file': 'arrowhead-file' };
path.setAttribute('marker-end', `url(#${markerMap[activeType] || 'arrowhead-default'})`);
path.style.stroke = activeColor;
path.style.filter = `drop-shadow(0 0 5px ${activeColor}66)`;
const conn = { startSlotId: outId, endSlotId: inId, pathEl: path, hitEl: hit };
hit.onclick = (e) => {
e.stopPropagation();
path.remove(); hit.remove();
window.connections = window.connections.filter(c => c !== conn);
};
const svg = document.getElementById('connection-svg');
svg.appendChild(hit);
svg.appendChild(path);
window.connections.push(conn);
updatePathPosition(conn, outId, inId);
}
window.currentZoom = 1;
function startConnectionDrag(e, el) {
if (e.button !== 0) return;
e.preventDefault(); e.stopPropagation();
const startSlot = el;
const dragLayer = document.getElementById('drag-layer');
if (!dragLayer) return;
// 获取屏幕中心点
const sDot = startSlot.querySelector('.slot-dot') || startSlot;
const sRect = sDot.getBoundingClientRect();
const p1 = { x: sRect.left + sRect.width / 2, y: sRect.top + sRect.height / 2 };
sDot.classList.add('active');
let currentTarget = null;
const tempPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
tempPath.style.fill = 'none';
tempPath.style.stroke = '#00f7ff';
tempPath.style.strokeWidth = '4px';
tempPath.style.strokeDasharray = '8,4';
tempPath.style.pointerEvents = 'none';
dragLayer.appendChild(tempPath);
const findNearestSlot = (x, y) => {
let best = null;
let minDist = 40;
document.querySelectorAll('.slot').forEach(slot => {
if (slot === startSlot || slot.dataset.slotType === startSlot.dataset.slotType) return;
const dot = slot.querySelector('.slot-dot') || slot;
const dRect = dot.getBoundingClientRect();
const dist = Math.hypot(x - (dRect.left + dRect.width / 2), y - (dRect.top + dRect.height / 2));
if (dist < minDist) { minDist = dist; best = slot; }
});
return best;
};
const onMouseMove = (moveEvent) => {
const p2 = { x: moveEvent.clientX, y: moveEvent.clientY };
const dx = Math.abs(p1.x - p2.x) * 0.5;
tempPath.setAttribute('d', `M ${p1.x} ${p1.y} C ${p1.x + dx} ${p1.y}, ${p2.x - dx} ${p2.y}, ${p2.x} ${p2.y}`);
const newTarget = findNearestSlot(moveEvent.clientX, moveEvent.clientY);
if (newTarget !== currentTarget) {
if (currentTarget) currentTarget.querySelector('.slot-dot')?.classList.remove('hover');
currentTarget = newTarget;
if (currentTarget) currentTarget.querySelector('.slot-dot')?.classList.add('hover');
}
};
const onMouseUp = (upEvent) => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
tempPath.remove();
sDot.classList.remove('active');
if (currentTarget) currentTarget.querySelector('.slot-dot')?.classList.remove('hover');
// 最终落点检测
const target = findNearestSlot(upEvent.clientX, upEvent.clientY);
console.log("[NoirFlow] 释放鼠标,命中的目标:", target?.id || '无');
if (target && target !== startSlot) {
if (target.dataset.slotType !== startSlot.dataset.slotType) {
createConnection(startSlot.id, target.id);
} else {
console.warn("[NoirFlow] 无法连接相同类型的插槽");
}
}
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}
window.removeNode = (nodeId) => {
window.connections.forEach(c => {
const s = document.getElementById(c.startSlotId);
const e = document.getElementById(c.endSlotId);
if ((s && s.dataset.nodeId === nodeId) || (e && e.dataset.nodeId === nodeId)) {
c.pathEl.remove(); c.hitEl.remove();
}
});
window.connections = window.connections.filter(c => {
const s = document.getElementById(c.startSlotId), e = document.getElementById(c.endSlotId);
return !((s && s.dataset.nodeId === nodeId) || (e && e.dataset.nodeId === nodeId));
});
document.getElementById(nodeId)?.remove();
};
window.addEventListener('mousedown', (e) => {
if (!e.target.closest('.context-menu')) closeContext();
const node = e.target.closest('.node-box');
document.querySelectorAll('.node-box').forEach(n => n.classList.remove('selected'));
if (node) {
node.classList.add('selected');
window.selectedNodeId = node.id;
}
});
window.clearCanvas = () => {
document.getElementById('node-container').innerHTML = '';
window.connections.forEach(c => { c.pathEl.remove(); c.hitEl.remove(); });
window.connections = [];
};
// 初始清空示例,改为根据配置生成
document.addEventListener('DOMContentLoaded', () => { closeContext(); });
// 全局粘贴与点击逻辑保持不变...
window.addEventListener('paste', function (e) {
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
let file = null;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
file = items[i].getAsFile();
break;
}
}
if (file) {
e.preventDefault();
let selectedNode = window.selectedNodeId ? document.getElementById(window.selectedNodeId) : null;
if (selectedNode && (selectedNode.getAttribute('data-node-type') === 'image_upload' || selectedNode.id.includes('image_upload'))) {
window.uploadToNode(window.selectedNodeId, file);
} else {
const params = new URLSearchParams({ type: 'image_upload', x: 250, y: 250 });
fetch(`/api/create_node?${params.toString()}`, { method: 'POST' })
.then(res => res.text())
.then(html => {
const wrapper = document.createElement('div');
wrapper.innerHTML = html.trim();
const newNode = wrapper.firstElementChild;
document.getElementById('node-container').appendChild(newNode);
htmx.process(newNode);
setTimeout(() => {
document.querySelectorAll('.node-box').forEach(n => n.classList.remove('selected'));
newNode.classList.add('selected');
window.selectedNodeId = newNode.id;
window.uploadToNode(newNode.id, file);
}, 10);
});
}
}
}, true);
window.uploadToNode = function (nodeId, file) {
const formData = new FormData();
formData.append('file', file);
const contentEl = document.querySelector(`#${nodeId} .node-content`);
if (contentEl) contentEl.innerHTML = '<div class="placeholder"><div class="loader-ripple"></div><span>正在上传图片...</span></div>';
fetch('/api/upload_asset', { method: 'POST', body: formData })
.then(res => res.text())
.then(html => { if (contentEl) { contentEl.innerHTML = html; htmx.process(contentEl); } });
};
async function handleChatSubmit(e) {
e.preventDefault();
const input = e.target.querySelector('input[name="message"]');
const message = input.value.trim();
if (!message) return;
// 1. Clear input
input.value = '';
// 2. Append User Message
const chatBox = document.getElementById('chat-messages');
chatBox.insertAdjacentHTML('beforeend', `<div class="msg user">${message}</div>`);
// 3. Append AI Placeholder
const aiMsgId = `ai-msg-${Date.now()}`;
chatBox.insertAdjacentHTML('beforeend', `<div class="msg ai" id="${aiMsgId}"><span class="typing-indicator">...</span></div>`);
const aiMsgEl = document.getElementById(aiMsgId);
// Scroll to bottom
chatBox.scrollTop = chatBox.scrollHeight;
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `message=${encodeURIComponent(message)}`
});
if (!response.ok) throw new Error("Network response was not ok");
if (!response.body) throw new Error("ReadableStream not supported");
const reader = response.body.getReader();
const decoder = new TextDecoder();
aiMsgEl.innerHTML = ''; // Clear processing indicator
let accumulatedText = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
accumulatedText += chunk;
// Revert <br> mechanism for proper markdown
const rawText = accumulatedText.replace(/<br>/g, '\n');
aiMsgEl.innerHTML = marked.parse(rawText);
chatBox.scrollTop = chatBox.scrollHeight;
}
} catch (error) {
console.error(error);
aiMsgEl.innerHTML += `<br>[System Error: ${error.message}]`;
}
}
2026-02-07 00:17:23 +08:00
</script>
{% endblock %}