aicosmos-client 0.0.1__tar.gz → 0.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ # build files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # pack files
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+
11
+ # virtual environment
12
+ venv/
13
+ .env/
14
+
15
+ # IDE settings
16
+ .vscode/
17
+ .idea/
18
+
19
+ # local directory
20
+ local/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aicosmos_client
3
- Version: 0.0.1
3
+ Version: 0.0.3
4
4
  Summary: client for AICosmos platform
5
5
  Project-URL: Homepage, https://github.com/pypa/sampleproject
6
6
  Project-URL: Issues, https://github.com/pypa/sampleproject/issues
@@ -11,6 +11,7 @@ Classifier: Operating System :: OS Independent
11
11
  Classifier: Programming Language :: Python :: 3
12
12
  Requires-Python: >=3.9
13
13
  Requires-Dist: requests>=2.25.0
14
+ Requires-Dist: textual>=5.0.0
14
15
  Description-Content-Type: text/markdown
15
16
 
16
17
  # Example Package
@@ -0,0 +1,479 @@
1
+ import asyncio
2
+ from typing import Dict
3
+
4
+ from textual import on, work
5
+ from textual.app import App, ComposeResult
6
+ from textual.containers import Container
7
+ from textual.reactive import reactive
8
+ from textual.screen import Screen
9
+ from textual.widgets import (
10
+ Button,
11
+ Footer,
12
+ Header,
13
+ Input,
14
+ Label,
15
+ ListItem,
16
+ ListView,
17
+ LoadingIndicator,
18
+ RichLog,
19
+ Static,
20
+ )
21
+ from .client import AICosmosClient
22
+
23
+
24
+ class LoginScreen(Screen):
25
+ CSS = """
26
+ #login-container {
27
+ width: 100%;
28
+ height: 100%;
29
+ border: solid $accent;
30
+ padding: 1;
31
+ }
32
+ #login-title {
33
+ width: 100%;
34
+ text-align: center;
35
+ margin-bottom: 1;
36
+ text-style: bold;
37
+ }
38
+ #login-error {
39
+ width: 100%;
40
+ color: red;
41
+ text-align: center;
42
+ margin-top: 1;
43
+ }
44
+ """
45
+
46
+ def compose(self) -> ComposeResult:
47
+ yield Header()
48
+ with Container(id="login-container"):
49
+ yield Label("Welcome to AICosmos", id="login-title")
50
+ yield Input(placeholder="server url", id="server_url")
51
+ yield Input(placeholder="username", id="username")
52
+ yield Input(
53
+ placeholder="password",
54
+ id="password",
55
+ password=True,
56
+ )
57
+ yield Button("Login", id="login-button", variant="primary")
58
+ yield Label("", id="login-error")
59
+ yield Footer()
60
+
61
+ @on(Button.Pressed, "#login-button")
62
+ def on_login(self) -> None:
63
+ server_url = self.query_one("#server_url", Input).value
64
+ username = self.query_one("#username", Input).value
65
+ password = self.query_one("#password", Input).value
66
+
67
+ if not server_url:
68
+ self.query_one("#login-error").update("Server url cannot be empty")
69
+ return
70
+ if not username:
71
+ self.query_one("#login-error").update("User cannot be empty")
72
+ return
73
+ if not password:
74
+ self.query_one("#login-error").update("Password cannot be empty")
75
+ return
76
+
77
+ try:
78
+ self.app.client = AICosmosClient(
79
+ base_url=server_url, username=username, password=password
80
+ )
81
+ self.app.push_screen("session")
82
+ except ValueError:
83
+ self.query_one("#login-error").update(
84
+ "Login failed, please check your information"
85
+ )
86
+
87
+
88
+ class SessionScreen(Screen):
89
+ CSS = """
90
+ #session-container {
91
+ width: 100%;
92
+ height: 100%;
93
+ border: solid $accent;
94
+ padding: 1;
95
+ }
96
+ #session-header {
97
+ width: 100%;
98
+ layout: horizontal;
99
+ height: auto;
100
+ margin-bottom: 1;
101
+ }
102
+ #session-title {
103
+ width: 100%;
104
+ text-align: center;
105
+ text-style: bold;
106
+ content-align: center middle;
107
+ }
108
+ #session-actions {
109
+ width: 100%;
110
+ layout: horizontal;
111
+ height: auto;
112
+ margin-bottom: 1;
113
+ }
114
+ #create-session {
115
+ width: 30%;
116
+ margin-right: 1;
117
+ }
118
+ #logout-button {
119
+ width: 30%;
120
+ }
121
+ .spacer {
122
+ width: 40%;
123
+ }
124
+ .subtitle {
125
+ width: 100%;
126
+ margin-top: 1;
127
+ margin-bottom: 1;
128
+ text-style: underline;
129
+ }
130
+ #session-list {
131
+ width: 100%;
132
+ height: 60%;
133
+ border: solid $panel;
134
+ }
135
+ #session-error {
136
+ width: 100%;
137
+ color: red;
138
+ text-align: center;
139
+ margin-top: 1;
140
+ }
141
+ """
142
+
143
+ sessions = reactive([])
144
+
145
+ def compose(self) -> ComposeResult:
146
+ yield Header()
147
+ with Container(id="session-container"):
148
+ with Container(id="session-header"):
149
+ yield Label("Select Session", id="session-title")
150
+ with Container(id="session-actions"):
151
+ yield Button("Create Session", id="create-session", variant="primary")
152
+ yield Static("", classes="spacer")
153
+ yield Button("Log out", id="logout-button", variant="error")
154
+ yield Label("Your sessions:", classes="subtitle")
155
+ yield ListView(id="session-list")
156
+ yield Label("", id="session-error")
157
+ yield Footer()
158
+
159
+ def sanitize_id(self, session_id: str) -> str:
160
+ """Turn session id into valid HTML identifier"""
161
+ if not isinstance(session_id, str):
162
+ session_id = str(session_id)
163
+
164
+ import re
165
+
166
+ clean_id = re.sub(r"[^a-zA-Z0-9_-]", "", session_id)
167
+
168
+ if clean_id and clean_id[0].isdigit():
169
+ clean_id = "s_" + clean_id
170
+
171
+ return clean_id or "invalid_session"
172
+
173
+ def on_mount(self) -> None:
174
+ self.load_sessions()
175
+
176
+ @work(exclusive=True)
177
+ async def load_sessions(self) -> None:
178
+ self.query_one("#session-list").clear()
179
+ self.sessions, message = self.app.client.get_my_sessions()
180
+
181
+ if message != "success":
182
+ self.query_one("#session-list").append(
183
+ ListItem(Label(f"{message}"), id="no-sessions")
184
+ )
185
+ return
186
+
187
+ if not self.sessions:
188
+ self.query_one("#session-list").append(
189
+ ListItem(Label("No session found"), id="no-sessions")
190
+ )
191
+ return
192
+
193
+ for session in self.sessions:
194
+ session_id = session["session_id"]
195
+ clean_id = self.sanitize_id(session_id)
196
+ title = session["environment_info"].get(
197
+ "title", f"Session {session_id[:6]}"
198
+ )
199
+ self.query_one("#session-list").append(
200
+ ListItem(Label(title), id=f"session-{clean_id}")
201
+ )
202
+
203
+ @on(Button.Pressed, "#create-session")
204
+ @work(exclusive=True)
205
+ async def create_session(self) -> None:
206
+ session_id = self.app.client.create_session()
207
+
208
+ if session_id:
209
+ self.app.current_session = session_id
210
+ self.app.uninstall_screen("chat")
211
+ self.app.install_screen(ChatScreen(), "chat")
212
+ self.app.push_screen("chat")
213
+ else:
214
+ self.query_one("#session-error").update("Failed to create session")
215
+
216
+ @on(ListView.Selected)
217
+ def session_selected(self, event: ListView.Selected) -> None:
218
+ """Handle session selection"""
219
+ if not event.item or not event.item.id or event.item.id == "no-sessions":
220
+ return
221
+
222
+ selected_index = event.list_view.index
223
+ if selected_index is None or selected_index >= len(self.sessions):
224
+ return
225
+
226
+ selected_session = self.sessions[selected_index]
227
+ session_id = selected_session["session_id"]
228
+
229
+ if session_id:
230
+ self.app.current_session = session_id
231
+ self.app.uninstall_screen("chat")
232
+ self.app.install_screen(ChatScreen(), "chat")
233
+ self.app.push_screen("chat")
234
+
235
+ @on(Button.Pressed, "#logout-button")
236
+ def logout(self) -> None:
237
+ """Go back to login page"""
238
+ self.app.pop_screen()
239
+ self.app.push_screen("login")
240
+
241
+
242
+ class ChatScreen(Screen):
243
+ CSS = """
244
+ #chat-container {
245
+ width: 100%;
246
+ height: 100%;
247
+ border: solid $accent;
248
+ padding: 1;
249
+ }
250
+
251
+ #chat-header {
252
+ width: 100%;
253
+ layout: horizontal;
254
+ height: 3;
255
+ margin-bottom: 1;
256
+ }
257
+
258
+ #chat-title-container {
259
+ width: 1fr;
260
+ height: 100%;
261
+ align: center middle;
262
+ }
263
+
264
+ #chat-title {
265
+ text-align: center;
266
+ text-style: bold;
267
+ }
268
+
269
+ #back-button {
270
+ width: auto;
271
+ height: 100%;
272
+ dock: right;
273
+ }
274
+
275
+ #chat-content {
276
+ width: 100%;
277
+ height: 1fr;
278
+ layout: vertical;
279
+ }
280
+
281
+ #chat-log {
282
+ width: 100%;
283
+ height: 1fr;
284
+ border: solid $panel;
285
+ padding: 1;
286
+ overflow-y: auto;
287
+ scrollbar-size: 1 1;
288
+ scrollbar-color: $accent;
289
+ }
290
+
291
+ #input-container {
292
+ width: 100%;
293
+ height: auto;
294
+ margin-top: 1;
295
+ layout: horizontal;
296
+ }
297
+
298
+ #message-input {
299
+ width: 1fr;
300
+ }
301
+
302
+ #send-button {
303
+ width: auto;
304
+ margin-left: 1;
305
+ }
306
+
307
+ #loading-indicator {
308
+ width: auto;
309
+ margin-left: 1;
310
+ }
311
+ """
312
+
313
+ def compose(self) -> ComposeResult:
314
+ yield Header()
315
+ with Container(id="chat-container"):
316
+ with Container(id="chat-header"):
317
+ with Container(id="chat-title-container"):
318
+ yield Label("Session", id="chat-title")
319
+ yield Button("Back", id="back-button", variant="default")
320
+
321
+ with Container(id="chat-content"):
322
+ yield RichLog(id="chat-log", wrap=True, markup=True, auto_scroll=True)
323
+
324
+ with Container(id="input-container"):
325
+ yield Input(placeholder="Enter your message...", id="message-input")
326
+ yield Button("Send", id="send-button", variant="primary")
327
+ yield LoadingIndicator(id="loading-indicator")
328
+ yield Footer()
329
+
330
+ def __init__(self):
331
+ super().__init__()
332
+ self.conversation = []
333
+
334
+ def on_resize(self) -> None:
335
+ """Recalculate message displays when window changes"""
336
+ self.update_message_display()
337
+
338
+ def update_message_display(self) -> None:
339
+ """Update message displays when window changes"""
340
+ log = self.query_one("#chat-log")
341
+ log.clear()
342
+ for message in self.conversation:
343
+ log.write(self.format_message(message))
344
+ log.scroll_end(animate=False)
345
+
346
+ def calculate_max_line_length(self) -> int:
347
+ """Max line length for current session"""
348
+ log_widget = self.query_one("#chat-log")
349
+ container_width = log_widget.content_size.width
350
+ return container_width - 1
351
+
352
+ def format_message(self, message: Dict) -> str:
353
+ """Format each message, with two spaces as indent"""
354
+ role = message["role"]
355
+ content = message["content"]
356
+ max_line_length = self.calculate_max_line_length() - 2
357
+
358
+ import textwrap
359
+
360
+ wrapped_lines = []
361
+ for line in content.split("\n"):
362
+ indented_line = textwrap.fill(
363
+ line,
364
+ width=max_line_length,
365
+ initial_indent=" ",
366
+ subsequent_indent=" ",
367
+ replace_whitespace=False,
368
+ drop_whitespace=False,
369
+ )
370
+ wrapped_lines.append(indented_line)
371
+
372
+ wrapped_content = "\n".join(wrapped_lines)
373
+
374
+ if role == "user":
375
+ return f"[cyan][b]user[/b][/cyan]\n{wrapped_content}\n"
376
+ elif role == "assistant":
377
+ char_name = message.get("character_name", "assistant")
378
+ return f"[green][b]{char_name}[/b][/green]\n{wrapped_content}\n"
379
+ else:
380
+ return f"[red][b]{role}[/b][/red]\n{wrapped_content}\n"
381
+
382
+ def add_message(self, message: Dict) -> None:
383
+ """Update history"""
384
+ self.conversation.append(message)
385
+ self.update_message_display()
386
+
387
+ def on_show(self) -> None:
388
+ """Hide loading indicator and show history"""
389
+ self.query_one("#loading-indicator").display = False
390
+ self.load_conversation()
391
+ self.query_one("#message-input").focus()
392
+
393
+ def on_mount(self) -> None:
394
+ """Hide loading indicator and show history"""
395
+ self.query_one("#loading-indicator").display = False
396
+ self.load_conversation()
397
+ self.query_one("#message-input").focus()
398
+
399
+ @work(exclusive=True)
400
+ async def load_conversation(self) -> None:
401
+ """Fetch conversation history"""
402
+ self.conversation, message = self.app.client.get_session_history(
403
+ self.app.current_session
404
+ )
405
+ if message != "success":
406
+ self.query_one("#chat-log").write(f"[red]Error: {message}[/red]")
407
+ return
408
+ self.update_message_display()
409
+
410
+ @on(Button.Pressed, "#send-button")
411
+ @on(Input.Submitted, "#message-input")
412
+ def send_message(self) -> None:
413
+ """Send message to backend"""
414
+ input_widget = self.query_one("#message-input")
415
+ message = input_widget.value.strip()
416
+
417
+ if not message:
418
+ return
419
+
420
+ input_widget.value = ""
421
+ self.add_message({"role": "user", "content": message})
422
+ self.process_response(message)
423
+
424
+ @work(exclusive=True)
425
+ async def process_response(self, message: str) -> None:
426
+ """Wait for response from backend"""
427
+ loading = self.query_one("#loading-indicator", LoadingIndicator)
428
+ input_widget = self.query_one("#message-input")
429
+ send_button = self.query_one("#send-button")
430
+
431
+ loading.display = True
432
+ input_widget.disabled = True
433
+ send_button.disabled = True
434
+
435
+ try:
436
+ conversation_history, message = await asyncio.to_thread(
437
+ self.app.client.chat, self.app.current_session, message
438
+ )
439
+
440
+ if message != "success":
441
+ self.query_one("#chat-log").write(f"[red]Error: {message}[/red]")
442
+ return
443
+ new_messages = conversation_history[len(self.conversation) :]
444
+ for msg in new_messages:
445
+ self.add_message(msg)
446
+
447
+ except Exception as e:
448
+ self.query_one("#chat-log").write(f"[red]Query failed: {str(e)}[/red]")
449
+
450
+ finally:
451
+ loading.display = False
452
+ input_widget.disabled = False
453
+ send_button.disabled = False
454
+ input_widget.focus()
455
+
456
+ @on(Button.Pressed, "#back-button")
457
+ def back_to_sessions(self) -> None:
458
+ """Go back to sessions list"""
459
+ self.app.pop_screen()
460
+
461
+
462
+ class AICosmosCLI(App):
463
+ CSS = """
464
+ Screen {
465
+ align: center middle;
466
+ }
467
+ """
468
+
469
+ FULL_SCREEN = True
470
+
471
+ SCREENS = {"login": LoginScreen, "session": SessionScreen, "chat": ChatScreen}
472
+
473
+ def __init__(self):
474
+ super().__init__()
475
+ self.client: AICosmosClient = None
476
+ self.current_session: str = None
477
+
478
+ def on_mount(self) -> None:
479
+ self.push_screen("login")
@@ -111,7 +111,7 @@ class AICosmosClient:
111
111
  def get_session_history(self, session_id: str):
112
112
  session, message = self._get_session_status(session_id)
113
113
  if not session:
114
- return None, message
114
+ return [], message
115
115
  else:
116
116
  return session.get("conversation", []), message
117
117
 
@@ -132,6 +132,6 @@ class AICosmosClient:
132
132
  if success:
133
133
  return response.json()["conversation_history"], "Success"
134
134
  else:
135
- return None, f"Status code: {response.status_code}"
135
+ return [], f"Status code: {response.status_code}"
136
136
  except Exception as e:
137
- return None, f"Error: {e}"
137
+ return [], f"Error: {e}"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "aicosmos_client"
7
- version = "0.0.1"
7
+ version = "0.0.3"
8
8
  authors = [{ name = "Example Author", email = "author@example.com" }]
9
9
  description = "client for AICosmos platform"
10
10
  readme = "README.md"
@@ -15,7 +15,7 @@ classifiers = [
15
15
  ]
16
16
  license = "MIT"
17
17
  license-files = ["LICEN[CS]E*"]
18
- dependencies = ["requests>=2.25.0"]
18
+ dependencies = ["requests>=2.25.0", "textual>=5.0.0"]
19
19
 
20
20
  [project.urls]
21
21
  Homepage = "https://github.com/pypa/sampleproject"
File without changes