CircuitPython could potentially be used to create a chatbot that can interact with users through hardware devices like sensors and LEDs -- ChatGPT
This program is large and complex. This guide will gloss over a lot of the details; if you'd like to learn more about using WiFi or displayio on CircuitPython there are dedicated guides for those topics. Below you'll find explanations of some key parts of the program functionality.
Fetching optional items from settings.toml
os.getenv
can be used to fetch string values from the settings.toml file. Providing a second argument gives a default value when the key is not present. Calling strip()
removes any whitespace from the start or end of the string, which can trip up ChatGPT. This is an easy way to add "no-code" customizations to your own CircuitPython programs:
prompt=os.getenv("MY_PROMPT", """ Write 1 sentence starting "you can" about an unconventional but useful superpower """).strip() please_wait=os.getenv("PLEASE_WAIT", """ Finding superpower """).strip()
Vertically scrolling wrapped text
There aren't yet any CircuitPython libraries for dealing with larger amounts of text. This project includes a class called WrappedTextDisplay which can help.
It allows showing a screen full of text which can be part of a larger document. The text can be scrolled by lines, and new words can be added at the end of the text incrementally, with relatively good performance.
This functionality is very closely matched to the needs of this program but it could provide some ideas for a future library.
The text is parsed into lines as it arrives. A certain number of lines can be visible on the screen at one time, an each one of those visible lines of text gets its own bitmap label object. Scrolling consists of changing which logical line in the document is the first line visible on the display. This approach performs relatively well in terms of both repaint time—especially while streaming content from ChatGPT—and memory used.
class WrappedTextDisplay(displayio.Group): ...
Once a response from ChatGPT is complete, this function repeatedly scrolls through the full response if it's more than one screenful, then returns when the button is pressed:
def wait_button_scroll_text(): led.switch_to_output(True) deadline = ticks_add(ticks_ms(), 5000 if wrapped_text.on_last_line() else 1000) while True: if (event := keys.events.get()) and event.pressed: break if wrapped_text.max_offset() > 0 and ticks_less(deadline, ticks_ms()): wrapped_text.scroll_next_line() wrapped_text.refresh() deadline = ticks_add(deadline, 5000 if wrapped_text.on_last_line() else 1000) led.value = False
Streaming an HTTP response by lines
When the OpenAI API is used with "stream": True
, the text is returned as it is generated. You can check the OpenAI API documentation for more details, but in short each line starts "data:" followed by a JSON document all on one line.
By using the iter_lines
function you can handle the HTTP response one line at a time:
def iter_lines(resp): partial_line = [] for c in resp.iter_content(): if c == b'\n': yield (b"".join(partial_line)).decode('utf-8') del partial_line[:] else: partial_line.append(c) if partial_line: yield (b"".join(partial_line)).decode('utf-8')
Prompt and Request
In this program, the Prompt is simple, it consists of just a single "user" message. The content of the message is the prompt, either from the top of the code or from settings.toml:
full_prompt = [ {"role": "user", "content": prompt}, ]
When there is a dialogue between ChatGPT and the user that takes place over multiple exchanges, then the full_prompt
can consist of multiple messages with various roles (system, user, and assistant). But in this project there's just one prompt and one response.
Within the program's forever loop, the full prompt is put together with other information in the json payload of the request (See OpenAI's dedicated documentation pages for more details on the API):
while True: wrapped_text.show(please_wait) with requests.post("https://api.openai.com/v1/chat/completions", json={"model": "gpt-3.5-turbo", "messages": full_prompt, "stream": True}, headers={ "Authorization": f"Bearer {openai_api_key}", }, ) as response:
If the response is successful, then it can be read line by line; each line may contain some additional part of the response (called the "delta"). This text is added to the display, which is refreshed. Just to emphasize that something is happening, the LED blinks during this process. At the end, the program waits for the button to be pressed before it repeats the process again.
# if response.status_code != 200: wrapped_text.show(f"Uh oh! {response.status_code}: {response.reason}") else: wrapped_text.show("") for line in iter_lines(response): led.switch_to_output(True) if line.startswith("data: [DONE]"): break if line.startswith("data:"): content = json.loads(line[5:]) try: token = content['choices'][0]['delta'].get('content', '') except (KeyError, IndexError) as e: token = None led.value = False if token: wrapped_text.add_show(token) wait_button_scroll_text()
Error handling
In the case of most errors, the program catches the error so that a press of the button can re-start from scratch. The original error is shown on the REPL for troubleshooting purposes.
except Exception as e: # pylint: disable=broad-except traceback.print_exception(e) # pylint: disable=no-value-for-parameter print(end="\n\n\nAn error occurred\n\nPress button\nto reload") display.root_group = displayio.CIRCUITPYTHON_TERMINAL display.auto_refresh = True while True: if (event1 := keys.events.get()) and event1.pressed: break supervisor.reload()
Text editor powered by tinymce.