diff --git a/refact-agent/gui/src/__fixtures__/chat.ts b/refact-agent/gui/src/__fixtures__/chat.ts index 854f1dba6..e6c7b351f 100644 --- a/refact-agent/gui/src/__fixtures__/chat.ts +++ b/refact-agent/gui/src/__fixtures__/chat.ts @@ -1496,3 +1496,179 @@ export const CHAT_WITH_KNOWLEDGE_TOOL: ChatThread = { createdAt: "2025-01-27T13:19:11.132Z", updatedAt: "2025-01-27T13:19:11.132Z", }; + +export const CHAT_MESSAGE_NODE: ChatThread = { + id: "948c8c68-6928-4687-8b98-8dc346070007", + messages: [ + { + role: "system", + content: + "[mode3] You are Refact Agent, an autonomous bot for coding tasks.\n\nCore Principles\n1. Use knowledge()\n - Always use knowledge() first when you encounter an agentic (complex) task.\n - This tool can access external data, including successful “trajectories” (examples of past solutions).\n - External database records begin with the icon “🗃️” followed by a record identifier.\n - Use these records to help solve your tasks by analogy.\n2. Use locate() with the Full Problem Statement\n - Provide the entire user request in the problem_statement argument to avoid losing any details (“telephone game” effect).\n - Include user’s emotional stance, code snippets, formatting, instructions—everything word-for-word.\n - Only omit parts of the user’s request if they are unrelated to the final solution.\n - Avoid using locate() if the problem is quite simple and can be solved without extensive project analysis.\n\nAnswering Strategy\n1. If the user’s question is unrelated to the project\n - Answer directly without using any special calls.\n2. If the user’s question is related to the project\n - First, call knowledge() for relevant information and best practices.\n3. Making Changes\n - If a solution requires file changes, use `*_textdoc()` tools.\n - It's a good practice to call cat() to track changes for changed files.\n\nImportant Notes\n1. Parallel Exploration\n - When you explore different ideas, use multiple parallel methods.\n2. Project-Related Questions\n - For any project question, always call knowledge() before taking any action.\n\nWhen running on user's laptop, you most likely have the shell() tool. It's for one-time dependency installations, or doing whatever\nuser is asking you to do. Tools the user can set up are better, because they don't require confimations when running on a laptop.\nWhen doing something typical for the project, offer the user to make a cmdline_* tool after you have run it.\nYou can do this by writing:\n\n🧩SETTINGS:cmdline_cargo_check\n\nfrom a new line, that will open (when clicked) a wizard that creates `cargo check` (in this example) command line tool.\n\nIn a similar way, service_* tools work. The difference is cmdline_* is designed for non-interactive blocking commands that immediately\nreturn text in stdout/stderr, and service_* is designed for blocking background commands, such as hypercorn server that runs forever until you hit Ctrl+C.\nHere is another example:\n\n🧩SETTINGS:service_hypercorn\n\n\nYou might receive additional instructions that start with 💿. Those are not coming from the user, they are programmed to help you operate\nwell and they are always in English. Answer in the language the user has asked the question.\n\n\n- below general information about the current project -\n\nThe current IDE workspace has these project directories:\n/Users/marc/Projects/refact/refact-agent/engine/tests/emergency_frog_situation\n\nThe active IDE file is:\n/Users/marc/Projects/refact/refact-agent/engine/tests/emergency_frog_situation/frog.py\nThe project is under git version control, located at:\n/Users/marc/Projects/refact\n\nThe Refact Agent project is a Rust-based executable designed to integrate seamlessly with IDEs like VSCode and JetBrains. Its primary function is to maintain up-to-date AST and VecDB indexes, ensuring efficient code completion and project analysis. The agent acts as an LSP server, providing tools for code completion, chat functionalities, and integration with various external tools such as browsers, databases, and debuggers. It supports multiple programming languages for AST capabilities and can be used both as a standalone command-line tool and within a Python program.\nThe project is structured with a main Rust source directory src/ containing modules for background tasks, integrations, HTTP handling, and more. The tests/ directory includes various test scripts mostly written in python, while the examples/ directory provides usage examples.\n\n", + }, + { + role: "user", + content: "add a swim method\n", + checkpoints: [ + { + workspace_folder: + "/Users/marc/Projects/refact/refact-agent/engine/tests/emergency_frog_situation", + commit_hash: "854b2527d4bc1b480c53d57efb9d7931c94bc97d", + }, + ], + }, + { + role: "assistant", + content: + "I'll help add a swim method to the project. Let me first check the current state of the code and then make the necessary addition.", + tool_calls: [ + { + id: "toolu_018RoVhrwy3w7RBU69sVLvTC", + function: { + arguments: + '{"goal": "Add a swim method to a Python class", "language_slash_framework": "python", "im_going_to_use_tools": "cat, update_textdoc", "im_going_to_apply_to": "emergency_frog_situation, frog.py"}', + name: "knowledge", + }, + type: "function", + index: 0, + }, + ], + finish_reason: "stop", + }, + { + role: "tool", + content: { + tool_call_id: "toolu_018RoVhrwy3w7RBU69sVLvTC", + content: + '🗃️d7be14dd3c\nYou have a specialization today: web development.\n\nYou only need to receive instructions from the user once, and then you can autonomously fill in the details of\nthe task, make the necessary changes, verify results and make adjustments and fixes.\n\nHere\'s your approximate web development plan:\n1. Investigate project to understand the task given by the user, start with calling tree() and looking into relevant files. If you see reference designs and sketches, read them using cat().\n2. Run the server. You don\'t have direct access to the command line. Look if there\'s a tool for that purpose. If there is not, you cannot run a web server.\n3. Make relevant screenshots of existing website using chrome(), open both desktop and mobile tabs if the task requires it.\n4. Form a complete interpretation of the task, and write a plan.\n5. Make changes in files using 📍-notation, after that call patch(). Really, first you need to write the updates using 📍-notation, only after that you can apply it using patch().\n6. Check if screenshots got better, or any errors appeared.\n7. Goto 5, unless you see the task is complete.\n\nAs a web developer agent, you need to pay attention to detail. The task is complete if all the elements\nare at the right place. You really need to cat() designs and sketches if they are present in the task.\n\nIf you don\'t see a way to run a real server for the website, then just use chrome() to look\nat .html pages using file:// addresses.\n\nHere is a compressed example of successful trajectory from another project:\n\nDON\'T DO STUPID THINGS:\n* DON\'T SKIP MAKING SCREENSHOTS\n* DON\'T CALL patch() UNTIL YOU FINIHSHED WRITING CODE IN 📍-NOTATION\n* DON\'T ASK USER ANYTHING, YOU HAVE AUTONOMOUS WORK TO DO\n* MAKE SURE IF HAVE A TOOL CALL IN THE END OF EACH RESPONSE, UNLESS YOU COMPLETED AND TESTED THE TASK\n\n\n🗃️8136f3b8d0\nAdditional instructions for django web development.\n\nYou only need to receive instructions from the user once, and then you can autonomously fill in the details of\nthe task, make the necessary changes, verify results and make adjustments and fixes.\n\nHere\'s your approximate web development plan:\n1. Investigate project to understand the task given by the user, start with calling tree() and locate(), looking into relevant files using cat(). If you see reference designs and sketches, read them using cat()\n2. Start django server\n3. Navigate to the place on the website that user wants to change, make a screenshot to make sure you understand what exactly needs to change\n4. Form a complete interpretation of the task, and write a plan.\n5. Make changes in files using 📍-notation, after that call patch(). Really, first you need to write the updates using 📍-notation, only after that you can apply it.\n6. Check if screenshots got better, or any errors appeared.\n7. Goto 5, unless you see the task is complete.\n\nAs a web developer agent, you need to pay attention to detail. The task is complete if all the elements\nare at the right place.\n\nDON\'T DO STUPID THINGS:\n* DON\'T SKIP MAKING SCREENSHOTS\n* DON\'T CALL patch() UNTIL YOU FINIHSHED WRITING CODE IN 📍-NOTATION\n* DON\'T ASK USER ANYTHING, YOU HAVE AUTONOMOUS WORK TO DO\n* MAKE SURE YOU HAVE A TOOL CALL IN THE END OF EACH RESPONSE, UNLESS YOU COMPLETED AND TESTED THE TASK\n\n🗃️ae072f138e\n[\n ["goal", "Compress the chat above."],\n ["thinking", "The user wants a summary of the chat with specific details and decision-making processes."],\n ["outcome", "SUCCESS"]\n]\n\n🗃️81e825a188\n[\n["goal", "Add swim method to Frog class in frog.py"],\n["thinking", "Can add swim method directly using REWRITE_ONE_SYMBOL since the file is small and class structure is clear"],\n["coding", "📍REWRITE_ONE_SYMBOL 000 added swim(dx, dy, pond_width, pond_height) method with position updates and boundary checks"],\n["outcome", "SUCCESS"]\n]\n\n🗃️a2e397d445\n[\n["goal", "Rename \'frog\' to \'bird\' across all project files"],\n["knowledge(goal=\\"Rename \'frog\' to \'bird\' across the project\\", language_slash_framework=\\"python\\", im_going_to_apply_to=\\"refact-lsp, emergency_frog_situation\\", im_going_to_use_tools=\\"search, cat, patch\\")", "Found knowledge about web development and bird-related Python code"],\n["locate(problem_statement=\\"rename frog to bird\\")", "Found 3 files: set_as_avatar.py, work_day.py, jump_to_conclusions.py with frog references"],\n["cat(paths=\\"tests/emergency_frog_situation/frog.py\\", skeleton=false)", "Found base Frog class with methods jump(), croak(), and bounce_off_banks()"],\n["coding", "📍REWRITE_WHOLE_FILE 001 created bird.py replacing frog.py, changed class Frog to Bird, croak to chirp"],\n["patch(tickets=\\"001\\", path=\\"tests/emergency_frog_situation/bird.py\\")", "Created new bird.py file"],\n["coding", "📍REWRITE_WHOLE_FILE 002 updated set_as_avatar.py to use bird.Bird instead of frog.Frog"],\n["patch(tickets=\\"002\\", path=\\"tests/emergency_frog_situation/set_as_avatar.py\\")", "Updated 8 references to frog/Frog in set_as_avatar.py"],\n["coding", "📍REWRITE_WHOLE_FILE 003 updated work_day.py to use bird.Bird"],\n["patch(tickets=\\"003\\", path=\\"tests/emergency_frog_situation/work_day.py\\")", "Updated 2 references to frog in work_day.py"],\n["coding", "📍REWRITE_WHOLE_FILE 004 updated jump_to_conclusions.py, renamed draw_hello_frog to draw_hello_bird"],\n["patch(tickets=\\"004\\", path=\\"tests/emergency_frog_situation/jump_to_conclusions.py\\")", "Updated function name and type annotations"],\n["shell(command=\\"rm tests/emergency_frog_situation/frog.py\\", workdir=\\"/Users/marc/Projects/refact-lsp\\")", "Removed original frog.py file"],\n["outcome", "SUCCESS"]\n]\n\n🗃️36338b63b3\n[\n["goal", "Discuss whether birds are real, their software, programming, and Python usage"],\n["thinking", "User is asking about birds and software. Evidence: birds are biological creatures, but there\'s research into bird-inspired algorithms and robotics."],\n["thinking", "When asked about bird programming, focused on research projects like BirdBrain, Flocking, and RoboBird that simulate or interact with birds."],\n["thinking", "When asked about Python-using birds, clarified that birds don\'t use programming languages, but Python is used by researchers to study birds."],\n["coding", "Provided example of Boid algorithm simulation in Python showing flocking behavior"],\n["coding", "Provided finite state machine simulation of bird behavior states (perched, flying, eating)"],\n["coding", "Provided bird population growth simulation using simple mathematical model"],\n["coding", "Provided example of bird song classification using RandomForestClassifier"],\n["outcome", "SUCCESS"]\n]\n', + }, + }, + { + role: "assistant", + content: + "\n\nLet me first check the current content of frog.py to see where to add the swim method.", + tool_calls: [ + { + id: "toolu_01DFnZT2heBEGkayXXVQwnrw", + function: { + arguments: '{"paths": "frog.py", "skeleton": false}', + name: "cat", + }, + type: "function", + index: 0, + }, + ], + finish_reason: "stop", + }, + { + role: "tool", + content: { + tool_call_id: "toolu_01DFnZT2heBEGkayXXVQwnrw", + content: + "Paths found:\n/Users/marc/Projects/refact/refact-agent/engine/tests/emergency_frog_situation/frog.py\n", + }, + }, + { + role: "context_file", + content: [ + { + file_name: "frog.py", + file_content: + 'import numpy as np\n\nDT = 0.01\n\nclass Frog:\n def __init__(self, x, y, vx, vy):\n self.x = x\n self.y = y\n self.vx = vx\n self.vy = vy\n\n def bounce_off_banks(self, pond_width, pond_height):\n if self.x < 0:\n self.vx = np.abs(self.vx)\n elif self.x > pond_width:\n self.vx = -np.abs(self.vx)\n if self.y < 0:\n self.vy = np.abs(self.vy)\n elif self.y > pond_height:\n self.vy = -np.abs(self.vy)\n\n def jump(self, pond_width, pond_height):\n self.x += self.vx * DT\n self.y += self.vy * DT\n self.bounce_off_banks(pond_width, pond_height)\n self.x = np.clip(self.x, 0, pond_width)\n self.y = np.clip(self.y, 0, pond_height)\n\n def croak(self, n_times):\n for n in range(n_times):\n print("croak")\n \n\nclass AlternativeFrog:\n def alternative_jump(self):\n pass\n\n\ndef standalone_jumping_function():\n print("I\'m a frog! Jump! Jump! Really!")\n print("I\'m a frog! Jump! Jump! Really!")\n', + line1: 1, + line2: 40, + }, + ], + }, + { + role: "assistant", + content: + "I see the Frog class and I'll add a swim method to it. The class already has position (x, y) and velocity (vx, vy) attributes, and includes boundary checking with bounce_off_banks(). I'll add a swim method that allows controlled movement in the pond while maintaining the boundary constraints.", + tool_calls: [ + { + id: "toolu_017vqpdmmgbzVfjk1F4wEEa3", + function: { + arguments: + '{"path": "/Users/marc/Projects/refact/refact-agent/engine/tests/emergency_frog_situation/frog.py", "old_str": " def croak(self, n_times):\\n for n in range(n_times):\\n print(\\"croak\\")\\n \\n\\nclass AlternativeFrog:", "replacement": " def croak(self, n_times):\\n for n in range(n_times):\\n print(\\"croak\\")\\n \\n def swim(self, dx, dy, pond_width, pond_height):\\n \\"\\"\\"Move the frog in the pond by the given displacement while respecting pond boundaries.\\n \\n Args:\\n dx: Horizontal displacement\\n dy: Vertical displacement\\n pond_width: Width of the pond\\n pond_height: Height of the pond\\n \\"\\"\\"\\n self.vx = dx / DT\\n self.vy = dy / DT\\n self.x += dx\\n self.y += dy\\n self.bounce_off_banks(pond_width, pond_height)\\n self.x = np.clip(self.x, 0, pond_width)\\n self.y = np.clip(self.y, 0, pond_height)\\n\\nclass AlternativeFrog:", "multiple": false}', + name: "update_textdoc", + }, + type: "function", + index: 0, + }, + ], + finish_reason: "stop", + }, + { + role: "diff", + content: [ + { + file_name: + "/Users/marc/Projects/refact/refact-agent/engine/tests/emergency_frog_situation/frog.py", + file_action: "edit", + line1: 33, + line2: 33, + lines_remove: "", + lines_add: + ' def swim(self, dx, dy, pond_width, pond_height):\n """Move the frog in the pond by the given displacement while respecting pond boundaries.\n \n Args:\n dx: Horizontal displacement\n dy: Vertical displacement\n pond_width: Width of the pond\n pond_height: Height of the pond\n """\n self.vx = dx / DT\n self.vy = dy / DT\n self.x += dx\n self.y += dy\n self.bounce_off_banks(pond_width, pond_height)\n self.x = np.clip(self.x, 0, pond_width)\n self.y = np.clip(self.y, 0, pond_height)\n', + file_name_rename: null, + application_details: "", + }, + ], + tool_call_id: "toolu_017vqpdmmgbzVfjk1F4wEEa3", + }, + { + role: "assistant", + content: + "\n\nI've added a swim method to the Frog class that:\n\n1. Takes displacement parameters (dx, dy) and pond dimensions\n2. Updates the frog's velocity based on the desired displacement\n3. Updates position directly with the displacement\n4. Ensures the frog stays within pond boundaries using existing bounce_off_banks() and clip functions\n5. Includes docstring documentation explaining the parameters\n\nThe swim method allows for controlled movement in the pond while maintaining all the safety checks that prevent the frog from leaving the pond boundaries. You can use it like this:\n\n```python\nfrog = Frog(x=5, y=5, vx=0, vy=0)\n# Swim right 2 units and up 1 unit\nfrog.swim(dx=2, dy=1, pond_width=10, pond_height=10)\n```\n\nLet me verify the changes by checking the file again:", + tool_calls: [ + { + id: "toolu_01MJPJQZ6jUaLBvDMuFLLmxK", + function: { + arguments: '{"paths": "frog.py", "skeleton": false}', + name: "cat", + }, + type: "function", + index: 0, + }, + ], + finish_reason: "stop", + }, + { + role: "tool", + content: { + tool_call_id: "toolu_01MJPJQZ6jUaLBvDMuFLLmxK", + content: + "Paths found:\n/Users/marc/Projects/refact/refact-agent/engine/tests/emergency_frog_situation/frog.py\n", + }, + }, + { + role: "context_file", + content: [ + { + file_name: "frog.py", + file_content: + 'import numpy as np\n\nDT = 0.01\n\nclass Frog:\n def __init__(self, x, y, vx, vy):\n self.x = x\n self.y = y\n self.vx = vx\n self.vy = vy\n\n def bounce_off_banks(self, pond_width, pond_height):\n if self.x < 0:\n self.vx = np.abs(self.vx)\n elif self.x > pond_width:\n self.vx = -np.abs(self.vx)\n if self.y < 0:\n self.vy = np.abs(self.vy)\n elif self.y > pond_height:\n self.vy = -np.abs(self.vy)\n\n def jump(self, pond_width, pond_height):\n self.x += self.vx * DT\n self.y += self.vy * DT\n self.bounce_off_banks(pond_width, pond_height)\n self.x = np.clip(self.x, 0, pond_width)\n self.y = np.clip(self.y, 0, pond_height)\n\n def croak(self, n_times):\n for n in range(n_times):\n print("croak")\n \n def swim(self, dx, dy, pond_width, pond_height):\n """Move the frog in the pond by the given displacement while respecting pond boundaries.\n \n Args:\n dx: Horizontal displacement\n dy: Vertical displacement\n pond_width: Width of the pond\n pond_height: Height of the pond\n """\n self.vx = dx / DT\n self.vy = dy / DT\n self.x += dx\n self.y += dy\n self.bounce_off_banks(pond_width, pond_height)\n self.x = np.clip(self.x, 0, pond_width)\n self.y = np.clip(self.y, 0, pond_height)\n\nclass AlternativeFrog:\n def alternative_jump(self):\n pass\n\n\ndef standalone_jumping_function():\n print("I\'m a frog! Jump! Jump! Really!")\n print("I\'m a frog! Jump! Jump! Really!")\n', + line1: 1, + line2: 56, + }, + ], + }, + { + role: "assistant", + content: + "The swim method has been successfully added to the Frog class. The implementation looks correct and includes:\n\n1. Proper docstring documentation\n2. Position and velocity updates\n3. Boundary checking using bounce_off_banks()\n4. Position clamping using np.clip()\n5. Consistent style with the rest of the class\n\nThe method integrates well with the existing code and maintains the same safety features as the jump method to keep the frog within the pond boundaries. The changes are complete and the code is ready to use.\n\nWould you like to test the new swim method or make any adjustments to its behavior?", + finish_reason: "stop", + }, + ], + title: "Add Swim Method", + model: "", + last_user_message_id: "73810759-9f69-4f9b-a1de-7becd7648302", + tool_use: "agent", + mode: "AGENT", + new_chat_suggested: { + wasSuggested: false, + }, + read: true, + isTitleGenerated: true, + createdAt: "2025-02-25T13:47:17.107Z", + updatedAt: "2025-02-25T13:47:17.107Z", +}; diff --git a/refact-agent/gui/src/__fixtures__/chat_db_cmessages.ts b/refact-agent/gui/src/__fixtures__/chat_db_cmessages.ts new file mode 100644 index 000000000..001d41f65 --- /dev/null +++ b/refact-agent/gui/src/__fixtures__/chat_db_cmessages.ts @@ -0,0 +1,227 @@ +import { CMessage, CMessageFromChatDB } from "../services/refact/types"; +export const CMESSAGES_STUB: CMessage[] = [ + { + cmessage_belongs_to_cthread_id: "test13thread1739988322_2", + cmessage_alt: 0, + cmessage_num: 0, + cmessage_prev_alt: -1, + cmessage_usage_model: "", + cmessage_usage_prompt: 0, + cmessage_usage_completion: 0, + cmessage_json: { + role: "system", + content: "You answer only with jokes.", + }, + }, + { + cmessage_belongs_to_cthread_id: "test13thread1739988322_2", + cmessage_alt: 0, + cmessage_num: 1, + cmessage_prev_alt: 0, + cmessage_usage_model: "", + cmessage_usage_prompt: 0, + cmessage_usage_completion: 0, + cmessage_json: { + role: "user", + content: "Hello mister assistant, I have a question for you", + }, + }, + { + cmessage_belongs_to_cthread_id: "test13thread1739988322_2", + cmessage_alt: 1, + cmessage_num: 1, + cmessage_prev_alt: 0, + cmessage_usage_model: "", + cmessage_usage_prompt: 0, + cmessage_usage_completion: 0, + cmessage_json: { + role: "user", + content: "Find Frog in this project", + }, + }, + { + cmessage_belongs_to_cthread_id: "test13thread1739988322_2", + cmessage_alt: 0, + cmessage_num: 2, + cmessage_prev_alt: 0, + cmessage_usage_model: "gpt-4o-mini", + cmessage_usage_prompt: 0, + cmessage_usage_completion: 0, + cmessage_json: { + role: "assistant", + content: "yes?.", + }, + }, + { + cmessage_belongs_to_cthread_id: "test13thread1739988322_2", + cmessage_alt: 1, + cmessage_num: 2, + cmessage_prev_alt: 1, + cmessage_usage_model: "gpt-4o-mini", + cmessage_usage_prompt: 0, + cmessage_usage_completion: 0, + cmessage_json: { + role: "assistant", + content: "Birds aren't real", + }, + }, + { + cmessage_belongs_to_cthread_id: "test13thread1739988322_2", + cmessage_alt: 0, + cmessage_num: 3, + cmessage_prev_alt: 0, + cmessage_usage_model: "gpt-4o-mini", + cmessage_usage_prompt: 0, + cmessage_usage_completion: 0, + cmessage_json: { + role: "user", + content: "Find Frog in this project", + }, + }, + { + cmessage_belongs_to_cthread_id: "test13thread1739988322_2", + cmessage_alt: 0, + cmessage_num: 4, + cmessage_prev_alt: 0, + cmessage_usage_model: "gpt-4o-mini", + cmessage_usage_prompt: 1210, + cmessage_usage_completion: 15, + cmessage_json: { + role: "assistant", + content: "", + tool_calls: [ + { + index: 0, + id: "call_8PSEh32Hhivfdxc50XKNwW8y", + function: { + arguments: '{"symbol":"Frog"}', + name: "references", + }, + type: "function", + }, + ], + // usage: { + // prompt_tokens: 1210, + // completion_tokens: 15, + // total_tokens: 1225, + // }, + }, + }, +]; + +export const CSMESSAGES_NETWORK_STUB: CMessageFromChatDB[] = CMESSAGES_STUB.map( + (cmessage) => { + return { + ...cmessage, + cmessage_json: JSON.stringify(cmessage.cmessage_json), + }; + }, +); + +export const CMESSAGES_WITH_NESTED_BRANCHES_STUB: CMessage[] = [ + CMESSAGES_STUB[0], + { + cmessage_belongs_to_cthread_id: "test13thread1739988322_2", + cmessage_alt: 0, + cmessage_num: 1, + cmessage_prev_alt: 0, + cmessage_usage_model: "", + cmessage_usage_prompt: 0, + cmessage_usage_completion: 0, + cmessage_json: { + role: "user", + content: "Hello", + }, + }, + { + cmessage_belongs_to_cthread_id: "test13thread1739988322_2", + cmessage_alt: 0, + cmessage_num: 2, + cmessage_prev_alt: 0, + cmessage_usage_model: "", + cmessage_usage_prompt: 0, + cmessage_usage_completion: 0, + cmessage_json: { + role: "assistant", + content: "Hello.", + }, + }, + { + cmessage_belongs_to_cthread_id: "test13thread1739988322_2", + cmessage_alt: 0, + cmessage_num: 3, + cmessage_prev_alt: 0, + cmessage_usage_model: "", + cmessage_usage_prompt: 0, + cmessage_usage_completion: 0, + cmessage_json: { + role: "user", + content: "1", + }, + }, + { + cmessage_belongs_to_cthread_id: "test13thread1739988322_2", + cmessage_alt: 1, + cmessage_num: 3, + cmessage_prev_alt: 0, + cmessage_usage_model: "", + cmessage_usage_prompt: 0, + cmessage_usage_completion: 0, + cmessage_json: { + role: "user", + content: "2", + }, + }, + { + cmessage_belongs_to_cthread_id: "test13thread1739988322_2", + cmessage_alt: 0, + cmessage_num: 4, + cmessage_prev_alt: 0, + cmessage_usage_model: "", + cmessage_usage_prompt: 0, + cmessage_usage_completion: 0, + cmessage_json: { + role: "assistant", + content: "1", + }, + }, + { + cmessage_belongs_to_cthread_id: "test13thread1739988322_2", + cmessage_alt: 1, + cmessage_num: 4, + cmessage_prev_alt: 1, + cmessage_usage_model: "", + cmessage_usage_prompt: 0, + cmessage_usage_completion: 0, + cmessage_json: { + role: "assistant", + content: "2", + }, + }, + { + cmessage_belongs_to_cthread_id: "test13thread1739988322_2", + cmessage_alt: 0, + cmessage_num: 5, + cmessage_prev_alt: 1, + cmessage_usage_model: "", + cmessage_usage_prompt: 0, + cmessage_usage_completion: 0, + cmessage_json: { + role: "user", + content: "4", + }, + }, + { + cmessage_belongs_to_cthread_id: "test13thread1739988322_2", + cmessage_alt: 0, + cmessage_num: 6, + cmessage_prev_alt: 0, + cmessage_usage_model: "", + cmessage_usage_prompt: 0, + cmessage_usage_completion: 0, + cmessage_json: { + role: "assistant", + content: "🏌️", + }, + }, +]; diff --git a/refact-agent/gui/src/__fixtures__/index.ts b/refact-agent/gui/src/__fixtures__/index.ts index 7d536e410..88e615bd3 100644 --- a/refact-agent/gui/src/__fixtures__/index.ts +++ b/refact-agent/gui/src/__fixtures__/index.ts @@ -9,3 +9,4 @@ export * from "./chat_links_response"; export * from "./chat_config_thread"; export * from "./chat_textdoc"; export * from "./markdown-issue"; +export * from "./chat_db_cmessages"; diff --git a/refact-agent/gui/src/app/store.ts b/refact-agent/gui/src/app/store.ts index 49b48ee80..2f7be1d06 100644 --- a/refact-agent/gui/src/app/store.ts +++ b/refact-agent/gui/src/app/store.ts @@ -52,6 +52,8 @@ import { knowledgeSlice } from "../features/Knowledge/knowledgeSlice"; import { checkpointsSlice } from "../features/Checkpoints/checkpointsSlice"; import { checkpointsApi } from "../services/refact/checkpoints"; import { patchesAndDiffsTrackerSlice } from "../features/PatchesAndDiffsTracker/patchesAndDiffsTrackerSlice"; +import { chatDbThreadsSlice } from "../features/ChatDB/chatDbSlice"; +import { chatDbMessageSlice } from "../features/ChatDB/chatDbMessagesSlice"; const tipOfTheDayPersistConfig = { key: "totd", @@ -115,6 +117,8 @@ const rootReducer = combineSlices( knowledgeSlice, checkpointsSlice, patchesAndDiffsTrackerSlice, + chatDbThreadsSlice, + chatDbMessageSlice, ); const rootPersistConfig = { diff --git a/refact-agent/gui/src/components/Chat/Chat.tsx b/refact-agent/gui/src/components/Chat/Chat.tsx index 5f411bd69..82359dd9f 100644 --- a/refact-agent/gui/src/components/Chat/Chat.tsx +++ b/refact-agent/gui/src/components/Chat/Chat.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useState } from "react"; -import { ChatForm, ChatFormProps } from "../ChatForm"; +import { ChatForm } from "../ChatForm"; import { ChatContent } from "../ChatContent"; import { Flex, Button, Text, Card } from "@radix-ui/themes"; import { @@ -11,7 +11,7 @@ import { useCapsForToolUse, useAgentUsage, } from "../../hooks"; -import { type Config } from "../../features/Config/configSlice"; +// import { type Config } from "../../features/Config/configSlice"; import { enableSend, selectIsStreaming, @@ -29,21 +29,18 @@ import { AgentUsage } from "../../features/AgentUsage"; import { useCheckpoints } from "../../hooks/useCheckpoints"; import { Checkpoints } from "../../features/Checkpoints"; import { SuggestNewChat } from "../ChatForm/SuggestNewChat"; +import { useThreadMessageSubmit } from "../../hooks/useThreadMessageSubmit"; export type ChatProps = { - host: Config["host"]; - tabbed: Config["tabbed"]; - backFromChat: () => void; - style?: React.CSSProperties; - unCalledTools: boolean; - maybeSendToSidebar: ChatFormProps["onClose"]; + // host: Config["host"]; + // tabbed: Config["tabbed"]; + // backFromChat: () => void; + // style?: React.CSSProperties; + // unCalledTools: boolean; + // maybeSendToSidebar: ChatFormProps["onClose"]; }; -export const Chat: React.FC = ({ - style, - unCalledTools, - maybeSendToSidebar, -}) => { +export const Chat: React.FC = () => { const dispatch = useAppDispatch(); const [isViewingRawJSON, setIsViewingRawJSON] = useState(false); @@ -52,12 +49,29 @@ export const Chat: React.FC = ({ const caps = useGetCapsQuery(); const chatId = useAppSelector(selectChatId); - const { submit, abort, retryFromIndex } = useSendChatRequest(); + const { + // submit, + abort, + retryFromIndex, + } = useSendChatRequest(); const chatToolUse = useAppSelector(getSelectedToolUse); const threadNewChatSuggested = useAppSelector(selectThreadNewChatSuggested); const messages = useAppSelector(selectMessages); + + const { submit } = useThreadMessageSubmit(); + + // can be a selector + const unCalledTools = React.useMemo(() => { + if (messages.length === 0) return false; + const last = messages[messages.length - 1]; + if (last.role !== "assistant") return false; + const maybeTools = last.tool_calls; + if (maybeTools && maybeTools.length > 0) return true; + return false; + }, [messages]); + const capsForToolUse = useCapsForToolUse(); const { disableInput } = useAgentUsage(); @@ -71,7 +85,7 @@ export const Chat: React.FC = ({ const handleSummit = useCallback( (value: string) => { - submit({ question: value }); + void submit(value); if (isViewingRawJSON) { setIsViewingRawJSON(false); } @@ -103,7 +117,6 @@ export const Chat: React.FC = ({ return ( = ({ justify="between" px="1" > + {" "} + {/** change messages? */} - {shouldCheckpointsPopupBeShown && } - = ({ )} - - {/* Two flexboxes are left for the future UI element on the right side */} {messages.length > 0 && ( diff --git a/refact-agent/gui/src/components/ChatContent/ChatContent.tsx b/refact-agent/gui/src/components/ChatContent/ChatContent.tsx index b4b34d138..0d46af1b6 100644 --- a/refact-agent/gui/src/components/ChatContent/ChatContent.tsx +++ b/refact-agent/gui/src/components/ChatContent/ChatContent.tsx @@ -22,7 +22,6 @@ import { selectIntegration, selectIsStreaming, selectIsWaiting, - selectMessages, selectThread, selectThreadUsage, } from "../../features/Chat/Thread/selectors"; @@ -35,6 +34,8 @@ import { telemetryApi } from "../../services/refact/telemetry"; import { PlaceHolderText } from "./PlaceHolderText"; import { UsageCounter } from "../UsageCounter"; import { getConfirmationPauseStatus } from "../../features/ToolConfirmation/confirmationSlice"; +import { MessageNode } from "../MessageNode"; +import { chatDbMessagesSliceSelectors } from "../../features/ChatDB/chatDbMessagesSlice"; export type ChatContentProps = { onRetry: (index: number, question: UserMessage["content"]) => void; @@ -43,11 +44,9 @@ export type ChatContentProps = { export const ChatContent: React.FC = ({ onStopStreaming, - onRetry, }) => { const dispatch = useAppDispatch(); const scrollRef = useRef(null); - const messages = useAppSelector(selectMessages); const isStreaming = useAppSelector(selectIsStreaming); const thread = useAppSelector(selectThread); const threadUsage = useAppSelector(selectThreadUsage); @@ -57,6 +56,9 @@ export const ChatContent: React.FC = ({ telemetryApi.useLazySendTelemetryChatEventQuery(); const integrationMeta = useAppSelector(selectIntegration); const isWaitingForConfirmation = useAppSelector(getConfirmationPauseStatus); + const messageTree = useAppSelector( + chatDbMessagesSliceSelectors.selectMessageTree, + ); const { handleScroll, @@ -67,10 +69,6 @@ export const ChatContent: React.FC = ({ scrollRef, }); - const onRetryWrapper = (index: number, question: UserMessage["content"]) => { - onRetry(index, question); - }; - const handleReturnToConfigurationClick = useCallback(() => { // console.log(`[DEBUG]: going back to configuration page`); // TBD: should it be allowed to run in the background? @@ -121,10 +119,11 @@ export const ChatContent: React.FC = ({ p="2" gap="1" > - {messages.length === 0 && } - {renderMessages(messages, onRetryWrapper)} - - {threadUsage && messages.length > 0 && } + {!messageTree && } + {/* {renderMessages(messages, onRetryWrapper)} */} + {messageTree} + {!messageTree && } + {threadUsage && messageTree && } = ({ ChatContent.displayName = "ChatContent"; -function renderMessages( +// TODO: can delete +function _renderMessages( messages: ChatMessages, onRetry: (index: number, question: UserMessage["content"]) => void, memo: React.ReactNode[] = [], @@ -185,18 +185,18 @@ function renderMessages( if (messages.length === 0) return memo; const [head, ...tail] = messages; if (head.role === "tool") { - return renderMessages(tail, onRetry, memo, index + 1); + return _renderMessages(tail, onRetry, memo, index + 1); } if (head.role === "plain_text") { const key = "plain-text-" + index; const nextMemo = [...memo, {head.content}</PlainText>]; - return renderMessages(tail, onRetry, nextMemo, index + 1); + return _renderMessages(tail, onRetry, nextMemo, index + 1); } if (head.role === "assistant") { const key = "assistant-input-" + index; - const isLast = !tail.some(isAssistantMessage); + const isLast = !tail.some(isAssistantMessage); // TODO: this is for knowledge const nextMemo = [ ...memo, <AssistantInput @@ -207,7 +207,7 @@ function renderMessages( />, ]; - return renderMessages(tail, onRetry, nextMemo, index + 1); + return _renderMessages(tail, onRetry, nextMemo, index + 1); } if (head.role === "user") { @@ -219,16 +219,17 @@ function renderMessages( {head.content} </UserInput>, ]; - return renderMessages(tail, onRetry, nextMemo, index + 1); + return _renderMessages(tail, onRetry, nextMemo, index + 1); } if (isChatContextFileMessage(head)) { const key = "context-file-" + index; const nextMemo = [...memo, <ContextFiles key={key} files={head.content} />]; - return renderMessages(tail, onRetry, nextMemo, index + 1); + return _renderMessages(tail, onRetry, nextMemo, index + 1); } if (isDiffMessage(head)) { + // TODO: do we still need to group diffs? const restInTail = takeWhile(tail, (message) => { return isDiffMessage(message) || isToolMessage(message); }); @@ -239,7 +240,7 @@ function renderMessages( const nextMemo = [...memo, <GroupedDiffs key={key} diffs={diffMessages} />]; - return renderMessages( + return _renderMessages( nextTail, onRetry, nextMemo, @@ -247,5 +248,5 @@ function renderMessages( ); } - return renderMessages(tail, onRetry, memo, index + 1); + return _renderMessages(tail, onRetry, memo, index + 1); } diff --git a/refact-agent/gui/src/components/ChatContent/UserInput.tsx b/refact-agent/gui/src/components/ChatContent/UserInput.tsx index 1b6800ec2..2eea9591a 100644 --- a/refact-agent/gui/src/components/ChatContent/UserInput.tsx +++ b/refact-agent/gui/src/components/ChatContent/UserInput.tsx @@ -18,55 +18,58 @@ import styles from "./ChatContent.module.css"; export type UserInputProps = { children: UserMessage["content"]; - messageIndex: number; + // TODO: remove when using nodes + messageIndex?: number; // maybe add images argument ? - onRetry: (index: number, question: UserMessage["content"]) => void; + onRetry?: (index: number, question: UserMessage["content"]) => void; // disableRetry?: boolean; }; export const UserInput: React.FC<UserInputProps> = ({ - messageIndex, + // messageIndex, children, - onRetry, + // onRetry, }) => { - const messages = useAppSelector(selectMessages); + // const messages = useAppSelector(selectMessages); - const [showTextArea, setShowTextArea] = useState(false); + // const [showTextArea, setShowTextArea] = useState(false); const [isEditButtonVisible, setIsEditButtonVisible] = useState(false); // const ref = React.useRef<HTMLButtonElement>(null); - const handleSubmit = useCallback( - (value: UserMessage["content"]) => { - onRetry(messageIndex, value); - setShowTextArea(false); - }, - [messageIndex, onRetry], - ); - - const handleShowTextArea = useCallback( - (value: boolean) => { - setShowTextArea(value); - if (isEditButtonVisible) { - setIsEditButtonVisible(false); - } - }, - [isEditButtonVisible], - ); + // const handleSubmit = useCallback( + // (value: UserMessage["content"]) => { + // onRetry && messageIndex && onRetry(messageIndex, value); + // setShowTextArea(false); + // }, + // [messageIndex, onRetry], + // ); + + // const handleShowTextArea = useCallback( + // (value: boolean) => { + // setShowTextArea(value); + // if (isEditButtonVisible) { + // setIsEditButtonVisible(false); + // } + // }, + // [isEditButtonVisible], + // ); // const lines = children.split("\n"); // won't work if it's an array const elements = process(children); const isString = typeof children === "string"; const linesLength = isString ? children.split("\n").length : Infinity; - const checkpointsFromMessage = useMemo(() => { - const maybeUserMessage = messages[messageIndex]; - if (!isUserMessage(maybeUserMessage)) return null; - return maybeUserMessage.checkpoints; - }, [messageIndex, messages]); + // TODO: add this back in + // const checkpointsFromMessage = useMemo(() => { + // if (!messageIndex) return null; + // const maybeUserMessage = messages[messageIndex]; + // if (!isUserMessage(maybeUserMessage)) return null; + // return maybeUserMessage.checkpoints; + // }, [messageIndex, messages]); return ( <Container position="relative" pt="1"> - {showTextArea ? ( + {/* {showTextArea ? ( <RetryForm onSubmit={handleSubmit} // TODO @@ -74,54 +77,54 @@ export const UserInput: React.FC<UserInputProps> = ({ value={children} onClose={() => handleShowTextArea(false)} /> - ) : ( + ) : ( */} + <Flex + direction="row" + // checking for the length of the lines to determine the position of the edit button + gap={linesLength <= 2 ? "2" : "1"} + // TODO: what is it's a really long sentence or word with out new lines? + align={linesLength <= 2 ? "center" : "end"} + my="1" + onMouseEnter={() => setIsEditButtonVisible(true)} + onMouseLeave={() => setIsEditButtonVisible(false)} + > + <Button + // ref={ref} + variant="soft" + size="4" + className={styles.userInput} + // TODO: should this work? + // onClick={() => handleShowTextArea(true)} + asChild + > + <div>{elements}</div> + </Button> <Flex - direction="row" - // checking for the length of the lines to determine the position of the edit button - gap={linesLength <= 2 ? "2" : "1"} - // TODO: what is it's a really long sentence or word with out new lines? - align={linesLength <= 2 ? "center" : "end"} - my="1" - onMouseEnter={() => setIsEditButtonVisible(true)} - onMouseLeave={() => setIsEditButtonVisible(false)} + direction={linesLength <= 3 ? "row" : "column"} + gap="1" + style={{ + opacity: isEditButtonVisible ? 1 : 0, + visibility: isEditButtonVisible ? "visible" : "hidden", + transition: "opacity 0.15s, visibility 0.15s", + }} > - <Button - // ref={ref} - variant="soft" - size="4" - className={styles.userInput} - // TODO: should this work? - // onClick={() => handleShowTextArea(true)} - asChild - > - <div>{elements}</div> - </Button> - <Flex - direction={linesLength <= 3 ? "row" : "column"} - gap="1" - style={{ - opacity: isEditButtonVisible ? 1 : 0, - visibility: isEditButtonVisible ? "visible" : "hidden", - transition: "opacity 0.15s, visibility 0.15s", - }} - > - {checkpointsFromMessage && checkpointsFromMessage.length > 0 && ( + {/* {checkpointsFromMessage && checkpointsFromMessage.length > 0 && ( <CheckpointButton checkpoints={checkpointsFromMessage} messageIndex={messageIndex} /> - )} - <IconButton + )} */} + {/* <IconButton title="Edit message" variant="soft" size={"2"} onClick={() => handleShowTextArea(true)} > <Pencil2Icon width={15} height={15} /> - </IconButton> - </Flex> + </IconButton> */} </Flex> - )} + </Flex> + {/* )} */} </Container> ); }; diff --git a/refact-agent/gui/src/components/ChatHistory/ChatHistory.tsx b/refact-agent/gui/src/components/ChatHistory/ChatHistory.tsx index dda2600d0..32a2722b1 100644 --- a/refact-agent/gui/src/components/ChatHistory/ChatHistory.tsx +++ b/refact-agent/gui/src/components/ChatHistory/ChatHistory.tsx @@ -1,61 +1,102 @@ -import { memo } from "react"; +import React, { useCallback, useEffect } from "react"; import { Flex, Box } from "@radix-ui/themes"; import { ScrollArea } from "../ScrollArea"; import { HistoryItem } from "./HistoryItem"; +// import { +// getHistory, +// type HistoryState, +// } from "../../features/History/historySlice"; +// import type { ChatThread } from "../../features/Chat/Thread/types"; +import { useAppDispatch, useAppSelector } from "../../hooks"; import { - ChatHistoryItem, - getHistory, - type HistoryState, -} from "../../features/History/historySlice"; - -export type ChatHistoryProps = { - history: HistoryState; - onHistoryItemClick: (id: ChatHistoryItem) => void; - onDeleteHistoryItem: (id: string) => void; - onOpenChatInTab?: (id: string) => void; - currentChatId?: string; -}; + chatDbSelectors, + chatDbActions, +} from "../../features/ChatDB/chatDbSlice"; +import { subscribeToThreadsThunk } from "../../services/refact/chatdb"; +import { push } from "../../features/Pages/pagesSlice"; +import { CThread } from "../../services/refact/types"; +import { chatDbMessageSliceActions } from "../../features/ChatDB/chatDbMessagesSlice"; + +// export type ChatHistoryProps = { +// history: HistoryState; +// onHistoryItemClick: (id: ChatThread) => void; +// onDeleteHistoryItem: (id: string) => void; +// onOpenChatInTab?: (id: string) => void; +// currentChatId?: string; +// }; + +function useGetHistory() { + // todo: search + const dispatch = useAppDispatch(); + const history = useAppSelector(chatDbSelectors.getChats, { + devModeChecks: { stabilityCheck: "never" }, + }); + const isLoading = useAppSelector(chatDbSelectors.getLoading); + + // move this to a dedicated hook + useEffect(() => { + const thunk = dispatch(subscribeToThreadsThunk()); + return () => { + try { + thunk.catch(() => ({})); + thunk.abort("unmounted"); + } catch { + // noop + } + }; + }, [dispatch]); + + const onDeleteHistoryItem = useCallback( + (id: string) => { + dispatch(chatDbActions.deleteCThread(id)); + }, + [dispatch], + ); -export const ChatHistory = memo( - ({ + const onHistoryItemClick = useCallback( + (thread: CThread) => { + dispatch(chatDbMessageSliceActions.setThread(thread)); + dispatch(push({ name: "chat", threadId: thread.cthread_id })); + }, + [dispatch], + ); + + return { history, + isLoading, onHistoryItemClick, onDeleteHistoryItem, - onOpenChatInTab, - currentChatId, - }: ChatHistoryProps) => { - const sortedHistory = getHistory({ history }); - return ( - <Box - style={{ - overflow: "hidden", - }} - pb="2" - flexGrow="1" - > - <ScrollArea scrollbars="vertical"> - <Flex - justify="center" - align="center" - pl="2" - pr="2" - direction="column" - > - {sortedHistory.map((item) => ( - <HistoryItem - onClick={() => onHistoryItemClick(item)} - onOpenInTab={onOpenChatInTab} - onDelete={onDeleteHistoryItem} - key={item.id} - historyItem={item} - disabled={item.id === currentChatId} - /> - ))} - </Flex> - </ScrollArea> - </Box> - ); - }, -); + }; +} + +export const ChatHistory: React.FC = () => { + const { history, onHistoryItemClick, onDeleteHistoryItem } = useGetHistory(); + + return ( + <Box + style={{ + overflow: "hidden", + }} + pb="2" + flexGrow="1" + > + <ScrollArea scrollbars="vertical"> + <Flex justify="center" align="center" pl="2" pr="2" direction="column"> + {history.map((item) => ( + <HistoryItem + // onClick={() => onHistoryItemClick(item)} + onClick={onHistoryItemClick} + // onOpenInTab={onOpenChatInTab} + onDelete={onDeleteHistoryItem} + key={item.cthread_id} + historyItem={item} + // disabled={item.cthread_id === currentChatId} + /> + ))} + </Flex> + </ScrollArea> + </Box> + ); +}; ChatHistory.displayName = "ChatHistory"; diff --git a/refact-agent/gui/src/components/ChatHistory/HistoryItem.tsx b/refact-agent/gui/src/components/ChatHistory/HistoryItem.tsx index f3c079575..71151bc66 100644 --- a/refact-agent/gui/src/components/ChatHistory/HistoryItem.tsx +++ b/refact-agent/gui/src/components/ChatHistory/HistoryItem.tsx @@ -1,33 +1,34 @@ import React from "react"; -import { Card, Flex, Text, Box, Spinner } from "@radix-ui/themes"; -// import type { ChatHistoryItem } from "../../hooks/useChatHistory"; +import { Card, Flex, Text, Box } from "@radix-ui/themes"; import { ChatBubbleIcon, DotFilledIcon } from "@radix-ui/react-icons"; import { CloseButton } from "../Buttons/Buttons"; -import { IconButton } from "@radix-ui/themes"; -import { OpenInNewWindowIcon } from "@radix-ui/react-icons"; -import type { ChatHistoryItem } from "../../features/History/historySlice"; -import { isUserMessage } from "../../services/refact"; -import { useAppSelector } from "../../hooks"; +import { CThread } from "../../services/refact"; export const HistoryItem: React.FC<{ - historyItem: ChatHistoryItem; - onClick: () => void; + historyItem: CThread; + onClick: (thread: CThread) => void; onDelete: (id: string) => void; - onOpenInTab?: (id: string) => void; - disabled: boolean; -}> = ({ historyItem, onClick, onDelete, onOpenInTab, disabled }) => { - const dateCreated = new Date(historyItem.createdAt); + // onOpenInTab?: (id: string) => void; + // disabled: boolean; +}> = ({ + historyItem, + onClick, + onDelete, + // onOpenInTab, + // disabled +}) => { + const dateCreated = new Date(historyItem.cthread_created_ts); const dateTimeString = dateCreated.toLocaleString(); - const cache = useAppSelector((app) => app.chat.cache); - - const isStreaming = historyItem.id in cache; + // maybe remove this? + // const cache = useAppSelector((app) => app.chat.cache); + // const isStreaming = historyItem.cthread_id in cache; return ( <Box style={{ position: "relative", width: "100%" }}> <Card style={{ width: "100%", marginBottom: "2px", - opacity: disabled ? 0.8 : 1, + // opacity: disabled ? 0.8 : 1, }} variant="surface" className="rt-Button" @@ -35,16 +36,19 @@ export const HistoryItem: React.FC<{ role="button" > <button - disabled={disabled} + // disabled={disabled} onClick={(event) => { event.preventDefault(); event.stopPropagation(); - onClick(); + onClick(historyItem); }} > <Flex gap="2px" align="center"> - {isStreaming && <Spinner style={{ minWidth: 16, minHeight: 16 }} />} - {!isStreaming && historyItem.read === false && ( + {/* {isStreaming && <Spinner style={{ minWidth: 16, minHeight: 16 }} />} + {!isStreaming && historyItem.cthread_anything_new && ( + <DotFilledIcon style={{ minWidth: 16, minHeight: 16 }} /> + )} */} + {historyItem.cthread_anything_new && ( <DotFilledIcon style={{ minWidth: 16, minHeight: 16 }} /> )} <Text @@ -57,7 +61,7 @@ export const HistoryItem: React.FC<{ whiteSpace: "nowrap", }} > - {historyItem.title} + {historyItem.cthread_title} </Text> </Flex> @@ -66,8 +70,7 @@ export const HistoryItem: React.FC<{ size="1" style={{ display: "flex", gap: "4px", alignItems: "center" }} > - <ChatBubbleIcon />{" "} - {historyItem.messages.filter(isUserMessage).length} + <ChatBubbleIcon /> {historyItem.cthread_n} </Text> <Text size="1">{dateTimeString}</Text> @@ -85,7 +88,7 @@ export const HistoryItem: React.FC<{ // justify to flex end > {/**TODO: open in tab button */} - {onOpenInTab && ( + {/* {onOpenInTab && ( <IconButton size="1" title="open in tab" @@ -98,7 +101,7 @@ export const HistoryItem: React.FC<{ > <OpenInNewWindowIcon width="10" height="10" /> </IconButton> - )} + )} */} <CloseButton size="1" @@ -106,7 +109,7 @@ export const HistoryItem: React.FC<{ onClick={(event) => { event.preventDefault(); event.stopPropagation(); - onDelete(historyItem.id); + onDelete(historyItem.cthread_id); }} iconSize={10} title="delete chat" diff --git a/refact-agent/gui/src/components/ChatLinks/UncommittedChangesWarning.tsx b/refact-agent/gui/src/components/ChatLinks/UncommittedChangesWarning.tsx index 75778251e..2e42288ba 100644 --- a/refact-agent/gui/src/components/ChatLinks/UncommittedChangesWarning.tsx +++ b/refact-agent/gui/src/components/ChatLinks/UncommittedChangesWarning.tsx @@ -5,7 +5,6 @@ import { Flex, Separator } from "@radix-ui/themes"; import { selectIsStreaming, selectIsWaiting, - selectMessages, selectThreadToolUse, } from "../../features/Chat"; import { getErrorMessage } from "../../features/Errors/errorsSlice"; @@ -18,7 +17,6 @@ export const UncommittedChangesWarning: React.FC = () => { const error = useAppSelector(getErrorMessage); const information = useAppSelector(getInformationMessage); const toolUse = useAppSelector(selectThreadToolUse); - const messages = useAppSelector(selectMessages); const hasCallout = React.useMemo(() => { return !!error || !!information; @@ -26,7 +24,6 @@ export const UncommittedChangesWarning: React.FC = () => { if ( toolUse !== "agent" || - messages.length !== 0 || hasCallout || isStreaming || isWaiting || diff --git a/refact-agent/gui/src/components/MessageNode/MessageNode.stories.tsx b/refact-agent/gui/src/components/MessageNode/MessageNode.stories.tsx new file mode 100644 index 000000000..d676595c2 --- /dev/null +++ b/refact-agent/gui/src/components/MessageNode/MessageNode.stories.tsx @@ -0,0 +1,100 @@ +import React from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { MessageNode } from "./MessageNode"; +import { + CMESSAGES_WITH_NESTED_BRANCHES_STUB, + CHAT_WITH_TEXTDOC, + CHAT_WITH_KNOWLEDGE_TOOL, + CHAT_WITH_MULTI_MODAL, + CHAT_MESSAGE_NODE, +} from "../../__fixtures__"; +import { makeMessageTree } from "../../features/ChatDB/makeMessageTree"; +import { Provider } from "react-redux"; +import { Theme } from "../Theme"; +import { AbortControllerProvider } from "../../contexts/AbortControllers"; +import { setUpStore } from "../../app/store"; +import { CMessageNode } from "../../features/ChatDB/chatDbMessagesSlice"; + +import type { ChatMessage, CMessage } from "../../services/refact/types"; + +function chatMessagesToCMessages(chatMessages: ChatMessage[]): CMessage[] { + const messagesWithSystemMessage: ChatMessage[] = + chatMessages[0].role === "system" + ? chatMessages + : [{ role: "system", content: "system message" }, ...chatMessages]; + + return messagesWithSystemMessage.map((message: ChatMessage, index) => { + const cmessage: CMessage = { + cmessage_belongs_to_cthread_id: "test13thread1739988322_2", + cmessage_alt: 0, + cmessage_num: index, + cmessage_prev_alt: message.role === "system" ? -1 : 0, + cmessage_usage_model: "", + cmessage_usage_prompt: 0, + cmessage_usage_completion: 0, + cmessage_json: message, + }; + + return cmessage; + }); +} + +const messageTree = makeMessageTree(CMESSAGES_WITH_NESTED_BRANCHES_STUB); + +const Template: React.FC<{ node: CMessageNode | null }> = ({ node }) => { + const store = setUpStore(); + + console.log({ node }); + + return ( + <Provider store={store}> + <Theme> + <AbortControllerProvider> + {node ? ( + <MessageNode>{node}</MessageNode> + ) : ( + <div>Could not make tree</div> + )} + </AbortControllerProvider> + </Theme> + </Provider> + ); +}; +const meta: Meta<typeof Template> = { + title: "components/MessageNode", + component: Template, +}; + +export default meta; + +export const Primary: StoryObj<typeof Template> = { + args: { node: messageTree }, +}; + +export const Textdoc: StoryObj<typeof Template> = { + args: { + node: makeMessageTree(chatMessagesToCMessages(CHAT_WITH_TEXTDOC.messages)), + }, +}; + +export const Knowledge: StoryObj<typeof Template> = { + args: { + node: makeMessageTree( + chatMessagesToCMessages(CHAT_WITH_KNOWLEDGE_TOOL.messages), + ), + }, +}; + +export const MultiModal: StoryObj<typeof Template> = { + args: { + node: makeMessageTree( + chatMessagesToCMessages(CHAT_WITH_MULTI_MODAL.messages), + ), + }, +}; + +export const Updated: StoryObj<typeof Template> = { + args: { + node: makeMessageTree(chatMessagesToCMessages(CHAT_MESSAGE_NODE.messages)), + }, +}; diff --git a/refact-agent/gui/src/components/MessageNode/MessageNode.tsx b/refact-agent/gui/src/components/MessageNode/MessageNode.tsx new file mode 100644 index 000000000..05861db5b --- /dev/null +++ b/refact-agent/gui/src/components/MessageNode/MessageNode.tsx @@ -0,0 +1,178 @@ +import React, { useCallback, useEffect, useMemo } from "react"; +import { + chatDbMessageSliceActions, + CMessageNode, + isUserCMessageNode, +} from "../../features/ChatDB/chatDbMessagesSlice"; +import { UserInput } from "../ChatContent/UserInput"; +import { AssistantInput } from "../ChatContent/AssistantInput"; +import { + ChatMessage, + isAssistantMessage, + isChatContextFileMessage, + isDiffMessage, + isPlainTextMessage, + isUserMessage, +} from "../../services/refact"; +import { Box, Flex, IconButton } from "@radix-ui/themes"; +import { ArrowLeftIcon, ArrowRightIcon } from "@radix-ui/react-icons"; +import { PlainText } from "../ChatContent/PlainText"; +import { ContextFiles } from "../ChatContent/ContextFiles"; +import { GroupedDiffs } from "../ChatContent/DiffContent"; +import { useAppDispatch } from "../../hooks"; + +const ElementForNodeMessage: React.FC<{ message: ChatMessage }> = ({ + message, +}) => { + if (isUserMessage(message)) { + return <UserInput>{message.content}</UserInput>; + } + + if (isAssistantMessage(message)) { + // find the tool result for the tool call + return ( + <AssistantInput + message={message.content} + toolCalls={message.tool_calls} + /> + ); + } + + if (isPlainTextMessage(message)) { + return <PlainText>{message.content}</PlainText>; + } + + if (isChatContextFileMessage(message)) { + return <ContextFiles files={message.content} />; + } + + if (isDiffMessage(message)) { + // TODO: do we still need to group diffs? + return <GroupedDiffs diffs={[message]} />; + } + + // add more case here from refact-agent/gui/src/components/ChatContent/ChatContent.tsx + + return false; +}; + +export type MessageNodeProps = { children?: CMessageNode | null }; + +// TODO: update tracking the end point +export const MessageNode: React.FC<MessageNodeProps> = ({ children }) => { + const dispatch = useAppDispatch(); + + useEffect(() => { + if (children?.children.length === 0) { + const action = chatDbMessageSliceActions.setEnd({ + number: children.message.cmessage_num, + alt: children.message.cmessage_alt, + }); + dispatch(action); + } + }, [ + children?.children.length, + children?.message.cmessage_num, + children?.message.cmessage_alt, + dispatch, + ]); + + if (!children) return null; + return ( + <> + <ElementForNodeMessage message={children.message.cmessage_json} /> + <MessageNodeChildren>{children.children}</MessageNodeChildren> + </> + ); +}; + +function makeDummyNode(lastMessage?: CMessageNode): CMessageNode { + return { + message: { + cmessage_usage_model: lastMessage?.message.cmessage_usage_model ?? "", + cmessage_usage_prompt: lastMessage?.message.cmessage_usage_prompt ?? 0, + cmessage_usage_completion: + lastMessage?.message.cmessage_usage_completion ?? 0, + cmessage_belongs_to_cthread_id: + lastMessage?.message.cmessage_belongs_to_cthread_id ?? "", + cmessage_num: lastMessage?.message.cmessage_num ?? 0, + + cmessage_alt: (lastMessage?.message.cmessage_alt ?? 0) + 1, + cmessage_prev_alt: lastMessage?.message.cmessage_alt ?? 0, + cmessage_json: { + role: "user", + content: "dummy text about making a new message", + }, // TODO: use a different type of message + }, + children: [], + }; +} + +const MessageNodeChildren: React.FC<{ children: CMessageNode[] }> = ({ + children, +}) => { + const [selectedNodeIndex, setSelectedNodeIndex] = React.useState<number>(0); + + const goBack = useCallback(() => { + setSelectedNodeIndex((prev) => { + if (prev === 0) return prev; + return prev - 1; + }); + }, []); + + const goForward = useCallback(() => { + setSelectedNodeIndex((prev) => { + if (prev === children.length) return prev; + return prev + 1; + }); + }, [children.length]); + + const canBranch = useMemo(() => { + if (children.length > 1) return true; + if (selectedNodeIndex >= children.length && selectedNodeIndex > 0) { + return true; + } + if ( + children[selectedNodeIndex] && + isUserCMessageNode(children[selectedNodeIndex]) + ) { + return true; + } + return false; + }, [children, selectedNodeIndex]); + + const nodeToRender = useMemo(() => { + return ( + children[selectedNodeIndex] ?? + makeDummyNode(children[children.length - 1]) + ); + }, [children, selectedNodeIndex]); + + if (!canBranch) { + return <MessageNode>{children[selectedNodeIndex]}</MessageNode>; + } + + return ( + <Box> + <Flex gap="4" justify="end"> + <IconButton + variant="outline" + size="1" + disabled={selectedNodeIndex === 0} + onClick={goBack} + > + <ArrowLeftIcon /> + </IconButton> + <IconButton + variant="outline" + size="1" + disabled={selectedNodeIndex === children.length} + onClick={goForward} + > + <ArrowRightIcon /> + </IconButton> + </Flex> + <MessageNode>{nodeToRender}</MessageNode> + </Box> + ); +}; diff --git a/refact-agent/gui/src/components/MessageNode/index.ts b/refact-agent/gui/src/components/MessageNode/index.ts new file mode 100644 index 000000000..ebe877c79 --- /dev/null +++ b/refact-agent/gui/src/components/MessageNode/index.ts @@ -0,0 +1 @@ +export * from "./MessageNode"; diff --git a/refact-agent/gui/src/components/Sidebar/Sidebar.tsx b/refact-agent/gui/src/components/Sidebar/Sidebar.tsx index 6e871a9ee..651f2e8f1 100644 --- a/refact-agent/gui/src/components/Sidebar/Sidebar.tsx +++ b/refact-agent/gui/src/components/Sidebar/Sidebar.tsx @@ -1,14 +1,7 @@ -import React, { useCallback } from "react"; +import React from "react"; import { Box, Flex } from "@radix-ui/themes"; import { ChatHistory, type ChatHistoryProps } from "../ChatHistory"; import { Spinner } from "@radix-ui/themes"; -import { useAppSelector, useAppDispatch } from "../../hooks"; -import { - ChatHistoryItem, - deleteChatById, -} from "../../features/History/historySlice"; -import { push } from "../../features/Pages/pagesSlice"; -import { restoreChat } from "../../features/Chat/Thread"; import { FeatureMenu } from "../../features/Config/FeatureMenu"; export type SidebarProps = { @@ -26,24 +19,25 @@ export type SidebarProps = { export const Sidebar: React.FC<SidebarProps> = ({ takingNotes, style }) => { // TODO: these can be lowered. - const dispatch = useAppDispatch(); - const history = useAppSelector((app) => app.history, { - // TODO: selector issue here - devModeChecks: { stabilityCheck: "never" }, - }); + // const dispatch = useAppDispatch(); + // void dispatch(subscribeToThreadsThunk()); + // const history = useAppSelector((app) => app.history, { + // // TODO: selector issue here + // devModeChecks: { stabilityCheck: "never" }, + // }); - const onDeleteHistoryItem = useCallback( - (id: string) => dispatch(deleteChatById(id)), - [dispatch], - ); + // const onDeleteHistoryItem = useCallback( + // (id: string) => dispatch(deleteChatById(id)), + // [dispatch], + // ); - const onHistoryItemClick = useCallback( - (thread: ChatHistoryItem) => { - dispatch(restoreChat(thread)); - dispatch(push({ name: "chat" })); - }, - [dispatch], - ); + // const onHistoryItemClick = useCallback( + // (thread: ChatThread) => { + // dispatch(restoreChat(thread)); + // dispatch(push({ name: "chat" })); + // }, + // [dispatch], + // ); return ( <Flex style={style}> @@ -53,11 +47,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ takingNotes, style }) => { <Spinner loading={takingNotes} title="taking notes" /> </Box> </Flex> - <ChatHistory - history={history} - onHistoryItemClick={onHistoryItemClick} - onDeleteHistoryItem={onDeleteHistoryItem} - /> + <ChatHistory /> </Flex> ); }; diff --git a/refact-agent/gui/src/features/App.tsx b/refact-agent/gui/src/features/App.tsx index aac76d736..6642abd65 100644 --- a/refact-agent/gui/src/features/App.tsx +++ b/refact-agent/gui/src/features/App.tsx @@ -175,13 +175,7 @@ export const InnerApp: React.FC<AppProps> = ({ style }: AppProps) => { }} /> )} - {page.name === "chat" && ( - <Chat - host={config.host} - tabbed={config.tabbed} - backFromChat={goBack} - /> - )} + {page.name === "chat" && <Chat />} {page.name === "fill in the middle debug page" && ( <FIMDebug host={config.host} tabbed={config.tabbed} /> )} diff --git a/refact-agent/gui/src/features/Chat/Chat.tsx b/refact-agent/gui/src/features/Chat/Chat.tsx index 93aef661e..5b1fd5f2b 100644 --- a/refact-agent/gui/src/features/Chat/Chat.tsx +++ b/refact-agent/gui/src/features/Chat/Chat.tsx @@ -1,49 +1,7 @@ import React from "react"; -import type { Config } from "../Config/configSlice"; import { Chat as ChatComponent } from "../../components/Chat"; -import { useAppSelector } from "../../hooks"; -import { selectMessages } from "./Thread"; - -export type ChatProps = { - host: Config["host"]; - tabbed: Config["tabbed"]; - style?: React.CSSProperties; - backFromChat: () => void; -}; - -export const Chat: React.FC<ChatProps> = ({ - style, - backFromChat, - host, - tabbed, -}) => { - const messages = useAppSelector(selectMessages); - - const sendToSideBar = () => { - // TODO: - }; - - const maybeSendToSideBar = - host === "vscode" && tabbed ? sendToSideBar : undefined; - - // can be a selector - const unCalledTools = React.useMemo(() => { - if (messages.length === 0) return false; - const last = messages[messages.length - 1]; - if (last.role !== "assistant") return false; - const maybeTools = last.tool_calls; - if (maybeTools && maybeTools.length > 0) return true; - return false; - }, [messages]); - - return ( - <ChatComponent - style={style} - host={host} - tabbed={tabbed} - backFromChat={backFromChat} - unCalledTools={unCalledTools} - maybeSendToSidebar={maybeSendToSideBar} - /> - ); +import { useThreadMessageSubscription } from "../../hooks/useThreadMessageSubscription"; +export const Chat: React.FC = () => { + useThreadMessageSubscription(); + return <ChatComponent />; }; diff --git a/refact-agent/gui/src/features/ChatDB/chatDbMessagesSlice.ts b/refact-agent/gui/src/features/ChatDB/chatDbMessagesSlice.ts new file mode 100644 index 000000000..234758491 --- /dev/null +++ b/refact-agent/gui/src/features/ChatDB/chatDbMessagesSlice.ts @@ -0,0 +1,127 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { + CMessageFromChatDB, + CThread, + CMessage, + ChatMessage, + UserCMessage, + isUserCMessage, +} from "../../services/refact"; +import { parseOrElse } from "../../utils"; +import { makeMessageTree, sortMessageList } from "./makeMessageTree"; +import { pagesSlice } from "../Pages/pagesSlice"; + +export interface CMessageNode { + message: CMessage; + children: CMessageNode[]; +} + +export type CMessageRoot = CMessageNode[]; + +export interface UserCMessageNode extends CMessageNode { + message: UserCMessage; +} + +export function isUserCMessageNode( + node: CMessageNode, +): node is UserCMessageNode { + return isUserCMessage(node.message); +} + +type InitialState = { + thread: Pick<CThread, "cthread_id" | "cthread_model" | "cthread_toolset">; + messageList: CMessage[]; + loading: boolean; + error: null | string; + endNumber: number; + endAlt: number; +}; + +const createChatThread = (): InitialState["thread"] => { + const thread = { + cthread_id: "", + cthread_toolset: "", + cthread_model: "", + }; + return thread; +}; + +const initialState: InitialState = { + thread: createChatThread(), + messageList: [], + loading: false, + error: null, + endNumber: 0, + endAlt: 0, +}; + +function parseCMessageFromChatDBToCMessage( + message: CMessageFromChatDB, +): CMessage | null { + // TODO: add a type guard to parseOrElse + const json = parseOrElse<ChatMessage | null>(message.cmessage_json, null); + if (json === null) return null; + return { + ...message, + cmessage_json: json, + }; +} + +export const chatDbMessageSlice = createSlice({ + name: "chatDbMessages", + initialState, + reducers: { + setThread: (state, action: PayloadAction<InitialState["thread"]>) => { + state.thread = action.payload; + }, + updateMessage: ( + state, + action: PayloadAction<{ threadId: string; message: CMessageFromChatDB }>, + ) => { + if (action.payload.threadId !== state.thread.cthread_id) return state; + const message = parseCMessageFromChatDBToCMessage(action.payload.message); + if (!message) return; + // Update message list + const updateIndex = state.messageList.findIndex( + (m) => + m.cmessage_num === message.cmessage_num && + m.cmessage_alt === message.cmessage_alt, + ); + if (updateIndex > -1) { + state.messageList[updateIndex] = message; + } else { + state.messageList.push(message); + state.messageList = sortMessageList(state.messageList); + } + }, + setEnd: (state, action: PayloadAction<{ number: number; alt: number }>) => { + state.endNumber = action.payload.number; + state.endAlt = action.payload.alt; + }, + }, + + extraReducers(builder) { + // TODO: maybe move this + builder.addMatcher(pagesSlice.actions.push.match, (state, action) => { + if (action.payload.name !== "chat") return state; + if (action.payload.threadId !== undefined) return state; + const thread = createChatThread(); + thread.cthread_model = state.thread.cthread_model; + thread.cthread_toolset = state.thread.cthread_toolset; + return { ...initialState, thread }; + }); + }, + + selectors: { + selectMessageTree: (state) => makeMessageTree(state.messageList), + selectThread: (state) => state.thread, + selectThreadId: (state) => state.thread.cthread_id, + selectLeafEndPosition: (state) => ({ + num: state.endNumber, + alt: state.endAlt, + }), + }, +}); + +export const chatDbMessageSliceActions = chatDbMessageSlice.actions; +export const chatDbMessagesSliceSelectors = chatDbMessageSlice.selectors; diff --git a/refact-agent/gui/src/features/ChatDB/chatDbSlice.ts b/refact-agent/gui/src/features/ChatDB/chatDbSlice.ts new file mode 100644 index 000000000..aa8413815 --- /dev/null +++ b/refact-agent/gui/src/features/ChatDB/chatDbSlice.ts @@ -0,0 +1,57 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { CThread } from "../../services/refact"; + +export type ChatDbState = { + loading: boolean; + error: string | null; + chats: Record<string, CThread>; +}; + +const initialState: ChatDbState = { + loading: false, + error: null, + chats: {}, +}; + +export const chatDbThreadsSlice = createSlice({ + name: "chatDbThreads", + initialState, + reducers: { + reset: () => initialState, + setLoading: (state, action: PayloadAction<boolean>) => { + state.loading = action.payload; + }, + setError: (state, action: PayloadAction<string>) => { + state.error = action.payload; + }, + startLoading: (state) => { + state.loading = true; + state.error = null; + state.chats = {}; + }, + updateCThread: (state, action: PayloadAction<CThread>) => { + state.chats[action.payload.cthread_id] = action.payload; + }, + deleteCThread: (state, action: PayloadAction<string>) => { + if (action.payload in state.chats) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete state.chats[action.payload]; + } + }, + }, + selectors: { + getChats: (state) => + Object.values(state.chats).sort( + (a, b) => b.cthread_updated_ts - a.cthread_updated_ts, + ), + getLoading: (state) => state.loading, + getError: (state) => state.error, + getThreadById: (state, id: string) => { + if (id in state.chats) return state.chats[id]; + return undefined; + }, + }, +}); + +export const chatDbActions = chatDbThreadsSlice.actions; +export const chatDbSelectors = chatDbThreadsSlice.selectors; diff --git a/refact-agent/gui/src/features/ChatDB/makeMessageTree.test.ts b/refact-agent/gui/src/features/ChatDB/makeMessageTree.test.ts new file mode 100644 index 000000000..3e861fce8 --- /dev/null +++ b/refact-agent/gui/src/features/ChatDB/makeMessageTree.test.ts @@ -0,0 +1,144 @@ +import { expect, describe, test } from "vitest"; +import { CMessageNode } from "./chatDbMessagesSlice"; +import { makeMessageTree } from "./makeMessageTree"; +import { + CMESSAGES_STUB, + CMESSAGES_WITH_NESTED_BRANCHES_STUB, +} from "../../__fixtures__"; + +const STUB = CMESSAGES_STUB; + +describe("makeMessageTree", () => { + test("no root", () => { + const tree = makeMessageTree([STUB[1], STUB[2]]); + expect(tree).toEqual(null); + }); + + test("only root", () => { + const tree = makeMessageTree([STUB[0]]); + expect(tree).toEqual({ + message: STUB[0], + children: [], + }); + }); + + test("root with one child", () => { + const input = [STUB[0], STUB[1]]; + const tree = makeMessageTree(input); + expect(tree).toEqual({ + message: input[0], + children: [ + { + message: input[1], + children: [], + }, + ], + }); + }); + + test("root with two children", () => { + const input = [STUB[0], STUB[1], STUB[2]]; + const tree = makeMessageTree(input); + expect(tree).toEqual({ + message: input[0], + children: [ + { + message: input[1], + children: [], + }, + { message: input[2], children: [] }, + ], + }); + }); + + test("root with nested children", () => { + const input = [STUB[0], STUB[1], STUB[2], STUB[3], STUB[4]]; + const tree = makeMessageTree(input); + expect(tree).toEqual({ + message: input[0], + children: [ + { + message: input[1], + children: [ + { + message: input[3], + children: [], + }, + ], + }, + { message: input[2], children: [{ message: input[4], children: [] }] }, + ], + }); + }); + + test("full tries and replies", () => { + const tree = makeMessageTree(STUB); + expect(tree).toEqual({ + message: STUB[0], + children: [ + { + message: STUB[1], + children: [ + { + message: STUB[3], + children: [ + { + message: STUB[5], + children: [ + { + message: STUB[6], + children: [], + }, + ], + }, + ], + }, + ], + }, + { message: STUB[2], children: [{ message: STUB[4], children: [] }] }, + ], + }); + }); + + test("tries from tires", () => { + const input = CMESSAGES_WITH_NESTED_BRANCHES_STUB; + + const tree = makeMessageTree(input); + + const expected: CMessageNode = { + message: input[0], + children: [ + { + message: input[1], + children: [ + { + message: input[2], + children: [ + { + message: input[3], + children: [{ message: input[5], children: [] }], + }, + { + message: input[4], + children: [ + { + message: input[6], + children: [ + { + message: input[7], + children: [{ message: input[8], children: [] }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }; + + expect(tree).toEqual(expected); + }); +}); diff --git a/refact-agent/gui/src/features/ChatDB/makeMessageTree.ts b/refact-agent/gui/src/features/ChatDB/makeMessageTree.ts new file mode 100644 index 000000000..06d0b0614 --- /dev/null +++ b/refact-agent/gui/src/features/ChatDB/makeMessageTree.ts @@ -0,0 +1,45 @@ +import { CMessage } from "../../services/refact"; +import { CMessageNode } from "./chatDbMessagesSlice"; +import { partition } from "../../utils"; + +const isRoot = (message: CMessage): boolean => { + return message.cmessage_prev_alt === -1; +}; + +export function sortMessageList(messages: CMessage[]): CMessage[] { + return messages.slice(0).sort((a, b) => { + if (a.cmessage_num === b.cmessage_num) { + return a.cmessage_alt - b.cmessage_alt; + } + return a.cmessage_num - b.cmessage_num; + }); +} + +export const makeMessageTree = (messages: CMessage[]): CMessageNode | null => { + const sortedMessages = sortMessageList(messages); + + const [nodes, roots] = partition(sortedMessages, isRoot); + if (roots.length === 0) return null; + // TODO: handle multiple roots; + const root = roots[0]; + const children = getChildren(root, nodes); + return { + message: root, + children, + }; +}; + +function getChildren(parent: CMessage, messages: CMessage[]): CMessageNode[] { + if (messages.length === 0) return []; + const rowNumber = parent.cmessage_num + 1; + const [other, siblings] = partition(messages, (m) => { + return ( + m.cmessage_num === rowNumber && + m.cmessage_prev_alt === parent.cmessage_alt + ); + }); + + return siblings.map((s) => { + return { message: s, children: getChildren(s, other) }; + }); +} diff --git a/refact-agent/gui/src/features/Pages/pagesSlice.ts b/refact-agent/gui/src/features/Pages/pagesSlice.ts index b9270d13b..b2abe86d3 100644 --- a/refact-agent/gui/src/features/Pages/pagesSlice.ts +++ b/refact-agent/gui/src/features/Pages/pagesSlice.ts @@ -14,6 +14,7 @@ export interface HistoryList { export interface ChatPage { name: "chat"; + threadId?: string; } export interface FIMDebugPage { diff --git a/refact-agent/gui/src/hooks/useThreadId.ts b/refact-agent/gui/src/hooks/useThreadId.ts new file mode 100644 index 000000000..d406a12f6 --- /dev/null +++ b/refact-agent/gui/src/hooks/useThreadId.ts @@ -0,0 +1,11 @@ +import { v4 as uuid } from "uuid"; +import { selectCurrentPage } from "../features/Pages/pagesSlice"; +import { useAppSelector } from "./useAppSelector"; + +export function useThreadId() { + const page = useAppSelector(selectCurrentPage); + if (page?.name !== "chat" || page.threadId === undefined) { + return uuid(); + } + return page.threadId; +} diff --git a/refact-agent/gui/src/hooks/useThreadMessageSubmit.ts b/refact-agent/gui/src/hooks/useThreadMessageSubmit.ts new file mode 100644 index 000000000..b5e9744f9 --- /dev/null +++ b/refact-agent/gui/src/hooks/useThreadMessageSubmit.ts @@ -0,0 +1,126 @@ +import { useCallback, useMemo } from "react"; +import { v4 as uuidv4 } from "uuid"; +import { useAppSelector } from "./useAppSelector"; +import { + chatDbMessageSliceActions, + chatDbMessagesSliceSelectors, +} from "../features/ChatDB/chatDbMessagesSlice"; +import { + updateCMessagesThunk, + updateThreadThunk, +} from "../services/refact/chatdb"; +import { useAppDispatch } from "./useAppDispatch"; +import { useSendChatRequest } from "./useSendChatRequest"; +import { + getSelectedSystemPrompt, + selectThreadToolUse, +} from "../features/Chat/Thread/selectors"; +import { CMessage, SystemMessage } from "../services/refact"; +import { useGetCapsQuery } from "./useGetCapsQuery"; +import { useGetPromptsQuery } from "./useGetPromptsQuery"; + +export function useThreadMessageSubmit() { + const dispatch = useAppDispatch(); + const { maybeAddImagesToQuestion } = useSendChatRequest(); + const selectedSystemPrompt = useAppSelector(getSelectedSystemPrompt); + const prompts = useGetPromptsQuery(); + const toolUse = useAppSelector(selectThreadToolUse); + const caps = useGetCapsQuery(); + + const thread = useAppSelector(chatDbMessagesSliceSelectors.selectThread); + const leafPosition = useAppSelector( + chatDbMessagesSliceSelectors.selectLeafEndPosition, + ); + + const systemMessageText = useMemo(() => { + const defualtPropmpt = prompts.data?.default?.text ?? ""; + const selected = Object.values(selectedSystemPrompt); + const prompt = selected.length > 0 ? selected[0].text : defualtPropmpt; + return prompt; + }, [prompts.data, selectedSystemPrompt]); + + const isNew = useMemo(() => { + return !thread.cthread_id; + }, [thread.cthread_id]); + + // TODO: use the hooks from crateApi for submitting threads and messages + const submit = useCallback( + async (question: string) => { + const threadId = thread.cthread_id || uuidv4(); + const threadModel = + (thread.cthread_model || caps.data?.code_chat_default_model) ?? ""; + const threadToolUse = (thread.cthread_toolset || toolUse) ?? ""; + const newThread = { + ...thread, + cthread_id: threadId, + cthread_model: threadModel, + cthread_toolset: threadToolUse, + }; + + const messagesToSend: CMessage[] = []; + + console.log({ thread, isNew }); + + if (isNew) { + const threadThunk = updateThreadThunk(newThread); + await dispatch(threadThunk); // .unwrap(); // TODO: handle errors + // this will subscribe to the thread's message list + dispatch(chatDbMessageSliceActions.setThread(newThread)); + + const systemMessage: SystemMessage = { + role: "system", + content: systemMessageText, + }; + + const systemCMessage: CMessage = { + cmessage_belongs_to_cthread_id: threadId, + cmessage_alt: 0, + cmessage_num: 0, + cmessage_prev_alt: -1, + cmessage_usage_model: threadModel, // could be default + cmessage_usage_prompt: 0, + cmessage_usage_completion: 0, + cmessage_json: systemMessage, + }; + + messagesToSend.push(systemCMessage); + } + + const userMessage = maybeAddImagesToQuestion(question); + const userCMessage: CMessage = { + cmessage_belongs_to_cthread_id: threadId, + cmessage_alt: leafPosition.alt, + cmessage_num: leafPosition.num + 1, + cmessage_prev_alt: leafPosition.alt, // TODO: add this to end tracker + cmessage_usage_model: threadModel, + cmessage_usage_prompt: 0, + cmessage_usage_completion: 0, + cmessage_json: userMessage, + }; + + messagesToSend.push(userCMessage); + + console.log({ isNew, messagesToSend }); + + const thunk = await dispatch(updateCMessagesThunk(messagesToSend)); + + return thunk; + }, + [ + caps.data?.code_chat_default_model, + dispatch, + isNew, + leafPosition.alt, + leafPosition.num, + maybeAddImagesToQuestion, + systemMessageText, + thread, + toolUse, + ], + ); + + return { submit }; + + // check if system message is needed, + // then send the messages +} diff --git a/refact-agent/gui/src/hooks/useThreadMessageSubscription.ts b/refact-agent/gui/src/hooks/useThreadMessageSubscription.ts new file mode 100644 index 000000000..c82c4bbe1 --- /dev/null +++ b/refact-agent/gui/src/hooks/useThreadMessageSubscription.ts @@ -0,0 +1,22 @@ +import { useEffect } from "react"; +import { useAppDispatch } from "./useAppDispatch"; +import { subscribeToThreadMessagesThunk } from "../services/refact"; +import { useAppSelector } from "./useAppSelector"; +import { chatDbMessagesSliceSelectors } from "../features/ChatDB/chatDbMessagesSlice"; + +export function useThreadMessageSubscription() { + const threadId = useAppSelector(chatDbMessagesSliceSelectors.selectThreadId); + const dispatch = useAppDispatch(); + useEffect(() => { + console.log("Subscribe to thread messages: " + threadId); + const thunk = dispatch(subscribeToThreadMessagesThunk(threadId)); + return () => { + try { + thunk.catch(() => ({})); + thunk.abort(`aborted: subscribeToThreadMessagesThunk(${threadId})`); + } catch (e) { + // no-op + } + }; + }, [dispatch, threadId]); +} diff --git a/refact-agent/gui/src/services/refact/chatdb.ts b/refact-agent/gui/src/services/refact/chatdb.ts new file mode 100644 index 000000000..06c0045e7 --- /dev/null +++ b/refact-agent/gui/src/services/refact/chatdb.ts @@ -0,0 +1,377 @@ +import { createAsyncThunk } from "@reduxjs/toolkit/react"; +import { AppDispatch, RootState } from "../../app/store"; +import { + CHAT_DB_THREADS_SUB, + CHAT_DB_MESSAGES_SUB, + CHAT_DB_MESSAGES_UPDATE, + CHAT_DB_THREADS_UPDATE, +} from "./consts"; +import { consumeStream } from "../../features/Chat/Thread/utils"; +import { + isCThreadSubResponseUpdate, + isCThreadSubResponseDelete, + isCMessageUpdateResponse, + CMessage, + CThread, + CMessageFromChatDB, +} from "./types"; +import { chatDbActions } from "../../features/ChatDB/chatDbSlice"; +import { chatDbMessageSliceActions } from "../../features/ChatDB/chatDbMessagesSlice"; + +const createAppAsyncThunk = createAsyncThunk.withTypes<{ + state: RootState; + dispatch: AppDispatch; +}>(); + +export type SubscribeToThreadArgs = + | { + quick_search?: string; + limit?: number; + } + | undefined; +function subscribeToThreads( + args: SubscribeToThreadArgs = {}, + port = 8001, + apiKey?: string | null, + abortSignal?: AbortSignal, +): Promise<Response> { + const url = `http://127.0.0.1:${port}${CHAT_DB_THREADS_SUB}`; + const headers = new Headers(); + headers.append("Content-Type", "application/json"); + if (apiKey) { + headers.append("Authorization", `Bearer ${apiKey}`); + } + + return fetch(url, { + method: "POST", + headers, + redirect: "follow", + cache: "no-cache", + body: JSON.stringify(args), + signal: abortSignal, + }); +} + +// type CThreadSubResponse = CThreadSubResponseUpdate | CThreadSubResponseDelete; +// function isCThreadSubResponseChunk(value: unknown): value is CThreadSubResponse { +// if (isCThreadSubResponseUpdate(value)) return true; +// if (isCThreadSubResponseDelete(value)) return true; +// return false; +// } + +export const subscribeToThreadsThunk = createAppAsyncThunk< + unknown, + SubscribeToThreadArgs +>("chatdbApi/subscribeToThreads", (args, thunkApi) => { + const state = thunkApi.getState() as unknown as RootState; + const port = state.config.lspPort; + const apiKey = state.config.apiKey; + return subscribeToThreads(args, port, apiKey, thunkApi.signal) + .then((response) => { + if (!response.ok) { + throw new Error(response.statusText); + } + const reader = response.body?.getReader(); + if (!reader) return; + + const onAbort = () => { + console.log("knowledge stream aborted"); + }; + + const onChunk = (chunk: unknown) => { + if (isCThreadSubResponseUpdate(chunk)) { + const action = chatDbActions.updateCThread(chunk.cthread_rec); + thunkApi.dispatch(action); + // dispatch update + } else if (isCThreadSubResponseDelete(chunk)) { + const action = chatDbActions.deleteCThread(chunk.cthread_id); + thunkApi.dispatch(action); + // dispatch delete + } else { + console.log("unknown thread chunk", chunk); + } + }; + + return consumeStream(reader, thunkApi.signal, onAbort, onChunk); + }) + .catch((err) => { + // eslint-disable-next-line no-console + console.error("Error in chat thread subscription", err); + // todo: handle error + }); +}); + +export function updateThread( + thread: Partial<CThread> & Pick<CThread, "cthread_id">, + port = 8001, + apiKey?: string | null, +): Promise<Response> { + const url = `http://127.0.0.1:${port}${CHAT_DB_THREADS_UPDATE}`; + const headers = new Headers(); + headers.append("Content-Type", "application/json"); + if (apiKey) { + headers.append("Authorization", `Bearer ${apiKey}`); + } + + return fetch(url, { + method: "POST", + headers, + redirect: "follow", + cache: "no-cache", + body: JSON.stringify(thread), + }); +} + +export const updateThreadThunk = createAppAsyncThunk< + unknown, + Partial<CThread> & Pick<CThread, "cthread_id"> +>("chatDbApi/updateThread", (thread, thunkApi) => { + const state = thunkApi.getState() as unknown as RootState; + const port = state.config.lspPort; + const apiKey = state.config.apiKey; + const updatedThread = { + ...thread, + cthread_updated_ts: Date.now(), + cthread_created_ts: thread.cthread_created_ts ?? Date.now(), + }; + return updateThread(updatedThread, port, apiKey).then((res) => { + return res.json(); + }); +}); + +function subscribeToThreadMessages( + cthreadId: string, + port = 8001, + apiKey?: string | null, + abortSignal?: AbortSignal, +): Promise<Response> { + const url = `http://127.0.0.1:${port}${CHAT_DB_MESSAGES_SUB}`; + const headers = new Headers(); + headers.append("Content-Type", "application/json"); + if (apiKey) { + headers.append("Authorization", `Bearer ${apiKey}`); + } + + return fetch(url, { + method: "POST", + headers, + redirect: "follow", + cache: "no-cache", + body: JSON.stringify({ cmessage_belongs_to_cthread_id: cthreadId }), + signal: abortSignal, + }); +} + +export const subscribeToThreadMessagesThunk = createAppAsyncThunk< + unknown, + string +>("chatDbApi/subscribeToThreadMessages", (cthreadId, thunkApi) => { + if (!cthreadId) return; + const state = thunkApi.getState() as unknown as RootState; + const port = state.config.lspPort; + const apiKey = state.config.apiKey; + + return subscribeToThreadMessages(cthreadId, port, apiKey, thunkApi.signal) + .then((response) => { + if (!response.ok) { + throw new Error(response.statusText); + } + const reader = response.body?.getReader(); + if (!reader) return; + + const onAbort = () => { + console.log("message stream aborted"); + }; + + const onChunk = (chunk: Record<string, unknown>) => { + console.log("cmessages chunks"); + console.log({ chunk }); + if (isCMessageUpdateResponse(chunk)) { + const action = chatDbMessageSliceActions.updateMessage({ + threadId: cthreadId, + message: chunk.cmessage_rec, + }); + thunkApi.dispatch(action); + } else { + console.log("invalid chunk"); + } + }; + + return consumeStream(reader, thunkApi.signal, onAbort, onChunk); + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.error("Error in chat thread subscription", error); + // todo: handle error + }); +}); + +// How does this add a context file ? or images ? +export function updateCMessage( + cmessages: CMessage[], + port = 8001, + apiKey?: string | null, +) { + const url = `http://127.0.0.1:${port}${CHAT_DB_MESSAGES_UPDATE}`; + const headers = new Headers(); + headers.append("Content-Type", "application/json"); + if (apiKey) { + headers.append("Authorization", `Bearer ${apiKey}`); + } + + const messages: CMessageFromChatDB[] = cmessages.map((message) => { + return { ...message, cmessage_json: JSON.stringify(message.cmessage_json) }; + }); + + return fetch(url, { + method: "POST", + headers, + redirect: "follow", + cache: "no-cache", + body: JSON.stringify(messages), + }); +} + +export const updateCMessagesThunk = createAppAsyncThunk<unknown, CMessage[]>( + "chatDbApi/updateCMessagesThunk", + (cmessages, thunkApi) => { + const state = thunkApi.getState() as unknown as RootState; + const port = state.config.lspPort; + const apiKey = state.config.apiKey; + return updateCMessage(cmessages, port, apiKey).then((res) => res.json()); + }, +); + +// export const sendMessagesThunk = createAppAsyncThunk< +// unknown, +// { +// messages: CMessage[]; +// } +// >("chatDbApi/sendThreadAndMessagesThunk", async (args, thunkApi) => { +// if (args.messages.length === 0) return; +// const id = args.messages.map((m) => m.cmessage_belongs_to_cthread_id)[0]; +// await thunkApi.dispatch( +// updateThreadThunk({ +// cthread_id: id, +// cthread_updated_ts: new Date().getTime(), +// }), +// ); + +// await thunkApi.dispatch(updateCMessagesThunk(args.messages)); +// }); + +// TODO: create thread an subscribe to messages + +// Types for the API + +// export interface Chore { +// chore_id: string; +// chore_title: string; +// chore_spontaneous_work_enable: boolean; +// chore_created_ts: number; +// chore_archived_ts: number; +// } + +// export interface ChoreEvent { +// chore_event_id: string; +// chore_event_belongs_to_chore_id: string; +// chore_event_summary: string; +// chore_event_ts: number; +// chore_event_link: string; +// chore_event_cthread_id: string | null; +// } + +// // Request types +// export interface CThreadSubscription { +// quicksearch?: string; +// limit?: number; +// } + +// export interface CMessagesSubscription { +// cmessage_belongs_to_cthread_id: string; +// } + +// API definition +// export const chatDbApi = createApi({ +// reducerPath: "chatdbApi", +// baseQuery: fetchBaseQuery({ +// prepareHeaders: (headers, { getState }) => { +// const token = (getState() as RootState).config.apiKey; +// if (token) { +// headers.set("Authorization", `Bearer ${token}`); +// } +// return headers; +// }, +// }), +// endpoints: (builder) => ({ +// // Threads +// subscribeCThreads: builder.mutation<void, CThreadSubscription>({ +// query: (subscription) => ({ +// url: "/cthreads-sub", +// method: "POST", +// body: subscription, +// }), +// }), +// updateCThread: builder.mutation< +// { status: string; cthread: CThread }, +// Partial<CThread> +// >({ +// query: (thread) => ({ +// url: "/cthread-update", +// method: "POST", +// body: thread, +// }), +// }), + +// // Messages +// subscribeCMessages: builder.mutation<void, CMessagesSubscription>({ +// query: (subscription) => ({ +// url: "/cmessages-sub", +// method: "POST", +// body: subscription, +// }), +// }), +// updateCMessages: builder.mutation<{ status: string }, CMessage[]>({ +// query: (messages) => ({ +// url: "/cmessages-update", +// method: "POST", +// body: messages, +// }), +// }), + +// // Chores +// subscribeChores: builder.mutation<void, void>({ +// query: () => ({ +// url: "/chores-sub", +// method: "POST", +// }), +// }), +// updateChore: builder.mutation<{ status: string }, Partial<Chore>>({ +// query: (chore) => ({ +// url: "/chore-update", +// method: "POST", +// body: chore, +// }), +// }), +// updateChoreEvent: builder.mutation<{ status: string }, Partial<ChoreEvent>>( +// { +// query: (event) => ({ +// url: "/chore-event-update", +// method: "POST", +// body: event, +// }), +// }, +// ), +// }), +// }); + +// // Export hooks for usage in components +// export const { +// useSubscribeCThreadsMutation, +// useUpdateCThreadMutation, +// useSubscribeCMessagesMutation, +// useUpdateCMessagesMutation, +// useSubscribeChoresMutation, +// useUpdateChoreMutation, +// useUpdateChoreEventMutation, +// } = chatDbApi; diff --git a/refact-agent/gui/src/services/refact/consts.ts b/refact-agent/gui/src/services/refact/consts.ts index dedc0afa8..dff274365 100644 --- a/refact-agent/gui/src/services/refact/consts.ts +++ b/refact-agent/gui/src/services/refact/consts.ts @@ -40,3 +40,8 @@ export const KNOWLEDGE_REMOVE_URL = "/v1/mem-erase"; export const KNOWLEDGE_UPDATE_USED_URL = "/v1/mem-update-used"; export const KNOWLEDGE_UPDATE_URL = "/v1/mem-upd"; export const KNOWLEDGE_CREATE_URL = "/v1/trajectory-save"; +// Chatdblinks +export const CHAT_DB_THREADS_SUB = "/db_v1/cthreads-sub"; +export const CHAT_DB_THREADS_UPDATE = "/db_v1/cthread-update"; +export const CHAT_DB_MESSAGES_SUB = "/db_v1/cmessages-sub"; +export const CHAT_DB_MESSAGES_UPDATE = "/db_v1/cmessages-update"; diff --git a/refact-agent/gui/src/services/refact/index.ts b/refact-agent/gui/src/services/refact/index.ts index 6153995d8..0f573cedc 100644 --- a/refact-agent/gui/src/services/refact/index.ts +++ b/refact-agent/gui/src/services/refact/index.ts @@ -13,3 +13,4 @@ export * from "./integrations"; export * from "./docker"; export * from "./telemetry"; export * from "./knowledge"; +export * from "./chatdb"; diff --git a/refact-agent/gui/src/services/refact/types.ts b/refact-agent/gui/src/services/refact/types.ts index f53cbf0b3..0ca40d88b 100644 --- a/refact-agent/gui/src/services/refact/types.ts +++ b/refact-agent/gui/src/services/refact/types.ts @@ -124,6 +124,15 @@ interface BaseMessage { | (UserMessageContentWithImage | ProcessedUserMessageContentWithImages)[]; } +function isBaseMessage(json: unknown): json is BaseMessage { + if (!json) return false; + if (typeof json !== "object") return false; + if (!("role" in json)) return false; + if (typeof json.role !== "string") return false; + if (!("content" in json)) return false; + return true; +} + export interface ChatContextFileMessage extends BaseMessage { role: "context_file"; content: ChatContextFile[]; @@ -220,7 +229,7 @@ export interface DiffMessage extends BaseMessage { tool_call_id: string; } -export function isUserMessage(message: ChatMessage): message is UserMessage { +export function isUserMessage(message: BaseMessage): message is UserMessage { return message.role === "user"; } @@ -301,6 +310,20 @@ export function isCDInstructionMessage( return message.role === "cd_instruction"; } +export function isAChatMessage(value: unknown) { + if (!value) return false; + if (typeof value !== "object") return false; +} + +// function isChatMessage(json: unknown): json is ChatMessage { +// if (!json) return false; +// if (typeof json !== "object") return false; +// if (!("role" in json)) return false; +// if(typeof json.role !== "string") return false; +// if(isChatContextFileMessage(json)) return false; + +// } + interface BaseDelta { role?: ChatRole | null; } @@ -671,3 +694,245 @@ export function isMCPEnvironmentsDict(json: unknown): json is MCPEnvs { return Object.values(json).every((value) => typeof value === "string"); } + +// ChatDB + +export type CThread = { + cthread_id: string; + cthread_belongs_to_chore_event_id: string | null; + cthread_title: string; + cthread_toolset: string; + cthread_model: string; + cthread_temperature: number; + cthread_n_ctx: number; + cthread_max_new_tokens: number; + cthread_n: number; + cthread_error: string; + cthread_anything_new: boolean; + cthread_created_ts: number; + cthread_updated_ts: number; + cthread_archived_ts: number; + cthread_locked_by: string; + cthread_locked_ts: number; +}; + +// export type CThreadDefault = Partial<CThread> & { +// cthread_id: null | string; +// cthread_title: string; +// cthread_toolset: string; +// cthread_model: string; +// }; +export function isCThread(value: unknown): value is CThread { + if (!value || typeof value !== "object") { + return false; + } + + if (!("cthread_id" in value) || typeof value.cthread_id !== "string") { + return false; + } + + if ( + !("cthread_belongs_to_chore_event_id" in value) || + (value.cthread_belongs_to_chore_event_id !== null && + typeof value.cthread_belongs_to_chore_event_id !== "string") + ) { + return false; + } + + if (!("cthread_title" in value) || typeof value.cthread_title !== "string") { + return false; + } + + if ( + !("cthread_toolset" in value) || + typeof value.cthread_toolset !== "string" + ) { + return false; + } + + if (!("cthread_model" in value) || typeof value.cthread_model !== "string") { + return false; + } + + if ( + !("cthread_temperature" in value) || + typeof value.cthread_temperature !== "number" + ) { + return false; + } + + if (!("cthread_n_ctx" in value) || typeof value.cthread_n_ctx !== "number") { + return false; + } + + if ( + !("cthread_max_new_tokens" in value) || + typeof value.cthread_max_new_tokens !== "number" + ) { + return false; + } + + if (!("cthread_n" in value) || typeof value.cthread_n !== "number") { + return false; + } + + if (!("cthread_error" in value) || typeof value.cthread_error !== "string") { + return false; + } + + if ( + !("cthread_anything_new" in value) || + typeof value.cthread_anything_new !== "boolean" + ) { + return false; + } + + if ( + !("cthread_created_ts" in value) || + typeof value.cthread_created_ts !== "number" + ) { + return false; + } + + if ( + !("cthread_updated_ts" in value) || + typeof value.cthread_updated_ts !== "number" + ) { + return false; + } + + if ( + !("cthread_archived_ts" in value) || + typeof value.cthread_archived_ts !== "number" + ) { + return false; + } + + if ( + !("cthread_locked_by" in value) || + typeof value.cthread_locked_by !== "string" + ) { + return false; + } + + if ( + !("cthread_locked_ts" in value) || + typeof value.cthread_locked_ts !== "number" + ) { + return false; + } + + return true; +} + +type CThreadSubResponseUpdate = { + sub_event: "cthread_update"; + cthread_rec: CThread; +}; + +export function isCThreadSubResponseUpdate( + value: unknown, +): value is CThreadSubResponseUpdate { + if (!value || typeof value !== "object") return false; + if (!("sub_event" in value)) return false; + if (typeof value.sub_event !== "string") return false; + if (!("cthread_rec" in value)) return false; + return isCThread(value.cthread_rec); +} + +type CThreadSubResponseDelete = { + sub_event: "cthread_delete"; + cthread_id: string; +}; + +export function isCThreadSubResponseDelete( + value: unknown, +): value is CThreadSubResponseDelete { + if (!value || typeof value !== "object") return false; + if (!("sub_event" in value)) return false; + if (typeof value.sub_event !== "string") return false; + if (!("cthread_id" in value)) return false; + if (typeof value.cthread_id !== "string") return false; + return true; +} + +export type CMessageFromChatDB = { + cmessage_belongs_to_cthread_id: string; + cmessage_alt: number; + cmessage_num: number; + cmessage_prev_alt: number; + cmessage_usage_model: string; + cmessage_usage_prompt: number; + cmessage_usage_completion: number; + cmessage_json: string; // stringified json ChatMessage +}; + +export function isCMessageFromChatDB( + value: unknown, +): value is CMessageFromChatDB { + if (!value || typeof value !== "object") return false; + if (!("cmessage_belongs_to_cthread_id" in value)) return false; + if (typeof value.cmessage_belongs_to_cthread_id !== "string") return false; + if (!("cmessage_alt" in value)) return false; + if (typeof value.cmessage_alt !== "number") return false; + if (!("cmessage_num" in value)) return false; + if (typeof value.cmessage_num !== "number") return false; + if (!("cmessage_prev_alt" in value)) return false; + if (typeof value.cmessage_prev_alt !== "number") return false; + if (!("cmessage_usage_model" in value)) return false; + if (typeof value.cmessage_usage_model !== "string") return false; + if (!("cmessage_usage_prompt" in value)) return false; + if (typeof value.cmessage_usage_prompt !== "number") return false; + if (!("cmessage_usage_completion" in value)) return false; + if (typeof value.cmessage_usage_completion !== "number") return false; + if (!("cmessage_json" in value)) return false; + if (typeof value.cmessage_json !== "string") return false; + return true; +} + +export type CMessage = Omit<CMessageFromChatDB, "cmessage_json"> & { + cmessage_json: ChatMessage; +}; + +export type UserCMessage = Omit<CMessage, "cmessage_json"> & { + cmessage_json: UserMessage; +}; + +export function isUserCMessage(value: unknown): value is UserCMessage { + if (!value || typeof value !== "object") return false; + if (!("cmessage_belongs_to_cthread_id" in value)) return false; + if (typeof value.cmessage_belongs_to_cthread_id !== "string") return false; + if (!("cmessage_alt" in value)) return false; + if (typeof value.cmessage_alt !== "number") return false; + if (!("cmessage_num" in value)) return false; + if (typeof value.cmessage_num !== "number") return false; + if (!("cmessage_prev_alt" in value)) return false; + if (typeof value.cmessage_prev_alt !== "number") return false; + if (!("cmessage_usage_model" in value)) return false; + if (typeof value.cmessage_usage_model !== "string") return false; + if (!("cmessage_usage_prompt" in value)) return false; + if (typeof value.cmessage_usage_prompt !== "number") return false; + if (!("cmessage_usage_completion" in value)) return false; + if (typeof value.cmessage_usage_completion !== "number") return false; + if (!("cmessage_json" in value)) return false; + if (!value.cmessage_json) return false; + if (!isBaseMessage(value.cmessage_json)) return false; + if (!isUserMessage(value.cmessage_json)) return false; + return true; +} + +export type CMessageUpdateResponse = { + sub_event: "cmessage_update"; + cmessage_rec: CMessageFromChatDB; +}; + +export function isCMessageUpdateResponse( + value: unknown, +): value is CMessageUpdateResponse { + if (!value || typeof value !== "object") return false; + if (!("sub_event" in value)) return false; + if (typeof value.sub_event !== "string") return false; + if (value.sub_event !== "cmessage_update") return false; + if (!("cmessage_rec" in value)) return false; + return isCMessageFromChatDB(value.cmessage_rec); +}