diff --git a/backend/agent.py b/backend/agent.py index 9cfdfb8..936e6e2 100644 --- a/backend/agent.py +++ b/backend/agent.py @@ -17,11 +17,45 @@ class AgentField: # Instances are created and destroyed constantly, agents must not depend solely on in-memory data # to be useful and consistent class Agent: - # Class variables: + # Class variables, must be defined by subclasses of Agent: # - identifier (for kind in creation) # - schema: list[AgentField{name, type: str}] (for configuration) - # - tools: list[ToolDescription] (actions the agent class support) - identifier = "basic" + # tools: list[ToolDescription] (custom actions supported by the class) + + 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 = [ AgentField('system_prompt', 'string', "\n".join([ @@ -32,13 +66,12 @@ class Agent: ]) ), ] + + # tools: list[ToolDescription] (actions the agent class support) tools = [] - def __init__(self, app, agent_id, data): - self.app = app - self.agent_id = agent_id - - self.config = {field.name: data.get(field.name, field.default) for field in Agent.schema_fields} + def __init__(self, app, data): + Agent.__init__(self, app, data) self.tool_prompt_section = "\n".join([ "# Commands", @@ -106,7 +139,6 @@ class Agent: return agent_response or full_response - # Receives a single string prompt, and must respond with a string async def prompt(self, content): response = await self._full_prompt(f"User Message", content) @@ -127,7 +159,7 @@ class Agent: self.app.send_chat_message( chat_id=message.chat_id, - sender=self.agent_id, + sender=self.id, content=response, ) @@ -154,17 +186,58 @@ class Agent: action_result = json.dumps(action_result) output += f"[{action.toolset_name}.{action.tool_name}]:{action_result}\n" return output - - def create_from_data(app, agent_id, kind, data): - return Agent(app, agent_id, data) - - 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 Llama8bAgent(Agent): + identifier = "llama-8b" + schema_fields = [ + AgentField('system_prompt', 'string', default="You are a helpful AI assistant."), + ] + tools = [] + + def __init__(self, app, data): + Agent.__init__(self, app, data) + + 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): 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) -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: def __init__(self): self.response = None diff --git a/backend/app.py b/backend/app.py index 65beeac..938b981 100644 --- a/backend/app.py +++ b/backend/app.py @@ -10,8 +10,8 @@ from quart import Quart, Response, request, send_from_directory, jsonify, websoc import replicate import database as db +import agent as agent_mod from server import start_server -from agent import Agent MESSAGE_PAGE_SIZE = 32 @@ -34,7 +34,12 @@ class App: 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 - self.agent_classes = [Agent] + self.agent_classes = [ + agent_mod.BasicAgent, + agent_mod.Llama8bAgent, + agent_mod.CoderV1Agent, + agent_mod.ToolUsingAgent, + ] def sql_session(self): return Session(self.sql_engine) @@ -255,8 +260,13 @@ class App: if not agent: 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 def get_agent_config_fields(self, agent_id): diff --git a/backend/database.py b/backend/database.py index ed7fdc9..f1e8049 100644 --- a/backend/database.py +++ b/backend/database.py @@ -29,7 +29,7 @@ class Agent(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) - handle: Mapped[Optional[str]] + agentname: Mapped[Optional[str]] visibility: Mapped[int] = mapped_column(default=0) # Comma separated list of permission symbols permissions: Mapped[str] = mapped_column(default="") @@ -86,4 +86,6 @@ class Message(Base): # Predefined set of message types, interpreted as 'text' when empty. kind: 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) diff --git a/frontend/lib/backend.dart b/frontend/lib/backend.dart index 0562d67..5a7f4e0 100644 --- a/frontend/lib/backend.dart +++ b/frontend/lib/backend.dart @@ -49,7 +49,7 @@ class Backend { } } - static Future auth(String username, String password) { + static Future auth(String username, String password) async { if (_credentials != null) { print("Cannot authenticate twice"); return Future.value(false); @@ -60,7 +60,11 @@ class Backend { _credentialsCompleter = null; } _loginCompleter = Completer(); - return _loginCompleter!.future; + final success = await _loginCompleter!.future; + if (!success) { + _credentials = null; + } + return success; } static void connect() { diff --git a/frontend/lib/chat_screen.dart b/frontend/lib/chat_screen.dart index 7dd96ec..f27d832 100644 --- a/frontend/lib/chat_screen.dart +++ b/frontend/lib/chat_screen.dart @@ -6,11 +6,12 @@ import 'backend.dart'; import 'dart:async'; class Message { + final int? id; final String sender; final String text; 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; EdgeInsets get padding => isFromUser ? EdgeInsets.zero : const EdgeInsets.only(left: 10); @@ -23,9 +24,8 @@ class ChatScreen extends StatefulWidget { static Future> getChatMessages(int chatId) async { final response = await Backend.sendAction("get-chat-messages", {'chatId': chatId}); - print("get-chat-messages response: $response"); List messageObjects = response['messages']; - List messages = messageObjects.map((msg) => Message(sender: msg['sender'], text: msg['content'] as String)).toList(); + List messages = messageObjects.map((msg) => Message(sender: msg['sender'], text: msg['content'])).toList(); return messages; } @@ -36,9 +36,10 @@ class ChatScreen extends StatefulWidget { class _ChatScreenState extends State { String? _title; List _messages = []; + Offset? _longPressOffset; + StreamSubscription? _chatSubscription; final TextEditingController _textController = TextEditingController(); final FocusNode _inputFocusNode = FocusNode(); - StreamSubscription? _chatSubscription; @override void initState() { @@ -50,11 +51,16 @@ class _ChatScreenState extends State { addMessage(message); } }); + BrowserContextMenu.disableContextMenu(); } @override void dispose() { + _chatSubscription?.cancel(); // Cancel the subscription Backend.removeChatListener(widget.chatId); + BrowserContextMenu.enableContextMenu(); + _textController.dispose(); + _inputFocusNode.dispose(); super.dispose(); } @@ -67,15 +73,6 @@ class _ChatScreenState extends State { }); } - void _handleNewMessage(Map message) { - final newMessage = Message(sender: message['sender'], text: message['content'] as String); - if (newMessage.sender!= "user") { - setState(() { - _messages.add(newMessage); - }); - } - } - void addMessage(Message message) { setState(() { _messages.add(message); @@ -83,6 +80,7 @@ class _ChatScreenState extends State { } void sendMessage(String content) { + if (content.trim().isEmpty) return; // Don't send empty messages _textController.clear(); final newMessage = Message(sender: "user", text: content); Backend.sendAction("send-chat-message", {'chatId': widget.chatId, 'content': content}).then((response) { @@ -99,6 +97,36 @@ class _ChatScreenState extends State { 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: [ + 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 Widget build(BuildContext context) { final isWideScreen = MediaQuery.of(context).size.width > 600; @@ -119,18 +147,30 @@ class _ChatScreenState extends State { itemCount: _messages.length, itemBuilder: (context, index) { final message = _messages[index]; - return 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), + return GestureDetector( + onLongPressStart: (details) { + _longPressOffset = details.globalPosition; + }, + onLongPress: () { + _showMessageContextMenu(context, _longPressOffset!); + _longPressOffset = null; + }, + onSecondaryTapUp: (details) { + _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 { ), Padding( padding: const EdgeInsets.all(8.0), - child: KeyboardListener( - focusNode: _inputFocusNode, - onKeyEvent: (event) { - // TODO: Probably doing this in the input focus node would be cleaner - if (HardwareKeyboard.instance.isControlPressed && HardwareKeyboard.instance.isLogicalKeyPressed(LogicalKeyboardKey.enter)) { - sendMessage(_textController.text); - _textController.clear(); - //return KeyEventResult.handled; - } - //return KeyEventResult.ignored; - }, - child: Column( - children: [ - Expanded( + child: Row( + children: [ + Expanded( + child: KeyboardListener( + focusNode: _inputFocusNode, + onKeyEvent: (event) { + // TODO: Probably doing this in the input focus node would be cleaner + if (HardwareKeyboard.instance.isControlPressed && HardwareKeyboard.instance.isLogicalKeyPressed(LogicalKeyboardKey.enter)) { + sendMessage(_textController.text); + //return KeyEventResult.handled; + } + //return KeyEventResult.ignored; + }, child: TextField( controller: _textController, - maxLines: null, - decoration: inputDecoration(), + maxLines: null, // Allows multiline input + keyboardType: TextInputType.multiline, // For better multiline support on mobile + decoration: inputDecoration().copyWith(hintText: 'Type a message...'), // Added a hint ), ), - SizedBox(width: 8), - ElevatedButton( - onPressed: () { - sendMessage(_textController.text); - _textController.clear(); - }, - child: Text('Send'), - ), - ], - ), + ), + SizedBox(width: 8), + ElevatedButton( + onPressed: () { + sendMessage(_textController.text); + }, + child: Text('Send'), + ), + ], ), ), ],