You can not select more than 25 topics Topics must start with a chinese character,a letter or number, can include dashes ('-') and can be up to 35 characters long.

utils.py 7.5 kB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. import asyncio
  2. import os
  3. import random
  4. import sys
  5. from asyncio import Future
  6. from agnext.components import DefaultTopicId, Image, RoutedAgent, message_handler
  7. from agnext.core import AgentRuntime, CancellationToken
  8. from textual.app import App, ComposeResult
  9. from textual.containers import ScrollableContainer
  10. from textual.widgets import Button, Footer, Header, Input, Markdown, Static
  11. from textual_imageview.viewer import ImageViewer
  12. sys.path.append(os.path.join(os.path.dirname(__file__), ".."))
  13. from common.types import (
  14. MultiModalMessage,
  15. PublishNow,
  16. RespondNow,
  17. TextMessage,
  18. ToolApprovalRequest,
  19. ToolApprovalResponse,
  20. )
  21. class ChatAppMessage(Static):
  22. def __init__(self, message: TextMessage | MultiModalMessage) -> None: # type: ignore
  23. self._message = message
  24. super().__init__()
  25. def on_mount(self) -> None:
  26. self.styles.margin = 1
  27. self.styles.padding = 1
  28. self.styles.border = ("solid", "blue")
  29. def compose(self) -> ComposeResult:
  30. if isinstance(self._message, TextMessage):
  31. yield Markdown(f"{self._message.source}:")
  32. yield Markdown(self._message.content)
  33. else:
  34. yield Markdown(f"{self._message.source}:")
  35. for content in self._message.content:
  36. if isinstance(content, str):
  37. yield Markdown(content)
  38. elif isinstance(content, Image):
  39. viewer = ImageViewer(content.image)
  40. viewer.styles.min_width = 50
  41. viewer.styles.min_height = 50
  42. yield viewer
  43. class WelcomeMessage(Static):
  44. def on_mount(self) -> None:
  45. self.styles.margin = 1
  46. self.styles.padding = 1
  47. self.styles.border = ("solid", "blue")
  48. class ChatInput(Input):
  49. def on_mount(self) -> None:
  50. self.focus()
  51. def on_input_submitted(self, event: Input.Submitted) -> None:
  52. self.clear()
  53. class ToolApprovalRequestNotice(Static):
  54. def __init__(self, request: ToolApprovalRequest, response_future: Future[ToolApprovalResponse]) -> None: # type: ignore
  55. self._tool_call = request.tool_call
  56. self._future = response_future
  57. super().__init__()
  58. def compose(self) -> ComposeResult:
  59. yield Static(f"Tool call: {self._tool_call.name}, arguments: {self._tool_call.arguments[:50]}")
  60. yield Button("Approve", id="approve", variant="warning")
  61. yield Button("Deny", id="deny", variant="default")
  62. def on_mount(self) -> None:
  63. self.styles.margin = 1
  64. self.styles.padding = 1
  65. self.styles.border = ("solid", "red")
  66. def on_button_pressed(self, event: Button.Pressed) -> None:
  67. button_id = event.button.id
  68. assert button_id is not None
  69. if button_id == "approve":
  70. self._future.set_result(ToolApprovalResponse(tool_call_id=self._tool_call.id, approved=True, reason=""))
  71. else:
  72. self._future.set_result(ToolApprovalResponse(tool_call_id=self._tool_call.id, approved=False, reason=""))
  73. self.remove()
  74. class TextualChatApp(App): # type: ignore
  75. """A Textual app for a chat interface."""
  76. def __init__(self, runtime: AgentRuntime, welcoming_notice: str | None = None, user_name: str = "User") -> None: # type: ignore
  77. self._runtime = runtime
  78. self._welcoming_notice = welcoming_notice
  79. self._user_name = user_name
  80. super().__init__()
  81. def compose(self) -> ComposeResult:
  82. yield Header()
  83. yield Footer()
  84. yield ScrollableContainer(id="chat-messages")
  85. yield ChatInput()
  86. def on_mount(self) -> None:
  87. if self._welcoming_notice is not None:
  88. chat_messages = self.query_one("#chat-messages")
  89. notice = WelcomeMessage(self._welcoming_notice, id="welcome")
  90. chat_messages.mount(notice)
  91. @property
  92. def welcoming_notice(self) -> str | None:
  93. return self._welcoming_notice
  94. @welcoming_notice.setter
  95. def welcoming_notice(self, value: str) -> None:
  96. self._welcoming_notice = value
  97. async def on_input_submitted(self, event: Input.Submitted) -> None:
  98. user_input = event.value
  99. await self.publish_user_message(user_input)
  100. async def post_request_user_input_notice(self) -> None:
  101. chat_messages = self.query_one("#chat-messages")
  102. notice = Static("Please enter your input.", id="typing")
  103. chat_messages.mount(notice)
  104. notice.scroll_visible()
  105. async def publish_user_message(self, user_input: str) -> None:
  106. chat_messages = self.query_one("#chat-messages")
  107. # Remove all typing messages.
  108. chat_messages.query("#typing").remove()
  109. # Publish the user message to the runtime.
  110. await self._runtime.publish_message(
  111. TextMessage(source=self._user_name, content=user_input), topic_id=DefaultTopicId()
  112. )
  113. async def post_runtime_message(self, message: TextMessage | MultiModalMessage) -> None: # type: ignore
  114. """Post a message from the agent runtime to the message list."""
  115. chat_messages = self.query_one("#chat-messages")
  116. msg = ChatAppMessage(message)
  117. chat_messages.mount(msg)
  118. msg.scroll_visible()
  119. async def handle_tool_approval_request(self, message: ToolApprovalRequest) -> ToolApprovalResponse: # type: ignore
  120. chat_messages = self.query_one("#chat-messages")
  121. future: Future[ToolApprovalResponse] = asyncio.get_event_loop().create_future() # type: ignore
  122. tool_call_approval_notice = ToolApprovalRequestNotice(message, future)
  123. chat_messages.mount(tool_call_approval_notice)
  124. tool_call_approval_notice.scroll_visible()
  125. return await future
  126. class TextualUserAgent(RoutedAgent): # type: ignore
  127. """An agent that is used to receive messages from the runtime."""
  128. def __init__(self, description: str, app: TextualChatApp) -> None: # type: ignore
  129. super().__init__(description)
  130. self._app = app
  131. @message_handler # type: ignore
  132. async def on_text_message(self, message: TextMessage, cancellation_token: CancellationToken) -> None: # type: ignore
  133. await self._app.post_runtime_message(message)
  134. @message_handler # type: ignore
  135. async def on_multi_modal_message(self, message: MultiModalMessage, cancellation_token: CancellationToken) -> None: # type: ignore
  136. # Save the message to file.
  137. # Generate a ramdom file name.
  138. for content in message.content:
  139. if isinstance(content, Image):
  140. filename = f"{self.metadata['type']}_{message.source}_{random.randbytes(16).hex()}.png"
  141. content.image.save(filename)
  142. await self._app.post_runtime_message(message)
  143. @message_handler # type: ignore
  144. async def on_respond_now(self, message: RespondNow, cancellation_token: CancellationToken) -> None: # type: ignore
  145. await self._app.post_request_user_input_notice()
  146. @message_handler # type: ignore
  147. async def on_publish_now(self, message: PublishNow, cancellation_token: CancellationToken) -> None: # type: ignore
  148. await self._app.post_request_user_input_notice()
  149. @message_handler # type: ignore
  150. async def on_tool_approval_request(
  151. self, message: ToolApprovalRequest, cancellation_token: CancellationToken
  152. ) -> ToolApprovalResponse:
  153. return await self._app.handle_tool_approval_request(message)