|
1 | 1 | # textual chat interface |
2 | 2 |
|
| 3 | +import re |
| 4 | +import os |
| 5 | + |
3 | 6 | from textual import on, work |
4 | 7 | from textual.app import App, ComposeResult |
5 | 8 | from textual.widget import Widget |
|
29 | 32 | ## sidebar |
30 | 33 | ## |
31 | 34 |
|
32 | | - |
33 | 35 | class Sidebar(Widget): |
34 | 36 | DEFAULT_CSS = """ |
35 | 37 | Sidebar { |
@@ -63,11 +65,16 @@ class Sidebar(Widget): |
63 | 65 | } |
64 | 66 | """ |
65 | 67 |
|
| 68 | + def __init__(self, convo, **kwargs): |
| 69 | + super().__init__(**kwargs) |
| 70 | + self.convo = convo |
| 71 | + |
66 | 72 | def compose(self): |
67 | 73 | with Vertical(): |
68 | 74 | yield Label("Chat History", id='history_title') |
69 | | - yield Label("Is Jupiter a planet?") |
70 | | - yield Label("What is the capital of France?") |
| 75 | + for title in self.convo: |
| 76 | + yield Label(title) |
| 77 | + |
71 | 78 |
|
72 | 79 | ## |
73 | 80 | ## widgets |
@@ -185,34 +192,72 @@ async def pipe_stream(self, generate, setter): |
185 | 192 | async for reply in cumcat(generate): |
186 | 193 | self.app.call_from_thread(setter, reply) |
187 | 194 |
|
| 195 | +class ConvoStore: |
| 196 | + def __init__(self, store): |
| 197 | + self.store = store |
| 198 | + self.load_store() |
| 199 | + |
| 200 | + @staticmethod |
| 201 | + def parse_convo(markdown): |
| 202 | + # match title (#!) |
| 203 | + title_match = re.match(r'^#! (.*)\n', markdown) |
| 204 | + if title_match is None: return None |
| 205 | + title = title_match.group(1) |
| 206 | + |
| 207 | + # match messages |
| 208 | + chunks = re.split(r'\n\n(SYSTEM|USER|ASSISTANT): ', markdown) |
| 209 | + messages = [ |
| 210 | + {'role': role, 'text': text} |
| 211 | + for role, text in zip(chunks[1::2], chunks[2::2]) |
| 212 | + ] |
| 213 | + |
| 214 | + # return title and messages |
| 215 | + return title, messages |
| 216 | + |
| 217 | + def load_store(self): |
| 218 | + self.convo = {} |
| 219 | + for file in os.listdir(self.store): |
| 220 | + with open(os.path.join(self.store, file), 'r') as fid: |
| 221 | + markdown = fid.read().strip() |
| 222 | + result = ConvoStore.parse_convo(markdown) |
| 223 | + if result is None: continue |
| 224 | + title, messages = result |
| 225 | + self.convo[title] = messages |
| 226 | + |
188 | 227 | class TextualChat(App): |
189 | 228 | BINDINGS = [("ctrl+s", "toggle_sidebar", "Toggle Sidebar")] |
190 | 229 |
|
191 | 230 | show_sidebar = reactive(False) |
192 | 231 |
|
193 | | - def __init__(self, chat, **kwargs): |
| 232 | + def __init__(self, chat, store=None, **kwargs): |
194 | 233 | super().__init__(**kwargs) |
195 | 234 | self.chat = chat |
| 235 | + |
| 236 | + # load conversation history |
| 237 | + self.store = ConvoStore(store) if store is not None else None |
| 238 | + |
| 239 | + # set window title |
196 | 240 | provider = self.chat.kwargs.get('provider', 'local') |
197 | 241 | self.title = f'oneping: {provider}' |
198 | 242 |
|
199 | 243 | def compose(self): |
200 | 244 | yield Header(id='header') |
201 | | - yield Sidebar() |
| 245 | + yield Sidebar(convo=self.store.convo) |
202 | 246 | yield ChatWindow(self.chat.stream_async, system=self.chat.system) |
203 | 247 |
|
204 | 248 | def on_mount(self): |
205 | 249 | query = self.query_one('BareQuery') |
206 | 250 | self.set_focus(query) |
207 | 251 |
|
208 | 252 | def action_toggle_sidebar(self): |
209 | | - self.show_sidebar = not self.show_sidebar |
| 253 | + if self.store is not None: |
| 254 | + self.show_sidebar = not self.show_sidebar |
210 | 255 |
|
211 | 256 | def watch_show_sidebar(self, show_sidebar): |
212 | 257 | self.query_one(Sidebar).set_class(show_sidebar, "-visible") |
213 | 258 |
|
214 | 259 | # textual powered chat interface |
215 | | -def main(**kwargs): |
| 260 | +def main(store=None, **kwargs): |
216 | 261 | chat = Chat(**kwargs) |
217 | | - app = TextualChat(chat) |
| 262 | + app = TextualChat(chat, store=store) |
218 | 263 | app.run() |
0 commit comments