diff --git a/examples/python/RAG_QA_chatbot/backend/agent_loop.py b/examples/python/RAG_QA_chatbot/backend/agent_loop.py new file mode 100644 index 0000000000..ce18a036ef --- /dev/null +++ b/examples/python/RAG_QA_chatbot/backend/agent_loop.py @@ -0,0 +1,588 @@ +"""Real agentic loop emitting the v6 UI Message Stream protocol. + +This is the un-mocked counterpart to the canned generators in `contract_stream.py`. +It drives a real LLM (via litellm) through a function-calling loop with two tools: + + * ``search_docs`` — auto-executed; runs the real Qdrant retrieval in ``rag.py``. + * ``send_summary_email`` — human-in-the-loop; the turn PAUSES on a v6 + ``tool-approval-request`` and only executes on resume, + after the user approves. + +The conversation is stateless across turns (cold/replay model): on the resume request the +frontend re-POSTs the full history, we reconstruct the OpenAI ``messages`` from it, resolve +the pending approval, and continue the loop. The real Agenta trace id is read from the +span and emitted as ``data-trace`` so "View trace" resolves in the Agenta UI. + +The emitted wire parts are byte-for-byte the same v6 contract the mock proves; only the +*content* is now real (real tokens, real retrieved sources, real tool side-effects, a real +trace). Tool-calls and approval are real because this is a genuine agent loop — the thing +the RAG bot in ``main.py`` is not. +""" + +import asyncio +import json +import os +import smtplib +import uuid +from email.message import EmailMessage +from pathlib import Path +from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple + +# Delay between streamed tool-output chunks (search hits revealed one at a time). The v6 +# protocol has no tool-output-delta, but `tool-output-available` accepts a `preliminary` +# flag, so we emit the growing output as preliminary updates then a final full one. +OUTPUT_CHUNK_DELAY_S = float(os.getenv("AGENT_OUTPUT_CHUNK_DELAY", "0.12")) + +# Heavy deps (litellm / qdrant via rag / agenta) are imported lazily inside the functions +# that use them, so this module's pure helpers stay importable without credentials. + +# Tools that run immediately vs. tools gated behind human approval. +AUTO_TOOLS = {"search_docs"} +APPROVAL_TOOLS = {"send_summary_email"} + +# Cap the agentic loop so a misbehaving model can't spin forever. +MAX_STEPS = 6 + +AGENT_SYSTEM_PROMPT = ( + "You are Agenta's documentation assistant. " + "Always call the `search_docs` tool to ground your answer in the docs before " + "replying, and cite the document titles you used. " + "If the user asks you to email a summary to someone, call `send_summary_email` — " + "that tool requires explicit human approval before it runs, so call it and wait. " + "Answer concisely in markdown." +) + +TOOL_SPECS: List[Dict[str, Any]] = [ + { + "type": "function", + "function": { + "name": "search_docs", + "description": "Search the Agenta documentation for passages relevant to a query.", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "The search query."}, + "top_k": { + "type": "integer", + "description": "How many passages to return.", + }, + }, + "required": ["query"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "send_summary_email", + "description": "Send a summary email. Requires human approval before it runs.", + "parameters": { + "type": "object", + "properties": { + "to": {"type": "string", "description": "Recipient email address."}, + "subject": {"type": "string"}, + "body": {"type": "string"}, + }, + "required": ["to", "subject", "body"], + }, + }, + }, +] + + +def _sse(obj: Dict[str, Any]) -> str: + return f"data: {json.dumps(obj)}\n\n" + + +# --------------------------------------------------------------------------- +# Real tool execution +# --------------------------------------------------------------------------- + + +def _run_search_docs(args: Dict[str, Any]) -> Tuple[Dict[str, Any], List[Any]]: + """Execute the real Qdrant-backed retrieval. Returns (tool_output, docs).""" + from .rag import retrieve # lazy: qdrant/litellm only loaded in real mode + + query = (args.get("query") or "").strip() + top_k = args.get("top_k") + docs = retrieve(query, top_k=top_k) + output = { + "hits": [ + { + "title": d.title, + "url": d.url, + "score": round(d.score, 3), + "snippet": (d.content or "")[:240], + } + for d in docs + ] + } + return output, docs + + +def _run_send_email(args: Dict[str, Any]) -> Dict[str, Any]: + """Really send the email when SMTP is configured; otherwise record it locally. + + Either way a real side-effect happens — this is not a fabricated ``{status: sent}``. + Configure SMTP via SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASSWORD / SMTP_FROM to + send for real; without it the message is appended to ``sent_emails.jsonl`` so the + approval gate still has an observable effect with no extra credentials. + """ + to = args.get("to") or "" + subject = args.get("subject") or "" + body = args.get("body") or "" + + host = os.getenv("SMTP_HOST") + if host: + msg = EmailMessage() + msg["From"] = os.getenv( + "SMTP_FROM", os.getenv("SMTP_USER", "agent@example.com") + ) + msg["To"] = to + msg["Subject"] = subject + msg.set_content(body) + with smtplib.SMTP(host, int(os.getenv("SMTP_PORT", "587"))) as server: + server.starttls() + user, password = os.getenv("SMTP_USER"), os.getenv("SMTP_PASSWORD") + if user and password: + server.login(user, password) + server.send_message(msg) + return {"status": "sent", "transport": "smtp", "to": to} + + # No SMTP configured — record locally (a real, inspectable side-effect). + log_path = Path(__file__).resolve().parent.parent / "sent_emails.jsonl" + record = {"to": to, "subject": subject, "body": body} + with log_path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(record) + "\n") + return { + "status": "recorded", + "transport": "local-file", + "path": str(log_path), + "to": to, + } + + +# --------------------------------------------------------------------------- +# Streaming one model step → v6 parts +# --------------------------------------------------------------------------- + + +async def _stream_step( + messages: List[Dict[str, Any]], + model: str, +) -> AsyncGenerator[Any, None]: + """Call the model once (streaming) and yield v6 SSE strings for text/reasoning. + + The final item yielded is a ``("__result__", text, tool_calls)`` tuple carrying the + assembled assistant text and any tool calls, so the caller can drive the loop. + """ + from litellm import acompletion # lazy: only needed in real mode + + response = await acompletion( + model=model, + messages=messages, + tools=TOOL_SPECS, + tool_choice="auto", + stream=True, + ) + + text_id: Optional[str] = None + reasoning_id: Optional[str] = None + reasoning_closed = False + text_buf = "" + tool_acc: Dict[int, Dict[str, str]] = {} + + async for chunk in response: + choice = chunk.choices[0] + delta = choice.delta + + reasoning = getattr(delta, "reasoning_content", None) + if reasoning: + if reasoning_id is None: + reasoning_id = str(uuid.uuid4()) + yield _sse({"type": "reasoning-start", "id": reasoning_id}) + yield _sse( + {"type": "reasoning-delta", "id": reasoning_id, "delta": reasoning} + ) + + content = getattr(delta, "content", None) + if content: + if reasoning_id is not None and not reasoning_closed: + yield _sse({"type": "reasoning-end", "id": reasoning_id}) + reasoning_closed = True + if text_id is None: + text_id = str(uuid.uuid4()) + yield _sse({"type": "text-start", "id": text_id}) + text_buf += content + yield _sse({"type": "text-delta", "id": text_id, "delta": content}) + + for tc in getattr(delta, "tool_calls", None) or []: + acc = tool_acc.setdefault(tc.index, {"id": "", "name": "", "args": "", "started": ""}) + if tc.id: + acc["id"] = tc.id + fn = getattr(tc, "function", None) + if fn and fn.name: + acc["name"] += fn.name + + # Open the v6 tool part as soon as we know id + name, then stream the input + # JSON as `tool-input-delta` chunks so the call's input renders progressively + # (client part state `input-streaming`) instead of appearing all at once. + if not acc["started"] and acc["id"] and acc["name"]: + acc["started"] = "1" + yield _sse( + {"type": "tool-input-start", "toolCallId": acc["id"], "toolName": acc["name"]} + ) + if acc["args"]: # flush args that arrived before the name + yield _sse( + { + "type": "tool-input-delta", + "toolCallId": acc["id"], + "inputTextDelta": acc["args"], + } + ) + + if fn and fn.arguments: + acc["args"] += fn.arguments + if acc["started"]: + yield _sse( + { + "type": "tool-input-delta", + "toolCallId": acc["id"], + "inputTextDelta": fn.arguments, + } + ) + + if reasoning_id is not None and not reasoning_closed: + yield _sse({"type": "reasoning-end", "id": reasoning_id}) + if text_id is not None: + yield _sse({"type": "text-end", "id": text_id}) + + tool_calls = [tool_acc[idx] for idx in sorted(tool_acc)] + yield ("__result__", text_buf, tool_calls) + + +def _parse_args(raw: str) -> Dict[str, Any]: + try: + return json.loads(raw) if raw else {} + except (json.JSONDecodeError, TypeError): + return {} + + +# --------------------------------------------------------------------------- +# History reconstruction (stateless resume) +# --------------------------------------------------------------------------- + + +def _messages_from_uimessage(body: Dict[str, Any]) -> List[Dict[str, Any]]: + """Track A: rebuild OpenAI messages from AI SDK ``UIMessage[]`` (parts).""" + out: List[Dict[str, Any]] = [] + for m in body.get("messages") or []: + role = m.get("role") + parts = m.get("parts") or [] + text = " ".join( + p.get("text", "") for p in parts if p.get("type") == "text" + ).strip() + if role == "user": + out.append({"role": "user", "content": text}) + elif role == "assistant": + tool_parts = [ + p for p in parts if str(p.get("type", "")).startswith("tool-") + ] + if tool_parts: + out.append( + { + "role": "assistant", + "content": text or None, + "tool_calls": [ + { + "id": p.get("toolCallId"), + "type": "function", + "function": { + "name": str(p["type"])[len("tool-") :], + "arguments": json.dumps(p.get("input") or {}), + }, + } + for p in tool_parts + ], + } + ) + # Tool results for calls that already resolved. The pending approval call + # (no output yet) is intentionally left unresolved; the loop adds it. + for p in tool_parts: + state = p.get("state") + if state == "output-available": + out.append( + { + "role": "tool", + "tool_call_id": p.get("toolCallId"), + "content": json.dumps(p.get("output")), + } + ) + elif state == "output-denied": + out.append( + { + "role": "tool", + "tool_call_id": p.get("toolCallId"), + "content": json.dumps({"status": "denied"}), + } + ) + elif text: + out.append({"role": "assistant", "content": text}) + return out + + +def _messages_from_agenta(body: Dict[str, Any]) -> List[Dict[str, Any]]: + """Track B: the FE already sends OpenAI-shaped messages; pass through the fields the + model needs (role/content/tool_calls/tool_call_id/name), dropping UI-only extras.""" + out: List[Dict[str, Any]] = [] + for m in body.get("messages") or []: + msg: Dict[str, Any] = {"role": m.get("role"), "content": m.get("content")} + if m.get("tool_calls"): + msg["tool_calls"] = m["tool_calls"] + if m.get("tool_call_id"): + msg["tool_call_id"] = m["tool_call_id"] + if m.get("name"): + msg["name"] = m["name"] + out.append(msg) + return out + + +# --------------------------------------------------------------------------- +# The turn +# --------------------------------------------------------------------------- + + +async def run_turn( + body: Dict[str, Any], + track: str, + pending: List[Dict[str, Any]], +) -> AsyncGenerator[str, None]: + """Drive one agent turn, emitting v6 SSE strings. `pending` are approval decisions + detected from the request (toolCallId, toolName, input, approved).""" + from .config import settings # lazy: pulls python-dotenv only in real mode + + model = settings.LLM_MODEL + # Echo the resolved session_id on the `start` part per the RFC (§6.2.4). + start: Dict[str, Any] = {"type": "start", "messageId": str(uuid.uuid4())} + if body.get("session_id"): + start["messageMetadata"] = {"sessionId": body["session_id"]} + yield _sse(start) + + history = ( + _messages_from_agenta(body) + if track == "agenta" + else _messages_from_uimessage(body) + ) + messages: List[Dict[str, Any]] = [ + {"role": "system", "content": AGENT_SYSTEM_PROMPT}, + *history, + ] + + # Open a real Agenta span so the run is traced and we can surface its trace id. + trace_id = None + answer_text = "" + span_cm = _open_span("agent_chat") + span = span_cm.__enter__() if span_cm else None + try: + # Record the conversation on the parent span (children auto-capture their own data). + _set_span_data("inputs", {"messages": history}) + + # 1) Resolve any pending approval decisions first (resume path). + for tool in pending: + tool_call_id = tool["toolCallId"] + if tool["approved"]: + output = _run_send_email(tool.get("input") or {}) + yield _sse( + { + "type": "tool-output-available", + "toolCallId": tool_call_id, + "output": output, + } + ) + messages.append( + { + "role": "tool", + "tool_call_id": tool_call_id, + "content": json.dumps(output), + } + ) + else: + yield _sse({"type": "tool-output-denied", "toolCallId": tool_call_id}) + messages.append( + { + "role": "tool", + "tool_call_id": tool_call_id, + "content": json.dumps( + {"status": "denied", "note": "User declined."} + ), + } + ) + + # 2) Agentic loop: model → tools → model … until a final text or an approval pause. + for _ in range(MAX_STEPS): + text_buf = "" + tool_calls: List[Dict[str, str]] = [] + async for item in _stream_step(messages, model): + if isinstance(item, tuple) and item and item[0] == "__result__": + _, text_buf, tool_calls = item + else: + yield item + if text_buf: + answer_text += text_buf + + assistant_msg: Dict[str, Any] = { + "role": "assistant", + "content": text_buf or None, + } + if tool_calls: + assistant_msg["tool_calls"] = [ + { + "id": tc["id"], + "type": "function", + "function": { + "name": tc["name"], + "arguments": tc["args"] or "{}", + }, + } + for tc in tool_calls + ] + messages.append(assistant_msg) + + if not tool_calls: + break # model produced a final answer + + approval_pending = False + for tc in tool_calls: + name, call_id = tc["name"], tc["id"] + args = _parse_args(tc["args"]) + # `tool-input-start` + `tool-input-delta`s were already streamed in + # `_stream_step`; here we just finalize the input. + yield _sse( + { + "type": "tool-input-available", + "toolCallId": call_id, + "toolName": name, + "input": args, + } + ) + + if name in AUTO_TOOLS: + output, docs = _run_search_docs(args) + for d in docs[:5]: + yield _sse( + { + "type": "source-url", + "sourceId": d.url, + "url": d.url, + "title": d.title, + } + ) + # Reveal the hits progressively as `preliminary` outputs, then a + # final full output. (The retrieval itself is one call — this just + # streams the rendering of the already-computed result.) + hits = output.get("hits") if isinstance(output, dict) else None + if isinstance(hits, list) and len(hits) > 1: + for k in range(1, len(hits)): + yield _sse( + { + "type": "tool-output-available", + "toolCallId": call_id, + "output": {"hits": hits[:k]}, + "preliminary": True, + } + ) + await asyncio.sleep(OUTPUT_CHUNK_DELAY_S) + yield _sse( + { + "type": "tool-output-available", + "toolCallId": call_id, + "output": output, + } + ) + messages.append( + { + "role": "tool", + "tool_call_id": call_id, + "content": json.dumps(output), + } + ) + elif name in APPROVAL_TOOLS: + yield _sse( + { + "type": "tool-approval-request", + "approvalId": f"approval_{uuid.uuid4().hex[:12]}", + "toolCallId": call_id, + } + ) + approval_pending = True + + if approval_pending: + break # pause the turn for human approval + + _set_span_data("outputs", {"response": answer_text}) + trace_id = _trace_id_of(span) + finally: + if span_cm: + span_cm.__exit__(None, None, None) + + if trace_id: + # data-trace part (legacy/fallback channel) … + yield _sse( + { + "type": "data-trace", + "data": { + "traceId": trace_id, + "url": f"{settings.AGENTA_HOST}/observability/traces/{trace_id}", + }, + } + ) + # … and the RFC-aligned channel: traceId on the finish messageMetadata. + yield _sse({"type": "finish", "messageMetadata": {"traceId": trace_id}}) + else: + yield _sse({"type": "finish"}) + yield "data: [DONE]\n\n" + + +# --------------------------------------------------------------------------- +# Agenta tracing helpers (no-op if the SDK isn't initialized) +# --------------------------------------------------------------------------- + + +def _open_span(name: str): + try: + import agenta as ag + + return ag.tracer.start_as_current_span(name) + except Exception: + return None + + +def _set_span_data(key: str, value: Any) -> None: + """Set `inputs`/`outputs` on the active Agenta span (no-op if tracing isn't init'd). + + The parent `agent_chat` span is a manual span, so it doesn't auto-capture data the way + `@ag.instrument()` children do — we populate it explicitly so the trace shows the + conversation in and the final answer out. + """ + try: + import agenta as ag + + span = ag.tracing.get_current_span() + if span is not None: + span.set_attributes({key: value}, namespace="data") + except Exception: + pass + + +def _trace_id_of(span) -> Optional[str]: + if span is None: + return None + try: + from opentelemetry.trace import format_trace_id + + ctx = span.get_span_context() + if ctx and ctx.is_valid: + return format_trace_id(ctx.trace_id) + except Exception: + return None + return None diff --git a/examples/python/RAG_QA_chatbot/backend/contract_stream.py b/examples/python/RAG_QA_chatbot/backend/contract_stream.py new file mode 100644 index 0000000000..f0cb271375 --- /dev/null +++ b/examples/python/RAG_QA_chatbot/backend/contract_stream.py @@ -0,0 +1,162 @@ +"""Agent chat slice endpoints — the real LLM agent loop over the v6 UI Message Stream. + +Two endpoints serve the streaming agent chat the frontend `useChat` hook consumes, one per +request-contract track (the team is still comparing them — see +`docs/design/agent-workflows/frontend-agent-chat-ui.md`): + + * **Track A** — `POST /api/agent/chat`. `messages` is the AI SDK `UIMessage[]` (parts); + the approval decision rides inside the assistant message's tool part. + * **Track B** — `POST /api/agent/chat-agenta`. `messages` is the Agenta `{role, content}` + shape; the approval decision rides in a top-level `tool_approvals` side field. + +Request envelope (FE as of 2026-06-19): `session_id` + `references` (+ Track B +`tool_approvals`) at the top level, with `data: {messages, parameters}` nested. +`_normalize_envelope` lifts `data.*` back to flat keys so the parsing below stays simple, +and it still accepts the older flat `{messages, ...}` shape. + +The response stream is identical across tracks. Both delegate to the real agent loop in +`agent_loop.py` (real LLM function-calling, real `search_docs` retrieval, an approval-gated +`send_summary_email`, a real Agenta trace). **Credentials are required** — set up +`.env` (OPENAI_API_KEY + QDRANT_URL/KEY + AGENTA_*) and ingest the docs; there is no +credential-free mock. The framing is SSE (`data: \\n\\n`, terminated by `[DONE]`, +header `x-vercel-ai-ui-message-stream: v1`); `session_id` is echoed on the `start` part's +`messageMetadata.sessionId`. +""" + +from typing import Any, Dict, List + +from fastapi import APIRouter, Request +from fastapi.responses import StreamingResponse + +from . import agent_loop + +router = APIRouter() + + +# ---- Track A: approvals read from UIMessage tool parts --------------------------------- + + +def _pending_approvals_uimessage( + messages: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + """Tool parts the user has just approved/denied but that have no output yet. + + Track A: the FE encodes the decision on the assistant message's tool part as + `state == "approval-responded"` with `approval: {id, approved}`. + """ + pending: List[Dict[str, Any]] = [] + for msg in messages: + if msg.get("role") != "assistant": + continue + for part in msg.get("parts") or []: + ptype = part.get("type", "") + if not ptype.startswith("tool-"): + continue + if part.get("state") != "approval-responded": + continue + approval = part.get("approval") or {} + pending.append( + { + "toolCallId": part.get("toolCallId"), + "toolName": ptype[len("tool-") :], + "input": part.get("input"), + "approved": bool(approval.get("approved")), + } + ) + return pending + + +# ---- Track B: approvals read from the `tool_approvals` side channel -------------------- + + +def _pending_approvals_agenta(body: Dict[str, Any]) -> List[Dict[str, Any]]: + """Track B: the Agenta `{role, content}` message contract has no slot for an approval + decision, so the FE adapter surfaces it in a top-level `tool_approvals` field: + + "tool_approvals": [ { "tool_call_id": "call_x", "approved": true } ] + + An entry is "pending" only while the matching tool call has no `tool` result message + yet — the same window Track A detects via `state == "approval-responded"`. + """ + approvals = body.get("tool_approvals") or [] + if not approvals: + return [] + + # tool_call_ids that already have a result (so they are no longer pending) + resolved: set = set() + for msg in body.get("messages") or []: + if msg.get("role") == "tool" and msg.get("tool_call_id"): + resolved.add(msg["tool_call_id"]) + + pending: List[Dict[str, Any]] = [] + for entry in approvals: + tool_call_id = entry.get("tool_call_id") + if not tool_call_id or tool_call_id in resolved: + continue + pending.append( + { + "toolCallId": tool_call_id, + "toolName": entry.get("tool_name", "tool"), + "input": entry.get("input"), + "approved": bool(entry.get("approved")), + } + ) + return pending + + +def _normalize_envelope(body: Dict[str, Any]) -> Dict[str, Any]: + """Accept the agent-protocol envelope `{session_id, references, data: {messages, + parameters}}` (what the FE sends) while staying backward-compatible with the older flat + `{messages, ag_config, ...}` shape. + + Lifts `data.messages` / `data.parameters` to the top level so the per-track parsing + below and `agent_loop.run_turn` can keep reading flat keys unchanged. `session_id` and + `tool_approvals` already travel at the top level, so they need no remapping. + """ + data = body.get("data") + if not isinstance(data, dict): + return body + merged = dict(body) + if "messages" not in merged and "messages" in data: + merged["messages"] = data.get("messages") + if "parameters" in data: + merged.setdefault("parameters", data.get("parameters")) + merged.setdefault("ag_config", data.get("parameters")) # legacy alias + return merged + + +def _build_response(body: Dict[str, Any], track: str) -> StreamingResponse: + """Parse the request per track, then stream the real agent loop as a v6 SSE response.""" + body = _normalize_envelope(body) + messages: List[Dict[str, Any]] = body.get("messages") or [] + pending = ( + _pending_approvals_agenta(body) + if track == "agenta" + else _pending_approvals_uimessage(messages) + ) + return StreamingResponse( + agent_loop.run_turn(body, track, pending), + media_type="text/event-stream", + headers={ + "x-vercel-ai-ui-message-stream": "v1", + "cache-control": "no-cache", + }, + ) + + +@router.post("/api/agent/chat") +async def agent_chat(request: Request) -> StreamingResponse: + """Track A — request `messages` is the AI SDK `UIMessage[]` shape (`{role, parts}`).""" + return _build_response(await request.json(), track="uimessage") + + +@router.post("/api/agent/chat-agenta") +async def agent_chat_agenta(request: Request) -> StreamingResponse: + """Track B — request `messages` is the Agenta `{role, content}` shape; the approval + decision rides in the `tool_approvals` side field.""" + return _build_response(await request.json(), track="agenta") + + +@router.get("/api/agent/health") +async def agent_health() -> Dict[str, str]: + return {"status": "healthy", "endpoint": "agent chat slice (real agent loop)"} diff --git a/examples/python/RAG_QA_chatbot/backend/main.py b/examples/python/RAG_QA_chatbot/backend/main.py index 4c4d275aae..556c58091a 100644 --- a/examples/python/RAG_QA_chatbot/backend/main.py +++ b/examples/python/RAG_QA_chatbot/backend/main.py @@ -13,6 +13,7 @@ from fastapi.responses import StreamingResponse from .config import settings +from .contract_stream import router as contract_router from .rag import format_context, generate, retrieve # Initialize Agenta for observability @@ -64,6 +65,11 @@ class ChatRequest(BaseModel): model_config = {"extra": "ignore"} # tolerate id, trigger, etc. from AI SDK v4+ +# Agent chat slice endpoints (POST /api/agent/chat[-agenta]) — the real agent loop over +# the v6 UI Message Stream. Requires credentials. See backend/contract_stream.py. +app.include_router(contract_router) + + @app.get("/health") async def health(): """Health check endpoint.""" diff --git a/examples/python/RAG_QA_chatbot/backend/rag.py b/examples/python/RAG_QA_chatbot/backend/rag.py index d95c9f69b0..77c7cb37c0 100644 --- a/examples/python/RAG_QA_chatbot/backend/rag.py +++ b/examples/python/RAG_QA_chatbot/backend/rag.py @@ -1,7 +1,9 @@ """RAG logic: retrieve and generate.""" +import re from dataclasses import dataclass from typing import AsyncGenerator, List, Optional, Tuple +from urllib.parse import urlsplit, urlunsplit import agenta as ag from agenta.sdk.managers.shared import SharedManager @@ -11,6 +13,27 @@ from .config import settings +_DOCUSAURUS_ORDER_PREFIX = re.compile(r"^\d+-") + + +def normalize_doc_url(url: str) -> str: + """Strip Docusaurus numeric ordering prefixes (`NN-`) from each path segment. + + The `.mdx` filenames carry sidebar-ordering prefixes (`01-architecture.mdx`) that the + public docs site drops from the URL (`/architecture`). Older ingests stored the URL with + the prefix, which 404s — this repairs them at read time so source links resolve. + """ + if not url: + return url + try: + parts = urlsplit(url) + except ValueError: + return url + new_path = "/".join( + _DOCUSAURUS_ORDER_PREFIX.sub("", seg) for seg in parts.path.split("/") + ) + return urlunsplit(parts._replace(path=new_path)) + @dataclass class RetrievedDoc: @@ -85,7 +108,7 @@ def retrieve( RetrievedDoc( content=point.payload["content"], title=point.payload["title"], - url=point.payload["url"], + url=normalize_doc_url(point.payload["url"]), score=point.score, ) ) diff --git a/examples/python/RAG_QA_chatbot/env.example b/examples/python/RAG_QA_chatbot/env.example index 5c37eb9a9b..72066e8143 100644 --- a/examples/python/RAG_QA_chatbot/env.example +++ b/examples/python/RAG_QA_chatbot/env.example @@ -26,3 +26,14 @@ TOP_K=10 # =========================================== AGENTA_API_KEY=your-agenta-api-key AGENTA_HOST=https://cloud.agenta.ai + +# =========================================== +# Agent chat slice (POST /api/agent/chat[-agenta]) +# =========================================== +# Optional: make the approval-gated `send_summary_email` tool send for real. Without +# these it records the message to sent_emails.jsonl (still a real, inspectable effect). +# SMTP_HOST=smtp.example.com +# SMTP_PORT=587 +# SMTP_USER=apikey +# SMTP_PASSWORD=your-smtp-password +# SMTP_FROM=agent@example.com diff --git a/examples/python/RAG_QA_chatbot/ingest/fix_urls.py b/examples/python/RAG_QA_chatbot/ingest/fix_urls.py new file mode 100644 index 0000000000..9e7ddb0158 --- /dev/null +++ b/examples/python/RAG_QA_chatbot/ingest/fix_urls.py @@ -0,0 +1,64 @@ +"""Backfill corrected public docs URLs into the vector-store payloads, in place. + +The public URL is derived from each doc's file path + frontmatter `slug` (see `loaders.py`: +Docusaurus strips numeric ordering prefixes, and an absolute frontmatter `slug` overrides +the path). Older ingests stored stale URLs (kept the `NN-` prefix, ignored frontmatter +slugs) that 404. This rewrites ONLY the `url` payload field — no re-embedding, no model +cost — by re-deriving URLs with the current loader and matching points by `file_path`. + + python -m ingest.fix_urls --source ../../../docs/docs --base-url https://docs.agenta.ai +""" + +import argparse +import os +from collections import defaultdict + +from dotenv import load_dotenv +from qdrant_client import QdrantClient + +from .loaders import load_mdx + + +def main(): + parser = argparse.ArgumentParser(description="Backfill corrected doc URLs in Qdrant") + parser.add_argument("--source", required=True, help="Path to docs directory") + parser.add_argument("--base-url", required=True, help="Base URL for doc links") + parser.add_argument("--collection", default=None, help="Collection (default: from env)") + args = parser.parse_args() + + load_dotenv() + collection = args.collection or os.getenv("COLLECTION_NAME", "docs_collection") + + url_by_path = {d.file_path: d.url for d in load_mdx(args.source, args.base_url)} + print(f"Re-derived {len(url_by_path)} URLs from {args.source}") + + client = QdrantClient(url=os.getenv("QDRANT_URL"), api_key=os.getenv("QDRANT_API_KEY")) + + pending: dict[str, list] = defaultdict(list) # correct_url -> [point ids needing it] + scanned = 0 + offset = None + while True: + points, offset = client.scroll( + collection, limit=256, with_payload=True, offset=offset + ) + for p in points: + scanned += 1 + correct = url_by_path.get(p.payload.get("file_path")) + if correct and correct != p.payload.get("url"): + pending[correct].append(p.id) + if offset is None: + break + + updated = 0 + for url, ids in pending.items(): + client.set_payload(collection, payload={"url": url}, points=ids) + updated += len(ids) + + print( + f"Scanned {scanned} points; updated {updated} URLs across " + f"{len(pending)} docs in '{collection}'." + ) + + +if __name__ == "__main__": + main() diff --git a/examples/python/RAG_QA_chatbot/ingest/loaders.py b/examples/python/RAG_QA_chatbot/ingest/loaders.py index 594c602740..de76643533 100644 --- a/examples/python/RAG_QA_chatbot/ingest/loaders.py +++ b/examples/python/RAG_QA_chatbot/ingest/loaders.py @@ -2,6 +2,7 @@ import glob import os +import re from dataclasses import dataclass from pathlib import Path from typing import List @@ -41,9 +42,18 @@ def load_mdx(docs_path: str, base_url: str) -> List[Document]: # Get title from frontmatter or filename title = post.get("title", Path(file_path).stem) - # Convert file path to URL - relative_path = os.path.relpath(file_path, docs_path) - url_path = os.path.splitext(relative_path)[0] + # Convert file path to the public docs URL. Docusaurus strips numeric + # ordering prefixes (`01-architecture.mdx` → `/architecture`), so strip + # `NN-` from each path segment. An absolute frontmatter `slug` wins. + slug = post.get("slug") + if isinstance(slug, str) and slug.startswith("/"): + url_path = slug.strip("/") + else: + relative_path = os.path.relpath(file_path, docs_path) + no_ext = os.path.splitext(relative_path)[0] + url_path = "/".join( + re.sub(r"^\d+-", "", seg) for seg in no_ext.split(os.sep) + ) url = f"{base_url.rstrip('/')}/{url_path}" documents.append( diff --git a/examples/python/RAG_QA_chatbot/ingest/store.py b/examples/python/RAG_QA_chatbot/ingest/store.py index 578b5f83d1..a1911ac882 100644 --- a/examples/python/RAG_QA_chatbot/ingest/store.py +++ b/examples/python/RAG_QA_chatbot/ingest/store.py @@ -71,29 +71,40 @@ def setup_collection( ) -def get_embeddings(text: str) -> Dict[str, List[float]]: +def _active_embedding_models() -> List[str]: + """Which named vectors to populate, honoring EMBEDDING_MODEL. + + `openai` (default) / `cohere` pick one; `both` populates both named vectors. The + retrieval path (`rag.py`) queries the single `using=EMBEDDING_MODEL` vector, so there + is no need to embed the other provider — and embedding both was force-calling Cohere + even when EMBEDDING_MODEL=openai, exhausting its trial rate limit. """ - Get embeddings using both OpenAI and Cohere models. + model = os.getenv("EMBEDDING_MODEL", "openai").strip().lower() + if model == "cohere": + return ["cohere"] + if model == "both": + return ["openai", "cohere"] + return ["openai"] - Args: - text: Text to embed - Returns: - Dict with 'openai' and 'cohere' embeddings - """ - # OpenAI embedding - openai_response = embedding(model="text-embedding-ada-002", input=[text]) - openai_embedding = openai_response["data"][0]["embedding"] - - # Cohere embedding - cohere_response = embedding( - model="cohere/embed-english-v3.0", - input=[text], - input_type="search_document", - ) - cohere_embedding = cohere_response["data"][0]["embedding"] +def embed_texts(texts: List[str]) -> Dict[str, List[List[float]]]: + """Batch-embed a list of texts for each active provider (one API call per provider). - return {"openai": openai_embedding, "cohere": cohere_embedding} + Returns provider → list of vectors, aligned with `texts`. + """ + out: Dict[str, List[List[float]]] = {} + models_ = _active_embedding_models() + if "openai" in models_: + resp = embedding(model="text-embedding-ada-002", input=texts) + out["openai"] = [d["embedding"] for d in resp["data"]] + if "cohere" in models_: + resp = embedding( + model="cohere/embed-english-v3.0", + input=texts, + input_type="search_document", + ) + out["cohere"] = [d["embedding"] for d in resp["data"]] + return out def generate_chunk_id(chunk: Chunk) -> str: @@ -113,30 +124,27 @@ def upsert_chunks(client: QdrantClient, collection_name: str, chunks: List[Chunk collection_name: Name of the collection chunks: List of chunks to upsert """ - for chunk in chunks: - # Get embeddings - embeddings = get_embeddings(chunk.content) - - # Create payload - payload = { - "content": chunk.content, - "title": chunk.title, - "url": chunk.url, - "file_path": chunk.file_path, - "chunk_index": chunk.chunk_index, - } - - # Generate unique ID - point_id = generate_chunk_id(chunk) - - # Upsert to Qdrant - client.upsert( - collection_name=collection_name, - points=[ - models.PointStruct( - id=point_id, - payload=payload, - vector=embeddings, - ) - ], + if not chunks: + return + + # One embedding call per provider for the whole batch, then one upsert. + vectors_by_model = embed_texts([chunk.content for chunk in chunks]) + + points = [] + for i, chunk in enumerate(chunks): + vector = {model: vecs[i] for model, vecs in vectors_by_model.items()} + points.append( + models.PointStruct( + id=generate_chunk_id(chunk), + payload={ + "content": chunk.content, + "title": chunk.title, + "url": chunk.url, + "file_path": chunk.file_path, + "chunk_index": chunk.chunk_index, + }, + vector=vector, + ) ) + + client.upsert(collection_name=collection_name, points=points) diff --git a/examples/python/RAG_QA_chatbot/run-agent-chat-slice.sh b/examples/python/RAG_QA_chatbot/run-agent-chat-slice.sh new file mode 100755 index 0000000000..1beccac979 --- /dev/null +++ b/examples/python/RAG_QA_chatbot/run-agent-chat-slice.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# +# Orchestrate the agent-chat slice: the REAL agent backend + the web dev server. +# +# ./examples/python/RAG_QA_chatbot/run-agent-chat-slice.sh +# +# Brings up: +# 1. The real agent backend (FastAPI) on :8000 — POST /api/agent/chat[-agenta], v6 UI +# Message Stream, real LLM + Qdrant retrieval + Agenta trace. +# 2. The web app (Next dev) with the slice flag on. +# +# Then visit: http://localhost:3000/w//p//apps//agent-chat +# Flip the A · UIMessage parts / B · Agenta {role,content} toggle on the page. +# +# REQUIRES credentials: a populated .env (OPENAI_API_KEY + QDRANT_URL/KEY + AGENTA_*) and +# the docs ingested into Qdrant. Ctrl-C tears both down. + +set -euo pipefail + +# --- config (override via env) --------------------------------------------- +BACKEND_PORT="${BACKEND_PORT:-8000}" +AGENT_CHAT_TRACK="${AGENT_CHAT_TRACK:-}" # "agenta" => default the page to Track B; empty => Track A +APP="${APP:-ee}" # which web app shell to serve: "ee" (default) or "oss" + +case "$APP" in + ee) APP_FILTER="@agenta/ee" ;; + oss) APP_FILTER="@agenta/oss" ;; + *) echo "!! APP must be 'ee' or 'oss', got '$APP'" >&2; exit 1 ;; +esac + +# --- paths ----------------------------------------------------------------- +REPO_ROOT="$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)" +EXAMPLE_DIR="$REPO_ROOT/examples/python/RAG_QA_chatbot" +WEB_DIR="$REPO_ROOT/web" +VENV="$EXAMPLE_DIR/.venv" + +cd "$REPO_ROOT" + +# Credentials are required — there is no credential-free mock. +if [ ! -f "$EXAMPLE_DIR/.env" ]; then + echo "!! Missing $EXAMPLE_DIR/.env" >&2 + echo " Copy env.example → .env and set OPENAI_API_KEY + QDRANT_URL/KEY + AGENTA_*," >&2 + echo " then ingest the docs (see below). The agent backend needs real credentials." >&2 + exit 1 +fi + +# --- teardown -------------------------------------------------------------- +BACKEND_PID="" +cleanup() { + echo "" + echo "==> Shutting down…" + [ -n "$BACKEND_PID" ] && kill "$BACKEND_PID" 2>/dev/null || true + wait 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +# --- 1. backend ------------------------------------------------------------ +if [ ! -d "$VENV" ]; then + echo "==> Installing example deps (first run only)…" + python3 -m venv "$VENV" + "$VENV/bin/pip" install --quiet --upgrade pip + "$VENV/bin/pip" install --quiet -e "$EXAMPLE_DIR" +fi + +echo "==> Starting agent backend (backend.main:app) on :$BACKEND_PORT …" +echo " (real LLM + Qdrant retrieval + Agenta trace; reads $EXAMPLE_DIR/.env)" +echo " Docs must be ingested into Qdrant first, e.g.:" +echo " $VENV/bin/python -m ingest.run --source ../../../docs/docs \\" +echo " --base-url https://docs.agenta.ai --recreate" +APP_MODULE="backend.main:app" +# --reload so backend edits (agent_loop.py, contract_stream.py, …) hot-reload without a +# manual restart while iterating. +( cd "$EXAMPLE_DIR" && exec "$VENV/bin/uvicorn" "$APP_MODULE" --port "$BACKEND_PORT" --reload ) & +BACKEND_PID=$! + +# wait for /health +echo -n "==> Waiting for backend" +for _ in $(seq 1 30); do + if curl -fsS "http://localhost:$BACKEND_PORT/health" >/dev/null 2>&1; then + echo " — up." + break + fi + if ! kill -0 "$BACKEND_PID" 2>/dev/null; then + echo "" + echo "!! Backend exited before becoming healthy. See output above." >&2 + exit 1 + fi + echo -n "." + sleep 1 +done + +# --- 2. web dev server (foreground) ---------------------------------------- +echo "==> Starting web dev server: $APP_FILTER (slice flag on)…" +echo "" +echo " App: $APP_FILTER (override with APP=oss)" +echo " Visit: http://localhost:3000/w//p//apps//agent-chat" +echo " Mock: http://localhost:$BACKEND_PORT/api/agent/chat" +[ -n "$AGENT_CHAT_TRACK" ] && echo " Track: defaulting to '$AGENT_CHAT_TRACK' (page toggle still works)" +echo "" +echo " NOTE: reaching the /w/../p/../apps//agent-chat route needs your authenticated dev" +echo " stack (backend + DB + auth) already running — this script only starts" +echo " the agent backend and the web app." +echo "" + +cd "$WEB_DIR" +NEXT_PUBLIC_AGENT_CHAT_SLICE=true \ + ${AGENT_CHAT_TRACK:+NEXT_PUBLIC_AGENT_CHAT_TRACK="$AGENT_CHAT_TRACK"} \ + pnpm --filter "$APP_FILTER" dev diff --git a/web/ee/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/agent-chat/index.tsx b/web/ee/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/agent-chat/index.tsx new file mode 100644 index 0000000000..2ab7470595 --- /dev/null +++ b/web/ee/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/agent-chat/index.tsx @@ -0,0 +1,3 @@ +import AgentChatPage from "@agenta/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/agent-chat" + +export default AgentChatPage diff --git a/web/oss/package.json b/web/oss/package.json index ad5f9e31ed..7ef0518ab5 100644 --- a/web/oss/package.json +++ b/web/oss/package.json @@ -34,6 +34,9 @@ "@ant-design/cssinjs": "^2.1.0", "@ant-design/icons": "^6.1.0", "@ant-design/x": "^2.5.0", + "@ant-design/x-markdown": "^2.8.0", + "@ai-sdk/react": "3.0.0-beta.153", + "ai": "6.0.0-beta.150", "@cloudflare/stream-react": "^1.9.3", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", diff --git a/web/oss/src/components/AgentChatSlice/assets/agConfig.ts b/web/oss/src/components/AgentChatSlice/assets/agConfig.ts new file mode 100644 index 0000000000..54269ef068 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/assets/agConfig.ts @@ -0,0 +1,104 @@ +import {workflowLatestRevisionQueryAtomFamily} from "@agenta/entities/workflow" +import {getDefaultStore, useAtomValue} from "jotai" + +/** + * Resolve a real `ag_config` + `references` payload from an app's LATEST revision, so the + * app-scoped agent-chat page (`…/apps/[app_id]/agent-chat`) sends the actual workflow + * config instead of a hardcoded stub. + * + * `appId` is the workflow artifact id (route param). `workflowLatestRevisionQueryAtomFamily` + * resolves and fetches the app's latest revision (skipping v0); its `data.parameters` IS the + * `ag_config`, and its id/slug/version fields give us `references` (UUID-guarded, since the + * backend rejects local-draft ids). + * + * `resolveAppAgConfig` reads imperatively so the transport sends the freshest config at send + * time; it returns `null` until the revision has loaded (caller falls back to the stub). + * `useAgConfigStatus` is the reactive companion — it keeps the query warm while the page is + * open and reports readiness for the header badge. + */ + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +const realId = (value: unknown): string | undefined => { + const s = typeof value === "string" ? value : undefined + return s && UUID_RE.test(s) ? s : undefined +} + +const str = (value: unknown): string | undefined => + typeof value === "string" && value ? value : undefined + +interface RevisionLike { + id?: string + slug?: string + version?: number | string | null + workflow_id?: string + workflow_slug?: string + workflow_variant_id?: string + workflow_variant_slug?: string + artifact_id?: string + artifact_slug?: string + variant_id?: string + variant_slug?: string + data?: {parameters?: Record | null} | null +} + +export interface ResolvedAgentConfig { + ag_config: Record + references: Record | null + version: number | null +} + +function buildReferences(rev: RevisionLike): Record | null { + const refs: Record = {} + + const appId = realId(rev.workflow_id) ?? realId(rev.artifact_id) + const appSlug = str(rev.workflow_slug) ?? str(rev.artifact_slug) + if (appId || appSlug) { + refs.application = {...(appId ? {id: appId} : {}), ...(appSlug ? {slug: appSlug} : {})} + } + + const variantId = realId(rev.workflow_variant_id) ?? realId(rev.variant_id) + const variantSlug = str(rev.workflow_variant_slug) ?? str(rev.variant_slug) + if (variantId || variantSlug) { + refs.application_variant = { + ...(variantId ? {id: variantId} : {}), + ...(variantSlug ? {slug: variantSlug} : {}), + } + } + + const revId = realId(rev.id) + const revSlug = str(rev.slug) + const revVersion = typeof rev.version === "number" ? String(rev.version) : str(rev.version) + if (revId || revSlug || revVersion) { + refs.application_revision = { + ...(revId ? {id: revId} : {}), + ...(revSlug ? {slug: revSlug} : {}), + ...(revVersion ? {version: revVersion} : {}), + } + } + + return Object.keys(refs).length > 0 ? refs : null +} + +function fromRevision(rev: RevisionLike | null | undefined): ResolvedAgentConfig | null { + const params = rev?.data?.parameters + if (!rev || !params || Object.keys(params).length === 0) return null + return { + ag_config: params, + references: buildReferences(rev), + version: typeof rev.version === "number" ? rev.version : null, + } +} + +export function resolveAppAgConfig(appId: string | null | undefined): ResolvedAgentConfig | null { + if (!appId) return null + const query = getDefaultStore().get(workflowLatestRevisionQueryAtomFamily(appId)) + return fromRevision(query?.data as RevisionLike | null | undefined) +} + +/** Reactive readiness for the header badge; subscribing also keeps the query warm. */ +export function useAgConfigStatus(appId: string): {ready: boolean; version: number | null} { + const query = useAtomValue(workflowLatestRevisionQueryAtomFamily(appId)) + const resolved = fromRevision(query?.data as RevisionLike | null | undefined) + return {ready: !!resolved, version: resolved?.version ?? null} +} diff --git a/web/oss/src/components/AgentChatSlice/assets/constants.ts b/web/oss/src/components/AgentChatSlice/assets/constants.ts new file mode 100644 index 0000000000..144f480153 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/assets/constants.ts @@ -0,0 +1,29 @@ +import {getEnv} from "@/oss/lib/helpers/dynamicEnv" + +/** + * The two request-contract tracks the slice exposes for the team to compare: + * - `uimessage` (Track A): POST the `useChat` `UIMessage[]` verbatim (parts). No FE + * translation; the service must speak AI SDK parts. + * - `agenta` (Track B): adapt to Agenta's existing `{role, content}` message shape (the + * contract `chat.py`/`completion.py` already parse), with approvals in `tool_approvals`. + * + * The *response* stream (text + tools + approval + trace) is identical for both; only the + * outgoing request body differs. + */ +export type AgentChatTrack = "uimessage" | "agenta" + +const API_BASE = getEnv("NEXT_PUBLIC_AGENT_CHAT_API") || "http://localhost:8000/api/agent/chat" + +/** Streaming endpoint per track. Track B appends `-agenta` to the base path. */ +export const trackApi = (track: AgentChatTrack): string => + track === "agenta" ? `${API_BASE}-agenta` : API_BASE + +/** Default track on first load. Override with `NEXT_PUBLIC_AGENT_CHAT_TRACK=agenta`. */ +export const DEFAULT_TRACK: AgentChatTrack = + (getEnv("NEXT_PUBLIC_AGENT_CHAT_TRACK") || "").toLowerCase() === "agenta" + ? "agenta" + : "uimessage" + +/** Whether the agent chat slice page is enabled. Feature-flagged, off by default. */ +export const isAgentChatSliceEnabled = (): boolean => + (getEnv("NEXT_PUBLIC_AGENT_CHAT_SLICE") || "").toLowerCase() === "true" diff --git a/web/oss/src/components/AgentChatSlice/assets/files.ts b/web/oss/src/components/AgentChatSlice/assets/files.ts new file mode 100644 index 0000000000..8e710d0a12 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/assets/files.ts @@ -0,0 +1,45 @@ +import type {FileUIPart, UIMessage} from "ai" + +/** + * Multi-modality helpers for the agent chat slice. Attachments are kept entirely on the + * client: there is no upload server, so a selected file is read into a `data:` URL and + * sent inline as an AI SDK v6 `file` part (`{type, mediaType, filename, url}`). The service + * receives the bytes in the request body — same channel as the text. + */ + +export type FileKind = "image" | "audio" | "video" | "file" + +/** Map an IANA media type to the `FileCard` `type` / a render branch. */ +export const fileKind = (mediaType: string): FileKind => { + if (mediaType.startsWith("image/")) return "image" + if (mediaType.startsWith("audio/")) return "audio" + if (mediaType.startsWith("video/")) return "video" + return "file" +} + +/** Read one `File` into a `data:` URL `file` part. */ +const fileToPart = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onerror = () => reject(reader.error) + reader.onload = () => + resolve({ + type: "file", + mediaType: file.type || "application/octet-stream", + filename: file.name, + url: reader.result as string, // data:;base64,<...> + }) + reader.readAsDataURL(file) + }) + +/** Convert picked `File`s into `file` parts for `sendMessage({text, files})`. */ +export const filesToParts = (files: File[]): Promise => + Promise.all(files.map(fileToPart)) + +/** The `file` parts of a message, in order. */ +export const fileParts = (message: UIMessage): FileUIPart[] => + message.parts.filter((p) => p.type === "file") as FileUIPart[] + +/** A readable label for a file part (filename, else the tail of its URL). */ +export const filePartName = (part: FileUIPart): string => + part.filename || part.url.split("/").pop()?.split("?")[0] || "file" diff --git a/web/oss/src/components/AgentChatSlice/assets/markdown.tsx b/web/oss/src/components/AgentChatSlice/assets/markdown.tsx new file mode 100644 index 0000000000..0a1db7e133 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/assets/markdown.tsx @@ -0,0 +1,19 @@ +import {XMarkdown} from "@ant-design/x-markdown" + +// Dark-mode-aware markdown styling (links/code/lists). `min-w-0` + `max-w-full` + the +// per-element width guards keep long lines / code blocks from widening their container; +// code blocks scroll within their own box instead. +export const MD_CLASS = + "min-w-0 max-w-full overflow-hidden break-words text-sm leading-relaxed " + + "[&_a]:text-colorPrimary [&_a]:underline [&_a]:break-all [&_p]:my-1 [&_p]:break-words " + + "[&_ul]:my-1 [&_ul]:pl-5 [&_ol]:my-1 [&_ol]:pl-5 [&_li]:my-0.5 [&_code]:rounded " + + "[&_code]:bg-colorFillTertiary [&_code]:px-1 [&_code]:break-words [&_pre]:bg-colorFillTertiary " + + "[&_pre]:p-2 [&_pre]:rounded [&_pre]:max-w-full [&_pre]:min-w-0 [&_pre]:overflow-x-auto" + +/** Shared markdown renderer for the slice — used by message bubbles and the composer + * live preview, so both render identically. */ +const Markdown = ({content}: {content: string}) => ( + +) + +export default Markdown diff --git a/web/oss/src/components/AgentChatSlice/assets/rewind.ts b/web/oss/src/components/AgentChatSlice/assets/rewind.ts new file mode 100644 index 0000000000..eb33bba849 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/assets/rewind.ts @@ -0,0 +1,34 @@ +import type {UIMessage} from "ai" + +/** + * Tools with no external side effect — safe to rewind/retry past silently. v1 hardcodes + * this; the principled source is a `readOnly` flag on the tool spec (see + * `docs/design/agent-workflows/agent-chat-rewind.md`). Everything not listed here is treated + * as potentially side-effecting, so the user is warned before rewinding past it. + */ +export const READ_ONLY_TOOLS = new Set(["search_docs"]) + +/** Concatenated text of a message's text parts. */ +export const messageText = (message: UIMessage): string => + message.parts + .filter((p) => p.type === "text") + .map((p) => (p as {text: string}).text) + .join("") + +/** + * Names of side-effecting tools that ALREADY produced output within `messages` — i.e. real + * actions a rewind cannot undo (e.g. a sent email). Read-only tools are ignored, and tool + * calls that never ran (still awaiting approval, denied, errored) are ignored. + */ +export const sideEffectingToolsInRange = (messages: UIMessage[]): string[] => { + const names = new Set() + for (const message of messages) { + for (const part of message.parts) { + if (!part.type.startsWith("tool-")) continue + const ran = (part as {state?: string}).state === "output-available" + const name = part.type.replace(/^tool-/, "") + if (ran && !READ_ONLY_TOOLS.has(name)) names.add(name) + } + } + return [...names] +} diff --git a/web/oss/src/components/AgentChatSlice/assets/toAgentaMessage.ts b/web/oss/src/components/AgentChatSlice/assets/toAgentaMessage.ts new file mode 100644 index 0000000000..28dbf728fa --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/assets/toAgentaMessage.ts @@ -0,0 +1,142 @@ +import type {FileUIPart, ToolUIPart, UIMessage} from "ai" + +import {fileKind, filePartName} from "./files" + +/** + * Track B adapter — the cost of keeping the request contract aligned with Agenta's + * existing services. + * + * `useChat` owns the conversation as `UIMessage[]` (typed parts). The existing Agenta + * runtime (`chat.py`, `completion.py`, the execution-item builder) speaks OpenAI/ACP-style + * `{role, content}` messages with `tool_calls` / `tool` result messages — NOT AI SDK parts. + * This function translates one into the other so the slice can POST the shape those + * services already parse. + * + * Two things the Agenta message contract has no native slot for, and what we do with them: + * - **reasoning parts** → dropped (no reasoning field in `{role, content}`). + * - **approval decisions** → there is no per-tool-call approval field on the Agenta + * request, so the decision is surfaced out-of-band in `tool_approvals`. This is a + * net-new convention Track B has to propose; it is exactly the seam to evaluate. + * + * Track A (the other option) skips this file entirely: `useChat`'s default transport posts + * the `UIMessage[]` verbatim, and the service is expected to speak parts. + */ + +export interface AgentaToolCall { + id: string + type: "function" + function: {name: string; arguments: string} +} + +/** + * OpenAI-style multimodal content parts. A message with attachments serializes `content` + * as this array instead of a plain string (images → `image_url`, other files → `file` with + * the bytes inline as a data URL). Like `tool_approvals`, the exact multimodal shape Track B + * sends is a net-new convention to validate against the backend. + */ +export type AgentaContentPart = + | {type: "text"; text: string} + | {type: "image_url"; image_url: {url: string}} + | {type: "file"; file: {filename: string; file_data: string}} + +export interface AgentaMessage { + role: string + content: string | AgentaContentPart[] + tool_calls?: AgentaToolCall[] + tool_call_id?: string + name?: string +} + +export interface AgentaToolApproval { + tool_call_id: string + tool_name: string + approved: boolean + input?: unknown +} + +export interface AgentaRequestMessages { + messages: AgentaMessage[] + tool_approvals: AgentaToolApproval[] +} + +const toolName = (part: ToolUIPart) => part.type.replace(/^tool-/, "") + +const textOf = (message: UIMessage): string => + message.parts + .filter((p) => p.type === "text") + .map((p) => (p as {text: string}).text) + .join("") + +const filePartToContent = (part: FileUIPart): AgentaContentPart => + fileKind(part.mediaType) === "image" + ? {type: "image_url", image_url: {url: part.url}} + : {type: "file", file: {filename: filePartName(part), file_data: part.url}} + +/** + * Message content for the Agenta request: a plain string when there are no attachments + * (the common case), or an OpenAI-style multimodal parts array when the message carries + * `file` parts (text first, then one entry per attachment). + */ +const contentOf = (message: UIMessage): string | AgentaContentPart[] => { + const files = message.parts.filter((p) => p.type === "file") as FileUIPart[] + const text = textOf(message) + if (files.length === 0) return text + return [...(text ? [{type: "text" as const, text}] : []), ...files.map(filePartToContent)] +} + +/** Convert the `useChat` `UIMessage[]` into the Agenta `{role, content}` request shape. */ +export const toAgentaMessages = (uiMessages: UIMessage[]): AgentaRequestMessages => { + const messages: AgentaMessage[] = [] + const toolApprovals: AgentaToolApproval[] = [] + + for (const ui of uiMessages) { + const toolParts = ui.parts.filter((p) => p.type.startsWith("tool-")) as ToolUIPart[] + + const toolCalls: AgentaToolCall[] = toolParts.map((tp) => ({ + id: tp.toolCallId, + type: "function", + function: { + name: toolName(tp), + arguments: JSON.stringify(tp.input ?? {}), + }, + })) + + messages.push({ + role: ui.role, + content: contentOf(ui), + ...(toolCalls.length ? {tool_calls: toolCalls} : {}), + }) + + // Resolved tool calls become OpenAI-style `tool` result messages. + for (const tp of toolParts) { + if (tp.state === "output-available") { + messages.push({ + role: "tool", + tool_call_id: tp.toolCallId, + name: toolName(tp), + content: JSON.stringify(tp.output ?? null), + }) + } else if (tp.state === "output-denied") { + messages.push({ + role: "tool", + tool_call_id: tp.toolCallId, + name: toolName(tp), + content: JSON.stringify({status: "denied"}), + }) + } + + // Pending approval decision → out-of-band side channel. + if (tp.state === "approval-responded") { + const approval = (tp as {approval?: {approved?: boolean}}).approval + toolApprovals.push({ + tool_call_id: tp.toolCallId, + tool_name: toolName(tp), + approved: Boolean(approval?.approved), + input: tp.input, + }) + } + } + } + + return {messages, tool_approvals: toolApprovals} +} diff --git a/web/oss/src/components/AgentChatSlice/assets/trace.ts b/web/oss/src/components/AgentChatSlice/assets/trace.ts new file mode 100644 index 0000000000..9d0f11fa09 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/assets/trace.ts @@ -0,0 +1,34 @@ +import type {UIMessage} from "ai" + +/** + * The custom `data-trace` part the service emits: `{type: "data-trace", data: {...}}`. + * The service sends both a `traceId` (preferred — `openTraceDrawerAtom` wants an id) and a + * `url` (human link). We parse the id out of the url as a fallback for older emitters that + * only send `{url}` (the original RAG_QA example did). + */ +interface TracePartData { + traceId?: string + url?: string +} + +const parseTraceIdFromUrl = (url?: string): string | undefined => { + if (!url) return undefined + const segments = url.split("?")[0].split("/").filter(Boolean) + return segments[segments.length - 1] || undefined +} + +/** + * Extract the trace id for a message. Prefers `message.metadata.traceId` (the RFC-aligned + * channel — the service sets it via `messageMetadata` on the `start`/`finish` parts), and + * falls back to the custom `data-trace` part for emitters that only send that. + */ +export const getMessageTraceId = (message: UIMessage): string | undefined => { + const metaTraceId = (message.metadata as {traceId?: string} | undefined)?.traceId + if (metaTraceId) return metaTraceId + + const tracePart = message.parts.find((p) => p.type === "data-trace") as + | {type: "data-trace"; data?: TracePartData} + | undefined + if (!tracePart?.data) return undefined + return tracePart.data.traceId || parseTraceIdFromUrl(tracePart.data.url) +} diff --git a/web/oss/src/components/AgentChatSlice/assets/transport.ts b/web/oss/src/components/AgentChatSlice/assets/transport.ts new file mode 100644 index 0000000000..a1f4095855 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/assets/transport.ts @@ -0,0 +1,131 @@ +import {projectIdAtom} from "@agenta/shared/state" +import {DefaultChatTransport, type UIMessage} from "ai" +import {getDefaultStore} from "jotai" + +import {getJWT} from "@/oss/services/api" + +import {resolveAppAgConfig} from "./agConfig" +import {type AgentChatTrack, trackApi} from "./constants" +import {toAgentaMessages} from "./toAgentaMessage" + +/** + * Transport for the agent chat slice (contract v1), parameterized by request-contract + * **track**. Both tracks consume the same v6 UI Message Stream response — only the + * outgoing request body shape differs (see ./constants and ./toAgentaMessage). + * + * The request is built the way the playground execution pipeline builds it, so the page + * can hit a real authenticated backend: + * - **Auth:** `Authorization: Bearer ` from `getJWT()` (omitted when unauthenticated, + * so the credential-free example backend still works). + * - **Query params:** `application_id` (the app id) and `project_id` (the current + * project, only sent alongside auth — mirroring `executionItems.ts`). + * - **Body:** the agent-protocol envelope — `session_id` + `references` at the top level, + * and `data: {messages, parameters}` nested (the config resolved from the app's LATEST + * revision via `resolveAppAgConfig`, else a stub). `parameters` is the stored workflow + * config (what the backend reads as `data.parameters`); `references` lines up at the top + * level. This matches Mahmoud's BE contract (2026-06-19). + * + * **Track A (`uimessage`)** — POST the `UIMessage[]` verbatim. The service speaks AI SDK + * parts; the approval decision is inside the assistant message's tool part. Zero FE + * translation (JP's "1:1 to UIMessage parts, no translation layer"). + * + * **Track B (`agenta`)** — adapt to Agenta's `{role, content}` + `tool_calls` shape via + * `toAgentaMessages`, with the approval decision in a `tool_approvals` side field. Uniform + * backend contract across workflow types, at the cost of a FE translation layer. + */ +const stubConfig = () => ({ + parameters: { + prompt: { + messages: [{role: "system", content: "You are a helpful agent."}], + llm_config: {model: "gpt-4o-mini", tools: []}, + }, + harness: "pi", + sandbox: "local", + }, + references: { + application: null, + application_variant: null, + application_revision: null, + }, +}) + +/** + * Real config from the app's latest revision when `appId` is set and loaded; else the stub. + * Returns `{parameters, references}`: `parameters` is the agent config the backend reads as + * `data.parameters`. `harness`/`sandbox` (agent-specific, not part of a stored workflow + * config) are defaulted but never override values the resolved config already carries. + */ +const configFor = (appId?: string | null) => { + const resolved = resolveAppAgConfig(appId) + if (!resolved) return stubConfig() + return { + parameters: {harness: "pi", sandbox: "local", ...resolved.ag_config}, + references: resolved.references, + } +} + +const withQuery = (url: string, params: Record): string => { + const qs = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + if (value) qs.set(key, value) + } + const suffix = qs.toString() + return suffix ? `${url}${url.includes("?") ? "&" : "?"}${suffix}` : url +} + +/** Per-request auth header + URL (with `application_id`/`project_id` query params), built + * the way the playground pipeline builds them so the page can hit a real backend. */ +async function requestMeta(track: AgentChatTrack, appId?: string | null) { + const jwt = await getJWT() + // `/messages` streams the Vercel UI Message Stream only when the client asks for SSE; without + // this header the server returns a batch JSON body that `useChat` cannot render as a stream. + const headers: Record = {Accept: "text/event-stream"} + if (jwt) headers.Authorization = `Bearer ${jwt}` + const projectId = getDefaultStore().get(projectIdAtom) || undefined + const api = withQuery(trackApi(track), { + application_id: appId || undefined, + // Mirror executionItems.ts: project_id only travels alongside auth. + project_id: jwt ? projectId : undefined, + }) + return {api, headers} +} + +export function createAgentChatTransport(track: AgentChatTrack, appId?: string | null) { + return new DefaultChatTransport({ + api: trackApi(track), + prepareSendMessagesRequest: async ({messages, id, body}) => { + const {parameters, references} = configFor(appId) + const {api, headers} = await requestMeta(track, appId) + + if (track === "agenta") { + // Track B: FE adapts down to the existing Agenta message contract. Same + // envelope; the approval decision stays in the top-level `tool_approvals` + // side field (the Agenta message shape has no per-tool approval slot). + const {messages: agentaMessages, tool_approvals} = toAgentaMessages(messages) + return { + api, + headers, + body: { + session_id: id, + references, + tool_approvals, + data: {messages: agentaMessages, parameters}, + ...body, + }, + } + } + + // Track A: post the `UIMessage[]` verbatim — the service reads `data.messages`. + return { + api, + headers, + body: { + session_id: id, + references, + data: {messages, parameters}, + ...body, + }, + } + }, + }) +} diff --git a/web/oss/src/components/AgentChatSlice/components/AgentChatConversation.tsx b/web/oss/src/components/AgentChatSlice/components/AgentChatConversation.tsx new file mode 100644 index 0000000000..4455093758 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/components/AgentChatConversation.tsx @@ -0,0 +1,282 @@ +import {useEffect, useMemo, useRef, useState} from "react" + +import {useChat} from "@ai-sdk/react" +import {Attachments, Bubble, Sender} from "@ant-design/x" +import {Paperclip} from "@phosphor-icons/react" +import {lastAssistantMessageIsCompleteWithApprovalResponses, type UIMessage} from "ai" +import {Alert, Button, Modal, Tag, Tooltip, Typography, type UploadFile} from "antd" +import {useSetAtom, useStore} from "jotai" + +import {useAgConfigStatus} from "../assets/agConfig" +import {type AgentChatTrack, trackApi} from "../assets/constants" +import {filesToParts} from "../assets/files" +import {messageText, sideEffectingToolsInRange} from "../assets/rewind" +import {createAgentChatTransport} from "../assets/transport" +import {persistSessionMessagesAtom, sessionMessagesAtom} from "../state/sessions" + +import AgentMessage from "./AgentMessage" + +const {Text} = Typography + +/** Reactive badge: shows whether the real per-revision `ag_config` has loaded (and keeps + * the latest-revision query warm so the transport can read it at send time). */ +const ConfigBadge = ({appId}: {appId: string}) => { + const {ready, version} = useAgConfigStatus(appId) + return ready ? ( + + config: revision{version != null ? ` v${version}` : ""} + + ) : ( + config: loading… (stub until ready) + ) +} + +/** + * One `useChat` conversation for a single request-contract track, rendered with Ant Design X + * (`Bubble` per message + `Sender` composer). The parent remounts this (via `key={track}`) + * when the track changes, so each track gets a clean session and a fresh transport. The + * streamed response + rendering are identical across tracks; only the outgoing request body + * differs (watch the Network tab to compare). + * + * When `appId` is set (the page is app-scoped), the transport sends the real `ag_config` + + * `references` resolved from that app's latest revision; otherwise it falls back to a stub. + */ +const AgentChatConversation = ({ + sessionId, + track, + appId, +}: { + sessionId: string + track: AgentChatTrack + appId: string | null +}) => { + const store = useStore() + const persistMessages = useSetAtom(persistSessionMessagesAtom) + const [input, setInput] = useState("") + // Pending attachments for the next message. Kept client-side only: `beforeUpload` + // returns false so antd never uploads; we read each `originFileObj` into a data: URL at + // send time (see `filesToParts`). + const [files, setFiles] = useState([]) + const [attachmentsOpen, setAttachmentsOpen] = useState(false) + // Seed once from the persisted store (read imperatively so our own writes below don't + // feed back). The session id is owned by the tab and travels to the backend as + // `session_id`; the `:${track}` in the parent's key remounts on a dev track flip, which + // rehydrates from here with a fresh transport. + const [initialMessages] = useState(() => store.get(sessionMessagesAtom)[sessionId] ?? []) + const senderRef = useRef>(null) + const dropContainerRef = useRef(null) + const transport = useMemo(() => createAgentChatTransport(track, appId), [track, appId]) + + const { + messages, + sendMessage, + status, + stop, + regenerate, + setMessages, + addToolApprovalResponse, + error, + } = useChat({ + id: sessionId, + messages: initialMessages, + transport, + sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses, + onError: (err) => { + console.error("[AgentChatSlice] useChat error:", err) + }, + }) + + const busy = status === "submitted" || status === "streaming" + + // Persist the conversation whenever its stream settles (skip mid-stream so we don't + // write on every token). Covers send (status "submitted"), finish/error ("ready"/ + // "error"), and clear/rewind (setMessages → "ready"). + useEffect(() => { + if (status === "streaming") return + persistMessages({id: sessionId, messages}) + }, [messages, status, sessionId, persistMessages]) + + const handleSubmit = async (text: string) => { + const trimmed = text.trim() + const fileObjs = files + .map((f) => f.originFileObj as File | undefined) + .filter((f): f is File => Boolean(f)) + if ((!trimmed && fileObjs.length === 0) || busy) return + // Read attachments into data: URL `file` parts; `sendMessage` adds them to the + // outgoing user message alongside the text part. + const fileParts = fileObjs.length ? await filesToParts(fileObjs) : undefined + sendMessage( + fileParts + ? trimmed + ? {text: trimmed, files: fileParts} + : {files: fileParts} + : {text: trimmed}, + ) + setInput("") + setFiles([]) + setAttachmentsOpen(false) + } + + /** + * Rewind the conversation to `message` (truncate-in-place). A user turn drops it + + * everything after and prefills the composer with its text to edit/resend; an assistant + * turn re-runs via `regenerate`. Confirms first if the dropped range contains a tool that + * already ran with a side effect (a rewind can't undo it). + */ + const handleRewind = (message: UIMessage) => { + if (busy) return + const idx = messages.findIndex((m) => m.id === message.id) + if (idx < 0) return + const isUser = message.role === "user" + // Everything from here on is dropped/re-run; already-executed side effects in this + // tail (incl. the assistant turn's own tools, which regenerate re-fires) won't undo. + const sideEffects = sideEffectingToolsInRange(messages.slice(idx)) + + const run = () => { + if (isUser) { + setMessages(messages.slice(0, idx)) + setInput(messageText(message)) + // Focus the composer so the user can edit the restored text immediately. + requestAnimationFrame(() => senderRef.current?.focus()) + } else { + regenerate({messageId: message.id}) + } + } + + if (sideEffects.length > 0) { + Modal.confirm({ + title: "Rewind past a tool that already ran?", + content: `${sideEffects.join(", ")} already executed. Rewinding re-runs the conversation from here but will NOT undo it.`, + okText: "Rewind anyway", + okButtonProps: {danger: true}, + cancelText: "Cancel", + onOk: run, + }) + } else { + run() + } + } + + return ( +
+
+
+ + POST {trackApi(track)} + + + session: {sessionId} + +
+
+ {appId && } + {messages.length > 0 && ( + setMessages([])} + > + Clear + + )} +
+
+ + {error && ( + + )} + +
+ {messages.length === 0 && ( +
+ Ask a question to start the agent conversation. +
+ )} + {messages.map((message) => ( + handleRewind(message)} + onApprovalResponse={addToolApprovalResponse} + /> + ))} + {status === "submitted" && messages[messages.length - 1]?.role !== "assistant" && ( + + )} +
+ + { + setFiles((prev) => [ + ...prev, + ...Array.from(pasted).map((file) => ({ + uid: `${file.name}-${file.lastModified}-${file.size}`, + name: file.name, + status: "done" as const, + originFileObj: file as UploadFile["originFileObj"], + })), + ]) + setAttachmentsOpen(true) + }} + prefix={ + +
+ ) +} + +export default AgentChatConversation diff --git a/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx b/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx new file mode 100644 index 0000000000..646919f01b --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx @@ -0,0 +1,249 @@ +import {memo} from "react" + +import {traceDataSummaryAtomFamily} from "@agenta/entities/loadable" +import {ExecutionMetricsDisplay} from "@agenta/ui/components/presentational" +import {Actions, Bubble, FileCard, type ActionsProps} from "@ant-design/x" +import {ArrowUUpLeft, Copy, Robot, TreeStructure, User} from "@phosphor-icons/react" +import type {FileUIPart, ToolUIPart, UIMessage} from "ai" +import {Avatar, Typography} from "antd" +import {useAtomValue, useSetAtom} from "jotai" + +import {openTraceDrawerAtom} from "@/oss/components/SharedDrawers/TraceDrawer/store/traceDrawerStore" + +import {fileKind, filePartName} from "../assets/files" +import Markdown from "../assets/markdown" +import {getMessageTraceId} from "../assets/trace" + +import ToolPart from "./ToolPart" + +const {Text} = Typography + +/** Cost / tokens / latency for a message, read from its trace (same data + component the + * playground and trace drawer use). */ +const TraceMetrics = ({traceId}: {traceId: string}) => { + const summary = useAtomValue(traceDataSummaryAtomFamily(traceId)) + return ( + + ) +} + +interface AgentMessageProps { + message: UIMessage + busy: boolean + onRewind: () => void + onApprovalResponse: (args: {id: string; approved: boolean}) => void +} + +const isToolPart = (type: string) => type.startsWith("tool-") || type === "dynamic-tool" + +const avatarFor = (isUser: boolean) => ( + : } /> +) + +/** + * Read-only renderer for one agent conversation message, rendered inside an Ant Design X + * `Bubble`. Walks `message.parts` in order (text → markdown, reasoning, tool calls + + * approvals, sources) for the bubble body, and puts the per-message action row in the + * footer. While an assistant message has no content yet, the bubble shows the loading state. + */ +const AgentMessage = ({message, busy, onRewind, onApprovalResponse}: AgentMessageProps) => { + const openTraceDrawer = useSetAtom(openTraceDrawerAtom) + const isUser = message.role === "user" + + const traceId = getMessageTraceId(message) + const fullText = message.parts + .filter((p) => p.type === "text") + .map((p) => (p as {text: string}).text) + .join("") + const sources = message.parts.filter((p) => p.type === "source-url") as { + type: "source-url" + url: string + title?: string + }[] + + const hasContent = message.parts.some( + (p) => + (p.type === "text" && (p as {text?: string}).text) || + (p.type === "reasoning" && (p as {text?: string}).text) || + isToolPart(p.type) || + p.type === "file" || + p.type === "source-url", + ) + + // Assistant turn that hasn't produced anything yet → show the bubble's loading state. + if (!isUser && !hasContent) { + return ( + + ) + } + + const body = ( +
+ {message.parts.map((part, i) => { + if (part.type === "text") { + const text = (part as {text: string}).text + if (!text) return null + // Render markdown for both roles so typed markdown displays properly. + return + } + if (part.type === "reasoning") { + const text = (part as {text: string}).text + if (!text) return null + return ( +
+ {text} +
+ ) + } + if (isToolPart(part.type)) { + return ( + + ) + } + // Multi-modality: render attachments (sent by the user or returned by the + // agent) as X `FileCard`s — images preview inline, other kinds show a typed + // file chip with a download link. + if (part.type === "file") { + const file = part as FileUIPart + const kind = fileKind(file.mediaType) + return ( + + {file.mediaType} + + ) : undefined + } + /> + ) + } + return null + })} + + {sources.length > 0 && ( +
+ + Sources + + {sources.map((s, i) => ( + + {s.title || s.url} + + ))} +
+ )} +
+ ) + + // Control toolbar — an X `Actions` row that FLOATS over the bubble's bottom edge. It is + // absolutely positioned (out of flow), so it adds no height: bubbles sit tight with no + // reserved lane, and revealing it only fades opacity — no layout shift either way. + // `pointer-events-none` while hidden keeps the invisible buttons unclickable. `Actions` + // items carry no `disabled`, so the busy guard lives in the handlers: `onRewind` → + // `handleRewind` early-returns while a stream is in flight (copy / view-trace are always + // safe). The item `label` renders as the hover tooltip. + const toolbarReveal = + "opacity-0 transition-opacity duration-150 pointer-events-none " + + "group-hover:opacity-100 group-hover:pointer-events-auto " + + "focus-within:opacity-100 focus-within:pointer-events-auto" + const rewindAction: ActionsProps["items"][number] = { + key: "rewind", + label: isUser + ? "Rewind here — edit and re-run the conversation from this message" + : "Rewind here — re-run this turn", + icon: , + onItemClick: () => onRewind(), + } + + const toolbar = isUser ? ( + + ) : ( + <> + {traceId && } + , + onItemClick: () => navigator.clipboard.writeText(fullText), + }, + rewindAction, + ...(traceId + ? [ + { + key: "trace", + label: "View trace", + icon: , + onItemClick: () => openTraceDrawer({traceId}), + }, + ] + : []), + ]} + /> + + ) + + // `group relative` → the floating toolbar reveals on hover/focus of the whole message row + // and anchors to the bubble without consuming layout space. + return ( +
+ + placement={isUser ? "end" : "start"} + variant={isUser ? "filled" : "outlined"} + avatar={avatarFor(isUser)} + className="min-w-0 max-w-full" + classNames={{ + content: "min-w-0 max-w-full overflow-hidden", + body: "min-w-0 max-w-full overflow-hidden", + }} + content={body} + /> +
+ {toolbar} +
+
+ ) +} + +export default memo(AgentMessage) diff --git a/web/oss/src/components/AgentChatSlice/components/SessionTabLabel.tsx b/web/oss/src/components/AgentChatSlice/components/SessionTabLabel.tsx new file mode 100644 index 0000000000..c6eec35bd8 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/components/SessionTabLabel.tsx @@ -0,0 +1,44 @@ +import {useState} from "react" + +import {Input} from "antd" + +/** + * A session tab's label. Double-click to rename inline (commit on Enter/blur). Clicks while + * editing are stopped so they don't also switch tabs. + */ +const SessionTabLabel = ({label, onRename}: {label: string; onRename: (next: string) => void}) => { + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(label) + + if (editing) { + const commit = () => { + setEditing(false) + if (draft.trim() !== label) onRename(draft) + } + return ( + setDraft(e.target.value)} + onPressEnter={commit} + onBlur={commit} + onClick={(e) => e.stopPropagation()} + className="!w-28 !text-xs" + /> + ) + } + + return ( + { + setDraft(label) + setEditing(true) + }} + > + {label} + + ) +} + +export default SessionTabLabel diff --git a/web/oss/src/components/AgentChatSlice/components/ToolPart.tsx b/web/oss/src/components/AgentChatSlice/components/ToolPart.tsx new file mode 100644 index 0000000000..883a848d9a --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/components/ToolPart.tsx @@ -0,0 +1,183 @@ +import {memo, useEffect, useState} from "react" + +import { + CaretDown, + CaretRight, + CheckCircle, + Prohibit, + Spinner, + Warning, + Wrench, +} from "@phosphor-icons/react" +import type {ToolUIPart} from "ai" +import {Button, Tag, Typography} from "antd" + +const {Text} = Typography + +/** Reveal `text` progressively (typewriter). When `enabled` is false it shows in full. + * If `text` grows (e.g. preliminary tool-output chunks), it keeps revealing from where it + * left off rather than restarting. */ +const useTypewriter = (text: string, enabled: boolean): string => { + const [shown, setShown] = useState(enabled ? 0 : text.length) + useEffect(() => { + if (!enabled) { + setShown(text.length) + return + } + let raf = 0 + const step = Math.max(4, Math.ceil(text.length / 40)) // ~40 frames regardless of size + const tick = () => { + setShown((s) => { + if (s >= text.length) return s + raf = requestAnimationFrame(tick) + return Math.min(text.length, s + step) + }) + } + raf = requestAnimationFrame(tick) + return () => cancelAnimationFrame(raf) + }, [text, enabled]) + return text.slice(0, Math.min(shown, text.length)) +} + +// v6 tool part states → label + antd tag color. `approval-*` states only exist on the +// AI SDK v6 tool part union; the cast keeps this readable without widening the type. +const STATE_META: Record = { + "input-streaming": {label: "Preparing", color: "default"}, + "input-available": {label: "Running", color: "processing"}, + "approval-requested": {label: "Awaiting approval", color: "warning"}, + "approval-responded": {label: "Responded", color: "blue"}, + "output-available": {label: "Completed", color: "success"}, + "output-error": {label: "Error", color: "error"}, + "output-denied": {label: "Denied", color: "default"}, +} + +const JsonBlock = ({value, typewriter = false}: {value: unknown; typewriter?: boolean}) => { + const full = typeof value === "string" ? value : JSON.stringify(value, null, 2) + const text = useTypewriter(full, typewriter) + return ( +
+            {text}
+        
+ ) +} + +interface ToolPartProps { + part: ToolUIPart + /** Resolve a pending approval. `id` is the approvalId. */ + onApprovalResponse: (args: {id: string; approved: boolean}) => void + disabled?: boolean +} + +/** + * Read-only renderer for a single v6 tool UI part: input → output lifecycle plus the + * human-in-the-loop approval round-trip. The FE renders tool calls; it never executes + * them. Approve/Deny call `addToolApprovalResponse`; the auto-resume (configured on + * `useChat`) re-sends the conversation and the service streams the tool output. + */ +const ToolPart = ({part, onApprovalResponse, disabled}: ToolPartProps) => { + const toolName = part.type.replace(/^tool-/, "") + const state = part.state as string + const meta = STATE_META[state] ?? {label: state, color: "default"} + const approval = (part as {approval?: {id: string; approved?: boolean; reason?: string}}) + .approval + + // Collapsible body. A pending approval is force-expanded so the buttons stay reachable. + const [open, setOpen] = useState(true) + const isApprovalPending = state === "approval-requested" + const expanded = isApprovalPending || open + + const StateIcon = + state === "output-available" + ? CheckCircle + : state === "output-error" + ? Warning + : state === "output-denied" + ? Prohibit + : state === "input-available" + ? Spinner + : Wrench + + return ( +
+ + + {expanded && ( +
+ {part.input !== undefined && ( +
+ + Input + + +
+ )} + + {part.state === "output-available" && ( +
+ + Output + + +
+ )} + + {part.state === "output-error" && ( +
+ + Error + + +
+ )} + + {state === "output-denied" && ( + + You denied this action; it was not executed. + + )} + + {state === "approval-requested" && approval?.id && ( +
+ Run this tool? + + +
+ )} +
+ )} +
+ ) +} + +export default memo(ToolPart) diff --git a/web/oss/src/components/AgentChatSlice/index.tsx b/web/oss/src/components/AgentChatSlice/index.tsx new file mode 100644 index 0000000000..6a1455093b --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/index.tsx @@ -0,0 +1,135 @@ +import {useEffect, useRef, useState} from "react" + +import {Segmented, Tabs, Tooltip, Typography} from "antd" +import {useAtomValue, useSetAtom} from "jotai" + +import {routerAppIdAtom} from "@/oss/state/app/atoms/fetcher" + +import {type AgentChatTrack, DEFAULT_TRACK} from "./assets/constants" +import AgentChatConversation from "./components/AgentChatConversation" +import SessionTabLabel from "./components/SessionTabLabel" +import { + activeSessionIdAtom, + addSessionAtom, + closeSessionAtom, + renameSessionAtom, + sessionLabel, + sessionMessagesAtom, + sessionsListAtom, + setActiveSessionAtom, +} from "./state/sessions" + +const {Text, Title} = Typography + +/** + * Agent chat streaming slice — contract v1. + * + * A real `useChat` conversation streaming the v6 UI Message Stream protocol from the RAG_QA + * contract service. Proves the FE↔service streaming contract end to end: text + tool-call + * lifecycle + one human approval + a trace link into the existing trace drawer. + * + * Multiple parallel conversations are exposed as top-level dynamic tabs (one `useChat` + * session each; add with `+`, close with `×`, double-click a tab to rename). The session + * list, active tab, and each conversation's messages persist to localStorage, so the tabs + * survive a reload. antd keeps visited panes mounted, so switching tabs preserves a + * session's live stream / approval state. Does NOT touch the Jotai/web-worker playground + * pipeline — `useChat` owns these conversations. + * + * The Track A/B request-contract toggle (an internal experiment comparing how the request + * body is shaped) is demoted to a dev-only control in the tab bar's extra slot; the response + * stream + rendering are identical across tracks. + */ +const AgentChatSlice = () => { + const [track, setTrack] = useState(DEFAULT_TRACK) + const appId = useAtomValue(routerAppIdAtom) + + const sessions = useAtomValue(sessionsListAtom) + const rawActiveId = useAtomValue(activeSessionIdAtom) + const allMessages = useAtomValue(sessionMessagesAtom) + const addSession = useSetAtom(addSessionAtom) + const closeSession = useSetAtom(closeSessionAtom) + const renameSession = useSetAtom(renameSessionAtom) + const setActiveSession = useSetAtom(setActiveSessionAtom) + + // Always keep at least one tab. Re-arms when the list drains (e.g. switching to an app + // with no sessions yet) without double-firing under StrictMode. + const seeded = useRef(false) + useEffect(() => { + if (sessions.length === 0 && !seeded.current) { + seeded.current = true + addSession() + } + if (sessions.length > 0) seeded.current = false + }, [sessions.length, addSession]) + + // Tolerate a stale active id (its tab was closed) by falling back to the first tab. + const activeId = sessions.some((s) => s.id === rawActiveId) ? rawActiveId : sessions[0]?.id + + return ( +
+
+ + Agent chat (contract v1) + + + Parallel agent conversations — add a tab for each. + +
+ + { + if (action === "add") addSession() + else if (typeof targetKey === "string") closeSession(targetKey) + }} + tabBarExtraContent={{ + right: ( + + + size="small" + value={track} + onChange={setTrack} + options={[ + {label: "A", value: "uimessage"}, + {label: "B", value: "agenta"}, + ]} + /> + + ), + }} + items={sessions.map((session, index) => ({ + key: session.id, + closable: sessions.length > 1, + label: ( + renameSession({id: session.id, title})} + /> + ), + children: ( + + ), + }))} + /> +
+ ) +} + +export default AgentChatSlice diff --git a/web/oss/src/components/AgentChatSlice/state/sessions.ts b/web/oss/src/components/AgentChatSlice/state/sessions.ts new file mode 100644 index 0000000000..80709515d7 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/state/sessions.ts @@ -0,0 +1,135 @@ +import type {UIMessage} from "ai" +import {atom} from "jotai" +import {atomWithStorage} from "jotai/utils" + +import {routerAppIdAtom} from "@/oss/state/app/atoms/fetcher" + +/** + * Multi-session model for the agent chat slice. The playground hosts several parallel agent + * conversations as top-level dynamic tabs (no side rail); this holds the tab list, the + * active tab, and each session's persisted messages. + * + * Scoping: the session LIST + active tab are app-scoped (the playground is app-scoped, like + * `selectedVariantsByAppAtom`), so each app keeps its own set of chats. Messages are keyed by + * the globally-unique session id, so they need no app dimension. + * + * Persistence: everything is `atomWithStorage`, so tabs and their conversations survive a + * reload. NOTE: attachments are stored inline as `data:` URLs (see `assets/files.ts`); a + * conversation with large files can approach the localStorage quota — acceptable for v1. + */ + +export interface AgentChatSession { + id: string + /** User-set title. When empty, the UI falls back to the first user message / "Chat N". */ + title?: string +} + +const GLOBAL_APP_KEY = "__global__" + +const appKeyAtom = atom((get) => get(routerAppIdAtom) || GLOBAL_APP_KEY) + +// One source of truth per concern, keyed by app id. Scoped accessors below derive the +// current app's slice (mirrors the playground's `selectedVariantsByAppAtom` pattern). +const sessionsByAppAtom = atomWithStorage>( + "agenta:agent-chat:sessions", + {}, +) +const activeByAppAtom = atomWithStorage>( + "agenta:agent-chat:active-session", + {}, +) + +/** Persisted messages per session id. Written when a conversation's stream settles. */ +export const sessionMessagesAtom = atomWithStorage>( + "agenta:agent-chat:messages", + {}, +) + +/** Sessions for the current app, in tab order. */ +export const sessionsListAtom = atom((get) => get(sessionsByAppAtom)[get(appKeyAtom)] ?? []) + +/** Active session id for the current app (may be stale if that tab was closed — the UI + * falls back to the first tab when this id isn't in the list). */ +export const activeSessionIdAtom = atom((get) => get(activeByAppAtom)[get(appKeyAtom)] ?? "") + +/** Create a session and make it active. Returns the new id. */ +export const addSessionAtom = atom(null, (get, set) => { + const key = get(appKeyAtom) + const all = get(sessionsByAppAtom) + const list = all[key] ?? [] + const id = crypto.randomUUID() + set(sessionsByAppAtom, {...all, [key]: [...list, {id}]}) + set(activeByAppAtom, {...get(activeByAppAtom), [key]: id}) + return id +}) + +/** Close a session: drop the tab, its persisted messages, and re-point the active tab to a + * neighbour if it was the one closed. */ +export const closeSessionAtom = atom(null, (get, set, id: string) => { + const key = get(appKeyAtom) + const all = get(sessionsByAppAtom) + const list = all[key] ?? [] + const nextList = list.filter((s) => s.id !== id) + set(sessionsByAppAtom, {...all, [key]: nextList}) + + const active = get(activeByAppAtom) + if (active[key] === id) { + const closedIdx = list.findIndex((s) => s.id === id) + const neighbour = nextList[Math.min(closedIdx, nextList.length - 1)]?.id ?? "" + set(activeByAppAtom, {...active, [key]: neighbour}) + } + + const messages = {...get(sessionMessagesAtom)} + if (id in messages) { + delete messages[id] + set(sessionMessagesAtom, messages) + } +}) + +export const renameSessionAtom = atom( + null, + (get, set, {id, title}: {id: string; title: string}) => { + const key = get(appKeyAtom) + const all = get(sessionsByAppAtom) + const list = (all[key] ?? []).map((s) => + s.id === id ? {...s, title: title.trim() || undefined} : s, + ) + set(sessionsByAppAtom, {...all, [key]: list}) + }, +) + +export const setActiveSessionAtom = atom(null, (get, set, id: string) => { + const key = get(appKeyAtom) + set(activeByAppAtom, {...get(activeByAppAtom), [key]: id}) +}) + +/** Write a session's messages to the persisted store (called when its stream settles). */ +export const persistSessionMessagesAtom = atom( + null, + (get, set, {id, messages}: {id: string; messages: UIMessage[]}) => { + set(sessionMessagesAtom, {...get(sessionMessagesAtom), [id]: messages}) + }, +) + +/** First user message text, used as the tab label when the session is untitled. */ +export const firstUserText = (messages: UIMessage[] | undefined): string => { + const first = messages?.find((m) => m.role === "user") + if (!first) return "" + return first.parts + .filter((p) => p.type === "text") + .map((p) => (p as {text: string}).text) + .join(" ") + .trim() +} + +/** Tab label: explicit title → first user message (truncated) → positional "Chat N". */ +export const sessionLabel = ( + session: AgentChatSession, + messages: UIMessage[] | undefined, + index: number, +): string => { + if (session.title) return session.title + const text = firstUserText(messages) + if (text) return text.length > 24 ? `${text.slice(0, 24)}…` : text + return `Chat ${index + 1}` +} diff --git a/web/oss/src/lib/helpers/dynamicEnv.ts b/web/oss/src/lib/helpers/dynamicEnv.ts index 567a98ad13..afac57ed58 100644 --- a/web/oss/src/lib/helpers/dynamicEnv.ts +++ b/web/oss/src/lib/helpers/dynamicEnv.ts @@ -4,6 +4,14 @@ export const processEnv = { NEXT_PUBLIC_AGENTA_API_URL: process.env.NEXT_PUBLIC_AGENTA_API_URL, NEXT_PUBLIC_POSTHOG_API_KEY: process.env.NEXT_PUBLIC_POSTHOG_API_KEY, NEXT_PUBLIC_CRISP_WEBSITE_ID: process.env.NEXT_PUBLIC_CRISP_WEBSITE_ID, + // Feature flag for the agent chat streaming slice (contract v1) page. + NEXT_PUBLIC_AGENT_CHAT_SLICE: process.env.NEXT_PUBLIC_AGENT_CHAT_SLICE, + // Streaming endpoint the agent chat slice points `useChat` at. Defaults to the + // local RAG_QA contract mock when unset (see AgentChatSlice/assets/constants.ts). + NEXT_PUBLIC_AGENT_CHAT_API: process.env.NEXT_PUBLIC_AGENT_CHAT_API, + // Default request-contract track for the agent chat slice: "uimessage" (Track A) or + // "agenta" (Track B). The page also has a runtime toggle. + NEXT_PUBLIC_AGENT_CHAT_TRACK: process.env.NEXT_PUBLIC_AGENT_CHAT_TRACK, NEXT_PUBLIC_AGENTA_AUTHN_EMAIL: process.env.NEXT_PUBLIC_AGENTA_AUTHN_EMAIL, NEXT_PUBLIC_AGENTA_AUTH_GOOGLE_OAUTH_CLIENT_ID: process.env.NEXT_PUBLIC_AGENTA_AUTH_GOOGLE_OAUTH_CLIENT_ID, diff --git a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/agent-chat/index.tsx b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/agent-chat/index.tsx new file mode 100644 index 0000000000..e11a907fc9 --- /dev/null +++ b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/agent-chat/index.tsx @@ -0,0 +1,34 @@ +import {Typography} from "antd" +import dynamic from "next/dynamic" + +import {isAgentChatSliceEnabled} from "@/oss/components/AgentChatSlice/assets/constants" +import {useBreadcrumbsEffect} from "@/oss/lib/hooks/useBreadcrumbs" + +// Client-only: `useChat` and the streaming transport are browser concerns. +const AgentChatSlice = dynamic(() => import("@/oss/components/AgentChatSlice"), {ssr: false}) + +/** + * Feature-flagged route for the agent chat streaming slice (contract v1). + * Enable with `NEXT_PUBLIC_AGENT_CHAT_SLICE=true`. + */ +const AgentChatPage = () => { + useBreadcrumbsEffect({breadcrumbs: {"agent-chat": {label: "Agent chat"}}}, []) + + if (!isAgentChatSliceEnabled()) { + return ( +
+ + Agent chat slice is disabled. Set NEXT_PUBLIC_AGENT_CHAT_SLICE=true to enable. + +
+ ) + } + + return ( +
+ +
+ ) +} + +export default AgentChatPage diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 46942f5d9b..131cfde572 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -352,6 +352,9 @@ importers: '@agentaai/nextstepjs': specifier: ^2.1.3-agenta.1 version: 2.1.3-agenta.2(motion@12.38.0(@emotion/is-prop-valid@0.7.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(next@15.5.18(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@ai-sdk/react': + specifier: 3.0.0-beta.153 + version: 3.0.0-beta.153(react@19.2.6)(zod@4.4.3) '@ant-design/colors': specifier: ^7.2.1 version: 7.2.1 @@ -364,6 +367,9 @@ importers: '@ant-design/x': specifier: ^2.5.0 version: 2.7.0(antd@6.3.7(date-fns@3.6.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@ant-design/x-markdown': + specifier: ^2.8.0 + version: 2.8.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@cloudflare/stream-react': specifier: ^1.9.3 version: 1.9.3(react@19.2.6) @@ -484,6 +490,9 @@ importers: '@types/react-window': specifier: ^1.8.8 version: 1.8.8 + ai: + specifier: 6.0.0-beta.150 + version: 6.0.0-beta.150(zod@4.4.3) ajv: specifier: ^8.18.0 version: 8.20.0 @@ -1421,6 +1430,38 @@ packages: react-router: optional: true + '@ai-sdk/gateway@2.0.0-beta.78': + resolution: {integrity: sha512-uxrHKxNKWHmT++OJozwZ4FYa6jXZuJDcT1NRN9b7iQYy9MbRV6YuqDoQKIj8x3DltV0Ueg81UHZGKOSC+wwkXg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@4.0.0-beta.47': + resolution: {integrity: sha512-nTR25V0+M/TGRegVXdhaCPqy6iaJfiyvhwtZ3ZDKhHmSVQXuwUb8zSXCPhEtpNgWXzWP3XNZvlzzgIHqByi6aw==} + engines: {node: '>=18'} + peerDependencies: + '@valibot/to-json-schema': ^1.3.0 + arktype: ^2.1.22 + effect: ^3.18.4 + zod: ^3.25.76 || ^4.1.8 + peerDependenciesMeta: + '@valibot/to-json-schema': + optional: true + arktype: + optional: true + effect: + optional: true + + '@ai-sdk/provider@3.0.0-beta.26': + resolution: {integrity: sha512-UQyOlrpahFL1CZ/QA0ZpFhAkE32fw1XXBx+6gu23YWSCMJCjaf/fiJUPV7xUhp/nXqVO/IC+PIIfLomx55D16A==} + engines: {node: '>=18'} + + '@ai-sdk/react@3.0.0-beta.153': + resolution: {integrity: sha512-wdldNFesBAWum/8CJ9nebSDV+jnCig2uuL3H7pugWzuDclgqXdBSirUldBk0pHT8mtzQFmNp4Db9opcAO9svYQ==} + engines: {node: '>=18'} + peerDependencies: + react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1 + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -1467,6 +1508,12 @@ packages: react: ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@ant-design/x-markdown@2.8.0': + resolution: {integrity: sha512-QRS0s81ykVt8RLqf9nGHJmP/y8hNQgQZdu7+7x+EMS5VuPBeQw74IpIUNchVLQy/P60VoNpkw+YTalyJtyLPPw==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + '@ant-design/x@2.7.0': resolution: {integrity: sha512-p5OtxQ9elbmeFRllGt1yj5wi6VHe41PIAmwrBU/OlaYydru5qIYsJzCS3DPRhkWkVdErU5oZwU74Z2oce2F5Uw==} peerDependencies: @@ -2298,6 +2345,10 @@ packages: resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==} engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.1': resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} @@ -3482,6 +3533,10 @@ packages: '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@vercel/oidc@3.0.5': + resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==} + engines: {node: '>= 20'} + '@vitest/coverage-v8@4.1.6': resolution: {integrity: sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==} peerDependencies: @@ -3604,6 +3659,12 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ai@6.0.0-beta.150: + resolution: {integrity: sha512-gBCzE2/m5w1ybx0bnWKf66ppCH+921jGFeGUTQv3+GWaaB1WLeaOa8H4sEmdx29YAZ5n+iqZKh+L5swhNSCIRg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + ajv-draft-04@1.0.0: resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} peerDependencies: @@ -4248,9 +4309,22 @@ packages: dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + dompurify@3.4.2: resolution: {integrity: sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==} + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -4283,6 +4357,14 @@ packages: resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} engines: {node: '>=10.13.0'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + es-abstract@1.24.2: resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} engines: {node: '>= 0.4'} @@ -4519,6 +4601,10 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -4806,12 +4892,27 @@ packages: hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + html-dom-parser@5.1.8: + resolution: {integrity: sha512-MCIUng//mF2qTtGHXJWr6OLfHWmg3Pm8ezpfiltF83tizPWY17JxT4dRLE8lykJ5bChJELoY3onQKPbufJHxYA==} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-react-parser@5.2.17: + resolution: {integrity: sha512-m+K/7Moq1jodAB4VL0RXSOmtwLUYoAsikZhwd+hGQe5Vtw2dbWfpFd60poxojMU0Tsh9w59mN1QLEcoHz0Dx9w==} + peerDependencies: + '@types/react': 0.14 || 15 || 16 || 17 || 18 || 19 + react: 0.14 || 15 || 16 || 17 || 18 || 19 + peerDependenciesMeta: + '@types/react': + optional: true + html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} @@ -4863,6 +4964,9 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -5165,6 +5269,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -5426,6 +5533,11 @@ packages: make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + marked@15.0.12: + resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} + engines: {node: '>= 18'} + hasBin: true + marked@16.4.2: resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} engines: {node: '>= 20'} @@ -6006,6 +6118,9 @@ packages: peerDependencies: react: '>=16.8.6' + react-property@2.0.2: + resolution: {integrity: sha512-+PbtI3VuDV0l6CleQMsx2gtK0JZbZKbpdu5ynr+lbsuvtmgbNcS3VM0tuY2QjFNOcWxvXeHjDpy42RO+4U2rug==} + react-qr-code@2.0.21: resolution: {integrity: sha512-xaywjo0eaF4S3LOz6ns5eoPbM2E+q9HYl4VATYpxK4bBniOhQ9noY2RJ9G4SnZFhUwzx63FUT6KdHzfKgUwyuQ==} peerDependencies: @@ -6369,6 +6484,12 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -6514,6 +6635,10 @@ packages: resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} engines: {node: '>=12.22'} + throttleit@2.1.0: + resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} + engines: {node: '>=18'} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -6953,6 +7078,41 @@ snapshots: optionalDependencies: next: 15.5.18(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@ai-sdk/gateway@2.0.0-beta.78(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 3.0.0-beta.26 + '@ai-sdk/provider-utils': 4.0.0-beta.47(zod@4.4.3) + '@vercel/oidc': 3.0.5 + zod: 4.4.3 + transitivePeerDependencies: + - '@valibot/to-json-schema' + - arktype + - effect + + '@ai-sdk/provider-utils@4.0.0-beta.47(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 3.0.0-beta.26 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.1.0 + zod: 4.4.3 + + '@ai-sdk/provider@3.0.0-beta.26': + dependencies: + json-schema: 0.4.0 + + '@ai-sdk/react@3.0.0-beta.153(react@19.2.6)(zod@4.4.3)': + dependencies: + '@ai-sdk/provider-utils': 4.0.0-beta.47(zod@4.4.3) + ai: 6.0.0-beta.150(zod@4.4.3) + react: 19.2.6 + swr: 2.4.1(react@19.2.6) + throttleit: 2.1.0 + transitivePeerDependencies: + - '@valibot/to-json-schema' + - arktype + - effect + - zod + '@alloc/quick-lru@5.2.0': {} '@ant-design/colors@7.2.1': @@ -7009,6 +7169,18 @@ snapshots: react-dom: 19.2.6(react@19.2.6) throttle-debounce: 5.0.2 + '@ant-design/x-markdown@2.8.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + clsx: 2.1.1 + dompurify: 3.4.2 + html-react-parser: 5.2.17(@types/react@19.2.14)(react@19.2.6) + katex: 0.16.45 + marked: 15.0.12 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + transitivePeerDependencies: + - '@types/react' + '@ant-design/x@2.7.0(antd@6.3.7(date-fns@3.6.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@ant-design/colors': 8.0.1 @@ -7864,6 +8036,8 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.1 + '@opentelemetry/api@1.9.0': {} + '@opentelemetry/api@1.9.1': {} '@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.1)': @@ -9039,6 +9213,8 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) + '@vercel/oidc@3.0.5': {} + '@vitest/coverage-v8@4.1.6(vitest@4.1.6)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -9197,6 +9373,18 @@ snapshots: acorn@8.17.0: {} + ai@6.0.0-beta.150(zod@4.4.3): + dependencies: + '@ai-sdk/gateway': 2.0.0-beta.78(zod@4.4.3) + '@ai-sdk/provider': 3.0.0-beta.26 + '@ai-sdk/provider-utils': 4.0.0-beta.47(zod@4.4.3) + '@opentelemetry/api': 1.9.0 + zod: 4.4.3 + transitivePeerDependencies: + - '@valibot/to-json-schema' + - arktype + - effect + ajv-draft-04@1.0.0(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -9908,10 +10096,28 @@ snapshots: '@babel/runtime': 7.29.2 csstype: 3.2.3 + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + dompurify@3.4.2: optionalDependencies: '@types/trusted-types': 2.0.7 + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dotenv@16.6.1: {} dunder-proto@1.0.1: @@ -9942,6 +10148,10 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@4.5.0: {} + + entities@7.0.1: {} + es-abstract@1.24.2: dependencies: array-buffer-byte-length: 1.0.2 @@ -10376,6 +10586,8 @@ snapshots: events@3.3.0: {} + eventsource-parser@3.1.0: {} + expect-type@1.3.0: {} fast-deep-equal@3.1.3: {} @@ -10680,10 +10892,32 @@ snapshots: dependencies: react-is: 16.13.1 + html-dom-parser@5.1.8: + dependencies: + domhandler: 5.0.3 + htmlparser2: 10.1.0 + html-escaper@2.0.2: {} + html-react-parser@5.2.17(@types/react@19.2.14)(react@19.2.6): + dependencies: + domhandler: 5.0.3 + html-dom-parser: 5.1.8 + react: 19.2.6 + react-property: 2.0.2 + style-to-js: 1.1.21 + optionalDependencies: + '@types/react': 19.2.14 + html-void-elements@3.0.0: {} + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + husky@9.1.7: {} hyphenate-style-name@1.1.0: {} @@ -10720,6 +10954,8 @@ snapshots: ini@1.3.8: {} + inline-style-parser@0.2.7: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -10997,6 +11233,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json2mq@0.2.0: @@ -11269,6 +11507,8 @@ snapshots: make-error@1.3.6: {} + marked@15.0.12: {} + marked@16.4.2: {} marked@17.0.6: {} @@ -11864,6 +12104,8 @@ snapshots: theming: 3.3.0(react@19.2.6) tiny-warning: 1.0.3 + react-property@2.0.2: {} + react-qr-code@2.0.21(react@19.2.6): dependencies: prop-types: 15.8.1 @@ -12360,6 +12602,14 @@ snapshots: strip-json-comments@3.1.1: {} + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.6): dependencies: client-only: 0.0.1 @@ -12518,6 +12768,8 @@ snapshots: throttle-debounce@5.0.2: {} + throttleit@2.1.0: {} + tiny-invariant@1.3.3: {} tiny-warning@1.0.3: {}