huabu/templates/board.html
24024 19b4428283 ```
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处理
- 实现打字指示器和消息滚动
- 添加错误处理和内容解析功能
```
2026-02-07 00:50:16 +08:00

629 lines
27 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% 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;">
<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>
</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();
});
}
// --- 统一执行逻辑 (级联触发) ---
window.queueWorkflow = function () {
console.log("[NoirFlow] Starting workflow...");
// 1. 找到所有“没有上游依赖”或者“生成器类型”的节点作为起点
// 简单起见,我们先触发所有 generator 和 input 类型的节点
const nodes = document.querySelectorAll('.node-box');
nodes.forEach(node => {
const nodeType = node.getAttribute('data-node-type');
// 只触发生产者节点
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;
}
// --- 连线引擎 ---
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); } });
};
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 %}