@@ -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 助手。您可以问我如何构建工作流。