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;">
|
2026-02-07 00:50:16 +08:00
|
|
|
|
<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:50:16 +08:00
|
|
|
|
// --- 统一执行逻辑 (级联触发) ---
|
2026-02-07 00:17:23 +08:00
|
|
|
|
window.queueWorkflow = function () {
|
2026-02-07 00:50:16 +08:00
|
|
|
|
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');
|
2026-02-07 00:50:16 +08:00
|
|
|
|
// 只触发生产者节点
|
|
|
|
|
|
if (['nano_banana', 'image_upload', 'text_input', 'sys_dict_api'].includes(nodeType)) {
|
|
|
|
|
|
triggerNodeExecution(node.id);
|
2026-02-07 00:17:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-07 00:50:16 +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 = {};
|
2026-02-07 00:50:16 +08:00
|
|
|
|
|
|
|
|
|
|
// 1. 收集自身控件的值
|
2026-02-07 00:17:23 +08:00
|
|
|
|
node.querySelectorAll('.node-input-field').forEach(field => {
|
2026-02-07 00:50:16 +08:00
|
|
|
|
if (field.type === 'radio' && !field.checked) return;
|
2026-02-07 00:17:23 +08:00
|
|
|
|
inputs[field.name] = field.value;
|
|
|
|
|
|
});
|
2026-02-07 00:50:16 +08:00
|
|
|
|
|
|
|
|
|
|
// 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); } });
|
|
|
|
|
|
};
|
2026-02-07 00:50:16 +08:00
|
|
|
|
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 %}
|