huabu/templates/board.html
2026-02-07 00:17:23 +08:00

509 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% 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;">
<form hx-post="/api/chat" hx-target="#chat-messages" hx-swap="beforeend">
<input type="text" name="message" placeholder="与 AI 对话..." class="chat-input">
<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>
</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();
});
}
// --- 统一执行逻辑 ---
window.queueWorkflow = function () {
console.log("Queueing workflow...");
// 找到所有非 input 类型的节点并触发执行 (简单版本)
const nodes = document.querySelectorAll('.node-box');
nodes.forEach(node => {
const btn = node.querySelector('.btn-run');
// 如果节点需要执行逻辑(如生成器),模拟点击
// 注意:我们之前移除了节点上的按钮,这里需要通过 HTMX 手动触发
const nodeId = node.id;
const nodeType = node.getAttribute('data-node-type');
if (nodeType === 'nano_banana' || nodeType === 'image_preview' || nodeType === 'sys_dict_api') {
htmx.ajax('POST', `/api/run_node/${nodeId}`, {
target: `#content-${nodeId}`,
values: collectNodeInputs(nodeId)
});
}
});
};
function collectNodeInputs(nodeId) {
const node = document.getElementById(nodeId);
const inputs = {};
node.querySelectorAll('.node-input-field').forEach(field => {
inputs[field.name] = field.value;
});
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); } });
};
</script>
{% endblock %}