borderless / ui /agent /messages.py
spagestic's picture
switched to qwen
71a7158
Raw
History Blame Contribute Delete
11.7 kB
# ui/agent/messages.py
from __future__ import annotations
import base64
import json
import mimetypes
import re
import uuid
from pathlib import Path
from typing import Any
from .config import TOOLS
TOOL_NAMES = {tool["function"]["name"] for tool in TOOLS}
TEXT_FILE_SUFFIXES = (".txt", ".md", ".csv", ".json", ".xml", ".html", ".htm")
def _file_path(item: Any) -> str | None:
if isinstance(item, str):
return item
if isinstance(item, dict):
if "path" in item:
return item["path"]
if item.get("type") == "file":
file_data = item.get("file") or {}
return file_data.get("path") or file_data.get("url")
return None
def _text_content(item: Any) -> str | None:
if isinstance(item, str):
return item
if isinstance(item, dict) and item.get("type") == "text":
return item.get("text") or ""
return None
def _file_to_api_part(path: str) -> dict[str, Any]:
mime_type, _ = mimetypes.guess_type(path)
mime_type = mime_type or "application/octet-stream"
name = Path(path).name
if mime_type.startswith("image/"):
data = Path(path).read_bytes()
encoded = base64.b64encode(data).decode("utf-8")
return {
"type": "image_url",
"image_url": {"url": f"data:{mime_type};base64,{encoded}"},
}
if mime_type.startswith("text/") or path.lower().endswith(TEXT_FILE_SUFFIXES):
try:
text = Path(path).read_text(encoding="utf-8", errors="replace")
return {"type": "text", "text": f"[File: {name}]\n{text}"}
except OSError:
pass
return {"type": "text", "text": f"[Attached file: {name}]"}
def _parts_to_api_content(parts: list[dict[str, Any]]) -> str | list[dict[str, Any]]:
if not parts:
return ""
if len(parts) == 1 and parts[0]["type"] == "text":
return parts[0]["text"]
return parts
def multimodal_input_to_api_content(
message: str | dict[str, Any],
) -> str | list[dict[str, Any]]:
"""Convert a ChatInterface user message to Inference API content."""
if isinstance(message, str):
return message
parts: list[dict[str, Any]] = []
for file_ref in message.get("files") or []:
path = _file_path(file_ref) if isinstance(file_ref, dict) else file_ref
if path:
parts.append(_file_to_api_part(path))
text = (message.get("text") or "").strip()
if text:
parts.append({"type": "text", "text": text})
return _parts_to_api_content(parts)
def chat_content_to_api(content: Any) -> str | list[dict[str, Any]] | None:
"""Convert Gradio chat history content to Inference API content."""
if isinstance(content, str):
return content or None
if not isinstance(content, list):
return None
parts: list[dict[str, Any]] = []
for item in content:
text = _text_content(item)
if text:
parts.append({"type": "text", "text": text})
continue
path = _file_path(item)
if path:
parts.append(_file_to_api_part(path))
if not parts:
return None
return _parts_to_api_content(parts)
def history_to_api_messages(history: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Convert Gradio chat history to API messages, skipping UI-only tool steps."""
messages: list[dict[str, Any]] = []
for msg in history:
if msg.get("metadata"):
continue
role = msg.get("role")
if role not in ("user", "assistant"):
continue
api_content = chat_content_to_api(msg.get("content"))
if api_content:
messages.append({"role": role, "content": api_content})
return messages
def assistant_message_dict(
content: str,
tool_calls: list[dict[str, Any]] | None,
) -> dict[str, Any]:
message: dict[str, Any] = {"role": "assistant", "content": content}
if tool_calls:
message["tool_calls"] = tool_calls
return message
def langchain_messages_to_api(messages: list[Any]) -> list[dict[str, Any]]:
"""Convert LangGraph/LangChain message objects to Inference API messages."""
from langchain_core.messages import AIMessage, BaseMessage, ToolMessage
api_messages: list[dict[str, Any]] = []
for message in messages:
if isinstance(message, dict):
role = message.get("role")
if role not in ("system", "user", "assistant", "tool"):
continue
entry: dict[str, Any] = {
"role": role,
"content": message.get("content", ""),
}
if role == "tool" and message.get("tool_call_id"):
entry["tool_call_id"] = message["tool_call_id"]
if role == "assistant" and message.get("tool_calls"):
entry["tool_calls"] = message["tool_calls"]
api_messages.append(entry)
continue
if isinstance(message, ToolMessage):
api_messages.append(
{
"role": "tool",
"content": message.content,
"tool_call_id": message.tool_call_id,
}
)
continue
if isinstance(message, AIMessage):
entry = {"role": "assistant", "content": message.content or ""}
if message.tool_calls:
entry["tool_calls"] = [
{
"id": tool_call.get("id")
if isinstance(tool_call, dict)
else tool_call["id"],
"type": "function",
"function": {
"name": tool_call.get("name")
if isinstance(tool_call, dict)
else tool_call["name"],
"arguments": json.dumps(
tool_call.get("args", {})
if isinstance(tool_call, dict)
else tool_call["args"]
),
},
}
for tool_call in message.tool_calls
]
api_messages.append(entry)
continue
if isinstance(message, BaseMessage):
role = message.type
if role == "human":
role = "user"
elif role == "ai":
role = "assistant"
api_messages.append({"role": role, "content": message.content})
return api_messages
def api_turn_to_ai_message(
content: str,
reasoning: str,
tool_calls: list[dict[str, Any]] | None,
) -> "AIMessage":
"""Build a LangChain AIMessage from one Inference API turn."""
from langchain_core.messages import AIMessage
additional_kwargs: dict[str, Any] = {}
if reasoning:
additional_kwargs["reasoning_content"] = reasoning
lc_tool_calls: list[dict[str, Any]] = []
if tool_calls:
for tool_call in tool_calls:
function = tool_call.get("function") or {}
raw_args = function.get("arguments", "{}")
if isinstance(raw_args, str):
try:
parsed_args = json.loads(raw_args)
except json.JSONDecodeError:
parsed_args = {"raw": raw_args}
else:
parsed_args = raw_args
lc_tool_calls.append(
{
"name": str(function.get("name") or ""),
"args": parsed_args,
"id": str(tool_call.get("id") or f"call_{uuid.uuid4().hex}"),
"type": "tool_call",
}
)
return AIMessage(
content=content or "",
additional_kwargs=additional_kwargs,
tool_calls=lc_tool_calls,
)
def parse_tool_calls(message: Any) -> list[dict[str, Any]] | None:
if not message.tool_calls:
return None
return [
{
"id": tool_call.id,
"type": tool_call.type,
"function": {
"name": tool_call.function.name,
"arguments": tool_call.function.arguments,
},
}
for tool_call in message.tool_calls
]
def parse_text_tool_calls(text: str) -> list[dict[str, Any]] | None:
"""Recover tool calls when a model emits them as text instead of tool_calls."""
if not text:
return None
for tool_name in TOOL_NAMES:
arguments = _parse_json_call(text, tool_name) or _parse_python_call(
text, tool_name
)
if arguments is None and tool_name == "update_globe":
arguments = _parse_update_globe_plan(text)
if arguments is None:
continue
return [
{
"id": f"call_{uuid.uuid4().hex}",
"type": "function",
"function": {
"name": tool_name,
"arguments": json.dumps(arguments),
},
}
]
return None
def _parse_json_call(text: str, tool_name: str) -> dict[str, Any] | None:
match = re.search(rf"(?:default_api:)?{re.escape(tool_name)}\s*\{{", text)
if not match:
return None
start = text.find("{", match.start())
if start == -1:
return None
decoder = json.JSONDecoder()
try:
arguments, _ = decoder.raw_decode(text[start:])
except json.JSONDecodeError:
return _parse_key_values(text[start:])
return arguments if isinstance(arguments, dict) else None
def _parse_python_call(text: str, tool_name: str) -> dict[str, Any] | None:
match = re.search(rf"{re.escape(tool_name)}\s*\((?P<args>[^)]*)\)", text)
if not match:
return None
return _parse_key_values(match.group("args"))
def _parse_key_values(text: str) -> dict[str, str] | None:
pairs = re.findall(r'(\w+)\s*=\s*["\']([^"\']+)["\']', text)
if not pairs:
pairs = re.findall(r'["\'](\w+)["\']\s*:\s*["\']([^"\']+)["\']', text)
if not pairs:
return None
return {key: value for key, value in pairs}
def _parse_update_globe_plan(text: str) -> dict[str, Any] | None:
"""Recover the common prose format: "I will call update_globe... Parameters: ..."."""
if not re.search(r"\bupdate_globe\b", text):
return None
action_match = re.search(r'\baction\s*[:=]\s*["\']?(\w+)["\']?', text)
if not action_match:
return None
countries_match = re.search(r"\bcountries\s*[:=]\s*\[(?P<countries>[^\]]+)\]", text)
countries = (
re.findall(r'["\']([A-Z]{2})["\']', countries_match.group("countries"))
if countries_match
else []
)
labels_match = re.search(r"\blabels\s*[:=]\s*\[(?P<labels>[^\]]+)\]", text)
labels = (
re.findall(r'["\']([^"\']+)["\']', labels_match.group("labels"))
if labels_match
else []
)
arguments: dict[str, Any] = {"action": action_match.group(1)}
if countries:
arguments["countries"] = countries
if labels:
arguments["labels"] = labels
zoom_match = re.search(r"\bzoom\s*[:=]\s*(\d+(?:\.\d+)?)", text)
if zoom_match:
arguments["zoom"] = float(zoom_match.group(1))
return arguments