Skip to content
Merged
2 changes: 1 addition & 1 deletion apps/website/content/docs/chat/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -6586,7 +6586,7 @@
{
"name": "buildA2uiActionMessage",
"kind": "function",
"description": "Builds an A2uiActionMessage from handler params and the current surface.\n The action.context is serialized as v1 DynamicValue-wrapped entries.",
"description": "Builds an A2uiActionMessage from handler params and the current surface.\n The action.context is serialized as v1 DynamicValue-wrapped entries.\n Sets action.label when the source component is a Button with a Text\n child whose literalString is non-empty.",
"signature": "buildA2uiActionMessage(params: Record<string, unknown>, surface: A2uiSurface): A2uiActionMessage",
"params": [
{
Expand Down
61 changes: 58 additions & 3 deletions cockpit/chat/a2ui/python/src/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@

import json
import logging
import os
import re
from typing import Any, Literal

from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, MessagesState, END
from langgraph.types import Command
from langgraph_sdk import get_client
from pydantic import BaseModel, Field, ValidationError, field_validator


Expand Down Expand Up @@ -757,14 +759,67 @@ def route(state: MessagesState) -> Command[Literal["build_form", "search_flights
return Command(goto="build_form")


# ── generate_title node (inline; matches Pattern D from spec
# 2026-05-19-llm-generated-labels-design.md) ──────────────────────────────

_TITLE_PROMPT = (
"In 3-5 words, summarize what the user is asking about. "
"Output ONLY the title — no quotes, no period, no prefix."
)
_TITLE_MODEL = "gpt-5-mini"


async def generate_title(state: MessagesState, config) -> dict:
"""Background title generation: on the first turn, summarize the user's
intent into 3-5 words and persist to LangGraph thread metadata so the
sidenav shows something meaningful instead of a UUID slice.

Idempotent — skips when metadata.thread_title already exists. Errors
are swallowed (title is a UX nicety, never a blocker). Runs after the
user-visible terminal node so it never blocks the response. See spec
2026-05-19-llm-generated-labels-design.md.
"""
thread_id = (config.get("configurable") or {}).get("thread_id")
if not thread_id:
return {}
sdk_url = os.environ.get("LANGGRAPH_API_URL", "http://localhost:2024")
try:
client = get_client(url=sdk_url)
thread = await client.threads.get(thread_id)
if (thread.get("metadata") or {}).get("thread_title"):
return {}
first_user = next(
(m for m in state["messages"] if getattr(m, "type", None) == "human"),
None,
)
if not first_user or not isinstance(first_user.content, str):
return {}
# Skip action-message JSON (those flow as human-role too)
if first_user.content.lstrip().startswith("{"):
return {}
llm = ChatOpenAI(model=_TITLE_MODEL, temperature=0)
response = await llm.ainvoke([
SystemMessage(content=_TITLE_PROMPT),
HumanMessage(content=first_user.content),
])
title = (response.content or "").strip().strip('"').strip("'")[:80]
if title:
await client.threads.update(thread_id, metadata={"thread_title": title})
except Exception as err: # noqa: BLE001 — title is a UX nicety; never block
_logger.warning("Thread title generation failed: %s", err)
return {}


_builder = StateGraph(MessagesState)
_builder.add_node("route", route)
_builder.add_node("build_form", build_form)
_builder.add_node("search_flights", search_flights)
_builder.add_node("confirm_booking", confirm_booking)
_builder.add_node("generate_title", generate_title)
_builder.set_entry_point("route")
_builder.add_edge("build_form", END)
_builder.add_edge("search_flights", END)
_builder.add_edge("confirm_booking", END)
_builder.add_edge("build_form", "generate_title")
_builder.add_edge("search_flights", "generate_title")
_builder.add_edge("confirm_booking", "generate_title")
_builder.add_edge("generate_title", END)

graph = _builder.compile()
Loading