```
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:
parent
76642b73f4
commit
19b4428283
Binary file not shown.
65
app.py
65
app.py
@ -1,5 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import requests
|
||||||
from flask import Flask, render_template, request, jsonify, send_from_directory
|
from flask import Flask, render_template, request, jsonify, send_from_directory
|
||||||
from node_engine import NodeEngine
|
from node_engine import NodeEngine
|
||||||
from database import init_db
|
from database import init_db
|
||||||
@ -67,7 +68,7 @@ def upload_asset():
|
|||||||
image_url = f"/static/uploads/{filename}"
|
image_url = f"/static/uploads/{filename}"
|
||||||
|
|
||||||
return f'''
|
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">
|
<img src="{image_url}" class="generated-art animated fadeIn">
|
||||||
<div class="meta-info">✅ 上传成功: {file.filename}</div>
|
<div class="meta-info">✅ 上传成功: {file.filename}</div>
|
||||||
<input type="hidden" name="uploaded_url" value="{image_url}">
|
<input type="hidden" name="uploaded_url" value="{image_url}">
|
||||||
@ -81,22 +82,70 @@ def run_node(node_id):
|
|||||||
|
|
||||||
if result.get('type') == 'image':
|
if result.get('type') == 'image':
|
||||||
return f'''
|
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">
|
<img src="{result["url"]}" class="generated-art animated fadeIn">
|
||||||
<div class="meta-info">处理完成 | 耗时: {result["time"]}s</div>
|
<div class="meta-info">处理完成 | 耗时: {result["time"]}s</div>
|
||||||
</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:
|
else:
|
||||||
error_msg = result.get('error', '未知错误')
|
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'])
|
@app.route('/api/chat', methods=['POST'])
|
||||||
def chat():
|
def chat():
|
||||||
message = request.form.get('message', '')
|
user_message = request.form.get('message', '')
|
||||||
return f'''
|
|
||||||
<div class="msg user">{message}</div>
|
def generate():
|
||||||
<div class="msg ai">已接收指令。如果您想生成图片,请点击右侧“Nano-banana”节点的“启动生成”按钮。</div>
|
# 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__':
|
if __name__ == '__main__':
|
||||||
app.run(debug=True, port=5000)
|
app.run(debug=True, port=5000)
|
||||||
|
|||||||
@ -167,7 +167,9 @@ class NodeEngine:
|
|||||||
model = data.get('model', 'nano-banana')
|
model = data.get('model', 'nano-banana')
|
||||||
|
|
||||||
# Prefix the proxy URL as requested: https://proxy.com/https://api.com/...
|
# 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}"
|
url = f"{Config.PROXY}{target_url}"
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
@ -183,19 +185,77 @@ class NodeEngine:
|
|||||||
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
try:
|
try:
|
||||||
# No proxies= dictionary needed because of URL prefixing
|
# 1. Submit Async Task
|
||||||
response = requests.post(url, headers=headers, json=payload, timeout=60)
|
response = requests.post(url, headers=headers, json=payload, timeout=30)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
res_data = response.json()
|
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}")
|
||||||
|
|
||||||
|
# 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:
|
except Exception as e:
|
||||||
print(f"API Error: {e}")
|
print(f"API Error: {e}")
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -118,22 +118,23 @@ body {
|
|||||||
box-shadow: 0 12px 30px rgba(255, 255, 255, 0.3);
|
box-shadow: 0 12px 30px rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* AI Assistant Modal */
|
/* AI Assistant Modal - Full Height */
|
||||||
.ai-modal {
|
.ai-modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 100px;
|
top: 0;
|
||||||
right: 32px;
|
right: 0;
|
||||||
width: 360px;
|
width: 450px;
|
||||||
height: 500px;
|
height: 100vh;
|
||||||
background: var(--bg-panel);
|
background: rgba(20, 20, 20, 0.95);
|
||||||
border: 1px solid var(--border-color);
|
backdrop-filter: blur(20px);
|
||||||
border-radius: 16px;
|
border-left: 1px solid var(--border-color);
|
||||||
|
border-right: none;
|
||||||
|
border-radius: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
z-index: 1000;
|
z-index: 2000;
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.8);
|
box-shadow: -10px 0 40px rgba(0, 0, 0, 0.5);
|
||||||
overflow: hidden;
|
transform-origin: right center;
|
||||||
transform-origin: bottom right;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Unified Execution Toolbar */
|
/* Unified Execution Toolbar */
|
||||||
@ -559,15 +560,19 @@ select.node-input-field {
|
|||||||
.chat-section {
|
.chat-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: none;
|
||||||
max-height: 40%;
|
/* remove top border since it is full height now */
|
||||||
|
height: 100%;
|
||||||
|
/* Fill remaining height */
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-box {
|
.chat-box {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
padding-bottom: 12px;
|
padding: 16px;
|
||||||
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
.msg {
|
.msg {
|
||||||
|
|||||||
@ -13,6 +13,8 @@
|
|||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600&family=Outfit:wght@500;700&display=swap"
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600&family=Outfit:wght@500;700&display=swap"
|
||||||
rel="stylesheet">
|
rel="stylesheet">
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<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>
|
</head>
|
||||||
|
|
||||||
<body class="bg-noir">
|
<body class="bg-noir">
|
||||||
|
|||||||
@ -73,15 +73,18 @@
|
|||||||
<div class="msg system">你好!我是 NoirFlow 助手。您可以问我如何构建工作流。</div>
|
<div class="msg system">你好!我是 NoirFlow 助手。您可以问我如何构建工作流。</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-input-wrapper" style="margin-top: 12px;">
|
<div class="chat-input-wrapper" style="margin-top: 12px;">
|
||||||
<form hx-post="/api/chat" hx-target="#chat-messages" hx-swap="beforeend">
|
<div class="chat-input-wrapper" style="margin-top: 12px;">
|
||||||
<input type="text" name="message" placeholder="与 AI 对话..." class="chat-input">
|
<form onsubmit="handleChatSubmit(event)">
|
||||||
<button type="submit" class="chat-send">
|
<input type="text" name="message" placeholder="与 AI 对话..." class="chat-input"
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
autocomplete="off">
|
||||||
stroke-width="2">
|
<button type="submit" class="chat-send">
|
||||||
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
</svg>
|
stroke-width="2">
|
||||||
</button>
|
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
|
||||||
</form>
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -240,32 +243,98 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 统一执行逻辑 ---
|
// --- 统一执行逻辑 (级联触发) ---
|
||||||
window.queueWorkflow = function () {
|
window.queueWorkflow = function () {
|
||||||
console.log("Queueing workflow...");
|
console.log("[NoirFlow] Starting workflow...");
|
||||||
// 找到所有非 input 类型的节点并触发执行 (简单版本)
|
// 1. 找到所有“没有上游依赖”或者“生成器类型”的节点作为起点
|
||||||
|
// 简单起见,我们先触发所有 generator 和 input 类型的节点
|
||||||
const nodes = document.querySelectorAll('.node-box');
|
const nodes = document.querySelectorAll('.node-box');
|
||||||
nodes.forEach(node => {
|
nodes.forEach(node => {
|
||||||
const btn = node.querySelector('.btn-run');
|
|
||||||
// 如果节点需要执行逻辑(如生成器),模拟点击
|
|
||||||
// 注意:我们之前移除了节点上的按钮,这里需要通过 HTMX 手动触发
|
|
||||||
const nodeId = node.id;
|
|
||||||
const nodeType = node.getAttribute('data-node-type');
|
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}`, {
|
if (['nano_banana', 'image_upload', 'text_input', 'sys_dict_api'].includes(nodeType)) {
|
||||||
target: `#content-${nodeId}`,
|
triggerNodeExecution(node.id);
|
||||||
values: collectNodeInputs(nodeId)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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) {
|
function collectNodeInputs(nodeId) {
|
||||||
const node = document.getElementById(nodeId);
|
const node = document.getElementById(nodeId);
|
||||||
const inputs = {};
|
const inputs = {};
|
||||||
|
|
||||||
|
// 1. 收集自身控件的值
|
||||||
node.querySelectorAll('.node-input-field').forEach(field => {
|
node.querySelectorAll('.node-input-field').forEach(field => {
|
||||||
|
if (field.type === 'radio' && !field.checked) return;
|
||||||
inputs[field.name] = field.value;
|
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;
|
return inputs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -505,5 +574,56 @@
|
|||||||
.then(res => res.text())
|
.then(res => res.text())
|
||||||
.then(html => { if (contentEl) { contentEl.innerHTML = html; htmx.process(contentEl); } });
|
.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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Loading…
Reference in New Issue
Block a user