feat(node-engine): 实现异步图像生成任务处理

- 添加异步任务提交功能,支持 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处理
- 实现打字指示器和消息滚动
- 添加错误处理和内容解析功能
```
This commit is contained in:
24024 2026-02-07 00:50:16 +08:00
parent 76642b73f4
commit 19b4428283
6 changed files with 291 additions and 55 deletions

65
app.py
View File

@ -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'''
<div class="result-container">
<div class="result-container" data-output-value="{image_url}" data-output-type="image">
<img src="{image_url}" class="generated-art animated fadeIn">
<div class="meta-info"> 上传成功: {file.filename}</div>
<input type="hidden" name="uploaded_url" value="{image_url}">
@ -81,22 +82,70 @@ def run_node(node_id):
if result.get('type') == 'image':
return f'''
<div class="result-container">
<div class="result-container" data-output-value="{result["url"]}" data-output-type="image">
<img src="{result["url"]}" class="generated-art animated fadeIn">
<div class="meta-info">处理完成 | 耗时: {result["time"]}s</div>
</div>
'''
elif result.get('type') == 'text':
return f'''
<div class="result-container" data-output-value='{result["content"]}' data-output-type="text">
<pre class="text-output animated fadeIn">{result["content"]}</pre>
<div class="meta-info">处理完成 | 耗时: {result["time"]}s</div>
</div>
'''
else:
error_msg = result.get('error', '未知错误')
return f'<div class="error-badge">❌ 错误: {error_msg}</div>'
return f'<div class="error-badge" data-error="{error_msg}">❌ 错误: {error_msg}</div>'
@app.route('/api/chat', methods=['POST'])
@app.route('/api/chat', methods=['POST'])
def chat():
message = request.form.get('message', '')
return f'''
<div class="msg user">{message}</div>
<div class="msg ai">已接收指令如果您想生成图片请点击右侧Nano-banana节点的启动生成按钮</div>
'''
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', '<br>')
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)

View File

@ -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()
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}")
# 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")
end_time = time.time()
return {
"type": "image",
"url": image_url,
"time": round(end_time - start_time, 2)
}
except Exception as e:
print(f"API Error: {e}")
return {

View File

@ -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 {

View File

@ -13,6 +13,8 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600&family=Outfit:wght@500;700&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="/static/css/style.css">
<!-- Marked.js for Markdown rendering -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
</head>
<body class="bg-noir">

View File

@ -73,8 +73,10 @@
<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">
<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">
@ -85,6 +87,7 @@
</div>
</div>
</div>
</div>
<!-- 主画布 -->
<main class="canvas-area" id="canvas" @contextmenu.prevent="showContext($event)" @wheel.passive="handleZoom($event)"
@ -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 = '<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);
}
}
});
}
});
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', `<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}]`;
}
}
</script>
{% endblock %}