diff --git a/.gitignore b/.gitignore index b692578a..94e9dc45 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ .idea/ llama-hub.iml llamahub/ -img_cache/ \ No newline at end of file +img_cache/ +.cache/ diff --git a/llama_hub/llama_packs/library.json b/llama_hub/llama_packs/library.json index 95d79703..41db00e0 100644 --- a/llama_hub/llama_packs/library.json +++ b/llama_hub/llama_packs/library.json @@ -34,6 +34,12 @@ "author": "AdkSarsen", "keywords": ["deeplake", "multimodal", "retriever"] }, + "PanelChatPack": { + "id": "llama_packs/panel_chatbot", + "author": "MarcSkovMadsen", + "keywords": ["panel", "chatbot", "github", "openai", "index"], + "extra_files": ["app.py", "llama_by_sophia_yang.png"] + }, "StreamlitChatPack": { "id": "llama_packs/streamlit_chatbot", "author": "carolinedlu", diff --git a/llama_hub/llama_packs/panel_chatbot/README.md b/llama_hub/llama_packs/panel_chatbot/README.md new file mode 100644 index 00000000..1a5e64ab --- /dev/null +++ b/llama_hub/llama_packs/panel_chatbot/README.md @@ -0,0 +1,66 @@ +# 🦙 Panel ChatBot Pack + +Build a chatbot to talk to your Github repository. + +Powered by LlamaIndex, OpenAI ChatGPT and [HoloViz Panel](https://panel.holoviz.org/reference/chat/ChatInterface.html). + +![Panel Chat Bot](panel_chatbot.png) + +## 💁‍♀️ Explanation + +This template + +- Downloads and indexes a Github repository using the the `llama_index` [`GithubRepositoryReader`](https://llamahub.ai/l/github_repo). The default repository is [holoviz/panel](https://github.com/holoviz/panel). +- Creates a [VectorStoreIndex](https://docs.llamaindex.ai/en/stable/changes/deprecated_terms.html#VectorStoreIndex) powered chat engine that will retrieve context from that data to respond to each user query. +- Creates a Panel [`ChatInterface`](https://panel.holoviz.org/reference/chat/ChatInterface.html) UI that will stream each answer from the chat engine. + +## 🖥️ CLI Usage + +You can download llamapacks directly using `llamaindex-cli`, which comes installed with the `llama-index` python package: + +```bash +llamaindex-cli download-llamapack PanelChatPack --download-dir ./panel_chat_pack +``` + +You can then inspect the files at `./panel_chat_pack` and use them as a template for your own project! + +To run the app directly, use in your terminal: + +```bash +export OPENAI_API_KEY="sk-..." +export GITHUB_TOKEN='...' +panel serve ./panel_chat_pack/base.py +``` + +As an alternative to `panel serve`, you can run + +```bash +python ./panel_chat_pack/base.py +``` + +## 🎓 Learn More + +- [`GithubRepositoryReader`](https://llamahub.ai/l/github_repo) +- [`VectorStoreIndex`](https://docs.llamaindex.ai/en/stable/changes/deprecated_terms.html#VectorStoreIndex) +- [Panel Chat Components](https://panel.holoviz.org/reference/index.html#chat) +- [Panel Chat Examples](https://github.com/holoviz-topics/panel-chat-examples) + +## 👍 Credits + +- [Marc Skov Madsen](https://twitter.com/MarcSkovMadsen) for creating the template +- [Sophia Yang](https://twitter.com/sophiamyang) for creating the cute LLama image. + +## 📈 Potential Improvements + +- [ ] Improved Multi-user support + - [ ] Loading queue: Users should not be able to download the same repository at the same time. +- [ ] Service Context + - [ ] Enable users to define the service context including `model`, `temperature` etc. +- [ ] Better loading experience + - [ ] Let the chat assistant show (more fine-grained) status messages. And provide more status changes +- [ ] Focus on the streaming text + - [ ] The streaming text is not always in focus. I believe its a matter of adjusting the `auto_scroll_limit` limit. +- [ ] Fix minor CSS issues + - [ ] See the `CSS_FIXES_TO_BE_UPSTREAMED_TO_PANEL` variable in the code +- [ ] Repo Manager + - [ ] Support using multiple repos. For example the full HoloViz suite diff --git a/llama_hub/llama_packs/panel_chatbot/__init__.py b/llama_hub/llama_packs/panel_chatbot/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/llama_hub/llama_packs/panel_chatbot/app.py b/llama_hub/llama_packs/panel_chatbot/app.py new file mode 100644 index 00000000..5ec015ee --- /dev/null +++ b/llama_hub/llama_packs/panel_chatbot/app.py @@ -0,0 +1,380 @@ +"""Provides a ChatBot UI for a Github Repository. Powered by Llama Index and Panel""" +import os +import pickle +from pathlib import Path + +import nest_asyncio +import panel as pn +import param +from llama_index import VectorStoreIndex, download_loader + +from llama_hub.github_repo import GithubClient, GithubRepositoryReader + +# needed because both Panel and GithubRepositoryReader starts up the ioloop +nest_asyncio.apply() + +CACHE_PATH = Path(".cache/panel_chatbot") +CACHE_PATH.mkdir(parents=True, exist_ok=True) + +CHAT_GPT_LOGO = "https://upload.wikimedia.org/wikipedia/commons/thumb/0/04/ChatGPT_logo.svg/512px-ChatGPT_logo.svg.png" +CHAT_GPT_URL = "https://chat.openai.com/" +LLAMA_INDEX_LOGO = ( + "https://cdn-images-1.medium.com/max/280/1*_mrG8FG_LiD23x0-mEtUkw@2x.jpeg" +) +PANEL_LOGO = { + "default": "https://panel.holoviz.org/_static/logo_horizontal_light_theme.png", + "dark": "https://panel.holoviz.org/_static/logo_horizontal_dark_theme.png", +} + +GITHUB_LOGO = "https://github.githubassets.com/assets/GitHub-Mark-ea2971cee799.png" +GITHUB_URL = "https://github.com/" +LLAMA_INDEX_URL = "https://www.llamaindex.ai/" +PANEL_URL = "https://panel.holoviz.org/index.html" +GITHUB_COPILOT_LOGO = ( + "https://plugins.jetbrains.com/files/17718/447537/icon/pluginIcon.svg" +) + +INDEX_NOT_LOADED = "No repository loaded" +INDEX_LOADED = "Repository loaded" +LOADING_EXISTING_DOCS = "Loading existing docs" +LOADING_NEW_DOCS = "Downloading documents" +LOADING_EXISTING_INDEX = "Loading existing index" +LOADING_NEW_INDEX = "Creating index" +CUTE_LLAMA = Path(__file__).parent / "llama_by_sophia_yang.png" +CUTE_LLAMA_URL = "https://x.com/sophiamyang/status/1729810715467252080?s=20" + +pn.chat.ChatMessage.default_avatars.update( + { + "assistant": GITHUB_COPILOT_LOGO, + "user": "🦙", + } +) +pn.chat.ChatMessage.show_reaction_icons = False + +ACCENT = "#ec4899" + +CSS_FIXES_TO_BE_UPSTREAMED_TO_PANEL = """ +#sidebar { + padding-left: 5px !important; + background-color: var(--panel-surface-color); +} +.pn-wrapper { + height: calc( 100vh - 150px); +} +.bk-active.bk-btn-primary {border-color: var(--accent-fill-active)} +.bk-btn-primary:hover {border-color: var(--accent-fill-hover)} +.bk-btn-primary {border-color: var(--accent-fill-rest)} +a {color: var(--accent-fill-rest) !important;} +a:hover {color: var(--accent-fill-hover) !important;} +""" + + +def _split_and_clean(cstext): + return cstext.split(",") + + +class IndexLoader(pn.viewable.Viewer): + """The IndexLoader enables the user to interactively create a VectorStoreIndex from a + github repository of choice""" + + value: VectorStoreIndex = param.ClassSelector(class_=VectorStoreIndex) + + status = param.String(constant=True, doc="A status message") + + owner: str = param.String( + default="holoviz", doc="The repository owner. For example 'holoviz'" + ) + repo: str = param.String( + default="panel", doc="The repository name. For example 'panel'" + ) + filter_directories: str = param.String( + default="examples,docs,panel", + label="Folders", + doc="A comma separated list of folders to include. For example 'examples,docs,panel'", + ) + filter_file_extensions: str = param.String( + default=".py,.md,.ipynb", + label="File Extensions", + doc="A comma separated list of file extensions to include. For example '.py,.md,.ipynb'", + ) + + _load = param.Event( + label="LOAD", + doc="Loads the repository index from the cache if it exists and otherwise from scratch", + ) + _reload = param.Event( + default=False, + label="RELOAD ALL", + doc="Loads the repository index from scratch", + ) + + def __init__(self): + super().__init__() + + if self.index_exists: + pn.state.execute(self.load) + else: + self._update_status(INDEX_NOT_LOADED) + + self._layout = pn.Column( + self.param.owner, + self.param.repo, + self.param.filter_directories, + self.param.filter_file_extensions, + pn.pane.HTML(self.github_url), + pn.widgets.Button.from_param( + self.param._load, + button_type="primary", + disabled=self._is_loading, + loading=self._is_loading, + ), + pn.widgets.Button.from_param( + self.param._reload, + button_type="primary", + button_style="outline", + disabled=self._is_loading, + loading=self._is_loading, + ), + pn.pane.Markdown("### Status", margin=(3, 5)), + pn.pane.Str(self.param.status), + ) + + def __panel__(self): + return self._layout + + @property + def _unique_id(self): + uid = ( + self.owner + + self.repo + + self.filter_directories + + self.filter_file_extensions + ) + uid = uid.replace(",", "").replace(".", "") + return uid + + @property + def _cached_docs_path(self): + return CACHE_PATH / f"docs_{self._unique_id}.pickle" + + @property + def _cached_index_path(self): + return CACHE_PATH / f"index_{self._unique_id}.pickle" + + async def _download_docs(self): + download_loader("GithubRepositoryReader") + + github_client = GithubClient(os.getenv("GITHUB_TOKEN")) + + filter_directories = _split_and_clean(self.filter_directories) + filter_file_extensions = _split_and_clean(self.filter_file_extensions) + + loader = GithubRepositoryReader( + github_client, + owner=self.owner, + repo=self.repo, + filter_directories=( + filter_directories, + GithubRepositoryReader.FilterType.INCLUDE, + ), + filter_file_extensions=( + filter_file_extensions, + GithubRepositoryReader.FilterType.INCLUDE, + ), + verbose=True, + concurrent_requests=10, + ) + return loader.load_data(branch="main") + + async def _get_docs(self): + docs_path = self._cached_docs_path + index_path = self._cached_index_path + + if docs_path.exists(): + self._update_status(LOADING_EXISTING_DOCS) + with docs_path.open("rb") as f: + return pickle.load(f) + + self._update_status(LOADING_NEW_DOCS) + docs = await self._download_docs() + + with docs_path.open("wb") as f: + pickle.dump(docs, f, pickle.HIGHEST_PROTOCOL) + + if index_path.exists(): + index_path.unlink() + + return docs + + async def _create_index(self, docs): + return VectorStoreIndex.from_documents(docs, use_async=True) + + async def _get_index(self, index): + index_path = self._cached_index_path + + if index_path.exists(): + self._update_status(LOADING_EXISTING_INDEX) + with index_path.open("rb") as f: + return pickle.load(f) + + self._update_status(LOADING_NEW_INDEX) + index = await self._create_index(index) + + with index_path.open("wb") as f: + pickle.dump(index, f, pickle.HIGHEST_PROTOCOL) + return index + + @param.depends("status") + def _is_loading(self): + return self.status not in [INDEX_LOADED, INDEX_NOT_LOADED] + + @param.depends("status") + def _is_not_loading(self): + return self.status in [INDEX_LOADED, INDEX_NOT_LOADED] + + @param.depends("_load", watch=True) + async def load(self): + """Loads the repository index either from the cache or by downloading from + the repository""" + self._update_status("Loading ...") + self.value = None + + docs = await self._get_docs() + self.value = await self._get_index(docs) + self._update_status(INDEX_LOADED) + + @param.depends("_reload", watch=True) + async def reload(self): + self._update_status("Deleteing cached index ...") + if self._cached_docs_path.exists(): + self._cached_docs_path.unlink() + if self._cached_index_path.exists(): + self._cached_index_path.unlink() + + await self.load() + + def _update_status(self, text): + with param.edit_constant(self): + self.status = text + print(text) + + @param.depends("owner", "repo") + def github_url(self): + """Returns a html string with a link to the github repository""" + text = f"{self.owner}/{self.repo}" + href = f"https://github.com/{text}" + return f"{text}" + + @property + def index_exists(self): + """Returns True if the index already exists""" + return self._cached_docs_path.exists() and self._cached_index_path.exists() + + +def powered_by(): + """Returns a component describing the frameworks powering the chat ui""" + params = {"height": 40, "sizing_mode": "fixed", "margin": (0, 10)} + return pn.Column( + pn.pane.Markdown("### AI Powered By", margin=(10, 5, 10, 0)), + pn.Row( + pn.pane.Image(LLAMA_INDEX_LOGO, link_url=LLAMA_INDEX_URL, **params), + pn.pane.Image(CHAT_GPT_LOGO, link_url=CHAT_GPT_URL, **params), + pn.pane.Image(PANEL_LOGO[pn.config.theme], link_url=PANEL_URL, **params), + align="center", + ), + ) + + +async def chat_component(index: VectorStoreIndex, index_loader: IndexLoader): + """Returns the chat component powering the main area of the application""" + if not index: + return pn.Column( + pn.chat.ChatMessage( + "You are a now a *GitHub Repository assistant*.", + user="System", + ), + pn.chat.ChatMessage( + "Please **load a GitHub Repository** to start chatting with me. This can take from seconds to minutes!", + user="Assistant", + ), + ) + + chat_engine = index.as_chat_engine(chat_mode="context", verbose=True) + + async def generate_response(contents, user, instance): + response = await chat_engine.astream_chat(contents) + text = "" + async for token in response.async_response_gen(): + text += token + yield text + + chat_interface = pn.chat.ChatInterface( + callback=generate_response, + sizing_mode="stretch_both", + ) + chat_interface.send( + pn.chat.ChatMessage( + "You are a now a *GitHub Repository Assistant*.", user="System" + ), + respond=False, + ) + chat_interface.send( + pn.chat.ChatMessage( + f"Hello! you can ask me anything about {index_loader.github_url()}.", + user="Assistant", + ), + respond=False, + ) + return chat_interface + + +def settings_components(index_loader: IndexLoader): + """Returns a list of the components to add to the sidebar""" + return [ + pn.pane.Image( + CUTE_LLAMA, + height=250, + align="center", + margin=(10, 5, 25, 5), + link_url=CUTE_LLAMA_URL, + ), + "## Github Repository", + index_loader, + powered_by(), + ] + + +def create_chat_ui(): + """Returns the Chat UI""" + pn.extension( + sizing_mode="stretch_width", raw_css=[CSS_FIXES_TO_BE_UPSTREAMED_TO_PANEL] + ) + + index_loader = IndexLoader() + + pn.state.location.sync( + index_loader, + parameters={ + "owner": "owner", + "repo": "repo", + "filter_directories": "folders", + "filter_file_extensions": "file_extensions", + }, + ) + + bound_chat_interface = pn.bind( + chat_component, index=index_loader.param.value, index_loader=index_loader + ) + + return pn.template.FastListTemplate( + title="Chat with GitHub", + sidebar=settings_components(index_loader), + main=[bound_chat_interface], + accent=ACCENT, + main_max_width="1000px", + main_layout=None, + ) + + +if pn.state.served: + create_chat_ui().servable() diff --git a/llama_hub/llama_packs/panel_chatbot/base.py b/llama_hub/llama_packs/panel_chatbot/base.py new file mode 100644 index 00000000..bc83db52 --- /dev/null +++ b/llama_hub/llama_packs/panel_chatbot/base.py @@ -0,0 +1,40 @@ +"""Provides the PanelChatPack""" +import os +from typing import Any, Dict + +from llama_index.llama_pack.base import BaseLlamaPack + +ENVIRONMENT_VARIABLES = [ + "GITHUB_TOKEN", + "OPENAI_API_KEY", +] + + +class PanelChatPack(BaseLlamaPack): + """Panel chatbot pack.""" + + def get_modules(self) -> Dict[str, Any]: + """Get modules.""" + return {} + + def run(self, *args: Any, **kwargs: Any) -> Any: + """Run the pipeline.""" + for variable in ENVIRONMENT_VARIABLES: + if variable not in os.environ: + raise ValueError("%s environment variable is not set", variable) + + import panel as pn + + if __name__ == "__main__": + # 'pytest tests' will fail if app is imported elsewhere + from app import create_chat_ui + + pn.serve(create_chat_ui) + elif __name__.startswith("bokeh"): + from app import create_chat_ui + + create_chat_ui().servable() + else: + print( + "To serve the Panel ChatBot please run this file with 'panel serve' or 'python'" + ) diff --git a/llama_hub/llama_packs/panel_chatbot/llama_by_sophia_yang.png b/llama_hub/llama_packs/panel_chatbot/llama_by_sophia_yang.png new file mode 100644 index 00000000..ce8665c0 Binary files /dev/null and b/llama_hub/llama_packs/panel_chatbot/llama_by_sophia_yang.png differ diff --git a/llama_hub/llama_packs/panel_chatbot/panel_chatbot.png b/llama_hub/llama_packs/panel_chatbot/panel_chatbot.png new file mode 100644 index 00000000..122a9811 Binary files /dev/null and b/llama_hub/llama_packs/panel_chatbot/panel_chatbot.png differ diff --git a/llama_hub/llama_packs/panel_chatbot/requirements.txt b/llama_hub/llama_packs/panel_chatbot/requirements.txt new file mode 100644 index 00000000..a02d8b8b --- /dev/null +++ b/llama_hub/llama_packs/panel_chatbot/requirements.txt @@ -0,0 +1,4 @@ +llama-hub +nest-asyncio +openai +panel \ No newline at end of file