From 29f7103a8b165507e4a27124e2225070935cd788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sun, 15 Mar 2026 19:52:45 +0100 Subject: [PATCH] feat: chat agent can find and show existing product renders Added find_product_renders tool to the AI agent: - Searches completed renders by product name/ID - Returns viewable image URLs (relative paths) - Supports transparent_only filter - AI formats results as Markdown images/links in chat Frontend: - ChatPanel ReactMarkdown renders and tags - Images shown inline (max 200px height, rounded, bordered) - Links open in new tab with accent color System prompt updated to instruct AI to use Markdown image syntax when showing renders: ![description](/renders/...) Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/services/chat_service.py | 99 +++++++++++++++++++++- frontend/src/components/chat/ChatPanel.tsx | 2 + 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/backend/app/services/chat_service.py b/backend/app/services/chat_service.py index f910e77..9b05945 100644 --- a/backend/app/services/chat_service.py +++ b/backend/app/services/chat_service.py @@ -29,7 +29,9 @@ You can: - Check material mapping status - Query the database for statistics -Always be concise and helpful. Execute actions immediately without asking for confirmation — the user expects you to act, not ask. If creating an order, also submit and dispatch it automatically. Combine multiple steps into one action when possible. Respond in the same language the user writes in.""" +Always be concise and helpful. Execute actions immediately without asking for confirmation — the user expects you to act, not ask. If creating an order, also submit and dispatch it automatically. Combine multiple steps into one action when possible. Respond in the same language the user writes in. + +When the user asks to SEE a product image or render, use find_product_renders to get URLs, then format them as Markdown image links: ![description](url) or as clickable links: [View render](url). The URLs are relative paths like /renders/... that work in the browser.""" # ── Tool definitions (OpenAI function-calling schema) ──────────────────────── @@ -232,6 +234,33 @@ TOOLS = [ }, }, }, + { + "type": "function", + "function": { + "name": "find_product_renders", + "description": "Find existing render images for a product. Returns URLs that can be shown to the user as clickable links or inline images. Use this when the user asks to SEE a product image, rendering, or thumbnail.", + "parameters": { + "type": "object", + "properties": { + "product_name": { + "type": "string", + "description": "Product name or partial match.", + "default": "", + }, + "product_id": { + "type": "string", + "description": "Product UUID (if known).", + "default": "", + }, + "transparent_only": { + "type": "boolean", + "description": "Only return renders with transparent background.", + "default": False, + }, + }, + }, + }, + }, ] @@ -287,6 +316,8 @@ async def _execute_tool( return await _tool_get_render_stats(db, tenant_id) elif name == "check_materials": return await _tool_check_materials(db, tenant_id, user_id, **arguments) + elif name == "find_product_renders": + return await _tool_find_product_renders(db, tenant_id, **arguments) elif name == "query_database": return await _tool_query_database(db, tenant_id, **arguments) else: @@ -596,6 +627,72 @@ async def _tool_check_materials(db: AsyncSession, tenant_id: str, user_id: str = return json.dumps({"error": f"Failed to check materials: {exc}"}) +async def _tool_find_product_renders( + db: AsyncSession, tenant_id: str, + product_name: str = "", product_id: str = "", transparent_only: bool = False, +) -> str: + """Find existing render images for a product, returning viewable URLs.""" + conditions = ["o.tenant_id = :tenant_id", "ol.render_status = 'completed'", "ol.result_path IS NOT NULL"] + params: dict = {"tenant_id": tenant_id} + + if product_id: + conditions.append("p.id = :pid") + params["pid"] = product_id + elif product_name: + conditions.append("p.name ILIKE :pname") + params["pname"] = f"%{product_name}%" + else: + return json.dumps({"error": "Provide product_name or product_id"}) + + if transparent_only: + conditions.append("ot.transparent_bg = true") + + where = " AND ".join(conditions) + sql = f""" + SELECT p.name AS product_name, p.id AS product_id, + ot.name AS output_type, ot.transparent_bg, + ol.result_path, ol.id AS line_id, + ol.render_completed_at + FROM order_lines ol + JOIN orders o ON o.id = ol.order_id + JOIN products p ON p.id = ol.product_id + LEFT JOIN output_types ot ON ot.id = ol.output_type_id + WHERE {where} + ORDER BY ol.render_completed_at DESC + LIMIT 10 + """ + result = await db.execute(text(sql), params) + rows = result.mappings().all() + + if not rows: + return json.dumps({"message": "No completed renders found for this product.", "renders": []}) + + renders = [] + for r in rows: + path = r["result_path"] or "" + # Convert internal path to servable URL + url = None + if "/renders/" in path: + url = path[path.index("/renders/"):] + elif "/thumbnails/" in path: + url = path[path.index("/thumbnails/"):] + + renders.append({ + "product_name": r["product_name"], + "product_id": str(r["product_id"]), + "output_type": r["output_type"], + "transparent_bg": r["transparent_bg"], + "image_url": url, + "line_id": str(r["line_id"]), + "rendered_at": str(r["render_completed_at"]), + }) + + return json.dumps({ + "message": f"Found {len(renders)} render(s). Show the user these image URLs as clickable links.", + "renders": renders, + }, indent=2, default=str) + + async def _tool_query_database(db: AsyncSession, tenant_id: str, sql: str = "") -> str: """Execute a read-only SQL query, tenant-scoped.""" sql_upper = sql.strip().upper() diff --git a/frontend/src/components/chat/ChatPanel.tsx b/frontend/src/components/chat/ChatPanel.tsx index 9c27f7e..0889e10 100644 --- a/frontend/src/components/chat/ChatPanel.tsx +++ b/frontend/src/components/chat/ChatPanel.tsx @@ -240,6 +240,8 @@ export default function ChatPanel({ open, onClose, contextType, contextId }: Cha ol: ({ children }) =>
    {children}
, li: ({ children }) =>
  • {children}
  • , code: ({ children }) => {children}, + a: ({ href, children }) =>
    {children}, + img: ({ src, alt }) => {alt, }} > {msg.content}