Merge pull request #18 from devchat-ai/chatmark

Python implementation for ChatMark
This commit is contained in:
boob.yang 2024-01-02 16:42:30 +08:00 committed by GitHub
commit 21fe7678a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 685 additions and 0 deletions

1
libs/chatmark/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
tmp/

5
libs/chatmark/README.md Normal file
View File

@ -0,0 +1,5 @@
# ChatMark
ChatMark is a markup language for user interaction in chat message.
This module provides python implementation for common widgets in ChatMark. It is a replacement for `ui_utils/`.

10
libs/chatmark/__init__.py Normal file
View File

@ -0,0 +1,10 @@
from .widgets import Checkbox, TextEditor, Radio, Button
from .form import Form
__all__ = [
"Checkbox",
"TextEditor",
"Radio",
"Button",
"Form",
]

View File

@ -0,0 +1,16 @@
# chatmark_exmaple
This is an example of how to use the chatmark module.
Usage:
1. Copy the `chatmark_example` folder under `~/.chat/workflow/org`
2. Create `command.yml` under `~/.chat/workflow/org/chatmark_example` with the following content:
```yaml
description: chatmark examples
steps:
- run: $command_python $command_path/main.py
```
3. Use the command `/chatmark_example` in devchat vscode plugin.

View File

@ -0,0 +1,144 @@
import sys
import os
sys.path.append(os.path.join(os.path.dirname(__file__), "..", "libs"))
sys.path.append(os.path.join(os.path.dirname(__file__), "..", "..", "libs"))
from chatmark import Checkbox, TextEditor, Radio, Button, Form # noqa: E402
def main():
print("\n\n---\n\n")
# Button
print("\n\n# Button Example\n\n")
button = Button(
[
"Yes",
"Or",
"No",
],
)
button.render()
idx = button.clicked
print("\n\nButton result\n\n")
print(f"\n\n{idx}: {button.buttons[idx]}\n\n")
print("\n\n---\n\n")
# Checkbox
print("\n\n# Checkbox Example\n\n")
checkbox = Checkbox(
[
"A",
"B",
"C",
"D",
],
[True, False, False, True],
)
checkbox.render()
print(f"\n\ncheckbox.selections: {checkbox.selections}\n\n")
for idx in checkbox.selections:
print(f"\n\n{idx}: {checkbox.options[idx]}\n\n")
print("\n\n---\n\n")
# TextEditor
print("\n\n# TextEditor Example\n\n")
text_editor = TextEditor(
"hello world\nnice to meet you",
)
text_editor.render()
print(f"\n\ntext_editor.new_text:\n\n{text_editor.new_text}\n\n")
print("\n\n---\n\n")
# Radio
print("\n\n# Radio Example\n\n")
radio = Radio(
[
"Sun",
"Moon",
"Star",
],
)
radio.render()
print(f"\n\nradio.selection: {radio.selection}\n\n")
if radio.selection is not None:
print(f"\n\nradio.options[radio.selection]: {radio.options[radio.selection]}\n\n")
print("\n\n---\n\n")
# Form
print("\n\n# Form Example\n\n")
checkbox_1 = Checkbox(
[
"Sprint",
"Summer",
"Autumn",
"Winter",
]
)
checkbox_2 = Checkbox(
[
"",
"",
"",
"",
"",
],
)
radio_1 = Radio(
[
"Up",
"Down",
],
)
radio_2 = Radio(
[
"Left",
"Center",
"Right",
],
)
text_editor_1 = TextEditor(
"hello world\nnice to meet you",
)
text_editor_2 = TextEditor(
"hihihihihi",
)
form = Form(
[
"Some string in a form",
checkbox_1,
"Another string in a form",
radio_1,
"the third string in a form",
checkbox_2,
"the fourth string in a form",
radio_2,
"the fifth string in a form",
text_editor_1,
"the last string in a form",
text_editor_2,
],
)
form.render()
print(f"\n\ncheckbox_1.selections: {checkbox_1.selections}\n\n")
print(f"\n\ncheckbox_2.selections: {checkbox_2.selections}\n\n")
print(f"\n\nradio_1.selection: {radio_1.selection}\n\n")
print(f"\n\nradio_2.selection: {radio_2.selection}\n\n")
print(f"\n\ntext_editor_1.new_text:\n\n{text_editor_1.new_text}\n\n")
print(f"\n\ntext_editor_2.new_text:\n\n{text_editor_2.new_text}\n\n")
if __name__ == "__main__":
main()

85
libs/chatmark/form.py Normal file
View File

@ -0,0 +1,85 @@
from typing import List, Optional, Dict
from .iobase import pipe_interaction
from .widgets import Widget, Button
class Form:
"""
A container for different widgets
Syntax:
"""
def __init__(
self,
components: List[Widget | str],
title: Optional[str] = None,
):
"""
components: components in the form, can be widgets (except Button) or strings
title: title of the form
"""
assert (
any(isinstance(c, Button) for c in components) is False
), "Button is not allowed in Form"
self._components = components
self._title = title
self._rendered = False
@property
def components(self) -> List[Widget | str]:
"""
Return the components
"""
return self._components
def _in_chatmark(self) -> str:
"""
Generate ChatMark syntax for all components
"""
lines = []
if self._title:
lines.append(self._title)
for c in self.components:
if isinstance(c, str):
lines.append(c)
elif isinstance(c, Widget):
lines.append(c._in_chatmark())
else:
raise ValueError(f"Invalid component {c}")
return "\n".join(lines)
def _parse_response(self, response: Dict):
"""
Parse response from user input
"""
for c in self.components:
if isinstance(c, Widget):
c._parse_response(response)
def render(self):
"""
Render to receive user input
"""
if self._rendered:
# already rendered once
# not sure if the constraint is necessary
# could be removed if re-rendering is needed
raise RuntimeError("Widget can only be rendered once")
self._rendered = True
lines = [
"```chatmark type=form",
self._in_chatmark(),
"```",
]
chatmark = "\n".join(lines)
response = pipe_interaction(chatmark)
self._parse_response(response)

45
libs/chatmark/iobase.py Normal file
View File

@ -0,0 +1,45 @@
import yaml
# almost the same as ui_utils/iobase.py
def _send_message(message):
out_data = f"""\n{message}\n"""
print(out_data, flush=True)
def _parse_chatmark_response(response):
# resonse looks like:
"""
``` some_name
some key name 1: value1
some key name 2: value2
```
"""
# parse key values
lines = response.strip().split("\n")
if len(lines) <= 2:
return {}
data = yaml.safe_load("\n".join(lines[1:-1]))
return data
def pipe_interaction(message: str):
_send_message(message)
lines = []
while True:
try:
line = input()
if line.strip().startswith("```yaml"):
lines = []
elif line.strip() == "```":
lines.append(line)
break
lines.append(line)
except EOFError:
pass
response = "\n".join(lines)
return _parse_chatmark_response(response)

379
libs/chatmark/widgets.py Normal file
View File

@ -0,0 +1,379 @@
from typing import List, Optional, Dict, Tuple
from .iobase import pipe_interaction
from abc import ABC, abstractmethod
from uuid import uuid4
class Widget(ABC):
"""
Abstract base class for widgets
"""
def __init__(self):
self._rendered = False
# Prefix for IDs/keys in the widget
self._id_prefix = self.gen_id_prefix()
@abstractmethod
def _in_chatmark(self) -> str:
"""
Generate ChatMark syntax for the widget
"""
pass
@abstractmethod
def _parse_response(self, response: Dict) -> None:
"""
Parse ChatMark response from user input
"""
pass
def render(self) -> None:
"""
Render the widget to receive user input
"""
if self._rendered:
# already rendered once
# not sure if the constraint is necessary
# could be removed if re-rendering is needed
raise RuntimeError("Widget can only be rendered once")
self._rendered = True
lines = [
"```chatmark",
self._in_chatmark(),
"```",
]
chatmark = "\n".join(lines)
response = pipe_interaction(chatmark)
self._parse_response(response)
@staticmethod
def gen_id_prefix() -> str:
return uuid4().hex
@staticmethod
def gen_id(id_prefix: str, index: int) -> str:
return f"{id_prefix}_{index}"
@staticmethod
def parse_id(a_id: str) -> Tuple[Optional[str], Optional[int]]:
try:
id_prefix, index = a_id.split("_")
return id_prefix, int(index)
except Exception:
return None, None
class Checkbox(Widget):
"""
ChatMark syntax:
```chatmark
Which files would you like to commit? I've suggested a few.
> [x](file1) devchat/engine/prompter.py
> [x](file2) devchat/prompt.py
> [](file3) tests/test_cli_prompt.py
```
Response:
```yaml
file1: checked
file3: checked
```
"""
def __init__(
self,
options: List[str],
check_states: Optional[List[bool]] = None,
title: Optional[str] = None,
):
"""
options: options to be selected
check_states: initial check states of options, default to all False
title: title of the widget
"""
super().__init__()
if check_states is not None:
assert len(options) == len(check_states)
else:
check_states = [False for _ in options]
self._options = options
self._states = check_states
self._title = title
self._selections: Optional[List[int]] = None
@property
def selections(self) -> Optional[List[int]]:
"""
Get the indices of selected options
"""
return self._selections
@property
def options(self) -> List[str]:
"""
Get the options
"""
return self._options
def _in_chatmark(self) -> str:
"""
Generate ChatMark syntax for checkbox options
Use the index of option to generate id/key
"""
lines = []
if self._title:
lines.append(self._title)
for idx, (option, state) in enumerate(zip(self._options, self._states)):
mark = "[x]" if state else "[]"
key = self.gen_id(self._id_prefix, idx)
lines.append(f"> {mark}({key}) {option}")
text = "\n".join(lines)
return text
def _parse_response(self, response: Dict):
selections = []
for key, value in response.items():
prefix, index = self.parse_id(key)
# check if the prefix is the same as the widget's
if prefix != self._id_prefix:
continue
if value == "checked":
selections.append(index)
self._selections = selections
class TextEditor(Widget):
"""
ChatMark syntax:
```chatmark
I've drafted a commit message for you as below. Feel free to modify it.
> | (ID)
> fix: prevent racing of requests
>
> Introduce a request id and a reference to latest request. Dismiss
> incoming responses other than from latest request.
>
> Reviewed-by: Z
> Refs: #123
```
Response:
```yaml
ID: |
fix: prevent racing of requests
Introduce a request ID and a reference to latest request. Dismiss
incoming responses other than from latest request.
Reviewed-by: Z
Refs: #123
```
"""
def __init__(self, text: str, title: Optional[str] = None):
super().__init__()
self._title = title
self._text = text
self._editor_key = self.gen_id(self._id_prefix, 0)
self._new_text: Optional[str] = None
@property
def new_text(self):
return self._new_text
def _in_chatmark(self) -> str:
"""
Generate ChatMark syntax for text editor
Use _editor_key as id
"""
lines = self._text.split("\n")
new_lines = []
if self._title:
new_lines.append(self._title)
new_lines.append(f"> | ({self._editor_key})")
new_lines.extend([f"> {line}" for line in lines])
text = "\n".join(new_lines)
return text
def _parse_response(self, response: Dict):
self._new_text = response.get(self._editor_key, None)
class Radio(Widget):
"""
ChatMark syntax:
```chatmark
How would you like to make the change?
> - (insert) Insert the new code.
> - (new) Put the code in a new file.
> - (replace) Replace the current code.
```
Reponse:
```yaml
replace: checked
```
"""
def __init__(
self,
options: List[str],
# TODO: implement default_selected after the design is ready
# default_selected: Optional[int] = None,
title: Optional[str] = None,
) -> None:
"""
options: options to be selected
default_selected: index of the option to be selected by default, default to None
title: title of the widget
"""
# TODO: implement default_selected after the design is ready
# if default_selected is not None:
# assert 0 <= default_selected < len(options)
super().__init__()
self._options = options
self._title = title
self._selection: Optional[int] = None
@property
def options(self) -> List[str]:
"""
Return the options
"""
return self._options
@property
def selection(self) -> Optional[int]:
"""
Return the index of the selected option
"""
return self._selection
def _in_chatmark(self) -> str:
"""
Generate ChatMark syntax for options
Use the index of option to generate id/key
"""
lines = []
if self._title:
lines.append(self._title)
for idx, option in enumerate(self._options):
key = self.gen_id(self._id_prefix, idx)
lines.append(f"> - ({key}) {option}")
text = "\n".join(lines)
return text
def _parse_response(self, response: Dict):
selected = None
for key, value in response.items():
prefix, idx = self.parse_id(key)
# check if the prefix is the same as the widget's
if prefix != self._id_prefix:
continue
if value == "checked":
selected = idx
break
self._selection = selected
class Button(Widget):
"""
ChatMark syntax:
```chatmark
Would you like to pay $0.02 for this LLM query?
> (Confirm) Yes, go ahead!
> (Cancel) No, let's skip this.
```
```yaml
Confirm: clicked
```
# NOTE: almost the same as Radio essentially
"""
def __init__(
self,
buttons: List[str],
title: Optional[str] = None,
) -> None:
"""
buttons: button names to show
title: title of the widget
"""
super().__init__()
self._buttons = buttons
self._title = title
self._clicked: Optional[int] = None
@property
def clicked(self) -> Optional[int]:
"""
Return the index of the clicked button
"""
return self._clicked
@property
def buttons(self) -> List[str]:
"""
Return the buttons
"""
return self._buttons
def _in_chatmark(self) -> str:
"""
Generate ChatMark syntax for options
Use the index of button to generate id/key
"""
lines = []
if self._title:
lines.append(self._title)
for idx, button in enumerate(self._buttons):
key = self.gen_id(self._id_prefix, idx)
lines.append(f"> ({key}) {button}")
text = "\n".join(lines)
return text
def _parse_response(self, response: Dict[str, str]):
clicked = None
for key, value in response.items():
prefix, idx = self.parse_id(key)
# check if the prefix is the same as the widget's
if prefix != self._id_prefix:
continue
if value == "clicked":
clicked = idx
break
self._clicked = clicked