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.
- {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/PKG-INFO +1 -1
- {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/pyproject.toml +1 -1
- {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli/__init__.py +1 -1
- {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli/chat/command.py +2 -1
- virtuai_cli-0.4.0/src/virtuai_cli/chat/history.py +44 -0
- {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli/chat/tui.py +141 -8
- {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli.egg-info/PKG-INFO +1 -1
- {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli.egg-info/SOURCES.txt +1 -0
- {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/README.md +0 -0
- {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/setup.cfg +0 -0
- {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli/chat/__init__.py +0 -0
- {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli/chat/sse.py +0 -0
- {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli/chat/widgets.py +0 -0
- {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli/config.py +0 -0
- {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli/executor.py +0 -0
- {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli/main.py +0 -0
- {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli/runner.py +0 -0
- {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli/security.py +0 -0
- {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
- {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
- {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli.egg-info/requires.txt +0 -0
- {virtuai_cli-0.2.2 → virtuai_cli-0.4.0}/src/virtuai_cli.egg-info/top_level.txt +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""VirtuAI local CLI."""
|
|
2
|
-
__version__ = "0.
|
|
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.
|
|
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
|
|
171
|
-
" /clear, /new
|
|
172
|
-
" /
|
|
173
|
-
"
|
|
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.
|
|
336
|
+
model_id=self.current_model_id,
|
|
204
337
|
):
|
|
205
338
|
await self._handle_event(event, turn)
|
|
206
339
|
except asyncio.CancelledError:
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|