virtuai-cli 0.7.7__tar.gz → 0.8.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 (24) hide show
  1. {virtuai_cli-0.7.7 → virtuai_cli-0.8.0}/PKG-INFO +1 -1
  2. {virtuai_cli-0.7.7 → virtuai_cli-0.8.0}/pyproject.toml +1 -1
  3. {virtuai_cli-0.7.7 → virtuai_cli-0.8.0}/src/virtuai_cli/__init__.py +1 -1
  4. virtuai_cli-0.8.0/src/virtuai_cli/chat/sse.py +138 -0
  5. {virtuai_cli-0.7.7 → virtuai_cli-0.8.0}/src/virtuai_cli/chat/tui.py +128 -8
  6. {virtuai_cli-0.7.7 → virtuai_cli-0.8.0}/src/virtuai_cli.egg-info/PKG-INFO +1 -1
  7. virtuai_cli-0.7.7/src/virtuai_cli/chat/sse.py +0 -63
  8. {virtuai_cli-0.7.7 → virtuai_cli-0.8.0}/README.md +0 -0
  9. {virtuai_cli-0.7.7 → virtuai_cli-0.8.0}/setup.cfg +0 -0
  10. {virtuai_cli-0.7.7 → virtuai_cli-0.8.0}/src/virtuai_cli/chat/__init__.py +0 -0
  11. {virtuai_cli-0.7.7 → virtuai_cli-0.8.0}/src/virtuai_cli/chat/ask.py +0 -0
  12. {virtuai_cli-0.7.7 → virtuai_cli-0.8.0}/src/virtuai_cli/chat/command.py +0 -0
  13. {virtuai_cli-0.7.7 → virtuai_cli-0.8.0}/src/virtuai_cli/chat/history.py +0 -0
  14. {virtuai_cli-0.7.7 → virtuai_cli-0.8.0}/src/virtuai_cli/chat/widgets.py +0 -0
  15. {virtuai_cli-0.7.7 → virtuai_cli-0.8.0}/src/virtuai_cli/config.py +0 -0
  16. {virtuai_cli-0.7.7 → virtuai_cli-0.8.0}/src/virtuai_cli/executor.py +0 -0
  17. {virtuai_cli-0.7.7 → virtuai_cli-0.8.0}/src/virtuai_cli/main.py +0 -0
  18. {virtuai_cli-0.7.7 → virtuai_cli-0.8.0}/src/virtuai_cli/runner.py +0 -0
  19. {virtuai_cli-0.7.7 → virtuai_cli-0.8.0}/src/virtuai_cli/security.py +0 -0
  20. {virtuai_cli-0.7.7 → virtuai_cli-0.8.0}/src/virtuai_cli.egg-info/SOURCES.txt +0 -0
  21. {virtuai_cli-0.7.7 → virtuai_cli-0.8.0}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
  22. {virtuai_cli-0.7.7 → virtuai_cli-0.8.0}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
  23. {virtuai_cli-0.7.7 → virtuai_cli-0.8.0}/src/virtuai_cli.egg-info/requires.txt +0 -0
  24. {virtuai_cli-0.7.7 → virtuai_cli-0.8.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.7.7
3
+ Version: 0.8.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.7.7"
7
+ version = "0.8.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.7.7"
2
+ __version__ = "0.8.0"
@@ -0,0 +1,138 @@
1
+ """Async SSE chat client — POSTs a user message and yields parsed events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import mimetypes
7
+ import ssl
8
+ from pathlib import Path
9
+ from typing import AsyncIterator, Optional, Sequence
10
+
11
+ import certifi
12
+ import httpx
13
+ from httpx_sse import aconnect_sse
14
+
15
+
16
+ def _ssl_context() -> ssl.SSLContext:
17
+ return ssl.create_default_context(cafile=certifi.where())
18
+
19
+
20
+ async def stream_chat(
21
+ server_url: str,
22
+ token: str,
23
+ agent_id: str,
24
+ message: str,
25
+ session_id: Optional[str] = None,
26
+ model_id: Optional[str] = None,
27
+ timeout: float = 600.0,
28
+ ) -> AsyncIterator[dict]:
29
+ """Open an SSE chat stream and yield each event as a parsed dict.
30
+
31
+ The CLI token is passed as `Authorization: Bearer <token>`; the chat
32
+ principal now accepts CLI tokens (see web_chat/deps.py).
33
+ """
34
+ payload: dict = {"message": message}
35
+ if session_id:
36
+ payload["session_id"] = session_id
37
+ if model_id:
38
+ payload["model_id"] = model_id
39
+
40
+ headers = {"Authorization": f"Bearer {token}"}
41
+ url = f"{server_url.rstrip('/')}/api/web-chat/chat/{agent_id}/stream"
42
+ verify = certifi.where()
43
+
44
+ async with httpx.AsyncClient(verify=verify, timeout=httpx.Timeout(timeout, connect=15.0)) as client:
45
+ async with aconnect_sse(client, "POST", url, json=payload, headers=headers) as event_source:
46
+ async for event in _iter_normalized_sse(event_source):
47
+ yield event
48
+
49
+
50
+ # Mirror of frontend/src/utils/webChatMultimodalCatalog.ts
51
+ WEBCHAT_MAX_FILES = 3
52
+ WEBCHAT_MAX_FILE_SIZE_MB = 10
53
+ WEBCHAT_ALLOWED_EXTENSIONS = {
54
+ ".jpg", ".jpeg", ".png", ".gif", ".webp",
55
+ ".pdf",
56
+ ".txt", ".csv", ".md",
57
+ ".docx", ".xlsx", ".pptx",
58
+ }
59
+ _EXTRA_MIMES = {
60
+ ".md": "text/markdown",
61
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
62
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
63
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
64
+ }
65
+
66
+
67
+ def guess_mime(path: Path) -> str:
68
+ """Return a best-effort MIME type for an attachment path.
69
+
70
+ Falls back to a small extra map for office types and markdown that
71
+ Python's mimetypes module doesn't ship reliably across platforms.
72
+ """
73
+ suffix = path.suffix.lower()
74
+ if suffix in _EXTRA_MIMES:
75
+ return _EXTRA_MIMES[suffix]
76
+ mime, _ = mimetypes.guess_type(str(path))
77
+ return mime or "application/octet-stream"
78
+
79
+
80
+ async def stream_chat_multimodal(
81
+ server_url: str,
82
+ token: str,
83
+ agent_id: str,
84
+ message: str,
85
+ files: Sequence[Path],
86
+ session_id: Optional[str] = None,
87
+ model_id: Optional[str] = None,
88
+ timeout: float = 600.0,
89
+ ) -> AsyncIterator[dict]:
90
+ """Like stream_chat() but POSTs to the multimodal endpoint with attachments.
91
+
92
+ The web chat's multimodal stream accepts up to WEBCHAT_MAX_FILES files of
93
+ the types listed in WEBCHAT_ALLOWED_EXTENSIONS; the server extracts text
94
+ from PDFs/Office docs and feeds it (or the raw image bytes) to the model.
95
+ """
96
+ data: dict = {"message": message}
97
+ if session_id:
98
+ data["session_id"] = session_id
99
+ if model_id:
100
+ data["model_id"] = model_id
101
+
102
+ file_uploads = []
103
+ for path in files:
104
+ try:
105
+ content = path.read_bytes()
106
+ except OSError as exc:
107
+ raise FileNotFoundError(f"can't read {path}: {exc}") from exc
108
+ file_uploads.append(("files", (path.name, content, guess_mime(path))))
109
+
110
+ headers = {"Authorization": f"Bearer {token}"}
111
+ url = f"{server_url.rstrip('/')}/api/web-chat/chat/{agent_id}/multimodal/stream"
112
+ verify = certifi.where()
113
+
114
+ async with httpx.AsyncClient(verify=verify, timeout=httpx.Timeout(timeout, connect=15.0)) as client:
115
+ async with aconnect_sse(
116
+ client, "POST", url, data=data, files=file_uploads, headers=headers
117
+ ) as event_source:
118
+ async for event in _iter_normalized_sse(event_source):
119
+ yield event
120
+
121
+
122
+ async def _iter_normalized_sse(event_source) -> AsyncIterator[dict]:
123
+ """Shared SSE-to-typed-event loop for both stream_chat variants."""
124
+ async for sse in event_source.aiter_sse():
125
+ if not sse.data:
126
+ continue
127
+ if sse.data.strip() == "[DONE]":
128
+ yield {"type": "done"}
129
+ continue
130
+ try:
131
+ parsed = json.loads(sse.data)
132
+ except json.JSONDecodeError:
133
+ continue
134
+ if isinstance(parsed, dict):
135
+ yield parsed
136
+ elif isinstance(parsed, str):
137
+ # Classic agents stream tokens as bare JSON strings.
138
+ yield {"type": "token", "content": parsed}
@@ -16,7 +16,13 @@ from textual.widgets import Footer, Header, Static, TextArea
16
16
 
17
17
  from virtuai_cli import runner as ws_runner
18
18
  from virtuai_cli.chat.history import list_conversations, load_conversation
19
- from virtuai_cli.chat.sse import stream_chat
19
+ from virtuai_cli.chat.sse import (
20
+ WEBCHAT_ALLOWED_EXTENSIONS,
21
+ WEBCHAT_MAX_FILES,
22
+ WEBCHAT_MAX_FILE_SIZE_MB,
23
+ stream_chat,
24
+ stream_chat_multimodal,
25
+ )
20
26
  from virtuai_cli.chat.widgets import AssistantTurn, ChatInput, TextSegment, UserBubble
21
27
 
22
28
 
@@ -28,6 +34,9 @@ SLASH_COMMANDS: list[tuple[str, str]] = [
28
34
  ("/new", "alias for /clear"),
29
35
  ("/copy", "copy the last assistant response to the clipboard"),
30
36
  ("/select", "toggle terminal-native text selection (also F2)"),
37
+ ("/attach", "attach a file to your next message: /attach <path>"),
38
+ ("/attachments", "list pending attachments"),
39
+ ("/detach", "remove an attachment by name or index: /detach <n|name>"),
31
40
  ("/history", "list this agent's recent conversations"),
32
41
  ("/load", "load a past conversation: /load <session_id>"),
33
42
  ("/models", "list models available for this agent"),
@@ -119,6 +128,9 @@ class ChatApp(App):
119
128
  self._streaming: bool = False
120
129
  self._status_message: str = ""
121
130
  self._spinner_idx: int = 0
131
+ # Pending attachments to include with the next message — flipped to the
132
+ # multimodal endpoint when non-empty, cleared after a successful send.
133
+ self._pending_attachments: list[Path] = []
122
134
 
123
135
  # ── Layout ────────────────────────────────────────────────────────────
124
136
  def compose(self) -> ComposeResult:
@@ -316,6 +328,9 @@ class ChatApp(App):
316
328
  return
317
329
 
318
330
  await self._append(UserBubble(text))
331
+ if self._pending_attachments:
332
+ preview = " · ".join(f"📎 {p.name}" for p in self._pending_attachments)
333
+ await self._append(Static(f" [dim]{preview}[/dim]"))
319
334
  turn = AssistantTurn()
320
335
  await self._append(turn)
321
336
  self._current_turn = turn
@@ -350,6 +365,12 @@ class ChatApp(App):
350
365
  self.action_copy_last()
351
366
  elif head == "/select":
352
367
  self.action_toggle_select_mode()
368
+ elif head == "/attach":
369
+ await self._attach_file(arg)
370
+ elif head == "/attachments":
371
+ await self._show_attachments()
372
+ elif head == "/detach":
373
+ await self._detach_file(arg)
353
374
  elif head == "/help":
354
375
  await self._append(Static(
355
376
  "[b]Commands[/b]\n"
@@ -357,6 +378,9 @@ class ChatApp(App):
357
378
  " /clear, /new start a fresh conversation\n"
358
379
  " /copy copy the last assistant response to clipboard\n"
359
380
  " /select toggle terminal-native text selection\n"
381
+ " /attach <path> attach a file to your next message\n"
382
+ " /attachments list pending attachments\n"
383
+ " /detach <n|name> remove an attachment (or /detach to clear all)\n"
360
384
  " /history list this agent's recent conversations\n"
361
385
  " /load <id> load a past conversation by session_id\n"
362
386
  " /models list models available for this agent\n"
@@ -396,6 +420,90 @@ class ChatApp(App):
396
420
  lines.append("\n[dim]Use [/dim][b]/model <id>[/b][dim] to switch.[/dim]")
397
421
  await self._append(Static("\n".join(lines)))
398
422
 
423
+ async def _attach_file(self, arg: str) -> None:
424
+ if not arg:
425
+ await self._append(Static(
426
+ "Usage: [b]/attach <path>[/b]\n"
427
+ f"[dim]Allowed: {', '.join(sorted(WEBCHAT_ALLOWED_EXTENSIONS))}\n"
428
+ f"Limit: {WEBCHAT_MAX_FILES} files per message, "
429
+ f"{WEBCHAT_MAX_FILE_SIZE_MB} MB each.[/dim]"
430
+ ))
431
+ return
432
+ # Trim surrounding quotes and expand ~/$HOME
433
+ raw = arg.strip().strip('"').strip("'")
434
+ path = Path(raw).expanduser()
435
+ try:
436
+ path = path.resolve(strict=True)
437
+ except (FileNotFoundError, OSError) as exc:
438
+ await self._append(Static(f"[red]File not found:[/red] {raw} ({exc})"))
439
+ return
440
+ if not path.is_file():
441
+ await self._append(Static(f"[red]Not a file:[/red] {path}"))
442
+ return
443
+ if path.suffix.lower() not in WEBCHAT_ALLOWED_EXTENSIONS:
444
+ await self._append(Static(
445
+ f"[red]Unsupported file type:[/red] {path.suffix or '(no extension)'}\n"
446
+ f"[dim]Allowed: {', '.join(sorted(WEBCHAT_ALLOWED_EXTENSIONS))}[/dim]"
447
+ ))
448
+ return
449
+ size_mb = path.stat().st_size / (1024 * 1024)
450
+ if size_mb > WEBCHAT_MAX_FILE_SIZE_MB:
451
+ await self._append(Static(
452
+ f"[red]File too large:[/red] {size_mb:.1f} MB "
453
+ f"(limit {WEBCHAT_MAX_FILE_SIZE_MB} MB)"
454
+ ))
455
+ return
456
+ if len(self._pending_attachments) >= WEBCHAT_MAX_FILES:
457
+ await self._append(Static(
458
+ f"[yellow]Already at the {WEBCHAT_MAX_FILES}-file limit.[/yellow] "
459
+ f"Use [b]/detach <n>[/b] to drop one."
460
+ ))
461
+ return
462
+ if any(p == path for p in self._pending_attachments):
463
+ await self._append(Static(f"[yellow]Already attached:[/yellow] {path.name}"))
464
+ return
465
+ self._pending_attachments.append(path)
466
+ await self._append(Static(
467
+ f"[green]📎 Attached:[/green] [b]{path.name}[/b] "
468
+ f"[dim]({size_mb:.1f} MB · {path})[/dim]\n"
469
+ f"[dim]{len(self._pending_attachments)}/{WEBCHAT_MAX_FILES} files pending. "
470
+ f"Send your message normally; the agent will see them.[/dim]"
471
+ ))
472
+
473
+ async def _show_attachments(self) -> None:
474
+ if not self._pending_attachments:
475
+ await self._append(Static("[dim]No attachments pending.[/dim]"))
476
+ return
477
+ lines = [f"[b]Pending attachments[/b] [dim](sent with your next message)[/dim]"]
478
+ for i, path in enumerate(self._pending_attachments, start=1):
479
+ size_mb = path.stat().st_size / (1024 * 1024)
480
+ lines.append(f" {i}. [b]{path.name}[/b] [dim]({size_mb:.1f} MB · {path})[/dim]")
481
+ lines.append("\n[dim]Drop one with [/dim][b]/detach <n>[/b]")
482
+ await self._append(Static("\n".join(lines)))
483
+
484
+ async def _detach_file(self, arg: str) -> None:
485
+ if not self._pending_attachments:
486
+ await self._append(Static("[dim]No attachments to detach.[/dim]"))
487
+ return
488
+ if not arg:
489
+ self._pending_attachments.clear()
490
+ await self._append(Static("[yellow]Cleared all pending attachments.[/yellow]"))
491
+ return
492
+ # By index (1-based)
493
+ if arg.isdigit():
494
+ idx = int(arg) - 1
495
+ if 0 <= idx < len(self._pending_attachments):
496
+ removed = self._pending_attachments.pop(idx)
497
+ await self._append(Static(f"[yellow]Detached:[/yellow] {removed.name}"))
498
+ return
499
+ # By filename
500
+ for p in list(self._pending_attachments):
501
+ if p.name == arg or str(p) == arg:
502
+ self._pending_attachments.remove(p)
503
+ await self._append(Static(f"[yellow]Detached:[/yellow] {p.name}"))
504
+ return
505
+ await self._append(Static(f"[red]No match for:[/red] {arg}"))
506
+
399
507
  async def _show_history(self) -> None:
400
508
  try:
401
509
  convs = await list_conversations(self.server_url, self.token, self.agent_id, limit=20)
@@ -610,15 +718,27 @@ class ChatApp(App):
610
718
  async def _stream_response(self, message: str, turn: AssistantTurn) -> None:
611
719
  self._streaming = True
612
720
  self._refresh_status_bar()
613
- try:
614
- async for event in stream_chat(
615
- self.server_url,
616
- self.token,
617
- self.agent_id,
618
- message,
721
+ # Snapshot + clear pending attachments BEFORE the request so a new
722
+ # /attach during streaming doesn't bleed into this turn or the next.
723
+ attachments = list(self._pending_attachments)
724
+ self._pending_attachments.clear()
725
+
726
+ if attachments:
727
+ stream_iter = stream_chat_multimodal(
728
+ self.server_url, self.token, self.agent_id, message,
729
+ files=attachments,
730
+ session_id=self.session_id,
731
+ model_id=self.current_model_id,
732
+ )
733
+ else:
734
+ stream_iter = stream_chat(
735
+ self.server_url, self.token, self.agent_id, message,
619
736
  session_id=self.session_id,
620
737
  model_id=self.current_model_id,
621
- ):
738
+ )
739
+
740
+ try:
741
+ async for event in stream_iter:
622
742
  await self._handle_event(event, turn)
623
743
  self._follow_bottom()
624
744
  except asyncio.CancelledError:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: virtuai-cli
3
- Version: 0.7.7
3
+ Version: 0.8.0
4
4
  Summary: Run VirtuAI deep agents on your local machine
5
5
  Author-email: uCloudStore <lmoreno@ucloudstore.com>
6
6
  License: Proprietary
@@ -1,63 +0,0 @@
1
- """Async SSE chat client — POSTs a user message and yields parsed events."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import ssl
7
- from typing import AsyncIterator, Optional
8
-
9
- import certifi
10
- import httpx
11
- from httpx_sse import aconnect_sse
12
-
13
-
14
- def _ssl_context() -> ssl.SSLContext:
15
- return ssl.create_default_context(cafile=certifi.where())
16
-
17
-
18
- async def stream_chat(
19
- server_url: str,
20
- token: str,
21
- agent_id: str,
22
- message: str,
23
- session_id: Optional[str] = None,
24
- model_id: Optional[str] = None,
25
- timeout: float = 600.0,
26
- ) -> AsyncIterator[dict]:
27
- """Open an SSE chat stream and yield each event as a parsed dict.
28
-
29
- The CLI token is passed as `Authorization: Bearer <token>`; the chat
30
- principal now accepts CLI tokens (see web_chat/deps.py).
31
- """
32
- payload: dict = {"message": message}
33
- if session_id:
34
- payload["session_id"] = session_id
35
- if model_id:
36
- payload["model_id"] = model_id
37
-
38
- headers = {"Authorization": f"Bearer {token}"}
39
- url = f"{server_url.rstrip('/')}/api/web-chat/chat/{agent_id}/stream"
40
- verify = certifi.where()
41
-
42
- async with httpx.AsyncClient(verify=verify, timeout=httpx.Timeout(timeout, connect=15.0)) as client:
43
- async with aconnect_sse(client, "POST", url, json=payload, headers=headers) as event_source:
44
- async for sse in event_source.aiter_sse():
45
- if not sse.data:
46
- continue
47
- # Classic-agent path emits `data: [DONE]` after the stream;
48
- # treat that as a done event for symmetry with the deep path.
49
- if sse.data.strip() == "[DONE]":
50
- yield {"type": "done"}
51
- continue
52
- try:
53
- parsed = json.loads(sse.data)
54
- except json.JSONDecodeError:
55
- continue
56
- if isinstance(parsed, dict):
57
- yield parsed
58
- elif isinstance(parsed, str):
59
- # Classic agents stream tokens as bare JSON strings
60
- # (`data: "hello "`). Wrap them so downstream handlers
61
- # don't need to know about the legacy protocol.
62
- yield {"type": "token", "content": parsed}
63
- # Other shapes (lists, ints, null) are dropped silently.
File without changes
File without changes