Merge pull request #18 from devchat-ai/chatmark
Python implementation for ChatMark
This commit is contained in:
commit
21fe7678a1
1
libs/chatmark/.gitignore
vendored
Normal file
1
libs/chatmark/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
tmp/
|
5
libs/chatmark/README.md
Normal file
5
libs/chatmark/README.md
Normal 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
10
libs/chatmark/__init__.py
Normal file
@ -0,0 +1,10 @@
|
||||
from .widgets import Checkbox, TextEditor, Radio, Button
|
||||
from .form import Form
|
||||
|
||||
__all__ = [
|
||||
"Checkbox",
|
||||
"TextEditor",
|
||||
"Radio",
|
||||
"Button",
|
||||
"Form",
|
||||
]
|
16
libs/chatmark/chatmark_example/README.md
Normal file
16
libs/chatmark/chatmark_example/README.md
Normal 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.
|
||||
|
144
libs/chatmark/chatmark_example/main.py
Normal file
144
libs/chatmark/chatmark_example/main.py
Normal 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
85
libs/chatmark/form.py
Normal 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
45
libs/chatmark/iobase.py
Normal 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
379
libs/chatmark/widgets.py
Normal 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
|
Loading…
x
Reference in New Issue
Block a user