virtuai-cli 0.2.2__tar.gz → 0.4.0__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.
Files changed (22) hide show
  1. {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/PKG-INFO +1 -1
  2. {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/pyproject.toml +1 -1
  3. {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli/__init__.py +1 -1
  4. {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli/chat/command.py +2 -1
  5. virtuai_cli-0.4.0/src/virtuai_cli/chat/history.py +44 -0
  6. {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli/chat/tui.py +141 -8
  7. {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli.egg-info/PKG-INFO +1 -1
  8. {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli.egg-info/SOURCES.txt +1 -0
  9. {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/README.md +0 -0
  10. {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/setup.cfg +0 -0
  11. {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli/chat/__init__.py +0 -0
  12. {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli/chat/sse.py +0 -0
  13. {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli/chat/widgets.py +0 -0
  14. {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli/config.py +0 -0
  15. {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli/executor.py +0 -0
  16. {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli/main.py +0 -0
  17. {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli/runner.py +0 -0
  18. {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli/security.py +0 -0
  19. {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
  20. {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
  21. {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli.egg-info/requires.txt +0 -0
  22. {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: virtuai-cli
3
- Version: 0.2.2
3
+ Version: 0.4.0
4
4
  Summary: Run VirtuAI deep agents on your local machine
5
5
  Author-email: uCloudStore <lmoreno@ucloudstore.com>
6
6
  License: Proprietary
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "virtuai-cli"
7
- version = "0.2.2"
7
+ version = "0.4.0"
8
8
  description = "Run VirtuAI deep agents on your local machine"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,2 +1,2 @@
1
1
  """VirtuAI local CLI."""
2
- __version__ = "0.2.2"
2
+ __version__ = "0.4.0"
@@ -92,6 +92,7 @@ def run_chat(
92
92
  agent_id=picked["agent_id"],
93
93
  agent_name=picked["name"],
94
94
  workspace_name=info["workspace_name"],
95
- default_model_id=picked.get("default_model_id"),
95
+ default_model_id=picked.get("default_model_id") or None,
96
+ allowed_models=picked.get("allowed_models") or [],
96
97
  )
97
98
  app.run()
@@ -0,0 +1,44 @@
1
+ """HTTP helpers for browsing past conversations from the TUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import certifi
8
+ import httpx
9
+
10
+
11
+ _VERIFY = certifi.where()
12
+
13
+
14
+ async def list_conversations(
15
+ server_url: str,
16
+ token: str,
17
+ agent_id: str,
18
+ limit: int = 20,
19
+ ) -> list[dict]:
20
+ """Return up to `limit` of the user's recent conversations for this agent."""
21
+ headers = {"Authorization": f"Bearer {token}"}
22
+ url = f"{server_url.rstrip('/')}/api/web-chat/conversations/{agent_id}"
23
+ async with httpx.AsyncClient(verify=_VERIFY, timeout=15.0) as client:
24
+ resp = await client.get(url, headers=headers, params={"limit": limit, "offset": 0})
25
+ resp.raise_for_status()
26
+ return resp.json()
27
+
28
+
29
+ async def load_conversation(
30
+ server_url: str,
31
+ token: str,
32
+ agent_id: str,
33
+ session_id: str,
34
+ message_limit: int = 200,
35
+ ) -> Optional[dict]:
36
+ """Return {conversation, messages} for one session_id, or None if not found."""
37
+ headers = {"Authorization": f"Bearer {token}"}
38
+ url = f"{server_url.rstrip('/')}/api/web-chat/conversations/{agent_id}/{session_id}"
39
+ async with httpx.AsyncClient(verify=_VERIFY, timeout=30.0) as client:
40
+ resp = await client.get(url, headers=headers, params={"message_limit": message_limit})
41
+ if resp.status_code == 404:
42
+ return None
43
+ resp.raise_for_status()
44
+ return resp.json()
@@ -14,6 +14,7 @@ from textual.reactive import reactive
14
14
  from textual.widgets import Footer, Header, Input, Static
15
15
 
16
16
  from virtuai_cli import runner as ws_runner
17
+ from virtuai_cli.chat.history import list_conversations, load_conversation
17
18
  from virtuai_cli.chat.sse import stream_chat
18
19
  from virtuai_cli.chat.widgets import AssistantTurn, UserBubble
19
20
 
@@ -64,6 +65,7 @@ class ChatApp(App):
64
65
  agent_name: str,
65
66
  workspace_name: str,
66
67
  default_model_id: Optional[str] = None,
68
+ allowed_models: Optional[list[dict]] = None,
67
69
  ) -> None:
68
70
  super().__init__()
69
71
  self.server_url = server_url
@@ -73,6 +75,8 @@ class ChatApp(App):
73
75
  self.agent_name = agent_name
74
76
  self.workspace_name = workspace_name
75
77
  self.default_model_id = default_model_id
78
+ self.current_model_id = default_model_id
79
+ self.allowed_models = list(allowed_models or [])
76
80
 
77
81
  self.session_id: Optional[str] = None
78
82
  self._stream_task: Optional[asyncio.Task] = None
@@ -94,7 +98,7 @@ class ChatApp(App):
94
98
  yield Footer()
95
99
 
96
100
  def _initial_status(self) -> str:
97
- model = f" · {self.default_model_id}" if self.default_model_id else ""
101
+ model = f" · {self.current_model_id}" if self.current_model_id else ""
98
102
  return f"{self.workspace_name} · {self.agent_name}{model} · connecting…"
99
103
 
100
104
  # ── Mount: start the WS runner in the background ──────────────────────
@@ -119,9 +123,10 @@ class ChatApp(App):
119
123
  def _set_status(self, text: str) -> None:
120
124
  # The status widget may have been torn down during app shutdown —
121
125
  # swallow the lookup error so we don't surface a NoMatches on exit.
126
+ model = f" · {self.current_model_id}" if self.current_model_id else ""
122
127
  try:
123
128
  self.query_one("#status", Static).update(
124
- f"{self.workspace_name} · {self.agent_name} · {text}"
129
+ f"{self.workspace_name} · {self.agent_name}{model} · {text}"
125
130
  )
126
131
  except Exception:
127
132
  pass
@@ -160,20 +165,148 @@ class ChatApp(App):
160
165
  async def _handle_slash(self, cmd: str) -> None:
161
166
  parts = cmd.split(None, 1)
162
167
  head = parts[0].lower()
168
+ arg = parts[1].strip() if len(parts) > 1 else ""
163
169
  if head in ("/exit", "/quit"):
164
170
  self.exit()
165
171
  elif head in ("/clear", "/new"):
166
172
  await self.action_clear_conversation()
173
+ elif head == "/models":
174
+ await self._show_models()
175
+ elif head == "/model":
176
+ await self._set_model(arg)
177
+ elif head == "/history":
178
+ await self._show_history()
179
+ elif head == "/load":
180
+ await self._load_session(arg)
167
181
  elif head == "/help":
168
182
  await self._append(Static(
169
183
  "[b]Commands[/b]\n"
170
- " /help this list\n"
171
- " /clear, /new start a fresh conversation\n"
172
- " /exit, /quit close the TUI\n"
173
- " Esc cancel current response\n"
184
+ " /help this list\n"
185
+ " /clear, /new start a fresh conversation\n"
186
+ " /history list this agent's recent conversations\n"
187
+ " /load <id> load a past conversation by session_id\n"
188
+ " /models list models available for this agent\n"
189
+ " /model <id> switch the model for the next message\n"
190
+ " /exit, /quit close the TUI\n"
191
+ " Esc cancel current response\n"
174
192
  ))
175
193
  else:
176
- await self._append(Static(f"[red]Unknown command: {head}[/red]"))
194
+ await self._append(Static(f"[red]Unknown command: {head}[/red] (try /help)"))
195
+
196
+ async def _show_models(self) -> None:
197
+ if not self.allowed_models:
198
+ await self._append(Static(
199
+ "[yellow]No model catalog available for this agent.[/yellow]"
200
+ ))
201
+ return
202
+ lines = ["[b]Available models[/b]"]
203
+ for m in self.allowed_models:
204
+ marker = "•" if m["id"] != self.current_model_id else "[green]✓[/green]"
205
+ label = m.get("label") or m["id"]
206
+ provider = f" [dim]({m['provider']})[/dim]" if m.get("provider") else ""
207
+ lines.append(f" {marker} [bold]{m['id']}[/bold] — {label}{provider}")
208
+ lines.append("\n[dim]Use [/dim][b]/model <id>[/b][dim] to switch.[/dim]")
209
+ await self._append(Static("\n".join(lines)))
210
+
211
+ async def _show_history(self) -> None:
212
+ try:
213
+ convs = await list_conversations(self.server_url, self.token, self.agent_id, limit=20)
214
+ except Exception as exc:
215
+ await self._append(Static(f"[red]Could not load history:[/red] {exc}"))
216
+ return
217
+ if not convs:
218
+ await self._append(Static("[dim]No past conversations with this agent.[/dim]"))
219
+ return
220
+ lines = ["[b]Recent conversations[/b] [dim](most recent first)[/dim]"]
221
+ for c in convs:
222
+ sid = c.get("session_id", "")
223
+ title = c.get("title") or "(untitled)"
224
+ when = (c.get("last_message_at") or c.get("updated_at") or "")[:19]
225
+ count = c.get("message_count", 0)
226
+ marker = "[green]✓[/green] " if sid == self.session_id else " "
227
+ lines.append(f"{marker}[bold]{sid}[/bold] {when} · {count} msgs · {title}")
228
+ lines.append("\n[dim]Use [/dim][b]/load <session_id>[/b][dim] to switch.[/dim]")
229
+ await self._append(Static("\n".join(lines)))
230
+
231
+ async def _load_session(self, session_id: str) -> None:
232
+ if not session_id:
233
+ await self._append(Static(
234
+ "Usage: [b]/load <session_id>[/b] (see [b]/history[/b] for IDs)"
235
+ ))
236
+ return
237
+ if self._stream_task and not self._stream_task.done():
238
+ await self._append(Static(
239
+ "[yellow]Cancel the in-flight response (Esc) before loading another conversation.[/yellow]"
240
+ ))
241
+ return
242
+ try:
243
+ data = await load_conversation(self.server_url, self.token, self.agent_id, session_id)
244
+ except Exception as exc:
245
+ await self._append(Static(f"[red]Could not load conversation:[/red] {exc}"))
246
+ return
247
+ if data is None:
248
+ await self._append(Static(f"[red]Conversation not found:[/red] {session_id}"))
249
+ return
250
+
251
+ # Clear the screen and rebuild it with the loaded history.
252
+ scroll = self.query_one("#conversation", VerticalScroll)
253
+ for child in list(scroll.children):
254
+ await child.remove()
255
+
256
+ conv = data.get("conversation") or {}
257
+ messages = data.get("messages") or []
258
+ self.session_id = conv.get("session_id") or session_id
259
+ self._current_turn = None
260
+
261
+ header = (
262
+ f"[b]Loaded conversation[/b] {self.session_id}\n"
263
+ f"[dim]{conv.get('title') or '(untitled)'} · {len(messages)} message(s)[/dim]"
264
+ )
265
+ await scroll.mount(Static(header))
266
+
267
+ for msg in messages:
268
+ mtype = (msg.get("message_type") or "").lower()
269
+ content = msg.get("content") or ""
270
+ if mtype in ("user", "human"):
271
+ await scroll.mount(UserBubble(content))
272
+ else:
273
+ turn = AssistantTurn()
274
+ await scroll.mount(turn)
275
+ if content:
276
+ await turn.append_token(content)
277
+ turn.mark_final()
278
+ scroll.scroll_end(animate=False)
279
+ self._set_status(f"loaded {self.session_id}")
280
+
281
+ async def _set_model(self, requested: str) -> None:
282
+ if not requested:
283
+ current = self.current_model_id or "(none)"
284
+ await self._append(Static(
285
+ f"Current model: [b]{current}[/b]\n"
286
+ f"Usage: [b]/model <id>[/b] (see [b]/models[/b] for the list)"
287
+ ))
288
+ return
289
+ if self._stream_task and not self._stream_task.done():
290
+ await self._append(Static(
291
+ "[yellow]Cancel the in-flight response (Esc) before switching models.[/yellow]"
292
+ ))
293
+ return
294
+ if self.allowed_models:
295
+ valid_ids = {m["id"] for m in self.allowed_models}
296
+ if requested not in valid_ids:
297
+ # Loose match by label too
298
+ by_label = {m.get("label", ""): m["id"] for m in self.allowed_models}
299
+ if requested in by_label:
300
+ requested = by_label[requested]
301
+ else:
302
+ await self._append(Static(
303
+ f"[red]'{requested}' is not in this agent's catalog.[/red] "
304
+ f"Run [b]/models[/b] to see valid IDs."
305
+ ))
306
+ return
307
+ self.current_model_id = requested
308
+ self._set_status(f"model set to {requested}")
309
+ await self._append(Static(f"[green]✓[/green] Model switched to [b]{requested}[/b]."))
177
310
 
178
311
  async def action_clear_conversation(self) -> None:
179
312
  self.session_id = None
@@ -200,7 +333,7 @@ class ChatApp(App):
200
333
  self.agent_id,
201
334
  message,
202
335
  session_id=self.session_id,
203
- model_id=self.default_model_id,
336
+ model_id=self.current_model_id,
204
337
  ):
205
338
  await self._handle_event(event, turn)
206
339
  except asyncio.CancelledError:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: virtuai-cli
3
- Version: 0.2.2
3
+ Version: 0.4.0
4
4
  Summary: Run VirtuAI deep agents on your local machine
5
5
  Author-email: uCloudStore <lmoreno@ucloudstore.com>
6
6
  License: Proprietary
@@ -14,6 +14,7 @@ src/virtuai_cli.egg-info/requires.txt
14
14
  src/virtuai_cli.egg-info/top_level.txt
15
15
  src/virtuai_cli/chat/__init__.py
16
16
  src/virtuai_cli/chat/command.py
17
+ src/virtuai_cli/chat/history.py
17
18
  src/virtuai_cli/chat/sse.py
18
19
  src/virtuai_cli/chat/tui.py
19
20
  src/virtuai_cli/chat/widgets.py
File without changes
File without changes