From ce2346eb1705a7fec24fa747b058c787f7db441e Mon Sep 17 00:00:00 2001 From: Tian-hao Zhang Date: Mon, 16 Feb 2026 16:14:48 -0500 Subject: [PATCH 1/3] implemented chat on demand --- client/src/api.js | 21 ++ client/src/components/InlineHelpChat.js | 470 ++++++++++++++++++++++++ client/src/components/InputSelector.js | 102 ++++- scripts/start.sh | 2 +- server_api/chatbot/chatbot.py | 98 ++++- server_api/main.py | 72 +++- 6 files changed, 755 insertions(+), 10 deletions(-) create mode 100644 client/src/components/InlineHelpChat.js diff --git a/client/src/api.js b/client/src/api.js index b1c45da..2cf62c2 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -348,6 +348,27 @@ export async function clearChat() { } } +export async function queryHelperChat(taskKey, query, fieldContext) { + try { + const res = await axios.post(`${BASE_URL}/chat/helper/query`, { + taskKey, + query, + fieldContext, + }); + return res.data?.response; + } catch (error) { + handleError(error); + } +} + +export async function clearHelperChat(taskKey) { + try { + await axios.post(`${BASE_URL}/chat/helper/clear`, { taskKey }); + } catch (error) { + handleError(error); + } +} + export async function getConfigPresets() { return makeApiRequest("pytc/configs", "get"); } diff --git a/client/src/components/InlineHelpChat.js b/client/src/components/InlineHelpChat.js new file mode 100644 index 0000000..ba0b6ca --- /dev/null +++ b/client/src/components/InlineHelpChat.js @@ -0,0 +1,470 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import ReactDOM from "react-dom"; +import { Button, Input, Space, Spin, Typography } from "antd"; +import { QuestionCircleOutlined, SendOutlined } from "@ant-design/icons"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { queryHelperChat } from "../api"; + +const { TextArea } = Input; +const { Text } = Typography; + +/** + * Build the automatic first-message prompt that explains a field and asks the + * helper agent for a recommendation. + */ +const buildInitialPrompt = ({ + label, + yamlKey, + value, + projectContext, + taskContext, +}) => { + return [ + `Explain this setting and recommend a concrete value if possible:`, + `- Label: ${label}`, + yamlKey ? `- YAML key: ${yamlKey}` : null, + value !== undefined && value !== null && value !== "" + ? `- Current value: ${JSON.stringify(value)}` + : null, + projectContext ? `- Project context: ${projectContext}` : null, + taskContext ? `- Task context: ${taskContext}` : null, + `Use plain language for non-technical users and give a recommended setting.`, + ] + .filter(Boolean) + .join("\n"); +}; + +const clamp = (value, min, max) => Math.min(Math.max(value, min), max); + +/** + * A small "?" button that opens a floating, draggable chat panel connected to + * the button by a dashed SVG line. The panel auto-fires an initial prompt on + * first open and lets users ask follow-up questions. + * + * Props: + * taskKey – unique key for the helper chat session (e.g. "inference") + * label – human-readable field name (e.g. "Input Image") + * yamlKey – optional YAML config key (e.g. "DATASET.INPUT_IMAGE") + * value – current value of the field + * projectContext – short project description for the LLM + * taskContext – short task description for the LLM + */ +function InlineHelpChat({ + taskKey, + label, + yamlKey, + value, + projectContext, + taskContext, +}) { + const anchorRef = useRef(null); + const panelRef = useRef(null); + const dragState = useRef({ dragging: false, offsetX: 0, offsetY: 0 }); + const messagesEndRef = useRef(null); + + const [open, setOpen] = useState(false); + const [messages, setMessages] = useState([]); + const [inputValue, setInputValue] = useState(""); + const [isSending, setIsSending] = useState(false); + const [panelPos, setPanelPos] = useState({ + top: 0, + left: 0, + width: 360, + height: 300, + }); + const [anchorPoint, setAnchorPoint] = useState({ x: 0, y: 0 }); + + const initialPrompt = useMemo( + () => + buildInitialPrompt({ + label, + yamlKey, + value, + projectContext, + taskContext, + }), + [label, yamlKey, value, projectContext, taskContext], + ); + + // Build a field-context string passed to the backend so the LLM knows + // exactly which field the user is asking about. + const fieldContext = useMemo(() => { + const parts = [`Field: "${label}"`]; + if (yamlKey) parts.push(`YAML key: ${yamlKey}`); + if (taskContext) parts.push(`Task: ${taskContext}`); + return parts.join(". "); + }, [label, yamlKey, taskContext]); + + // Scroll to bottom when messages change + useEffect(() => { + if (messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, [messages, isSending]); + + // ------- Send a message to the helper backend ------- + const sendMessage = async (text, options = {}) => { + const { hideUser = false } = options; + if (!text.trim() || isSending) return; + setIsSending(true); + if (!hideUser) { + setMessages((prev) => [...prev, { text, isUser: true }]); + } + try { + const helperTaskKey = `${taskKey}:${label}`; + const responseText = await queryHelperChat( + helperTaskKey, + text, + fieldContext, + ); + setMessages((prev) => [ + ...prev, + { + text: responseText || "Sorry, I could not generate a response.", + isUser: false, + }, + ]); + } catch (error) { + setMessages((prev) => [ + ...prev, + { + text: error.message || "Error contacting helper chatbot.", + isUser: false, + }, + ]); + } finally { + setIsSending(false); + } + }; + + // ------- Open the floating panel ------- + const openPanel = () => { + if (!anchorRef.current) return; + const rect = anchorRef.current.getBoundingClientRect(); + const anchor = { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }; + const viewportW = window.innerWidth; + const viewportH = window.innerHeight; + const width = panelPos.width || 360; + const height = panelPos.height || 300; + + // Prefer placing panel to the right of the "?" icon + let left = rect.right + 12; + let top = rect.top - 12; + if (left + width > viewportW) { + left = rect.left - width - 12; + } + if (left < 16) { + left = clamp(rect.left, 16, viewportW - width - 16); + } + if (top + height > viewportH) { + top = clamp(viewportH - height - 16, 16, viewportH - height - 16); + } + if (top < 16) top = 16; + + setAnchorPoint(anchor); + setPanelPos((prev) => ({ ...prev, top, left })); + setOpen(true); + + // Auto-fire the initial explainer prompt on first open + if (messages.length === 0) { + sendMessage(initialPrompt, { hideUser: true }); + } + }; + + // ------- Follow-up send ------- + const handleSend = async () => { + if (!inputValue.trim()) return; + const query = inputValue; + setInputValue(""); + await sendMessage(query); + }; + + // ------- Drag handling ------- + useEffect(() => { + if (!open) return; + const onMove = (event) => { + if (!dragState.current.dragging) return; + const nextLeft = clamp( + event.clientX - dragState.current.offsetX, + 8, + window.innerWidth - panelPos.width - 8, + ); + const nextTop = clamp( + event.clientY - dragState.current.offsetY, + 8, + window.innerHeight - panelPos.height - 8, + ); + setPanelPos((prev) => ({ ...prev, left: nextLeft, top: nextTop })); + }; + const onUp = () => { + dragState.current.dragging = false; + }; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + return () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + }, [open, panelPos.width, panelPos.height]); + + const startDrag = (event) => { + if (!panelRef.current) return; + dragState.current.dragging = true; + dragState.current.offsetX = event.clientX - panelPos.left; + dragState.current.offsetY = event.clientY - panelPos.top; + }; + + // Track panel resize via CSS resize: both + const handlePanelMouseUp = () => { + const newRect = panelRef.current?.getBoundingClientRect(); + if (newRect) { + setPanelPos((prev) => ({ + ...prev, + width: newRect.width, + height: newRect.height, + })); + } + }; + + // ------- Render the floating panel via portal ------- + const panel = open ? ( + <> + {/* SVG tether line from "?" icon to the panel */} + + + + + {/* The floating panel */} +
+ {/* Draggable header */} +
+ {label} + +
+ + {/* Messages area */} +
+ {messages.map((msg, index) => ( +
+ {msg.isUser ? ( + {msg.text} + ) : ( + ( + {children} + ), + p: ({ children }) => ( +

+ {children} +

+ ), + ul: ({ children }) => ( +
    + {children} +
+ ), + ol: ({ children }) => ( +
    + {children} +
+ ), + table: ({ children }) => ( + + {children} +
+ ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), + code: ({ inline, children }) => + inline ? ( + + {children} + + ) : ( +
+                          {children}
+                        
+ ), + }} + > + {msg.text} +
+ )} +
+ ))} + {isSending && ( +
+ +
+ )} +
+
+ + {/* Input area */} +
+ +