File size: 20,339 Bytes
d2e3e49
8a5733d
d2e3e49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c599b1d
d2e3e49
cdb6e0f
 
 
62d2652
 
 
 
 
cdb6e0f
 
 
62d2652
 
 
cdb6e0f
 
 
 
 
 
62d2652
cdb6e0f
 
 
 
62d2652
 
 
 
 
 
 
 
 
 
cdb6e0f
 
 
 
d2e3e49
 
878e587
 
d2e3e49
391977c
d2e3e49
391977c
d2e3e49
 
 
 
 
 
f430727
d2e3e49
 
 
 
 
 
 
 
 
 
 
 
 
96940db
 
d2e3e49
84ac6e6
d2e3e49
 
 
 
 
84ac6e6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d2e3e49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10c2304
ddc6dc3
10c2304
 
d2e3e49
878e587
 
d2e3e49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
878e587
 
 
 
 
 
d2e3e49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96940db
 
 
 
 
 
d2e3e49
 
 
 
 
878e587
96940db
878e587
 
 
 
 
10c2304
ddc6dc3
10c2304
 
 
878e587
10c2304
d2e3e49
878e587
d2e3e49
 
878e587
 
4b13c32
 
 
878e587
d2e3e49
10c2304
 
 
d2e3e49
 
 
 
96940db
 
 
 
 
 
 
 
 
 
 
878e587
d2e3e49
 
878e587
d2e3e49
 
878e587
 
 
96940db
 
 
878e587
cdb6e0f
 
 
 
093a5f3
62d2652
878e587
cdb6e0f
62d2652
cdb6e0f
62d2652
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
093a5f3
d2e3e49
 
 
 
878e587
d2e3e49
878e587
d2e3e49
878e587
d2e3e49
cdb6e0f
878e587
 
 
d2e3e49
 
 
 
 
 
 
 
 
 
 
cdb6e0f
d2e3e49
 
 
 
cdb6e0f
d2e3e49
878e587
d2e3e49
878e587
d2e3e49
cdb6e0f
 
 
 
d2e3e49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
878e587
d2e3e49
 
 
 
878e587
d2e3e49
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
import os
import re
import streamlit as st
import requests
import base64
import json
import shutil
from urllib.parse import urlparse
from git import Repo
from git.exc import GitCommandError
from typing import List, Dict, Any, TypedDict, Annotated
import operator
import asyncio
from langchain.tools import StructuredTool, Tool
from langchain_core.pydantic_v1 import BaseModel, Field
from langchain_core.messages import BaseMessage, HumanMessage
from langchain_anthropic import ChatAnthropic
from langchain_community.tools import ShellTool
from langgraph.prebuilt import create_react_agent
from langgraph.checkpoint.memory import MemorySaver

st.markdown("""
<style>
    .stCodeBlock {
        background-color: #f6f8fa;
        border: 1px solid #e1e4e8;
        border-radius: 6px;
        padding: 16px;
        position: relative;
    }
    .stCodeBlock pre {
        margin: 0;
        padding: 0;
        white-space: pre-wrap;
        word-break: break-word;
    }
    .copyButton {
        position: absolute;
        top: 5px;
        right: 5px;
        padding: 5px 10px;
        background-color: #0366d6;
        color: white;
        border: none;
        border-radius: 3px;
        cursor: pointer;
        font-size: 12px;
    }
    .copyButton:hover {
        background-color: #0056b3;
    }
    code {
        padding: 2px 4px;
        background-color: #f6f8fa;
        border-radius: 3px;
        font-family: monospace;
    }
</style>
""", unsafe_allow_html=True)


# Show title and description.
# Add a radio button for mode selection
mode = st.radio("Select Mode", ["Q/A", "Task"])
st.title("Coder for NextJS Templates")
st.markdown(
    "This chatbot connects to a Next.JS Github Repository to answer questions and modify code "
    "given the user's prompt. Please input your repo url and github token to allow the AI to connect, then query it by asking questions or requesting feature changes! Watch video about this app [here](https://www.youtube.com/watch?v=A3XCfAVWrH4&t=17s)"
)

# Ask user for their Github Repo URL, Github Token, and Anthropic API key via `st.text_input`.
os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_ENDPOINT"] = "https://api.smith.langchain.com"
os.environ["LANGCHAIN_PROJECT"] = "Github-Agent"
os.environ["LANGCHAIN_API_KEY"] = os.getenv("LANGCHAIN_API_KEY")

github_repo_url = st.text_input("Github Repo URL (e.g., https://github.com/user/repo)")

# Use st.markdown for the hyperlink text
st.markdown(
    '[How to get your Github Token](https://docs.github.com/en/[email protected]/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)'
)
github_token = st.text_input("Enter your Github Token", type="password")

# anthropic_api_key = st.text_input("Anthropic API Key", type="password")

anthropic_api_key = os.getenv("ANTHROPIC_API_KEY")

graph_tools = []

if not (github_repo_url and github_token and anthropic_api_key):
    st.info("Please add your Github Repo URL and Github Personal Token to continue.", icon="🗝️")
else:
    # Set environment variables
    os.environ["ANTHROPIC_API_KEY"] = anthropic_api_key
    os.environ["GITHUB_TOKEN"] = github_token

    # Add the buttons after the inputs are provided
    if "use_sonnet" not in st.session_state:
        st.session_state.use_sonnet = False

    if "show_system_prompt" not in st.session_state:
        st.session_state.show_system_prompt = False

    col1, col2 = st.columns(2)

    with col1:
        if st.button("Show System Prompt" if not st.session_state.show_system_prompt else "Hide System Prompt"):
            st.session_state.show_system_prompt = not st.session_state.show_system_prompt

    with col2:
        if st.button("Use Sonnet 3.5"):
            st.session_state.use_sonnet = True

    if st.session_state.use_sonnet:
        sonnet_api_key = st.text_input("Input Anthropic API Key for Sonnet 3.5", type="password")
        if sonnet_api_key:
            os.environ["ANTHROPIC_API_KEY"] = sonnet_api_key

    # Parse the repository URL to extract user_name and REPO_NAME
    parsed_url = urlparse(github_repo_url)
    path_parts = parsed_url.path.strip('/').split('/')
    if len(path_parts) == 2:
        user_name, repo_name = path_parts
    else:
        st.error("Invalid GitHub repository URL. Please ensure it is in the format: https://github.com/user/repo")
        st.stop()

    REPO_URL = f"https://{github_token}@github.com/{user_name}/{repo_name}.git"
    
    headers = {
        'Authorization': f'token {github_token}',
        'Accept': 'application/vnd.github.v3+json',
    }

    def force_clone_repo(*args, **kwargs) -> str:
        if os.path.exists(repo_name):
            shutil.rmtree(repo_name)
        try:
            Repo.clone_from(REPO_URL, repo_name)
            return f"Repository {repo_name} forcefully cloned successfully."
        except GitCommandError as e:
            return f"Error cloning repository: {str(e)}"

    force_clone_tool = Tool(
        name="force_clone_repo",
        func=force_clone_repo,
        description="Forcefully clone the repository, removing any existing local copy."
    )

    class WriteFileInput(BaseModel):
        file_path: str = Field(..., description="The path of the file to write to")
        content: str = Field(..., description="The content to write to the file")

    def write_file_content(file_path: str, content: str) -> str:
        full_path = os.path.join(repo_name, file_path)
        try:
            with open(full_path, 'w') as file:
                file.write(content)
            return f"Successfully wrote to {full_path}"
        except Exception as e:
            return f"Error writing to file: {str(e)}"

    file_write_tool = StructuredTool.from_function(
        func=write_file_content,
        name="write_file",
        description="Write content to a specific file in the repository.",
        args_schema=WriteFileInput
    )

    def read_file_content(file_path: str) -> str:
        force_clone_repo()  # Ensure we have the latest version before reading
        full_path = os.path.join(repo_name, file_path)
        try:
            with open(full_path, 'r') as file:
                content = file.read()
            return f"File content:\n{content}"
        except Exception as e:
            return f"Error reading file: {str(e)}"

    file_read_tool = Tool(
        name="read_file",
        func=read_file_content,
        description="Read content from a specific file in the repository."
    )

    class CommitPushInput(BaseModel):
        commit_message: str = Field(..., description="The commit message")

    def commit_and_push(commit_message: str) -> str:
        try:
            repo = Repo(repo_name)
            repo.git.add(A=True)
            repo.index.commit(commit_message)
            origin = repo.remote(name='origin')
            push_info = origin.push()

            if push_info:
                if push_info[0].flags & push_info[0].ERROR:
                    return f"Error pushing changes: {push_info[0].summary}"
                else:
                    return f"Changes committed and pushed successfully with message: {commit_message}"
            else:
                return "No changes to push"
        except GitCommandError as e:
            return f"GitCommandError: {str(e)}"
        except Exception as e:
            return f"Unexpected error: {str(e)}"

    commit_push_tool = StructuredTool.from_function(
        func=commit_and_push,
        name="commit_and_push",
        description="Commit and push changes to the repository with a specific commit message.",
        args_schema=CommitPushInput
    )

    tools = [force_clone_tool, file_read_tool, file_write_tool, commit_push_tool, ShellTool()]

    class AgentState(TypedDict):
        messages: Annotated[List[BaseMessage], operator.add]

    if st.session_state.use_sonnet and "ANTHROPIC_API_KEY" in os.environ:
        llm = ChatAnthropic(temperature=0, model_name="claude-3-5-sonnet-20240620")
    else:
        llm = ChatAnthropic(temperature=0, model_name="claude-3-haiku-20240307")

    # Modify the system prompts
    task_system_prompt_template = """You are an AI specialized in managing and analyzing a GitHub repository for a Next.js blog website.
    Your task is to answer user queries about the repository or execute tasks for modifying it.
    Before performing any operation, always use the force_clone_repo tool to ensure you have the latest version of the repository.
    Here is all of the code from the repository as well as the file paths for context of how the repo is structured: {REPO_CONTENT}
    Given this context, follow this prompt in completing the user's task:
    For user questions, provide direct answers based on the current state of the repository.
    For tasks given by the user, use the available tools and your knowledge of the repo to make necessary changes to the repository.
    When making changes, remember to force clone the repository first, make the changes, and then commit and push the changes.
    Available tools:
    1. shell_tool: Execute shell commands
    2. write_file: Write content to a specific file. Use as: write_file(file_path: str, content: str)
    3. force_clone_repo: Forcefully clone the repository, removing any existing local copy
    4. commit_and_push: Commit and push changes to the repository
    5. read_file: Read content from a specific file in the repository
    When using the write_file tool, always provide both the file_path and the content as separate arguments.
    Respond to the human's messages and use tools when necessary to complete tasks. Take a deep breath and think through the task step by step:"""
    
    qa_system_prompt_template = """You are an AI specialized in analyzing a GitHub repository for a Next.js blog website.
    Your task is to answer user queries about the repository based on the provided content.
    Here is all of the code from the repository as well as the file paths for context of how the repo is structured: {REPO_CONTENT}
    Given this context, provide direct answers to user questions based on the current state of the repository.
    Take a deep breath and think through the question step by step before answering:"""


    memory = MemorySaver()

    def extract_repo_info(url):
        parts = url.split('/')
        if 'github.com' not in parts:
            raise ValueError("Not a valid GitHub URL")

        owner = parts[parts.index('github.com') + 1]
        repo = parts[parts.index('github.com') + 2]

        path_start_index = parts.index(repo) + 1
        if path_start_index < len(parts) and parts[path_start_index] == 'tree':
            path_start_index += 2

        path = '/'.join(parts[path_start_index:])

        return owner, repo, path

    def get_repo_contents(owner, repo, path=''):
        api_url = f'https://api.github.com/repos/{owner}/{repo}/contents/{path}'
        response = requests.get(api_url, headers=headers)
        return response.json()

    def get_file_content_and_metadata(file_url):
        response = requests.get(file_url, headers=headers)
        content_data = response.json()
        content = content_data.get('content', '')

        if content:
            try:
                decoded_content = base64.b64decode(content)
                decoded_content_str = decoded_content.decode('utf-8')
            except (base64.binascii.Error, UnicodeDecodeError):
                decoded_content_str = content
        else:
            decoded_content_str = ''

        last_modified = content_data.get('last_modified') or response.headers.get('Last-Modified', '')

        return decoded_content_str, last_modified

    def is_valid_extension(filename):
        valid_extensions = ['.ipynb', '.py', '.js', '.md', '.mdx', 'tsx', 'ts', 'css', '.json']
        return any(filename.endswith(ext) for ext in valid_extensions)

    def process_repo(repo_url):
        owner, repo, initial_path = extract_repo_info(repo_url)
        result = []
        stack = [(initial_path, f'https://api.github.com/repos/{owner}/{repo}/contents/{initial_path}')]

        while stack:
            path, url = stack.pop()
            contents = get_repo_contents(owner, repo, path)

            if isinstance(contents, dict) and 'message' in contents:
                print(f"Error: {contents['message']}")
                return []

            for item in contents:
                if item['type'] == 'file':
                    if is_valid_extension(item['name']):
                        file_url = item['url']
                        file_content, last_modified = get_file_content_and_metadata(file_url)
                        if file_content:
                            result.append({
                                'url': item['html_url'],
                                'markdown': file_content,
                                'last_modified': last_modified
                            })
                elif item['type'] == 'dir':
                    stack.append((item['path'], item['url']))

        return result
    # Instead, add this block after the radio button for mode selection:
    if "task_system_prompt" not in st.session_state or "qa_system_prompt" not in st.session_state:
        st.session_state.task_system_prompt = task_system_prompt_template.format(REPO_CONTENT="")
        st.session_state.qa_system_prompt = qa_system_prompt_template.format(REPO_CONTENT="")
    
    # Modify the refresh_repo_data() function:
    def refresh_repo_data():
        repo_contents = process_repo(github_repo_url)
        repo_contents_json = json.dumps(repo_contents, ensure_ascii=False, indent=2)
        st.session_state.REPO_CONTENT = repo_contents_json
        st.success("Repository content refreshed successfully.")
    
        # Update both system prompts with the new repo content
        st.session_state.task_system_prompt = task_system_prompt_template.format(REPO_CONTENT=st.session_state.REPO_CONTENT)
        st.session_state.qa_system_prompt = qa_system_prompt_template.format(REPO_CONTENT=st.session_state.REPO_CONTENT)
    
        # Recreate the graphs with the updated system prompts
        global task_graph, qa_graph
        if st.session_state.use_sonnet and "ANTHROPIC_API_KEY" in os.environ:
            new_llm = ChatAnthropic(temperature=0, model_name="claude-3-5-sonnet-20240620")
        else:
            new_llm = ChatAnthropic(temperature=0, model_name="claude-3-haiku-20240307")
        
        task_graph = create_react_agent(
            new_llm,
            tools=tools,
            messages_modifier=st.session_state.task_system_prompt,
            checkpointer=memory
        )
        qa_graph = create_react_agent(
            new_llm,
            tools = graph_tools,
            messages_modifier=st.session_state.qa_system_prompt,
            checkpointer=memory
        )

    if st.session_state.use_sonnet and "ANTHROPIC_API_KEY" in os.environ:
        refresh_repo_data()

    # Automatically refresh repo data when keys are provided
    if "REPO_CONTENT" not in st.session_state:
        refresh_repo_data()

    # Modify the code that displays the current system prompt:
    if st.session_state.show_system_prompt:
        current_prompt = st.session_state.task_system_prompt if mode == "Task" else st.session_state.qa_system_prompt
        st.text_area("Current System Prompt", current_prompt, height=300)
    
    # Update the graph initialization:
    if st.session_state.use_sonnet and "ANTHROPIC_API_KEY" in os.environ:
        llm = ChatAnthropic(temperature=0, model_name="claude-3-5-sonnet-20240620")
    else:
        llm = ChatAnthropic(temperature=0, model_name="claude-3-haiku-20240307")
    
    task_graph = create_react_agent(
        llm,
        tools=tools,
        messages_modifier=st.session_state.task_system_prompt,
        checkpointer=memory
    )
    
    qa_graph = create_react_agent(
        llm,
        tools=graph_tools,
        messages_modifier=st.session_state.qa_system_prompt,
        checkpointer=memory
    )

    def format_ai_response(response):
        def replace_code_block(match):
            code = match.group(1).strip()
            code = re.sub(r'^(python|typescript|javascript)\n', '', code, flags=re.IGNORECASE)
            return f'<div class="stCodeBlock"><button class="copyButton" onclick="copyCode(this)">Copy</button><pre><code>{code}</code></pre></div>'
    
        formatted_response = re.sub(r'```(.*?)```', replace_code_block, response, flags=re.DOTALL)
        formatted_response = re.sub(r'`([^`\n]+)`', r'<code>\1</code>', formatted_response)
        
        # Add JavaScript for copy functionality
        js = """
        <script>
        function copyCode(button) {
            const pre = button.nextElementSibling;
            const code = pre.querySelector('code');
            const range = document.createRange();
            range.selectNode(code);
            window.getSelection().removeAllRanges();
            window.getSelection().addRange(range);
            document.execCommand('copy');
            window.getSelection().removeAllRanges();
            button.textContent = 'Copied!';
            setTimeout(() => {
                button.textContent = 'Copy';
            }, 2000);
        }
        </script>
        """
        return js + formatted_response
        
    async def run_github_editor(query: str, thread_id: str = "default"):
        inputs = {"messages": [HumanMessage(content=query)]}
        config = {
            "configurable": {"thread_id": thread_id},
            "recursion_limit": 50
        }
    
        st.write(f"Human: {query}\n")
    
        current_thought = ""
        full_response = ""
    
        graph = task_graph if mode == "Task" else qa_graph
    
        async for event in graph.astream_events(inputs, config=config, version="v2"):
            kind = event["event"]
            if kind == "on_chat_model_start":
                st.write("AI is thinking...")
            elif kind == "on_chat_model_stream":
                data = event["data"]
                if data["chunk"].content:
                    content = data["chunk"].content
                    if isinstance(content, list) and content and isinstance(content[0], dict):
                        text = content[0].get('text', '')
                        current_thought += text
                        full_response += text
                        if text.endswith(('.', '?', '!')):
                            st.write(current_thought.strip())
                            current_thought = ""
                    else:
                        full_response += content
                        st.write(content, end="")
            elif kind == "on_tool_start" and mode == "Task":
                st.write(f"\nUsing tool: {event['name']}")
            elif kind == "on_tool_end" and mode == "Task":
                st.write(f"Tool result: {event['data']['output']}\n")
    
        # Format and display the full response with proper code block formatting
        formatted_response = format_ai_response(full_response)
        st.markdown(formatted_response)

    # Create a session state variable to store the chat messages. This ensures that the
    # messages persist across reruns.
    if "messages" not in st.session_state:
        st.session_state.messages = []

    # Display the current system prompt if show_system_prompt is True
    if st.session_state.show_system_prompt:
        st.text_area("Current System Prompt", st.session_state.system_prompt, height=300)

    # Display the existing chat messages via `st.chat_message`.
    for message in st.session_state.messages:
        with st.chat_message(message["role"]):
            st.markdown(message["content"])

    # Create a chat input field to allow the user to enter a message. This will display
    # automatically at the bottom of the page.
    if prompt := st.chat_input(f"{'Ask a question' if mode == 'Q/A' else 'Give me a Task'}!"):
        # Store and display the current prompt.
        st.session_state.messages.append({"role": "user", "content": prompt})
        with st.chat_message("user"):
            st.markdown(prompt)
    
        # Generate a response using the custom chatbot logic.
        asyncio.run(run_github_editor(prompt))