virtuai-cli 0.7.7__tar.gz → 0.8.1__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.7.7 → virtuai_cli-0.8.1}/PKG-INFO +1 -1
- {virtuai_cli-0.7.7 → virtuai_cli-0.8.1}/pyproject.toml +1 -1
- {virtuai_cli-0.7.7 → virtuai_cli-0.8.1}/src/virtuai_cli/__init__.py +1 -1
- virtuai_cli-0.8.1/src/virtuai_cli/chat/sse.py +138 -0
- {virtuai_cli-0.7.7 → virtuai_cli-0.8.1}/src/virtuai_cli/chat/tui.py +133 -10
- {virtuai_cli-0.7.7 → virtuai_cli-0.8.1}/src/virtuai_cli/chat/widgets.py +27 -5
- {virtuai_cli-0.7.7 → virtuai_cli-0.8.1}/src/virtuai_cli.egg-info/PKG-INFO +1 -1
- virtuai_cli-0.7.7/src/virtuai_cli/chat/sse.py +0 -63
- {virtuai_cli-0.7.7 → virtuai_cli-0.8.1}/README.md +0 -0
- {virtuai_cli-0.7.7 → virtuai_cli-0.8.1}/setup.cfg +0 -0
- {virtuai_cli-0.7.7 → virtuai_cli-0.8.1}/src/virtuai_cli/chat/__init__.py +0 -0
- {virtuai_cli-0.7.7 → virtuai_cli-0.8.1}/src/virtuai_cli/chat/ask.py +0 -0
- {virtuai_cli-0.7.7 → virtuai_cli-0.8.1}/src/virtuai_cli/chat/command.py +0 -0
- {virtuai_cli-0.7.7 → virtuai_cli-0.8.1}/src/virtuai_cli/chat/history.py +0 -0
- {virtuai_cli-0.7.7 → virtuai_cli-0.8.1}/src/virtuai_cli/config.py +0 -0
- {virtuai_cli-0.7.7 → virtuai_cli-0.8.1}/src/virtuai_cli/executor.py +0 -0
- {virtuai_cli-0.7.7 → virtuai_cli-0.8.1}/src/virtuai_cli/main.py +0 -0
- {virtuai_cli-0.7.7 → virtuai_cli-0.8.1}/src/virtuai_cli/runner.py +0 -0
- {virtuai_cli-0.7.7 → virtuai_cli-0.8.1}/src/virtuai_cli/security.py +0 -0
- {virtuai_cli-0.7.7 → virtuai_cli-0.8.1}/src/virtuai_cli.egg-info/SOURCES.txt +0 -0
- {virtuai_cli-0.7.7 → virtuai_cli-0.8.1}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
- {virtuai_cli-0.7.7 → virtuai_cli-0.8.1}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
- {virtuai_cli-0.7.7 → virtuai_cli-0.8.1}/src/virtuai_cli.egg-info/requires.txt +0 -0
- {virtuai_cli-0.7.7 → virtuai_cli-0.8.1}/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.8.1"
|
|
@@ -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
|
|
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,19 +718,34 @@ 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
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
721
|
+
cancelled = False
|
|
722
|
+
# Snapshot + clear pending attachments BEFORE the request so a new
|
|
723
|
+
# /attach during streaming doesn't bleed into this turn or the next.
|
|
724
|
+
attachments = list(self._pending_attachments)
|
|
725
|
+
self._pending_attachments.clear()
|
|
726
|
+
|
|
727
|
+
if attachments:
|
|
728
|
+
stream_iter = stream_chat_multimodal(
|
|
729
|
+
self.server_url, self.token, self.agent_id, message,
|
|
730
|
+
files=attachments,
|
|
731
|
+
session_id=self.session_id,
|
|
732
|
+
model_id=self.current_model_id,
|
|
733
|
+
)
|
|
734
|
+
else:
|
|
735
|
+
stream_iter = stream_chat(
|
|
736
|
+
self.server_url, self.token, self.agent_id, message,
|
|
619
737
|
session_id=self.session_id,
|
|
620
738
|
model_id=self.current_model_id,
|
|
621
|
-
)
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
try:
|
|
742
|
+
async for event in stream_iter:
|
|
622
743
|
await self._handle_event(event, turn)
|
|
623
744
|
self._follow_bottom()
|
|
624
745
|
except asyncio.CancelledError:
|
|
625
|
-
#
|
|
746
|
+
# User hit Esc (or app is shutting down). Skip the expensive
|
|
747
|
+
# mark_final flush — see widgets.AssistantTurn.mark_final.
|
|
748
|
+
cancelled = True
|
|
626
749
|
raise
|
|
627
750
|
except Exception as exc:
|
|
628
751
|
try:
|
|
@@ -634,7 +757,7 @@ class ChatApp(App):
|
|
|
634
757
|
self._streaming = False
|
|
635
758
|
self._refresh_status_bar()
|
|
636
759
|
try:
|
|
637
|
-
await turn.mark_final()
|
|
760
|
+
await turn.mark_final(cancelled=cancelled)
|
|
638
761
|
except Exception:
|
|
639
762
|
pass
|
|
640
763
|
try:
|
|
@@ -220,6 +220,7 @@ class TextSegment(Static):
|
|
|
220
220
|
self._buf = ""
|
|
221
221
|
self._last_render_t: float = 0.0
|
|
222
222
|
self._render_pending: bool = False
|
|
223
|
+
self._render_timer: Any = None
|
|
223
224
|
|
|
224
225
|
def append(self, chunk: str) -> None:
|
|
225
226
|
self._buf += chunk
|
|
@@ -232,14 +233,28 @@ class TextSegment(Static):
|
|
|
232
233
|
self._flush()
|
|
233
234
|
else:
|
|
234
235
|
self._render_pending = True
|
|
235
|
-
self.
|
|
236
|
+
self._render_timer = self.set_timer(
|
|
237
|
+
self._RENDER_INTERVAL - elapsed, self._flush
|
|
238
|
+
)
|
|
236
239
|
|
|
237
240
|
def flush(self) -> None:
|
|
238
241
|
"""Force a render now — used to finalize the segment at end-of-stream."""
|
|
239
242
|
self._flush()
|
|
240
243
|
|
|
244
|
+
def cancel_pending(self) -> None:
|
|
245
|
+
"""Drop any scheduled render — used on stream cancel so the cancel
|
|
246
|
+
doesn't trigger another expensive Markdown reparse a moment later."""
|
|
247
|
+
if self._render_timer is not None:
|
|
248
|
+
try:
|
|
249
|
+
self._render_timer.stop()
|
|
250
|
+
except Exception:
|
|
251
|
+
pass
|
|
252
|
+
self._render_timer = None
|
|
253
|
+
self._render_pending = False
|
|
254
|
+
|
|
241
255
|
def _flush(self) -> None:
|
|
242
256
|
self._render_pending = False
|
|
257
|
+
self._render_timer = None
|
|
243
258
|
self._last_render_t = time.monotonic()
|
|
244
259
|
try:
|
|
245
260
|
self.update(Markdown(normalize_artifacts(self._buf)))
|
|
@@ -616,13 +631,20 @@ class AssistantTurn(Vertical):
|
|
|
616
631
|
await self.mount(self._todo)
|
|
617
632
|
self._todo.set_items(items)
|
|
618
633
|
|
|
619
|
-
async def mark_final(self) -> None:
|
|
634
|
+
async def mark_final(self, *, cancelled: bool = False) -> None:
|
|
620
635
|
await self._stop_thinking()
|
|
621
|
-
#
|
|
622
|
-
#
|
|
636
|
+
# On graceful completion: do a final synchronous flush so the last
|
|
637
|
+
# sub-50ms of tokens definitely render. On cancel: SKIP the flush —
|
|
638
|
+
# building the markdown AST from a multi-KB buffer is a 100-500ms
|
|
639
|
+
# blocking spike on the event loop right when the user wants to
|
|
640
|
+
# type again, and trying to "polish" the last fragment of an
|
|
641
|
+
# interrupted response isn't worth the lag.
|
|
623
642
|
for seg in self.query(TextSegment):
|
|
624
643
|
try:
|
|
625
|
-
|
|
644
|
+
if cancelled:
|
|
645
|
+
seg.cancel_pending()
|
|
646
|
+
else:
|
|
647
|
+
seg.flush()
|
|
626
648
|
except Exception:
|
|
627
649
|
pass
|
|
628
650
|
self._final = True
|
|
@@ -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
|
|
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
|