backend: separated agent classes. frontend: chat message context menu (does nothing)
This commit is contained in:
1 parent
3f37f727af
commit
22fe5619d8
5 files changed
+203
-80
No files matched your search
+92
-24
@@ -17,11 +17,45 @@ class AgentField:
|
|||||||
# Instances are created and destroyed constantly, agents must not depend solely on in-memory data
|
# Instances are created and destroyed constantly, agents must not depend solely on in-memory data
|
||||||
# to be useful and consistent
|
# to be useful and consistent
|
||||||
class Agent:
|
class Agent:
|
||||||
# Class variables:
|
# Class variables, must be defined by subclasses of Agent:
|
||||||
# - identifier (for kind in creation)
|
# - identifier (for kind in creation)
|
||||||
# - schema: list[AgentField{name, type: str}] (for configuration)
|
# - schema: list[AgentField{name, type: str}] (for configuration)
|
||||||
# - tools: list[ToolDescription] (actions the agent class support)
|
# tools: list[ToolDescription] (custom actions supported by the class)
|
||||||
identifier = "basic"
|
|
||||||
|
def __init__(self, app, data):
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
# These variables are set by '_set_basic_info'
|
||||||
|
self.id = None
|
||||||
|
self.name = None
|
||||||
|
|
||||||
|
# Auto populated based on the schema fields
|
||||||
|
agent_class = type(self)
|
||||||
|
self.config = {field.name: data.get(field.name, field.default) for field in getattr(agent_class, 'schema_fields', [])}
|
||||||
|
|
||||||
|
def _set_basic_info(self, id, name):
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
# Receives a single string prompt, and must return a string response
|
||||||
|
async def prompt(self, content):
|
||||||
|
raise Exception("'prompt' is not implemented for this agent class")
|
||||||
|
|
||||||
|
# Receives a chat message object, agent can send messages to the chat in response
|
||||||
|
async def received_message(self, message):
|
||||||
|
raise Exception("'received_message' is not implemented for this agent class")
|
||||||
|
|
||||||
|
# Executes a tool/action in this agent
|
||||||
|
async def execute_action(self, name: str, argument: dict):
|
||||||
|
# TODO: For security, check that the method is in the tool list
|
||||||
|
method = getattr(self, name)
|
||||||
|
result = method(**argument)
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
result = await result
|
||||||
|
return result
|
||||||
|
|
||||||
|
class ToolUsingAgent:
|
||||||
|
identifier = "tool-using"
|
||||||
schema_fields = [
|
schema_fields = [
|
||||||
AgentField('system_prompt', 'string',
|
AgentField('system_prompt', 'string',
|
||||||
"\n".join([
|
"\n".join([
|
||||||
@@ -32,13 +66,12 @@ class Agent:
|
|||||||
])
|
])
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# tools: list[ToolDescription] (actions the agent class support)
|
||||||
tools = []
|
tools = []
|
||||||
|
|
||||||
def __init__(self, app, agent_id, data):
|
def __init__(self, app, data):
|
||||||
self.app = app
|
Agent.__init__(self, app, data)
|
||||||
self.agent_id = agent_id
|
|
||||||
|
|
||||||
self.config = {field.name: data.get(field.name, field.default) for field in Agent.schema_fields}
|
|
||||||
|
|
||||||
self.tool_prompt_section = "\n".join([
|
self.tool_prompt_section = "\n".join([
|
||||||
"# Commands",
|
"# Commands",
|
||||||
@@ -106,7 +139,6 @@ class Agent:
|
|||||||
|
|
||||||
return agent_response or full_response
|
return agent_response or full_response
|
||||||
|
|
||||||
|
|
||||||
# Receives a single string prompt, and must respond with a string
|
# Receives a single string prompt, and must respond with a string
|
||||||
async def prompt(self, content):
|
async def prompt(self, content):
|
||||||
response = await self._full_prompt(f"User Message", content)
|
response = await self._full_prompt(f"User Message", content)
|
||||||
@@ -127,7 +159,7 @@ class Agent:
|
|||||||
|
|
||||||
self.app.send_chat_message(
|
self.app.send_chat_message(
|
||||||
chat_id=message.chat_id,
|
chat_id=message.chat_id,
|
||||||
sender=self.agent_id,
|
sender=self.id,
|
||||||
content=response,
|
content=response,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -155,16 +187,57 @@ class Agent:
|
|||||||
output += f"[{action.toolset_name}.{action.tool_name}]:{action_result}\n"
|
output += f"[{action.toolset_name}.{action.tool_name}]:{action_result}\n"
|
||||||
return output
|
return output
|
||||||
|
|
||||||
def create_from_data(app, agent_id, kind, data):
|
class Llama8bAgent(Agent):
|
||||||
return Agent(app, agent_id, data)
|
identifier = "llama-8b"
|
||||||
|
schema_fields = [
|
||||||
|
AgentField('system_prompt', 'string', default="You are a helpful AI assistant."),
|
||||||
|
]
|
||||||
|
tools = []
|
||||||
|
|
||||||
async def execute_action(self, name: str, argument: dict):
|
def __init__(self, app, data):
|
||||||
# TODO: For security, check that the method is in the tool list
|
Agent.__init__(self, app, data)
|
||||||
method = getattr(self, name)
|
|
||||||
result = method(**argument)
|
async def prompt(self, content):
|
||||||
if inspect.isawaitable(result):
|
messages = [
|
||||||
result = await result
|
LlamaMessage("system", self.config['system_prompt']),
|
||||||
return result
|
LlamaMessage("user", content)
|
||||||
|
]
|
||||||
|
response = await replicate_llama_completion(self.app, messages)
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def received_message(self, message):
|
||||||
|
def sender_name(message):
|
||||||
|
sender = message['sender']
|
||||||
|
is_digit = isinstance(sender, (int, str)) and str(sender).isdigit()
|
||||||
|
is_self = is_digit and str(sender) == str(self.id)
|
||||||
|
return self.name if is_self else sender
|
||||||
|
|
||||||
|
chat_context = self.app.get_chat_context(message.chat_id)
|
||||||
|
chat_summary = "\n".join(f"[{sender_name(message)}]: {message['content']}" for message in chat_context.messages)
|
||||||
|
messages = [
|
||||||
|
LlamaMessage("system", f"{self.config['system_prompt']}\nYou are given the context of the conversation, respond with your next message in the conversation."),
|
||||||
|
LlamaMessage("user", f"Recent Messages:\n{chat_summary}")
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await replicate_llama_completion(self.app, messages)
|
||||||
|
self.app.send_chat_message(
|
||||||
|
chat_id=message.chat_id,
|
||||||
|
sender=self.id,
|
||||||
|
content=response,
|
||||||
|
)
|
||||||
|
|
||||||
|
class BasicAgent(Llama8bAgent):
|
||||||
|
identifier = "basic"
|
||||||
|
schema_fields = Llama8bAgent.schema_fields
|
||||||
|
tools = Llama8bAgent.tools
|
||||||
|
|
||||||
|
class CoderV1Agent(Llama8bAgent):
|
||||||
|
identifier = "coder-v1"
|
||||||
|
schema_fields = [
|
||||||
|
AgentField('system_prompt', 'string', default="You are an expert Software Engineer profficient in multiple programming languages."),
|
||||||
|
]
|
||||||
|
# TODO: add gral functions to the tool list
|
||||||
|
tools = []
|
||||||
|
|
||||||
async def gral_message(self, message_history: list, file_map: dict):
|
async def gral_message(self, message_history: list, file_map: dict):
|
||||||
return await gral.gral_message(self.app, self.config['system_prompt'], message_history, file_map)
|
return await gral.gral_message(self.app, self.config['system_prompt'], message_history, file_map)
|
||||||
@@ -173,11 +246,6 @@ class Agent:
|
|||||||
return await gral.gral_update_file(self.app, self.config['system_prompt'], message_history, filepath, file_content)
|
return await gral.gral_update_file(self.app, self.config['system_prompt'], message_history, filepath, file_content)
|
||||||
|
|
||||||
|
|
||||||
class IntroAgent(Agent):
|
|
||||||
def __init__(self, app, agent_id, _data):
|
|
||||||
Agent.__init__(self, app, agent_id)
|
|
||||||
self.system_prompt = "You are a useful and precise AI assistant. Respond concisely."
|
|
||||||
|
|
||||||
class ParsedResponse:
|
class ParsedResponse:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.response = None
|
self.response = None
|
||||||
|
|||||||
+13
-3
@@ -10,8 +10,8 @@ from quart import Quart, Response, request, send_from_directory, jsonify, websoc
|
|||||||
import replicate
|
import replicate
|
||||||
|
|
||||||
import database as db
|
import database as db
|
||||||
|
import agent as agent_mod
|
||||||
from server import start_server
|
from server import start_server
|
||||||
from agent import Agent
|
|
||||||
|
|
||||||
MESSAGE_PAGE_SIZE = 32
|
MESSAGE_PAGE_SIZE = 32
|
||||||
|
|
||||||
@@ -34,7 +34,12 @@ class App:
|
|||||||
raise Exception("Horde only works with a replicate connection. Please provide an API Token.")
|
raise Exception("Horde only works with a replicate connection. Please provide an API Token.")
|
||||||
|
|
||||||
# `Agent` is the kind 'basic', will be class `BasicAgent` in the future
|
# `Agent` is the kind 'basic', will be class `BasicAgent` in the future
|
||||||
self.agent_classes = [Agent]
|
self.agent_classes = [
|
||||||
|
agent_mod.BasicAgent,
|
||||||
|
agent_mod.Llama8bAgent,
|
||||||
|
agent_mod.CoderV1Agent,
|
||||||
|
agent_mod.ToolUsingAgent,
|
||||||
|
]
|
||||||
|
|
||||||
def sql_session(self):
|
def sql_session(self):
|
||||||
return Session(self.sql_engine)
|
return Session(self.sql_engine)
|
||||||
@@ -256,7 +261,12 @@ class App:
|
|||||||
if not agent:
|
if not agent:
|
||||||
raise Exception(f"Agent not found with id {agent_id}")
|
raise Exception(f"Agent not found with id {agent_id}")
|
||||||
|
|
||||||
control = Agent.create_from_data(app, agent_id, agent.kind, agent.data)
|
agent_class = next((kind for kind in self.agent_classes if kind.identifier == agent.kind), None)
|
||||||
|
if agent_class is None:
|
||||||
|
raise Exception(f"invalid agent kind: {agent.kind}")
|
||||||
|
|
||||||
|
control = agent_class(app, agent.data)
|
||||||
|
control._set_basic_info(agent_id, agent.name)
|
||||||
return control
|
return control
|
||||||
|
|
||||||
def get_agent_config_fields(self, agent_id):
|
def get_agent_config_fields(self, agent_id):
|
||||||
|
|||||||
+3
-1
@@ -29,7 +29,7 @@ class Agent(Base):
|
|||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||||
|
|
||||||
handle: Mapped[Optional[str]]
|
agentname: Mapped[Optional[str]]
|
||||||
visibility: Mapped[int] = mapped_column(default=0)
|
visibility: Mapped[int] = mapped_column(default=0)
|
||||||
# Comma separated list of permission symbols
|
# Comma separated list of permission symbols
|
||||||
permissions: Mapped[str] = mapped_column(default="")
|
permissions: Mapped[str] = mapped_column(default="")
|
||||||
@@ -86,4 +86,6 @@ class Message(Base):
|
|||||||
# Predefined set of message types, interpreted as 'text' when empty.
|
# Predefined set of message types, interpreted as 'text' when empty.
|
||||||
kind: Mapped[str]
|
kind: Mapped[str]
|
||||||
content: Mapped[str]
|
content: Mapped[str]
|
||||||
|
# Tells whether this message continues in the chat after the session when it was sent.
|
||||||
|
persistent: Mapped[bool] = mapped_column(default=False)
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ class Backend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<bool> auth(String username, String password) {
|
static Future<bool> auth(String username, String password) async {
|
||||||
if (_credentials != null) {
|
if (_credentials != null) {
|
||||||
print("Cannot authenticate twice");
|
print("Cannot authenticate twice");
|
||||||
return Future.value(false);
|
return Future.value(false);
|
||||||
@@ -60,7 +60,11 @@ class Backend {
|
|||||||
_credentialsCompleter = null;
|
_credentialsCompleter = null;
|
||||||
}
|
}
|
||||||
_loginCompleter = Completer();
|
_loginCompleter = Completer();
|
||||||
return _loginCompleter!.future;
|
final success = await _loginCompleter!.future;
|
||||||
|
if (!success) {
|
||||||
|
_credentials = null;
|
||||||
|
}
|
||||||
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void connect() {
|
static void connect() {
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import 'backend.dart';
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
class Message {
|
class Message {
|
||||||
|
final int? id;
|
||||||
final String sender;
|
final String sender;
|
||||||
final String text;
|
final String text;
|
||||||
final bool isFromUser;
|
final bool isFromUser;
|
||||||
|
|
||||||
Message({required this.sender, required this.text}) : isFromUser = sender == "user";
|
Message({required this.sender, required this.text, this.id}) : isFromUser = sender == "user";
|
||||||
|
|
||||||
Alignment get alignment => isFromUser ? Alignment.centerRight : Alignment.centerLeft;
|
Alignment get alignment => isFromUser ? Alignment.centerRight : Alignment.centerLeft;
|
||||||
EdgeInsets get padding => isFromUser ? EdgeInsets.zero : const EdgeInsets.only(left: 10);
|
EdgeInsets get padding => isFromUser ? EdgeInsets.zero : const EdgeInsets.only(left: 10);
|
||||||
@@ -23,9 +24,8 @@ class ChatScreen extends StatefulWidget {
|
|||||||
|
|
||||||
static Future<List<Message>> getChatMessages(int chatId) async {
|
static Future<List<Message>> getChatMessages(int chatId) async {
|
||||||
final response = await Backend.sendAction("get-chat-messages", {'chatId': chatId});
|
final response = await Backend.sendAction("get-chat-messages", {'chatId': chatId});
|
||||||
print("get-chat-messages response: $response");
|
|
||||||
List<dynamic> messageObjects = response['messages'];
|
List<dynamic> messageObjects = response['messages'];
|
||||||
List<Message> messages = messageObjects.map((msg) => Message(sender: msg['sender'], text: msg['content'] as String)).toList();
|
List<Message> messages = messageObjects.map((msg) => Message(sender: msg['sender'], text: msg['content'])).toList();
|
||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,9 +36,10 @@ class ChatScreen extends StatefulWidget {
|
|||||||
class _ChatScreenState extends State<ChatScreen> {
|
class _ChatScreenState extends State<ChatScreen> {
|
||||||
String? _title;
|
String? _title;
|
||||||
List<Message> _messages = [];
|
List<Message> _messages = [];
|
||||||
|
Offset? _longPressOffset;
|
||||||
|
StreamSubscription? _chatSubscription;
|
||||||
final TextEditingController _textController = TextEditingController();
|
final TextEditingController _textController = TextEditingController();
|
||||||
final FocusNode _inputFocusNode = FocusNode();
|
final FocusNode _inputFocusNode = FocusNode();
|
||||||
StreamSubscription? _chatSubscription;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -50,11 +51,16 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
addMessage(message);
|
addMessage(message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
BrowserContextMenu.disableContextMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_chatSubscription?.cancel(); // Cancel the subscription
|
||||||
Backend.removeChatListener(widget.chatId);
|
Backend.removeChatListener(widget.chatId);
|
||||||
|
BrowserContextMenu.enableContextMenu();
|
||||||
|
_textController.dispose();
|
||||||
|
_inputFocusNode.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,15 +73,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleNewMessage(Map<String, dynamic> message) {
|
|
||||||
final newMessage = Message(sender: message['sender'], text: message['content'] as String);
|
|
||||||
if (newMessage.sender!= "user") {
|
|
||||||
setState(() {
|
|
||||||
_messages.add(newMessage);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void addMessage(Message message) {
|
void addMessage(Message message) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_messages.add(message);
|
_messages.add(message);
|
||||||
@@ -83,6 +80,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void sendMessage(String content) {
|
void sendMessage(String content) {
|
||||||
|
if (content.trim().isEmpty) return; // Don't send empty messages
|
||||||
_textController.clear();
|
_textController.clear();
|
||||||
final newMessage = Message(sender: "user", text: content);
|
final newMessage = Message(sender: "user", text: content);
|
||||||
Backend.sendAction("send-chat-message", {'chatId': widget.chatId, 'content': content}).then((response) {
|
Backend.sendAction("send-chat-message", {'chatId': widget.chatId, 'content': content}).then((response) {
|
||||||
@@ -99,6 +97,36 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
return messageColor;
|
return messageColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showMessageContextMenu(BuildContext context, Offset position) {
|
||||||
|
showMenu(
|
||||||
|
context: context,
|
||||||
|
position: RelativeRect.fromLTRB(position.dx, position.dy, position.dx + 1, position.dy + 1),
|
||||||
|
items: <PopupMenuEntry>[
|
||||||
|
PopupMenuItem(
|
||||||
|
child: Text('Pin'),
|
||||||
|
onTap: () {
|
||||||
|
// TODO: Implement Pin functionality
|
||||||
|
print('Pin clicked');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
child: Text('Edit'),
|
||||||
|
onTap: () {
|
||||||
|
// TODO: Implement Edit functionality
|
||||||
|
print('Edit clicked');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
child: Text('Delete'),
|
||||||
|
onTap: () {
|
||||||
|
// TODO: Implement Delete functionality
|
||||||
|
print('Delete clicked');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isWideScreen = MediaQuery.of(context).size.width > 600;
|
final isWideScreen = MediaQuery.of(context).size.width > 600;
|
||||||
@@ -119,18 +147,30 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
itemCount: _messages.length,
|
itemCount: _messages.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final message = _messages[index];
|
final message = _messages[index];
|
||||||
return Padding(
|
return GestureDetector(
|
||||||
padding: isWideScreen ? message.padding : EdgeInsets.zero,
|
onLongPressStart: (details) {
|
||||||
child: Align(
|
_longPressOffset = details.globalPosition;
|
||||||
alignment: isWideScreen ? Alignment.centerLeft : message.alignment,
|
},
|
||||||
child: Container(
|
onLongPress: () {
|
||||||
margin: EdgeInsets.symmetric(vertical: 2, horizontal: 8),
|
_showMessageContextMenu(context, _longPressOffset!);
|
||||||
padding: EdgeInsets.all(10),
|
_longPressOffset = null;
|
||||||
decoration: BoxDecoration(
|
},
|
||||||
color: getMessageColor(context),
|
onSecondaryTapUp: (details) {
|
||||||
borderRadius: BorderRadius.circular(12),
|
_showMessageContextMenu(context, details.globalPosition);
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: isWideScreen ? message.padding : EdgeInsets.zero,
|
||||||
|
child: Align(
|
||||||
|
alignment: isWideScreen ? Alignment.centerLeft : message.alignment,
|
||||||
|
child: Container(
|
||||||
|
margin: EdgeInsets.symmetric(vertical: 2, horizontal: 8),
|
||||||
|
padding: EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: getMessageColor(context),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(message.text),
|
||||||
),
|
),
|
||||||
child: Text(message.text),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -139,36 +179,35 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: KeyboardListener(
|
child: Row(
|
||||||
focusNode: _inputFocusNode,
|
children: [
|
||||||
onKeyEvent: (event) {
|
Expanded(
|
||||||
// TODO: Probably doing this in the input focus node would be cleaner
|
child: KeyboardListener(
|
||||||
if (HardwareKeyboard.instance.isControlPressed && HardwareKeyboard.instance.isLogicalKeyPressed(LogicalKeyboardKey.enter)) {
|
focusNode: _inputFocusNode,
|
||||||
sendMessage(_textController.text);
|
onKeyEvent: (event) {
|
||||||
_textController.clear();
|
// TODO: Probably doing this in the input focus node would be cleaner
|
||||||
//return KeyEventResult.handled;
|
if (HardwareKeyboard.instance.isControlPressed && HardwareKeyboard.instance.isLogicalKeyPressed(LogicalKeyboardKey.enter)) {
|
||||||
}
|
sendMessage(_textController.text);
|
||||||
//return KeyEventResult.ignored;
|
//return KeyEventResult.handled;
|
||||||
},
|
}
|
||||||
child: Column(
|
//return KeyEventResult.ignored;
|
||||||
children: [
|
},
|
||||||
Expanded(
|
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _textController,
|
controller: _textController,
|
||||||
maxLines: null,
|
maxLines: null, // Allows multiline input
|
||||||
decoration: inputDecoration(),
|
keyboardType: TextInputType.multiline, // For better multiline support on mobile
|
||||||
|
decoration: inputDecoration().copyWith(hintText: 'Type a message...'), // Added a hint
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(width: 8),
|
),
|
||||||
ElevatedButton(
|
SizedBox(width: 8),
|
||||||
onPressed: () {
|
ElevatedButton(
|
||||||
sendMessage(_textController.text);
|
onPressed: () {
|
||||||
_textController.clear();
|
sendMessage(_textController.text);
|
||||||
},
|
},
|
||||||
child: Text('Send'),
|
child: Text('Send'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in new issue
Block a user