From 19b44282838130a77a41ddde21be6d3bee948825 Mon Sep 17 00:00:00 2001 From: 24024 <240241002@qq.com> Date: Sat, 7 Feb 2026 00:50:16 +0800 Subject: [PATCH] =?UTF-8?q?```=20feat(node-engine):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E5=BC=82=E6=AD=A5=E5=9B=BE=E5=83=8F=E7=94=9F=E6=88=90=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加异步任务提交功能,支持 task_id 查询机制 - 实现轮询机制监控任务状态(processing/succeeded/failed) - 增加超时控制和错误处理机制 - 兼容同步模式回退处理 feat(app): 集成AI聊天助手功能 - 添加 requests 依赖用于API调用 - 实现流式响应处理Gemini API - 添加代理URL配置支持 - 集成Marked.js进行Markdown渲染 refactor(ui): 重构AI助手模态框为全屏布局 - 调整样式为固定右侧全高度布局 - 移除边框阴影,增加背景模糊效果 - 优化滚动行为和输入区域样式 feat(board): 实现节点级联执行逻辑 - 添加工作流自动触发机制 - 实现节点间数据传递和连接处理 - 添加加载状态反馈和执行监控 feat(chat): 优化聊天交互体验 - 替换HTMX表单提交为原生JavaScript处理 - 实现打字指示器和消息滚动 - 添加错误处理和内容解析功能 ``` --- __pycache__/node_engine.cpython-312.pyc | Bin 8607 -> 11671 bytes app.py | 65 ++++++++-- node_engine.py | 80 ++++++++++-- static/css/style.css | 37 +++--- templates/base.html | 2 + templates/board.html | 162 +++++++++++++++++++++--- 6 files changed, 291 insertions(+), 55 deletions(-) diff --git a/__pycache__/node_engine.cpython-312.pyc b/__pycache__/node_engine.cpython-312.pyc index 97e446c1f7bce978ffef02f3e75675523cd48f26..ff1a1fcad172d06b2a438ab051dd780f62a4c996 100644 GIT binary patch delta 3095 zcmb7FTTC1275>M@27|eniy7nFOn_hlm`eiTX0ikm0!hflm?)bVRFgBSPbMcPj zDi5oD*ax;#DMZ^RO(E|sacB$R@!ZweIger+pH?}AHy_k zwfj)p^Z#eQbN+L_bD8mtzH8?T9_Hug0{F2T-}e93ey^aWEKj;u!s|K6yZ=VnZ3T*pl}FVgEoWJ4XQb&vK0WY5Ae%MoITURRHmJ=<6{_g%eS=)!A6>J4}MQ=qaIB#|-H*C=O zjk|I@z#6}uBWsMBh+@__2Pi93CkJMYM6tw+sh1HsB}z-Q%363vCCgD0Ym%C#Yn2Hv zAhZqv+h|{#j7H77*c@aHM0*5=fPKPQ6s{et8S+!h*6_TU=!9{;+VNg2Yj%5Y=#tT> zWmgWC?_mFowMZsWVKYoGELSy%BZOP5>w;YbCyS4DH`FhCT%XdBIk*Gc(Z*E7Qc}$;=7IPL+D(40cz^oh75YC*Udq zD|`t0Q7j?vehIIC!0SmC9Jc?ZzbnpR+0#{oyJ9#U+j#;3m}-)_yxyl;Udysn1*k$~ z_p|zj>=6Oyz>?zKQ%vTaA=yJ)LloOJHN7mhI|Bsh&q17B1hsJv3 z75BBV6|r!36BQ95cBX$>9r`ACu=iFCHh z-PhOea;4SV+@MxH;q?b3)sf!8FilDNJxVEw7cKk?@Z?n4v(KYb8_Gej4>RkcDasY{S*aDL4+x(Dzn?wsfVRus6~iK zwJDSfK}x2ujMfYT#y>+6SSXA$n0JC9DXgstqe)*VNYIhI-cy6yo;$JH3U1sKJ_U1= zScQV=EMI7LF|C{p&8Ah9H#kYc3I#}VHl6E-tYNW&K9Xvcs;dl;!E`RU;3H=nLhS--U!g^GKVN=;Kj7xeMmgN-Qk+1zu(dI!w_FRn z7l?O=`i6G~J}ar%G*#U=e*O50KCvfJDw^67mjzQt%H$ACj#W}LIhQo+SWQCnht|K8 z{?+g&!$Tu~)+-Epc^@J6kwWzmxR?1g2eDfe*Je1TPd!YtK{6_ut`en;q z^+$!b3sbfp!Pc`%$3$BnU*qJRZhq9myT|zQ@pq1HXmv{`;nN5&n+3Ev ziSA#os=syO=7|qaE^9VWGmkYVY7<7@;z*)BTWYYkaXEM0h{w+fM*Gh@SN#vvLeI(M zX?Jq}D1Y9^PtNj}=Y`1y;rzmSZA+p}sO?-H+QjQ_S#DYqIU?Ti2p?H}Gxo{Y$72r$ zM90Xo8pepL;@%bHrZ4VZ!B>v0I94@ld)H9jGL%G*Z5naD+94Pnn+*p)8oNEVdPHpK zd(<$#7Ww0q-(BHH$Ham0LNl_K?F|2%Y5kh#RLLMOuW|Q5Q}?wZO_*)VhZlLwe~_#0ZdgXqaAiNWjrVt z4?Y8mGFJDfUbFK>r@;?Qki;r-s92?gH?jv9}s-e=hCSjI=8sTAZz}+AO|A0dM#+4bge1ig`sYTqzn6Nyb&uT5B6=%}(l+&@x$? z(<9xO3Pzf;ll#;X)TEM-){MGc<<84aI&{$8;{uJ_p?q9!bhj%Xw;+-XwGQrw*f8=T z_c_+BII7^RxCM!FJ-EK&7R-v$N{jTj{VJz>CPah-WDj+l3*vhUd$W*&XFysrM*jhY ChjDKJ delta 537 zcmbOpJ>Qw{G%qg~0}$+FZ_Bh**vO|Y%FPDkG6V7FcPyJ7ME5fD3uQ8-Gp=R=DPv$r z(VF~6MwwA~vYxD(My*H+bB%C`EJTWdp+=-es0wIaiZ)z1#~kKhhRMg|0w+I|mtxeJ z{72rMQFpV8LKCC7n0}d|er9fBdTOzLdTL&3QDRAEeqQn97m7NJRg*=POpR=S25U0k zV$VoTOi3*&E^-9&i;_SD$TcrPL>0GVfTx0MQBi)8m4c?gWJZ-zCQZ@F$5gTyGbbCW zN{HX$1X~ebT9k8(BQ-B2z9cg@wODlWMOBH(UsVm6CNpi;Q1fDxVPlY#pU*#&|GI?U zMG3tX#g`?FFNm95F!8z|>OHwnJs9Za$Ljk25(@KqX7VfuTamOPwx-k+DMRqMY%Dz{?WmAK6(%_&zYO z@bdlm;>b8zLR*v7i;+PtFV(YVdnoRD}@nfu?d{f7U$%k>WxUQT;4$#P3 qY{~g~X_@Iopm;4}1rg1YeRU-mTPLUKD)GBAGxB|60FgyJKsf;87L>mL diff --git a/app.py b/app.py index 65e56d6..d47e051 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,6 @@ import os import json +import requests from flask import Flask, render_template, request, jsonify, send_from_directory from node_engine import NodeEngine from database import init_db @@ -67,7 +68,7 @@ def upload_asset(): image_url = f"/static/uploads/{filename}" return f''' -
+
✅ 上传成功: {file.filename}
@@ -81,22 +82,70 @@ def run_node(node_id): if result.get('type') == 'image': return f''' -
+
处理完成 | 耗时: {result["time"]}s
''' + elif result.get('type') == 'text': + return f''' +
+
{result["content"]}
+
处理完成 | 耗时: {result["time"]}s
+
+ ''' else: error_msg = result.get('error', '未知错误') - return f'
❌ 错误: {error_msg}
' + return f'
❌ 错误: {error_msg}
' +@app.route('/api/chat', methods=['POST']) @app.route('/api/chat', methods=['POST']) def chat(): - message = request.form.get('message', '') - return f''' -
{message}
-
已接收指令。如果您想生成图片,请点击右侧“Nano-banana”节点的“启动生成”按钮。
- ''' + user_message = request.form.get('message', '') + + def generate(): + # Do not yield user message here; frontend handles it. + + # Use proxy + baseurl + endpoint concatenation + target_url = f"{Config.BASE_URL.rstrip('/')}/v1/chat/completions" + url = f"{Config.PROXY}{target_url}" + + headers = { + "Authorization": f"Bearer {Config.API_KEY}", + "Content-Type": "application/json" + } + payload = { + "model": "gemini-3-flash-preview", + "messages": [ + {"role": "system", "content": "You are a professional Design Assistant (设计助理) for NoirFlow. Your goal is to help users maintain a consistent, premium, and aesthetic design language in their workflows. You provide advice on color theory, layout, and visual harmony, while also assisting with the technical node configuration when asked. Keep your tone professional, artistic, and concise."}, + {"role": "user", "content": user_message} + ], + "stream": True + } + + try: + with requests.post(url, headers=headers, json=payload, stream=True, timeout=60) as r: + r.raise_for_status() + for line in r.iter_lines(): + if line: + decoded_line = line.decode('utf-8') + if decoded_line.startswith('data: '): + json_str = decoded_line[6:] + if json_str == '[DONE]': + break + try: + data = json.loads(json_str) + content = data['choices'][0]['delta'].get('content', '') + if content: + # Basic formatting + formatted_content = content.replace('\n', '
') + yield formatted_content + except: + pass + except Exception as e: + yield f"[Error: {str(e)}]" + + return app.response_class(generate(), mimetype='text/html') if __name__ == '__main__': app.run(debug=True, port=5000) diff --git a/node_engine.py b/node_engine.py index c1e58b4..c9f8461 100644 --- a/node_engine.py +++ b/node_engine.py @@ -167,7 +167,9 @@ class NodeEngine: model = data.get('model', 'nano-banana') # Prefix the proxy URL as requested: https://proxy.com/https://api.com/... - target_url = f"{Config.BASE_URL.rstrip('/')}/v1/images/generations" + # Prefix the proxy URL as requested: https://proxy.com/https://api.com/... + # Add async=true query parameter + target_url = f"{Config.BASE_URL.rstrip('/')}/v1/images/generations?async=true" url = f"{Config.PROXY}{target_url}" headers = { @@ -183,19 +185,77 @@ class NodeEngine: start_time = time.time() try: - # No proxies= dictionary needed because of URL prefixing - response = requests.post(url, headers=headers, json=payload, timeout=60) + # 1. Submit Async Task + response = requests.post(url, headers=headers, json=payload, timeout=30) response.raise_for_status() res_data = response.json() - image_url = res_data.get('data', [{}])[0].get('url') + task_id = res_data.get('task_id') + if not task_id: + # Fallback to sync if no task_id returned (backward compatibility) + image_url = res_data.get('data', [{}])[0].get('url') + return {"type": "image", "url": image_url, "time": round(time.time() - start_time, 2)} + + print(f"Task submitted, ID: {task_id}. Polling for results...") + + # 2. Poll for Results + poll_url = f"{Config.PROXY}{Config.BASE_URL.rstrip('/')}/v1/images/tasks/{task_id}" + print(f"Polling URL: {poll_url}") - end_time = time.time() - return { - "type": "image", - "url": image_url, - "time": round(end_time - start_time, 2) - } + # Create a session for polling to avoid connection pool issues + with requests.Session() as session: + # GET request doesn't need Content-Type + poll_headers = headers.copy() + poll_headers.pop("Content-Type", None) + + max_retries = 30 + for i in range(max_retries): + time.sleep(2) # Wait 2s between checks + try: + poll_res = session.get(poll_url, headers=poll_headers, timeout=20, verify=False) + poll_res.raise_for_status() + task_data = poll_res.json() + print(f"DEBUG Task Data: {task_data}") # Debug output + + # Handle nested response structure (common in proxy/wrapper APIs) + inner_data = task_data + if 'data' in task_data and isinstance(task_data['data'], dict) and 'status' in task_data['data']: + inner_data = task_data['data'] + + status = inner_data.get('status') or inner_data.get('state') + print(f"Poll {i+1}/{max_retries}: Status={status}") + + if status in ['succeeded', 'SUCCESS']: + # Path: inner_data -> data (dict) -> data (list) -> [0] -> url + result_payload = inner_data.get('data') or inner_data.get('result') + + # Handle standard list format + if isinstance(result_payload, dict): + items = result_payload.get('data', []) + if items and len(items) > 0: + image_url = items[0].get('url') + return {"type": "image", "url": image_url, "time": round(time.time() - start_time, 2)} + # Direct url in payload + if result_payload.get('url'): + return {"type": "image", "url": result_payload.get('url'), "time": round(time.time() - start_time, 2)} + + elif status in ['failed', 'FAILURE']: + raise Exception(f"Task failed: {inner_data.get('fail_reason') or inner_data.get('error')}") + elif status in ['processing', 'pending', 'QUEUED', 'IN_PROGRESS', None]: + # If None, it might mean the proxy isn't returning the right data yet, wait. + continue + else: + continue + except requests.exceptions.SSLError: + print("SSL Error during polling, retrying...") + continue + except Exception as poll_e: + print(f"Polling error: {poll_e}") + # Don't break immediately on ephemeral network errors + continue + + raise Exception("Task timed out after 60 seconds") + except Exception as e: print(f"API Error: {e}") return { diff --git a/static/css/style.css b/static/css/style.css index 37ebf7a..7e83bd8 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -118,22 +118,23 @@ body { box-shadow: 0 12px 30px rgba(255, 255, 255, 0.3); } -/* AI Assistant Modal */ +/* AI Assistant Modal - Full Height */ .ai-modal { position: fixed; - bottom: 100px; - right: 32px; - width: 360px; - height: 500px; - background: var(--bg-panel); - border: 1px solid var(--border-color); - border-radius: 16px; + top: 0; + right: 0; + width: 450px; + height: 100vh; + background: rgba(20, 20, 20, 0.95); + backdrop-filter: blur(20px); + border-left: 1px solid var(--border-color); + border-right: none; + border-radius: 0; display: flex; flex-direction: column; - z-index: 1000; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.8); - overflow: hidden; - transform-origin: bottom right; + z-index: 2000; + box-shadow: -10px 0 40px rgba(0, 0, 0, 0.5); + transform-origin: right center; } /* Unified Execution Toolbar */ @@ -559,15 +560,19 @@ select.node-input-field { .chat-section { display: flex; flex-direction: column; - border-top: 1px solid var(--border-color); - max-height: 40%; + border-top: none; + /* remove top border since it is full height now */ + height: 100%; + /* Fill remaining height */ + padding: 0; } .chat-box { flex: 1; overflow-y: auto; - font-size: 12px; - padding-bottom: 12px; + font-size: 13px; + padding: 16px; + scroll-behavior: smooth; } .msg { diff --git a/templates/base.html b/templates/base.html index d85c8f4..32a27b8 100644 --- a/templates/base.html +++ b/templates/base.html @@ -13,6 +13,8 @@ + + diff --git a/templates/board.html b/templates/board.html index 7c9ea99..36fc693 100644 --- a/templates/board.html +++ b/templates/board.html @@ -73,15 +73,18 @@
你好!我是 NoirFlow 助手。您可以问我如何构建工作流。
-
- - -
+
+
+ + +
+
@@ -240,32 +243,98 @@ }); } - // --- 统一执行逻辑 --- + // --- 统一执行逻辑 (级联触发) --- window.queueWorkflow = function () { - console.log("Queueing workflow..."); - // 找到所有非 input 类型的节点并触发执行 (简单版本) + console.log("[NoirFlow] Starting workflow..."); + // 1. 找到所有“没有上游依赖”或者“生成器类型”的节点作为起点 + // 简单起见,我们先触发所有 generator 和 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) - }); + // 只触发生产者节点 + if (['nano_banana', 'image_upload', 'text_input', 'sys_dict_api'].includes(nodeType)) { + triggerNodeExecution(node.id); } }); }; + function triggerNodeExecution(nodeId) { + console.log(`[NoirFlow] Executing node: ${nodeId}`); + const inputs = collectNodeInputs(nodeId); + + // 视觉反馈:显示加载状态 + const contentEl = document.getElementById(`content-${nodeId}`); + if (contentEl) contentEl.innerHTML = '
Running...'; + + 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); + } + } + }); + } + }); + function collectNodeInputs(nodeId) { const node = document.getElementById(nodeId); const inputs = {}; + + // 1. 收集自身控件的值 node.querySelectorAll('.node-input-field').forEach(field => { + if (field.type === 'radio' && !field.checked) return; 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; + } + } + } + }); + return inputs; } @@ -505,5 +574,56 @@ .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', `
${message}
`); + + // 3. Append AI Placeholder + const aiMsgId = `ai-msg-${Date.now()}`; + chatBox.insertAdjacentHTML('beforeend', `
...
`); + 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
mechanism for proper markdown + const rawText = accumulatedText.replace(/
/g, '\n'); + aiMsgEl.innerHTML = marked.parse(rawText); + chatBox.scrollTop = chatBox.scrollHeight; + } + } catch (error) { + console.error(error); + aiMsgEl.innerHTML += `
[System Error: ${error.message}]`; + } + } {% endblock %} \ No newline at end of file