backend: separated agent classes. frontend: chat message context menu (does nothing)

This commit is contained in:
Shivering Ocelot committed 2025-06-01 23:59:38 -05:00
1 parent 3f37f727af
commit 22fe5619d8
5 files changed
+205 -82

No files matched your search

+94 -26
View File
@@ -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,
) )
@@ -154,17 +186,58 @@ class Agent:
action_result = json.dumps(action_result) action_result = json.dumps(action_result)
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 = [
async def execute_action(self, name: str, argument: dict): AgentField('system_prompt', 'string', default="You are a helpful AI assistant."),
# TODO: For security, check that the method is in the tool list ]
method = getattr(self, name) tools = []
result = method(**argument)
if inspect.isawaitable(result): def __init__(self, app, data):
result = await result Agent.__init__(self, app, data)
return result
async def prompt(self, content):
messages = [
LlamaMessage("system", self.config['system_prompt']),
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
View File
@@ -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)
@@ -255,8 +260,13 @@ 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}")
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.create_from_data(app, agent_id, agent.kind, agent.data) 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
View File
@@ -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)
+6 -2
View File
@@ -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() {
+89 -50
View File
@@ -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'),
), ),
], ],
),
), ),
), ),
], ],