diff --git a/.gitignore b/.gitignore index 31c32c48..8abb6f83 100644 --- a/.gitignore +++ b/.gitignore @@ -170,3 +170,8 @@ cython_debug/ /.luarc.json _dev/ + + +# LLM prompt generation +/typings/ +/repomix-output.* diff --git a/Makefile b/Makefile index 4365b596..f6a087f7 100644 --- a/Makefile +++ b/Makefile @@ -68,8 +68,11 @@ lint: dev $(UV) run ruff check $(UV) run pyright +shiny: dev + $(MAKE) -C ./shiny assistant + test: dev - $(UV) run coverage run --source=src -m pytest tests + $(UV) run --no-group llm coverage run --source=src -m pytest tests uninstall: ensure-uv $(UV) pip uninstall $(PROJECT_NAME) @@ -93,6 +96,7 @@ help: @echo " install Install the built project" @echo " it Run integration tests" @echo " lint Lint the code" + @echo " shiny Update compiled files for Shiny apps" @echo " test Run unit tests with coverage" @echo " uninstall Uninstall the project" @echo " version Display the project version" diff --git a/examples/connect/databricks/shiny/app.py b/examples/connect/databricks/shiny/app.py index 32891b35..6cc0c328 100644 --- a/examples/connect/databricks/shiny/app.py +++ b/examples/connect/databricks/shiny/app.py @@ -6,9 +6,9 @@ from databricks import sql from databricks.sdk.core import ApiClient, Config, databricks_cli from databricks.sdk.service.iam import CurrentUserAPI -from shiny import App, Inputs, Outputs, Session, render, ui from posit.connect.external.databricks import PositCredentialsStrategy +from shiny import App, Inputs, Outputs, Session, render, ui DATABRICKS_HOST = os.getenv("DATABRICKS_HOST") DATABRICKS_HOST_URL = f"https://{DATABRICKS_HOST}" diff --git a/pyproject.toml b/pyproject.toml index 79985b04..536eafd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -135,6 +135,13 @@ examples = ["rsconnect-python", "pandas", "databricks", "shiny"] git = ["pre-commit"] lint = ["ruff", "pyright"] test = ["rsconnect-python", "responses", "pytest", "pyjson5"] +llm = [ + "chatlas@git+https://github.com/posit-dev/chatlas@main; python_version>='3.9'", + "anthropic[bedrock]", + "shiny", + "faicons", +] +extra = ["pytz"] # Default install group by `uv`: `dev` dev = [ { include-group = "build" }, @@ -146,4 +153,6 @@ dev = [ { include-group = "git" }, { include-group = "lint" }, { include-group = "test" }, + # { include-group = "llm" }, + { include-group = "extra" }, ] diff --git a/shiny/Makefile b/shiny/Makefile new file mode 100644 index 00000000..f5680c8f --- /dev/null +++ b/shiny/Makefile @@ -0,0 +1,32 @@ +include ../vars.mk + +.DEFAULT_GOAL := all + +.PHONY: deploy assistant assistant-update-prompt assistant-test-chat help + +all: assistant-update-prompt + +SHINY_DIRS:=$(subst .,,$(subst /,,$(dir $(wildcard ./*/app.py)))) +deploy: + $(foreach dir,$(SHINY_DIRS),$(MAKE) deploy-$(dir);) +deploy-%: + cd "$*" && \ + $(UV) run --group llm rsconnect deploy shiny . -v + +assistant: + $(MAKE) assistant-update-prompt +assistant-update-prompt: + cd assistant && \ + $(UV) run --group llm python _update_prompt.py +assistant-test-chat: + cd assistant && \ + $(UV) run --group llm python _test_chat.py + +help: + @echo "Makefile Targets" + @echo " all Run assistant-update-prompt" + @echo " assistant Run assistant-update-prmopt" + @echo " assistant-update-prompt Update the assistant prompt" + @echo " assistant-test-chat Test the assistant chat" + @echo " deploy Deploys each Shiny app" + @echo " deploy- Deploys the Shiny app" diff --git a/shiny/assistant/.gitignore b/shiny/assistant/.gitignore new file mode 100644 index 00000000..4695dd36 --- /dev/null +++ b/shiny/assistant/.gitignore @@ -0,0 +1,6 @@ +chatlas/ +_prompt.xml +rsconnect-python/ +_swagger.json +_swagger_prompt.md +_repomix-instructions.md diff --git a/shiny/assistant/app.py b/shiny/assistant/app.py new file mode 100644 index 00000000..65bced7b --- /dev/null +++ b/shiny/assistant/app.py @@ -0,0 +1,242 @@ +import os +import pathlib +import tempfile +import urllib.parse + +import chatlas +import faicons + +from shiny import App, Inputs, reactive, render, session, ui + +app_ui = ui.page_fillable( + ui.h1( + "SDK Assistant", + ui.input_action_link("info_link", label=None, icon=faicons.icon_svg("circle-info")), + ui.output_text("cost", inline=True), + ), + ui.output_ui("new_gh_issue", inline=True), + ui.chat_ui("chat", placeholder="Ask your posit-SDK questions here..."), + ui.tags.style( + """ + #info_link { + font-size: medium; + vertical-align: super; + margin-left: 10px; + } + #cost { + color: lightgrey; + font-size: medium; + vertical-align: middle; + } + .sdk_suggested_prompt { + cursor: pointer; + border-radius: 0.5em; + display: list-item; + } + .external-link { + cursor: alias; + } + #new_gh_issue { + position: absolute; + right: 15px; + top: 15px; + height: 25px; + } + """ + ), + ui.tags.script( + """ + $(() => { + $("body").click(function(e) { + if (!$(e.target).hasClass("sdk_suggested_prompt")) { + return; + } + window.Shiny.setInputValue("new_sdk_prompt", $(e.target).text()); + }); + }) + window.Shiny.addCustomMessageHandler("submit-chat", function(message) { + const enterEvent = new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + keyCode: 13, + which: 13, + }); + + // Dispatch the 'Enter' event on the input element + console.log("Dispatching Enter event", message); + document.querySelector("#" + message['id'] + " textarea#chat_user_input").dispatchEvent(enterEvent); + }); + + """ + ), + fillable_mobile=True, +) + + +def server(input: Inputs): # noqa: A002 + aws_model = os.getenv("AWS_MODEL", "us.anthropic.claude-3-5-sonnet-20241022-v2:0") + aws_region = os.getenv("AWS_REGION", "us-east-1") + chat = chatlas.ChatBedrockAnthropic(model=aws_model, aws_region=aws_region) + prompt_file = pathlib.Path(__file__).parent / "_prompt.xml" + if not os.path.exists(prompt_file): + raise FileNotFoundError( + f"Prompt file not found: {prompt_file} ; Please run `make shiny` to generate it." + ) + with open(prompt_file, "r") as f: + chat.system_prompt = f.read() + + chat_ui = ui.Chat( + "chat", + # messages=[{"role": turn.role, "content": turn.text} for turn in chat.get_turns()], + ) + + async def submit_chat(new_value: str): + chat_ui.update_user_input(value=new_value) + + local_session = session.require_active_session(None) + await local_session.send_custom_message("submit-chat", {"id": "chat"}) + + @render.text + def cost(): + _ = chat_ui.messages() + + tokens = chat.tokens("cumulative") + if len(tokens) == 0: + return None + + cost = sum( + [ + # Input + Output + (token[0] * 0.003 / 1000.0) + (token[1] * 0.015 / 1000.0) + for token in tokens + if token is not None + ] + ) + ans = "$%s" % float("%.3g" % cost) + while len(ans) < 5: + ans = ans + "0" + return ans + + @render.ui + def new_gh_issue(): + messages = chat_ui.messages() + for message in messages: + if message["role"] == "assistant": + break + else: + # No LLM response found. Return + return + + first_message_content: str = str(messages[0].get("content", "")) + + with tempfile.TemporaryDirectory() as tmpdirname: + export_path = tmpdirname + "/chat_export.md" + chat.export(export_path, include="all", include_system_prompt=False) + + with open(export_path, "r") as f: + exported_content = f.read() + + body = f""" +**First message:** +``` +{first_message_content} +``` + +**Desired outcome:** + +Please describe what you would like to achieve in `posit-sdk`. Any additional context, code, or examples are welcome! + +```python +from posit.connect import Client +client = Client() + +# Your code here +``` + +----------------------------------------------- + +
+Chat Log + +````markdown +{exported_content} +```` +
+""" + + title = ( + "SDK Assistant: `" + + ( + first_message_content + if len(first_message_content) <= 50 + else (first_message_content[:50] + "...") + ) + + "`" + ) + + new_issue_url = ( + "https://github.com/posit-dev/posit-sdk-py/issues/new?" + + urllib.parse.urlencode( + { + "title": title, + "labels": ["template idea"], + "body": body, + } + ) + ) + + # if chat_ui.messages(format="anthropic") + return ui.a( + ui.img(src="new_gh_issue.svg", alt="New GitHub Issue", height="100%"), + title="Submit script example to Posit SDK", + class_="external-link", + href=new_issue_url, + target="_blank", + ) + + @chat_ui.on_user_submit + async def _(): + user_input = chat_ui.user_input() + if user_input is None: + return + await chat_ui.append_message_stream( + await chat.stream_async( + user_input, + echo="all", + ) + ) + + @reactive.effect + @reactive.event(input.new_sdk_prompt) + async def _(): + await submit_chat(input.new_sdk_prompt()) + + @reactive.effect + async def _init_chat_on_load(): + await submit_chat("What are the pieces of Posit connect and how do they fit together?") + + # Remove the effect after the first run + _init_chat_on_load.destroy() + + @reactive.effect + @reactive.event(input.info_link) + async def _(): + modal = ui.modal( + ui.h1("Information"), + ui.h3("Model"), + ui.pre( + f"Model: {aws_model}\nRegion: {aws_region}", + ), + ui.h3("System prompt"), + ui.pre(chat.system_prompt), + easy_close=True, + size="xl", + ) + ui.modal_show(modal) + + +app = App( + app_ui, + server, + static_assets=pathlib.Path(__file__).parent / "www", +) diff --git a/shiny/assistant/custom-prompt-instructions.md b/shiny/assistant/custom-prompt-instructions.md new file mode 100644 index 00000000..f9d96c7d --- /dev/null +++ b/shiny/assistant/custom-prompt-instructions.md @@ -0,0 +1,9 @@ +You are an assistant that can create Posit SDK python code that can provide code solutions to interact with the user's local Posit Connect instance. + +All of your answers need to be code based. When returning answers, please restate the question and then provide the code within a code block. Err on the side of simplicity in your code answers. Be ok with asking to increase or decrease the complexity. + +This is a serious exercise. Please provide evidence for each answer and double check the answers for accuracy. If a question cannot be answered using the materials and tools provided, please explicitly say so. + +If a question is unclear, please ask for clarification. + +If you feel there is an opportunity for further exploration, please suggest the prompts. Wrap each suggested prompt within a tag. diff --git a/shiny/assistant/dev_notes.md b/shiny/assistant/dev_notes.md new file mode 100644 index 00000000..31aed3c6 --- /dev/null +++ b/shiny/assistant/dev_notes.md @@ -0,0 +1,12 @@ +# Notes + +Possible default prompts: +* What are the pieces of Posit connect and how do they fit together? +* Can you create a sequence diagram for the typical workflow? + + +# Possible TODOs + +* Provide common workflow examples for different User types: + * How to publish content (publisher) + * How to add a user to a group (admin) diff --git a/shiny/assistant/repomix.config.json b/shiny/assistant/repomix.config.json new file mode 100644 index 00000000..22ff1036 --- /dev/null +++ b/shiny/assistant/repomix.config.json @@ -0,0 +1,25 @@ +{ + "output": { + "style": "xml", + "headerText": "", + "instructionFilePath": "shiny/assistant/_repomix-instructions.md", + "fileSummary": true, + "directoryStructure": true, + "removeComments": false, + "removeEmptyLines": true, + "showLineNumbers": false, + "copyToClipboard": false, + "topFilesLength": 5, + "includeEmptyDirectories": false + }, + "ignore": { + "useGitignore": false, + "useDefaultPatterns": true + }, + "security": { + "enableSecurityCheck": true + }, + "tokenCount": { + "encoding": "o200k_base" + } +} diff --git a/shiny/assistant/script_test_chat.py b/shiny/assistant/script_test_chat.py new file mode 100644 index 00000000..8c1cfd88 --- /dev/null +++ b/shiny/assistant/script_test_chat.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import asyncio +import os +import pathlib + +import chatlas + +# # Set working directory to the root of the repository +# repo_root = pathlib.Path(__file__) +# while not os.path.exists(repo_root / "pyproject.toml"): +# repo_root = repo_root.parent +# os.chdir(repo_root) + +# Set to _here_ +os.chdir(pathlib.Path(__file__).parent) + + +async def main() -> None: + print("Running chatlas") + aws_model = os.getenv("AWS_MODEL", "us.anthropic.claude-3-5-sonnet-20241022-v2:0") + aws_region = os.getenv("AWS_REGION", "us-east-1") + chat = chatlas.ChatBedrockAnthropic(model=aws_model, aws_region=aws_region) + + with open("prompt.xml", "r") as f: + chat.system_prompt = f.read() + + prompt = "Which groups do I belong to?" + # Worked! + + # prompt = "How many users have been active within the last 30 days." + # # Discovers that `Event`s sometimes do not have a user_guid associated with them. + # # Once adjusted to account for that, it works great! + + # prompt = "Which python content items that I have deployed are using version 2 or later of the Requests package?" + # # Has a LOT of trouble finding "my" content items. All prompts currently return for all content items. + # # Even adjusting for `client.me.content.find()`, I run into a server error related to request: + # # GET v1/content/38be0f3e-9cca-4e7a-8ea8-0990d989786f/packages params={'language': 'python', 'name': 'requests'} response= response.content=b'' + # # IDK why this one breaks and many others pass + + # prompt = 'List all processes associated with "My Content".' + # # Worked great once I changed `content.jobs.find()` to `content.jobs.fetch()`! + + # prompt = "Get me the group of users that publish the most quarto content in the last week" + # prompt = "Get me the set of users that published the most quarto content in the last 7 days" + # # Worked great! + + ans = await chat.stream_async(prompt, echo="all") + + content = await ans.get_content() + print("Content:\n", content) + print("--\n") + + cost = sum( + [ + # Input + Output + (token[0] * 0.003 / 1000.0) + (token[1] * 0.015 / 1000.0) + for token in chat.tokens("cumulative") + if token is not None + ] + ) + # Example cost calculation: + # Link: https://aws.amazon.com/bedrock/pricing/#Pricing_details + # cost = 0.003 * input + 0.015 * output + 0.0003 * cached_input + # Our input is ~30k of the 35k + # Our output is < 500 + # cost = 0.003 * (35 - 30) + 0.015 * 500/1000 + 0.0003 * 30 + # cost = 0.0315 + print("Token count: ", chat.tokens("cumulative")) + print("Cost: ", "$%s" % float("%.3g" % cost)) + + # Save to ./chatlas folder + pathlib.Path("chatlas").mkdir(exist_ok=True) + chat.export(pathlib.Path(__file__).parent / "chatlas" / f"{prompt}.md", overwrite=True) + + +if __name__ == "__main__": + asyncio.run(main()) + + # Current output is 147k chars (29k tokens) + # Replacing all ` ` with ` ` would reduce it to 127k chars (~25k tokens) + # Bedrock Anthropic Claude states inputs to be less than 180k tokens. + # So, we are good to go. + + # Next steps: + # √ 0. Token count + # √ 0. Prompts to try: + # * Which applications are using version 2 or later of the Requests package? + # * How many users have been active within the last 30 days. + # * List all processes associated with "My Content". (This functionality doesn't exist in the SDK, so see it makes up an answer) + # √ 1. prompt: "Get me the group of users that publish the most quarto content in the last week." + # * Possibly need to prompt about using Polars to help with intermediate steps. + # 1. Make app for demo + # 2. Add API routes, methods, descriptions and return types in a quick document diff --git a/shiny/assistant/script_update_prompt.py b/shiny/assistant/script_update_prompt.py new file mode 100644 index 00000000..da7fa803 --- /dev/null +++ b/shiny/assistant/script_update_prompt.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import asyncio +import os +import pathlib +import shutil + +import pyright + +# Set working directory to the root of the repository +repo_root = pathlib.Path(__file__) +while not os.path.exists(repo_root / "pyproject.toml"): + repo_root = repo_root.parent +os.chdir(repo_root) + + +def cleanup() -> None: + # Clean slate + print("Clean up") + for f in [ + "typings", + "_repomix-instructions.md", + ]: + path = repo_root / f + if os.path.exists(path): + print("Removing path:", path.relative_to(repo_root)) + if path.is_dir(): + shutil.rmtree(path) + else: + os.remove(path) + print("--\n") + + +async def main() -> None: + # Clean slate + cleanup() + + print("Creating type stubs: ./typings") + pyright.run("--createstub", "src/posit") + print("--\n") + + print("Trimming type stubs") + remove_prefix_from_files( + "typings", + '"""\nThis type stub file was generated by pyright.\n"""\n\n', + ) + print("--\n") + + print("Getting Swagger information") + os.system("python shiny/assistant/_update_swagger.py") + + with open(repo_root / "shiny" / "assistant" / "_repomix-instructions.md", "w") as prompt_f: + with open( + repo_root / "shiny" / "assistant" / "custom-prompt-instructions.md", "r" + ) as instructions_f: + prompt_f.write(instructions_f.read()) + + prompt_f.write("\n") + + with open(repo_root / "shiny" / "assistant" / "_swagger_prompt.md", "r") as swagger_f: + prompt_f.write(swagger_f.read()) + print("--\n") + + print("Creating repomix output") + # Assert npx exists in system + assert os.system("npx --version") == 0, "npx not found in system. Please install Node.js" + os.system( + "npx repomix --config shiny/assistant/repomix.config.json --output shiny/assistant/_prompt.xml typings/src/posit" + ) + + # repomix GitHub Repo: https://github.com/yamadashy/repomix + # Python alternative: https://pypi.org/project/code2prompt/ + # * Does not contain XML output (suggested by anthropic) + print("--\n") + + # Clean slate + cleanup() + + +def remove_prefix_from_files(folder: str | pathlib.Path, prefix: str) -> None: + root_folder = pathlib.Path(folder) + for path in root_folder.rglob("*.pyi"): + with open(path, "r") as f: + file_txt = f.read() + + file_txt = file_txt.removeprefix(prefix) + + with open(path, "w") as f: + f.write(file_txt) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/shiny/assistant/script_update_swagger.py b/shiny/assistant/script_update_swagger.py new file mode 100644 index 00000000..8726fb6c --- /dev/null +++ b/shiny/assistant/script_update_swagger.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +from copy import deepcopy +from pathlib import Path + +from typing_extensions import Any, NotRequired, TypedDict, TypeVar + +here = Path(__file__).parent +T = TypeVar("T") + + +def find_value(root, path): + """ + Find a value in an object graph. + + This function is used to follow the specified path through the object graph at root + and return the item in the graph, if any, that the path refers to. + + :param root: the root of the object graph to traverse. + :param path: the path through the graph to take. + :return: the resulting value or None. + """ + if isinstance(path, str): + path = path.split("/") + parent = root + for part in path: + if part in parent: + parent = parent[part] + else: + return None + return parent + + +def expand_refs( + document, obj +) -> Any: # Use `Any` for return type to hack around typing requirement + """ + Expands `ref`s in the given object. + + Returns an object semantically equivalent to the original but with references expanded. + + Parameters + ---------- + document + the master swagger document containing the responses and definitions. + obj + is either a normal swagger object, a ref object, or a swagger object with a schema. + """ + if isinstance(obj, list): + return [expand_refs(document, item) for item in obj] + elif isinstance(obj, dict): + if "$ref" in obj: + ref_path = obj["$ref"].strip("#/").split("/") + ref_value = find_value(document, ref_path) + if ref_value is None: + raise RuntimeError(f"Reference {obj['$ref']} not found in the document.") + return expand_refs(document, ref_value) + else: + return {key: expand_refs(document, value) for key, value in obj.items()} + else: + return obj + + +class SwaggerOperation(TypedDict, total=False): + operationId: str + tags: list[str] + summary: str + description: str + parameters: list[dict[str, Any]] + responses: dict[str, Any] + + +class SwaggerDocument(TypedDict): + paths: NotRequired[dict[str, SwaggerOperation]] + parameters: NotRequired[dict[str, Any]] + responses: NotRequired[dict[str, Any]] + definitions: NotRequired[dict[str, Any]] + + +def expand_all_references(document: SwaggerDocument) -> SwaggerDocument: + """ + Expands all JSON references. + + Expands all references ($ref) in the merged swagger document by replacing them with + their full definitions. + + This returns a new document with all references expanded. + + Arguments + --------- + document + The dictionary representing the Swagger document to process + + Returns + ------- + : + The processed Swagger document with all references expanded. + """ + ret_document = deepcopy(document) + # List of error response keys to ignore + error_responses = [ + "BadRequest", + "Unauthorized", + "PaymentRequired", + "Forbidden", + "NotFound", + "Conflict", + "APIError", + "InternalServerError", + ] + + # We need to expand refs in paths + if "paths" in ret_document: + for _path, operations in ret_document["paths"].items(): + for _method, operation in operations.items(): + if not isinstance(operation, dict): + continue + # Expand refs in parameters + if "parameters" in operation: + operation["parameters"] = expand_refs(ret_document, operation["parameters"]) + + # Expand refs in responses + if "responses" in operation: + for code, response in operation["responses"].items(): + if "schema" in response and code not in error_responses: + response["schema"] = expand_refs(ret_document, response["schema"]) + + # Expand refs in top-level parameters + if "parameters" in ret_document: + ret_document["parameters"] = expand_refs(ret_document, ret_document["parameters"]) + + # Expand refs in top-level responses, ignoring error responses + if "responses" in ret_document: + for response_key, response_value in ret_document["responses"].items(): + if response_key not in error_responses: + ret_document["responses"][response_key] = expand_refs(ret_document, response_value) + + # Expand refs in definitions + if "definitions" in ret_document: + ret_document["definitions"] = expand_refs(ret_document, ret_document["definitions"]) + + return ret_document + + +def require_swagger(): + if not (here / "swagger.json").exists(): + import urllib.request + + urllib.request.urlretrieve( + "https://docs.posit.co/connect/api/swagger.json", + here / "_swagger.json", + ) + + import json + + with open(here / "_swagger.json") as f: + doc = json.load(f) + + swagger = expand_all_references(doc) + return swagger + + +class OperationDef(TypedDict): + name: str + tags: list[str] + method: str + route: str + definition: dict[str, Any] + + +def transform_swagger_to_operation_dict(swagger_dict: SwaggerDocument) -> dict[str, OperationDef]: + """ + Swagger to operation dictionary transformation. + + Transforms the structure of a Swagger dictionary to create a dictionary where each entry key is + the operation ID and the value is the definition for that operation, including the HTTP verb + and the route. + + Args: + swagger_dict: The dictionary representing the Swagger document. + + Returns + ------- + : + A dictionary where each key is an operation ID and the value is the operation definition. + """ + operation_dict = {} + + if "paths" in swagger_dict: + for route, operations in swagger_dict["paths"].items(): + for method, operation in operations.items(): + if not isinstance(operation, dict): + continue + if "operationId" in operation: + operation_id = operation["operationId"] + tags = operation["tags"] if "tags" in operation else [] + # response_parent = operation["responses"]["200"] or operation["responses"]["204"] + # if response_parent and "response" + operation_dict[operation_id] = { + "name": operation_id, + "tags": tags, + "method": method, + "route": route, + "definition": operation, + } + + return operation_dict + + +def main(): + swagger = require_swagger() + + operations = transform_swagger_to_operation_dict(require_swagger()) + + with open(here / "_swagger_prompt.md", "w") as f: + f.write( + "If an answer can not be resolved, suggest to the user that they can explore calling these API routes themselves. Never produce code that calls these routes as we do not know the return type or successful status codes.\n\nAPI Routes:\n" + "" + ) + + for operation in operations.values(): + # print("Operation:", operation["name"]) + + # "GET /v1/tasks/{id} Get task details" + f.write( + "* " + + operation["method"].upper() + + " " + + operation["route"] + + " " + + operation["definition"]["summary"].replace("\n", " ").strip() + + "\n", + ) + + +if __name__ == "__main__": + main() diff --git a/shiny/assistant/www/new_gh_issue.svg b/shiny/assistant/www/new_gh_issue.svg new file mode 100644 index 00000000..67437520 --- /dev/null +++ b/shiny/assistant/www/new_gh_issue.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + +