padwan-cli 0.4.0__tar.gz → 0.5.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.
- {padwan_cli-0.4.0 → padwan_cli-0.5.0}/PKG-INFO +6 -3
- {padwan_cli-0.4.0 → padwan_cli-0.5.0}/padwan_cli/chat.py +129 -11
- {padwan_cli-0.4.0 → padwan_cli-0.5.0}/padwan_cli/run.py +6 -0
- padwan_cli-0.5.0/padwan_cli/talk.py +401 -0
- {padwan_cli-0.4.0 → padwan_cli-0.5.0}/padwan_cli/widgets.py +75 -0
- padwan_cli-0.5.0/pyproject.toml +50 -0
- padwan_cli-0.4.0/pyproject.toml → padwan_cli-0.5.0/pyproject.toml.orig +10 -7
- {padwan_cli-0.4.0 → padwan_cli-0.5.0}/README.md +0 -0
- {padwan_cli-0.4.0 → padwan_cli-0.5.0}/padwan_cli/__init__.py +0 -0
- {padwan_cli-0.4.0 → padwan_cli-0.5.0}/padwan_cli/__main__.py +0 -0
- {padwan_cli-0.4.0 → padwan_cli-0.5.0}/padwan_cli/batch/__init__.py +0 -0
- {padwan_cli-0.4.0 → padwan_cli-0.5.0}/padwan_cli/batch/format.py +0 -0
- {padwan_cli-0.4.0 → padwan_cli-0.5.0}/padwan_cli/batch/run.py +0 -0
- {padwan_cli-0.4.0 → padwan_cli-0.5.0}/padwan_cli/batch/utils.py +0 -0
- {padwan_cli-0.4.0 → padwan_cli-0.5.0}/padwan_cli/utils.py +0 -0
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: padwan-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: CLI and TUI for padwan-llm
|
|
5
5
|
Author-email: julien.brayere@polarsen.com
|
|
6
|
-
Requires-Dist: padwan-llm>=0.
|
|
7
|
-
Requires-Dist: piou[tui]>=0.
|
|
6
|
+
Requires-Dist: padwan-llm[realtime]>=0.8.0
|
|
7
|
+
Requires-Dist: piou[tui]>=0.36.0
|
|
8
|
+
Requires-Dist: tracktolib>=0.74.0
|
|
9
|
+
Requires-Dist: sounddevice>=0.5 ; extra == 'voice'
|
|
8
10
|
Requires-Python: >=3.14
|
|
11
|
+
Provides-Extra: voice
|
|
9
12
|
Description-Content-Type: text/markdown
|
|
10
13
|
|
|
11
14
|
# Padwan CLI
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import contextlib
|
|
2
2
|
import json
|
|
3
|
+
import mimetypes
|
|
3
4
|
import time
|
|
4
5
|
from contextlib import contextmanager
|
|
6
|
+
from pathlib import Path
|
|
5
7
|
from typing import Any
|
|
6
8
|
|
|
7
9
|
from piou import Option, CommandGroup
|
|
@@ -10,22 +12,30 @@ from textual.css.query import NoMatches
|
|
|
10
12
|
|
|
11
13
|
from padwan_llm import (
|
|
12
14
|
AgentSession,
|
|
15
|
+
ContentPart,
|
|
13
16
|
ConversationSnapshot,
|
|
14
17
|
LLMClient,
|
|
15
18
|
McpStreamable,
|
|
16
19
|
McpTool,
|
|
17
20
|
McpTransport,
|
|
18
21
|
ToolCallContext,
|
|
22
|
+
image_part,
|
|
23
|
+
supports_vision,
|
|
24
|
+
text_file_part,
|
|
25
|
+
text_part,
|
|
19
26
|
)
|
|
20
27
|
from padwan_llm.gemini import GeminiClient
|
|
21
28
|
from padwan_llm.gemini.models import ThinkingConfig
|
|
22
29
|
from .utils import ALL_MODELS, console
|
|
23
30
|
from .widgets import (
|
|
31
|
+
Attachment,
|
|
32
|
+
AttachmentBadge,
|
|
24
33
|
ErrorMessage,
|
|
25
34
|
StreamingMessage,
|
|
26
35
|
ThoughtMessage,
|
|
27
36
|
ToolCallMessage,
|
|
28
37
|
UserMessage,
|
|
38
|
+
render_attachments,
|
|
29
39
|
)
|
|
30
40
|
|
|
31
41
|
chat_group = CommandGroup(name="chat", help="Chat with an LLM")
|
|
@@ -85,6 +95,48 @@ def _format_tokens(session: AgentSession, ctx: TuiContext | None = None) -> str:
|
|
|
85
95
|
return " ".join(parts)
|
|
86
96
|
|
|
87
97
|
|
|
98
|
+
def _build_user_content(
|
|
99
|
+
text: str, attachments: list[Attachment]
|
|
100
|
+
) -> str | list[ContentPart]:
|
|
101
|
+
"""Assemble the user turn from typed text and queued attachments.
|
|
102
|
+
|
|
103
|
+
Returns the plain string when nothing is attached. Otherwise builds content
|
|
104
|
+
parts: the text, each text file inlined, and each supported image as a data
|
|
105
|
+
URL. Unsupported images (text-only model) and unreadable files are dropped
|
|
106
|
+
from the payload — they still appear in the sent badge.
|
|
107
|
+
"""
|
|
108
|
+
if not attachments:
|
|
109
|
+
return text
|
|
110
|
+
parts: list[ContentPart] = []
|
|
111
|
+
if text:
|
|
112
|
+
parts.append(text_part(text))
|
|
113
|
+
for a in attachments:
|
|
114
|
+
if a.is_image:
|
|
115
|
+
if a.supported:
|
|
116
|
+
parts.append(image_part(a.path))
|
|
117
|
+
else:
|
|
118
|
+
try:
|
|
119
|
+
parts.append(text_file_part(a.path))
|
|
120
|
+
except OSError, UnicodeDecodeError:
|
|
121
|
+
continue
|
|
122
|
+
return parts
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _parse_extra_params(raw: str | None) -> tuple[dict[str, Any] | None, bool]:
|
|
126
|
+
"""Parse --extra-params JSON; print the error and return ok=False on failure."""
|
|
127
|
+
if raw is None:
|
|
128
|
+
return None, True
|
|
129
|
+
try:
|
|
130
|
+
parsed = json.loads(raw)
|
|
131
|
+
except json.JSONDecodeError as e:
|
|
132
|
+
console.print(f"[red]--extra-params is not valid JSON: {e}[/red]")
|
|
133
|
+
return None, False
|
|
134
|
+
if not isinstance(parsed, dict):
|
|
135
|
+
console.print("[red]--extra-params must be a JSON object[/red]")
|
|
136
|
+
return None, False
|
|
137
|
+
return parsed, True
|
|
138
|
+
|
|
139
|
+
|
|
88
140
|
@chat_group.command("send", help="Send a message to the LLM")
|
|
89
141
|
async def chat_send_fn(
|
|
90
142
|
message: str = Option(..., help="Message to send"),
|
|
@@ -158,16 +210,9 @@ async def chat_send_fn(
|
|
|
158
210
|
else:
|
|
159
211
|
console.print(f"[green]MCP connected[/green] [dim]{transport.label}[/dim]")
|
|
160
212
|
|
|
161
|
-
parsed_extra
|
|
162
|
-
if
|
|
163
|
-
|
|
164
|
-
parsed_extra = json.loads(extra_params)
|
|
165
|
-
except json.JSONDecodeError as e:
|
|
166
|
-
console.print(f"[red]--extra-params is not valid JSON: {e}[/red]")
|
|
167
|
-
return
|
|
168
|
-
if not isinstance(parsed_extra, dict):
|
|
169
|
-
console.print("[red]--extra-params must be a JSON object[/red]")
|
|
170
|
-
return
|
|
213
|
+
parsed_extra, ok = _parse_extra_params(extra_params)
|
|
214
|
+
if not ok:
|
|
215
|
+
return
|
|
171
216
|
|
|
172
217
|
try:
|
|
173
218
|
client = LLMClient(model=model, on_thought=_on_thought, base_url=base_url)
|
|
@@ -196,16 +241,84 @@ async def chat_send_fn(
|
|
|
196
241
|
ctx.set_rule_above(add_class="chat-mode")
|
|
197
242
|
ctx.set_rule_below(add_class="chat-mode")
|
|
198
243
|
|
|
244
|
+
# Files dropped onto the terminal arrive as a paste of their path(s);
|
|
245
|
+
# queue them here and send with the next typed message.
|
|
246
|
+
pending: list[Attachment] = []
|
|
247
|
+
|
|
248
|
+
def _set_attach_warning(on: bool) -> None:
|
|
249
|
+
"""Turn the input bars amber (or back to chat-mode cyan)."""
|
|
250
|
+
add, remove = (
|
|
251
|
+
("attach-warning", "chat-mode")
|
|
252
|
+
if on
|
|
253
|
+
else ("chat-mode", "attach-warning")
|
|
254
|
+
)
|
|
255
|
+
ctx.set_rule_above(add_class=add, remove_class=remove)
|
|
256
|
+
ctx.set_rule_below(add_class=add, remove_class=remove)
|
|
257
|
+
|
|
258
|
+
def _on_drop(paths: list[Path]) -> None:
|
|
259
|
+
for p in paths:
|
|
260
|
+
try:
|
|
261
|
+
size = p.stat().st_size
|
|
262
|
+
except OSError:
|
|
263
|
+
continue
|
|
264
|
+
is_image = (mimetypes.guess_type(p)[0] or "").startswith("image/")
|
|
265
|
+
pending.append(
|
|
266
|
+
Attachment(
|
|
267
|
+
path=p,
|
|
268
|
+
name=p.name,
|
|
269
|
+
size=size,
|
|
270
|
+
is_image=is_image,
|
|
271
|
+
supported=(not is_image) or supports_vision(model),
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
blind = [a for a in pending if a.is_image and not a.supported]
|
|
275
|
+
warning = (
|
|
276
|
+
f"{model} can't read images — {len(blind)} will be skipped"
|
|
277
|
+
if blind
|
|
278
|
+
else None
|
|
279
|
+
)
|
|
280
|
+
ctx.set_attachments(render_attachments(pending, warning=warning))
|
|
281
|
+
_set_attach_warning(bool(blind))
|
|
282
|
+
ctx.set_hint(
|
|
283
|
+
f"{len(pending)} attached — type a message · Ctrl+U clears"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def _on_clear() -> None:
|
|
287
|
+
pending.clear()
|
|
288
|
+
ctx.set_attachments(None)
|
|
289
|
+
_set_attach_warning(False)
|
|
290
|
+
ctx.set_hint("Chat mode - press Ctrl+C to exit")
|
|
291
|
+
|
|
292
|
+
ctx.register_paste_handler(_on_drop)
|
|
293
|
+
ctx.register_attachment_clear(_on_clear)
|
|
294
|
+
|
|
199
295
|
try:
|
|
200
296
|
while user_input:
|
|
201
297
|
if ctx.is_tui:
|
|
298
|
+
sent = list(pending)
|
|
202
299
|
ctx.mount_widget(UserMessage(user_input))
|
|
300
|
+
if sent:
|
|
301
|
+
skipped = [
|
|
302
|
+
a for a in sent if a.is_image and not a.supported
|
|
303
|
+
]
|
|
304
|
+
badge_warning = (
|
|
305
|
+
f"{model} can't read images — {len(skipped)} skipped"
|
|
306
|
+
if skipped
|
|
307
|
+
else None
|
|
308
|
+
)
|
|
309
|
+
ctx.mount_widget(
|
|
310
|
+
AttachmentBadge(sent, warning=badge_warning)
|
|
311
|
+
)
|
|
312
|
+
content = _build_user_content(user_input, sent)
|
|
313
|
+
pending.clear()
|
|
314
|
+
ctx.set_attachments(None)
|
|
315
|
+
_set_attach_warning(False)
|
|
203
316
|
ctx.set_hint("Responding...")
|
|
204
317
|
ctx.set_silent_queue(True)
|
|
205
318
|
needs_new_text_widget = True
|
|
206
319
|
widget: StreamingMessage | None = None
|
|
207
320
|
try:
|
|
208
|
-
async for chunk in session.stream(
|
|
321
|
+
async for chunk in session.stream(content):
|
|
209
322
|
if needs_new_text_widget or widget is None:
|
|
210
323
|
widget = StreamingMessage()
|
|
211
324
|
ctx.mount_widget(widget)
|
|
@@ -237,6 +350,8 @@ async def chat_send_fn(
|
|
|
237
350
|
break
|
|
238
351
|
finally:
|
|
239
352
|
session.save()
|
|
353
|
+
ctx.register_paste_handler(None)
|
|
354
|
+
ctx.register_attachment_clear(None)
|
|
240
355
|
# Cleanup is best-effort — on a double-Ctrl+C the TUI screen
|
|
241
356
|
# is already tearing down, so query_one("#status-above") and
|
|
242
357
|
# friends raise NoMatches. Don't surface that as a misleading
|
|
@@ -245,9 +360,12 @@ async def chat_send_fn(
|
|
|
245
360
|
ctx.set_silent_queue(False)
|
|
246
361
|
ctx.clear_queue() # Discard any messages typed during streaming
|
|
247
362
|
ctx.set_status_above(None)
|
|
363
|
+
ctx.set_attachments(None)
|
|
248
364
|
ctx.set_hint(None)
|
|
249
365
|
ctx.set_rule_above(remove_class="chat-mode")
|
|
250
366
|
ctx.set_rule_below(remove_class="chat-mode")
|
|
367
|
+
ctx.set_rule_above(remove_class="attach-warning")
|
|
368
|
+
ctx.set_rule_below(remove_class="attach-warning")
|
|
251
369
|
if original_style:
|
|
252
370
|
ctx.set_prompt_style(original_style)
|
|
253
371
|
except Exception as e:
|
|
@@ -23,11 +23,15 @@ from padwan_llm.errors import Provider
|
|
|
23
23
|
from .utils import console
|
|
24
24
|
from .batch import batch_group
|
|
25
25
|
from .chat import chat_group
|
|
26
|
+
from .talk import talk_command
|
|
26
27
|
|
|
27
28
|
CUSTOM_CSS = """
|
|
28
29
|
Rule.chat-mode {
|
|
29
30
|
color: #87ceeb;
|
|
30
31
|
}
|
|
32
|
+
Rule.attach-warning {
|
|
33
|
+
color: #fbbf24;
|
|
34
|
+
}
|
|
31
35
|
"""
|
|
32
36
|
|
|
33
37
|
cli = Cli(description="Padwan CLI for the unified LLM client library")
|
|
@@ -35,6 +39,8 @@ cli = Cli(description="Padwan CLI for the unified LLM client library")
|
|
|
35
39
|
cli.add_command_group(batch_group)
|
|
36
40
|
cli.add_command_group(chat_group)
|
|
37
41
|
|
|
42
|
+
cli.command("talk", help="Speech-to-speech voice chat")(talk_command)
|
|
43
|
+
|
|
38
44
|
|
|
39
45
|
@cli.tui_on_ready
|
|
40
46
|
def _tui_startup_hint() -> None:
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import functools
|
|
6
|
+
import os
|
|
7
|
+
import queue
|
|
8
|
+
import signal
|
|
9
|
+
import sys
|
|
10
|
+
import termios
|
|
11
|
+
import threading
|
|
12
|
+
import tty
|
|
13
|
+
|
|
14
|
+
from piou import Option
|
|
15
|
+
from piou.tui import get_tui_context
|
|
16
|
+
from tracktolib.utils import BytesBuffer
|
|
17
|
+
|
|
18
|
+
from padwan_llm import RealtimeClient, RealtimeConnection, RealtimeServerEvent
|
|
19
|
+
from padwan_llm.errors import LLMError
|
|
20
|
+
from padwan_llm.openai.realtime import NO_TURN_DETECTION, REALTIME_SAMPLE_RATE
|
|
21
|
+
|
|
22
|
+
from .utils import console
|
|
23
|
+
|
|
24
|
+
SR = REALTIME_SAMPLE_RATE # 24 kHz mono PCM16, as gpt-realtime expects
|
|
25
|
+
CHANNELS = 1
|
|
26
|
+
DTYPE = "int16"
|
|
27
|
+
BLOCK = 1200 # 50 ms frames
|
|
28
|
+
|
|
29
|
+
# Terminals report no key-release: a held Space arrives as auto-repeated spaces.
|
|
30
|
+
# A gap longer than the keyboard's initial repeat delay (~0.66 s default) means
|
|
31
|
+
# the key was released; shorter turns are discarded as accidental taps.
|
|
32
|
+
_HOLD_RELEASE_GAP = 0.8
|
|
33
|
+
_MIN_TURN_SECS = 0.45
|
|
34
|
+
|
|
35
|
+
_DEFAULT_INSTRUCTIONS = """You are a friendly voice assistant.
|
|
36
|
+
Keep spoken replies brief and conversational — one or two sentences — so the
|
|
37
|
+
exchange stays a real back-and-forth."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Speaker:
|
|
41
|
+
"""Buffered PCM16 playback with barge-in support.
|
|
42
|
+
|
|
43
|
+
BytesBuffer keeps the audio callback O(read size): appends are reference-
|
|
44
|
+
only and reads copy at chunk boundaries, instead of memmoving the whole
|
|
45
|
+
queued backlog on every 50 ms tick.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, sd) -> None:
|
|
49
|
+
self._buf = BytesBuffer()
|
|
50
|
+
self._lock = threading.Lock()
|
|
51
|
+
self.stream = sd.RawOutputStream(
|
|
52
|
+
samplerate=SR,
|
|
53
|
+
channels=CHANNELS,
|
|
54
|
+
dtype=DTYPE,
|
|
55
|
+
blocksize=BLOCK,
|
|
56
|
+
callback=self._callback,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def _callback(self, outdata, frames, _time, _status) -> None: # PortAudio thread
|
|
60
|
+
need = frames * CHANNELS * 2
|
|
61
|
+
with self._lock:
|
|
62
|
+
take = self._buf.get(need) if len(self._buf) else b""
|
|
63
|
+
if len(take) < need:
|
|
64
|
+
take += b"\x00" * (need - len(take)) # underflow -> silence
|
|
65
|
+
outdata[:] = take
|
|
66
|
+
|
|
67
|
+
def play(self, pcm16: bytes) -> None:
|
|
68
|
+
with self._lock:
|
|
69
|
+
self._buf.put(pcm16)
|
|
70
|
+
|
|
71
|
+
def clear(self) -> None:
|
|
72
|
+
with self._lock:
|
|
73
|
+
self._buf = BytesBuffer()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _import_sounddevice():
|
|
77
|
+
"""Import sounddevice lazily, with a friendly hint if the extra is missing."""
|
|
78
|
+
try:
|
|
79
|
+
import sounddevice as sd
|
|
80
|
+
except OSError as e: # PortAudio shared library not found
|
|
81
|
+
console.print(f"[red]PortAudio unavailable: {e}[/red]")
|
|
82
|
+
console.print(
|
|
83
|
+
"[dim]Install the system library, e.g. `apt install libportaudio2`.[/dim]"
|
|
84
|
+
)
|
|
85
|
+
raise SystemExit(1)
|
|
86
|
+
except ImportError:
|
|
87
|
+
console.print("[red]sounddevice not installed.[/red]")
|
|
88
|
+
console.print("[dim]Install the voice extra: `uv sync --extra voice`.[/dim]")
|
|
89
|
+
raise SystemExit(1)
|
|
90
|
+
return sd
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@contextlib.contextmanager
|
|
94
|
+
def _cbreak(fd: int):
|
|
95
|
+
"""Put the terminal in cbreak mode so single keypresses arrive immediately."""
|
|
96
|
+
old = termios.tcgetattr(fd)
|
|
97
|
+
try:
|
|
98
|
+
tty.setcbreak(fd) # leaves ISIG on, so Ctrl-C still quits
|
|
99
|
+
yield
|
|
100
|
+
finally:
|
|
101
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def _handle_events(
|
|
105
|
+
conn: RealtimeConnection, speaker: Speaker, *, push_to_talk: bool
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Consume server events: play audio, stream transcripts, prompt for the turn.
|
|
108
|
+
|
|
109
|
+
Your own transcript arrives asynchronously and usually after the assistant has
|
|
110
|
+
started answering, so assistant text is held until the "you:" line has printed
|
|
111
|
+
(flushed unconditionally on response.done so nothing is ever lost).
|
|
112
|
+
"""
|
|
113
|
+
speaking = False # is the assistant mid-utterance on this line?
|
|
114
|
+
held: list[str] = [] # assistant transcript held until the "you:" line prints
|
|
115
|
+
user_shown = True # has the current turn's input transcript been printed?
|
|
116
|
+
prepaid = False # input transcript arrived before its response was created
|
|
117
|
+
|
|
118
|
+
def flush_held() -> None:
|
|
119
|
+
nonlocal speaking
|
|
120
|
+
if held:
|
|
121
|
+
console.print("[cyan]assistant:[/cyan] ", end="")
|
|
122
|
+
sys.stdout.write("".join(held))
|
|
123
|
+
sys.stdout.flush()
|
|
124
|
+
held.clear()
|
|
125
|
+
speaking = True
|
|
126
|
+
|
|
127
|
+
async for event in conn:
|
|
128
|
+
kind = event.get("type")
|
|
129
|
+
if kind == RealtimeServerEvent.AUDIO_DELTA:
|
|
130
|
+
if pcm := conn.audio_delta_bytes(event):
|
|
131
|
+
speaker.play(pcm)
|
|
132
|
+
elif kind == RealtimeServerEvent.AUDIO_TRANSCRIPT_DELTA:
|
|
133
|
+
delta = event.get("delta", "")
|
|
134
|
+
if not user_shown:
|
|
135
|
+
held.append(delta)
|
|
136
|
+
continue
|
|
137
|
+
if not speaking:
|
|
138
|
+
console.print("[cyan]assistant:[/cyan] ", end="")
|
|
139
|
+
speaking = True
|
|
140
|
+
sys.stdout.write(delta)
|
|
141
|
+
sys.stdout.flush()
|
|
142
|
+
elif kind == RealtimeServerEvent.RESPONSE_CREATED:
|
|
143
|
+
if prepaid:
|
|
144
|
+
prepaid = False # transcript already printed for this turn
|
|
145
|
+
else:
|
|
146
|
+
user_shown = False
|
|
147
|
+
elif kind == RealtimeServerEvent.SPEECH_STARTED: # hands-free VAD only
|
|
148
|
+
speaker.clear() # barge-in: drop queued assistant audio
|
|
149
|
+
flush_held()
|
|
150
|
+
if speaking:
|
|
151
|
+
sys.stdout.write("\n")
|
|
152
|
+
speaking = False
|
|
153
|
+
elif kind == RealtimeServerEvent.INPUT_TRANSCRIPT_COMPLETED:
|
|
154
|
+
text = (event.get("transcript") or "").strip()
|
|
155
|
+
if speaking:
|
|
156
|
+
sys.stdout.write("\n")
|
|
157
|
+
speaking = False
|
|
158
|
+
if text:
|
|
159
|
+
console.print(f"[green]you:[/green] {text}")
|
|
160
|
+
if user_shown:
|
|
161
|
+
prepaid = True # transcript beat its response.created
|
|
162
|
+
else:
|
|
163
|
+
user_shown = True
|
|
164
|
+
flush_held()
|
|
165
|
+
elif kind == RealtimeServerEvent.RESPONSE_DONE:
|
|
166
|
+
flush_held() # transcript never arrived; don't lose the assistant text
|
|
167
|
+
user_shown = True
|
|
168
|
+
if speaking:
|
|
169
|
+
sys.stdout.write("\n")
|
|
170
|
+
speaking = False
|
|
171
|
+
if push_to_talk:
|
|
172
|
+
console.print("[dim]· hold Space to talk[/dim]")
|
|
173
|
+
elif kind == RealtimeServerEvent.ERROR:
|
|
174
|
+
console.print(f"\n[dim][error] {event.get('error')}[/dim]")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def _converse(
|
|
178
|
+
conn: RealtimeConnection, sd, speaker: Speaker, *, push_to_talk: bool
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Pump mic → model and run the event loop, in push-to-talk or hands-free mode."""
|
|
181
|
+
loop = asyncio.get_running_loop()
|
|
182
|
+
mic_q: queue.Queue[bytes] = queue.Queue()
|
|
183
|
+
recording = threading.Event()
|
|
184
|
+
if not push_to_talk:
|
|
185
|
+
recording.set() # hands-free: stream continuously, server VAD picks turns
|
|
186
|
+
|
|
187
|
+
def mic_callback(indata, _frames, _time, _status) -> None: # PortAudio thread
|
|
188
|
+
if recording.is_set():
|
|
189
|
+
mic_q.put(bytes(indata))
|
|
190
|
+
|
|
191
|
+
mic = sd.RawInputStream(
|
|
192
|
+
samplerate=SR,
|
|
193
|
+
channels=CHANNELS,
|
|
194
|
+
dtype=DTYPE,
|
|
195
|
+
blocksize=BLOCK,
|
|
196
|
+
callback=mic_callback,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
draining = False
|
|
200
|
+
sent_bytes = 0
|
|
201
|
+
|
|
202
|
+
async def pump_mic() -> None:
|
|
203
|
+
nonlocal draining, sent_bytes
|
|
204
|
+
# Timed get so the executor thread exits shortly after cancellation
|
|
205
|
+
# instead of blocking threading shutdown at exit.
|
|
206
|
+
blocking_get = functools.partial(mic_q.get, True, 0.25)
|
|
207
|
+
while True:
|
|
208
|
+
try:
|
|
209
|
+
chunk = await loop.run_in_executor(None, blocking_get)
|
|
210
|
+
except queue.Empty:
|
|
211
|
+
continue
|
|
212
|
+
# The transport lets roughly one send through per read-poll tick,
|
|
213
|
+
# so batch everything captured while we waited for the socket.
|
|
214
|
+
parts = [chunk]
|
|
215
|
+
while True:
|
|
216
|
+
try:
|
|
217
|
+
parts.append(mic_q.get_nowait())
|
|
218
|
+
except queue.Empty:
|
|
219
|
+
break
|
|
220
|
+
draining = True
|
|
221
|
+
try:
|
|
222
|
+
joined = b"".join(parts)
|
|
223
|
+
await conn.append_audio(joined)
|
|
224
|
+
sent_bytes += len(joined)
|
|
225
|
+
finally:
|
|
226
|
+
draining = False
|
|
227
|
+
|
|
228
|
+
turn_task: asyncio.Task | None = None
|
|
229
|
+
last_space = 0.0
|
|
230
|
+
turn_started_at = 0.0
|
|
231
|
+
|
|
232
|
+
async def start_turn() -> None:
|
|
233
|
+
nonlocal turn_started_at, sent_bytes
|
|
234
|
+
with mic_q.mutex:
|
|
235
|
+
mic_q.queue.clear() # drop audio captured before the press
|
|
236
|
+
speaker.clear() # stop the assistant if it was still talking
|
|
237
|
+
await conn.send_event({"type": "input_audio_buffer.clear"})
|
|
238
|
+
recording.set()
|
|
239
|
+
turn_started_at = loop.time()
|
|
240
|
+
sent_bytes = 0
|
|
241
|
+
console.print("[red]● recording…[/red] [dim]release Space to send[/dim]")
|
|
242
|
+
|
|
243
|
+
async def end_turn() -> None:
|
|
244
|
+
recording.clear()
|
|
245
|
+
# Wait until everything captured actually reached the socket, so the
|
|
246
|
+
# commit doesn't clip the tail of the turn.
|
|
247
|
+
deadline = loop.time() + 3
|
|
248
|
+
while (not mic_q.empty() or draining) and loop.time() < deadline:
|
|
249
|
+
await asyncio.sleep(0.05)
|
|
250
|
+
if loop.time() - turn_started_at < _MIN_TURN_SECS:
|
|
251
|
+
await conn.send_event({"type": "input_audio_buffer.clear"})
|
|
252
|
+
console.print("[dim](too short — hold Space while speaking)[/dim]")
|
|
253
|
+
return
|
|
254
|
+
await conn.commit_audio()
|
|
255
|
+
await conn.create_response()
|
|
256
|
+
console.print(f"[dim](sent {sent_bytes / (SR * 2):.1f}s of audio)[/dim]")
|
|
257
|
+
|
|
258
|
+
def _spawn_turn(coro_fn) -> None:
|
|
259
|
+
nonlocal turn_task
|
|
260
|
+
if turn_task is not None and not turn_task.done():
|
|
261
|
+
return # previous transition still in flight
|
|
262
|
+
turn_task = asyncio.create_task(coro_fn())
|
|
263
|
+
|
|
264
|
+
def on_key() -> None: # called by the event loop when stdin is readable
|
|
265
|
+
nonlocal last_space
|
|
266
|
+
try:
|
|
267
|
+
data = os.read(sys.stdin.fileno(), 1)
|
|
268
|
+
except OSError:
|
|
269
|
+
return
|
|
270
|
+
if data == b" ":
|
|
271
|
+
# Held Space shows up as auto-repeated spaces; each one refreshes
|
|
272
|
+
# the hold. The release watcher ends the turn once they stop.
|
|
273
|
+
last_space = loop.time()
|
|
274
|
+
if not recording.is_set():
|
|
275
|
+
_spawn_turn(start_turn)
|
|
276
|
+
elif data in (b"\r", b"\n") and recording.is_set():
|
|
277
|
+
_spawn_turn(end_turn) # manual send fallback
|
|
278
|
+
|
|
279
|
+
async def watch_release() -> None:
|
|
280
|
+
while True:
|
|
281
|
+
await asyncio.sleep(0.1)
|
|
282
|
+
if recording.is_set() and loop.time() - last_space > _HOLD_RELEASE_GAP:
|
|
283
|
+
_spawn_turn(end_turn)
|
|
284
|
+
|
|
285
|
+
use_keys = push_to_talk and sys.stdin.isatty()
|
|
286
|
+
with mic:
|
|
287
|
+
sender = asyncio.create_task(pump_mic())
|
|
288
|
+
watcher = asyncio.create_task(watch_release()) if use_keys else None
|
|
289
|
+
fd = sys.stdin.fileno()
|
|
290
|
+
try:
|
|
291
|
+
if use_keys:
|
|
292
|
+
with _cbreak(fd):
|
|
293
|
+
loop.add_reader(fd, on_key)
|
|
294
|
+
try:
|
|
295
|
+
await _handle_events(conn, speaker, push_to_talk=True)
|
|
296
|
+
finally:
|
|
297
|
+
loop.remove_reader(fd)
|
|
298
|
+
else:
|
|
299
|
+
await _handle_events(conn, speaker, push_to_talk=False)
|
|
300
|
+
finally:
|
|
301
|
+
sender.cancel()
|
|
302
|
+
if watcher is not None:
|
|
303
|
+
watcher.cancel()
|
|
304
|
+
if turn_task is not None:
|
|
305
|
+
turn_task.cancel()
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
async def talk_command(
|
|
309
|
+
voice: str = Option("marin", "--voice", help="Realtime voice (marin, cedar, …)"),
|
|
310
|
+
model: str = Option("gpt-realtime", "-m", "--model", help="Realtime model"),
|
|
311
|
+
instructions: str | None = Option(
|
|
312
|
+
None, "--instructions", help="System prompt for the voice assistant"
|
|
313
|
+
),
|
|
314
|
+
hands_free: bool = Option(
|
|
315
|
+
False, "--hands-free", help="Auto voice detection instead of push-to-talk"
|
|
316
|
+
),
|
|
317
|
+
check: bool = Option(
|
|
318
|
+
False, "--check", help="List audio devices and key status, then exit"
|
|
319
|
+
),
|
|
320
|
+
) -> None:
|
|
321
|
+
"""Talk with a real-time voice assistant (speech-to-speech)."""
|
|
322
|
+
# A live mic/speaker session needs the whole terminal; the Textual TUI owns it,
|
|
323
|
+
# so this command only works headless. Fail loudly instead of hanging silently.
|
|
324
|
+
if get_tui_context().is_tui:
|
|
325
|
+
console.print(
|
|
326
|
+
"[yellow]`talk` is a full-terminal voice session and can't run inside "
|
|
327
|
+
"the TUI.[/yellow]\n[dim]Run it headless:[/dim] [bold]uv run padwan-talk"
|
|
328
|
+
"[/bold] [dim](or `python -m padwan_cli talk`).[/dim]"
|
|
329
|
+
)
|
|
330
|
+
return
|
|
331
|
+
|
|
332
|
+
sd = _import_sounddevice()
|
|
333
|
+
|
|
334
|
+
if check:
|
|
335
|
+
console.print(sd.query_devices())
|
|
336
|
+
console.print(f"\nDefault (input, output): {sd.default.device}")
|
|
337
|
+
try:
|
|
338
|
+
RealtimeClient(model=model)
|
|
339
|
+
key_status = "found"
|
|
340
|
+
except LLMError:
|
|
341
|
+
key_status = "[red]MISSING[/red]"
|
|
342
|
+
console.print(f"OPENAI_API_KEY: {key_status}")
|
|
343
|
+
console.print(f"model={model} voice={voice}")
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
push_to_talk = not hands_free
|
|
347
|
+
if push_to_talk and not sys.stdin.isatty():
|
|
348
|
+
console.print(
|
|
349
|
+
"[yellow]push-to-talk needs a terminal; using --hands-free.[/yellow]"
|
|
350
|
+
)
|
|
351
|
+
push_to_talk = False
|
|
352
|
+
|
|
353
|
+
prompt = instructions or _DEFAULT_INSTRUCTIONS
|
|
354
|
+
# Push-to-talk disables server VAD so we commit each turn ourselves.
|
|
355
|
+
turn_detection = NO_TURN_DETECTION if push_to_talk else None
|
|
356
|
+
# The client validates the API key itself (LLMError when unset).
|
|
357
|
+
try:
|
|
358
|
+
client = RealtimeClient(
|
|
359
|
+
model=model,
|
|
360
|
+
instructions=prompt,
|
|
361
|
+
voice=voice,
|
|
362
|
+
turn_detection=turn_detection,
|
|
363
|
+
)
|
|
364
|
+
except LLMError as e:
|
|
365
|
+
console.print(f"[red]{e}[/red] [dim]— export it or add it to .env[/dim]")
|
|
366
|
+
return
|
|
367
|
+
speaker = Speaker(sd)
|
|
368
|
+
console.print(f"[dim]connecting to {model} (voice: {voice})…[/dim]")
|
|
369
|
+
# Route Ctrl-C through task cancellation: a raw KeyboardInterrupt tears the
|
|
370
|
+
# loop down without unwinding the session/mic/speaker, leaking pending tasks.
|
|
371
|
+
loop = asyncio.get_running_loop()
|
|
372
|
+
task = asyncio.current_task()
|
|
373
|
+
if task is not None:
|
|
374
|
+
loop.add_signal_handler(signal.SIGINT, task.cancel)
|
|
375
|
+
try:
|
|
376
|
+
async with client as conn:
|
|
377
|
+
with speaker.stream:
|
|
378
|
+
if push_to_talk:
|
|
379
|
+
console.print(
|
|
380
|
+
"[green]Ready.[/green] [dim]Hold Space while talking, "
|
|
381
|
+
"release to send. Ctrl-C to quit.[/dim]\n"
|
|
382
|
+
)
|
|
383
|
+
else:
|
|
384
|
+
console.print(
|
|
385
|
+
"[green]Go ahead.[/green] [dim](hands-free — just speak. "
|
|
386
|
+
"Ctrl-C to quit.)[/dim]\n"
|
|
387
|
+
)
|
|
388
|
+
await _converse(conn, sd, speaker, push_to_talk=push_to_talk)
|
|
389
|
+
except asyncio.CancelledError:
|
|
390
|
+
console.print("\n[dim]Bye! 👋[/dim]")
|
|
391
|
+
finally:
|
|
392
|
+
if task is not None:
|
|
393
|
+
loop.remove_signal_handler(signal.SIGINT)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def main() -> None:
|
|
397
|
+
"""Headless entry point (`padwan-talk`): run the voice chat without the TUI."""
|
|
398
|
+
# Deferred import avoids a circular import (run imports talk at module load).
|
|
399
|
+
from .run import cli
|
|
400
|
+
|
|
401
|
+
cli.run_with_args("talk", *sys.argv[1:])
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
4
6
|
from typing import TYPE_CHECKING, Any, cast
|
|
5
7
|
|
|
8
|
+
from rich import box
|
|
9
|
+
from rich.columns import Columns
|
|
10
|
+
from rich.console import Group, RenderableType
|
|
11
|
+
from rich.panel import Panel
|
|
6
12
|
from rich.text import Text
|
|
7
13
|
from textual.widgets import Static
|
|
8
14
|
|
|
@@ -17,6 +23,75 @@ ASSISTANT_COLOR = "#98fb98"
|
|
|
17
23
|
TOOL_CALL_COLOR = "#ffa500"
|
|
18
24
|
THOUGHT_COLOR = "#9370db"
|
|
19
25
|
ERROR_COLOR = "#ff6b6b"
|
|
26
|
+
ATTACHMENT_COLOR = USER_COLOR
|
|
27
|
+
WARN_COLOR = "#fbbf24"
|
|
28
|
+
CHIP_BG = "#1f2230"
|
|
29
|
+
CHIP_BORDER = "#3a3f55"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Attachment:
|
|
34
|
+
"""A file dropped into the chat, queued to send with the next message."""
|
|
35
|
+
|
|
36
|
+
path: Path
|
|
37
|
+
name: str
|
|
38
|
+
size: int
|
|
39
|
+
is_image: bool
|
|
40
|
+
supported: bool # whether the active model can use it (images need vision)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def human_size(num_bytes: int) -> str:
|
|
44
|
+
"""Format a byte count as a short human-readable string."""
|
|
45
|
+
size = float(num_bytes)
|
|
46
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
47
|
+
if size < 1024 or unit == "GB":
|
|
48
|
+
return f"{int(size)} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
|
49
|
+
size /= 1024
|
|
50
|
+
return f"{size:.1f} GB"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _chip_panel(a: Attachment) -> Panel:
|
|
54
|
+
"""Build one bordered pill chip for an attachment.
|
|
55
|
+
|
|
56
|
+
Unsupported files get an amber border (the "which"); the reason — a
|
|
57
|
+
model-level fact — is carried once by the warning bar and hint.
|
|
58
|
+
"""
|
|
59
|
+
label = Text()
|
|
60
|
+
label.append(f"📎 {a.name}", style=ATTACHMENT_COLOR)
|
|
61
|
+
label.append(f" {human_size(a.size)}", style="dim")
|
|
62
|
+
return Panel(
|
|
63
|
+
label,
|
|
64
|
+
box=box.ROUNDED,
|
|
65
|
+
border_style=WARN_COLOR if not a.supported else CHIP_BORDER,
|
|
66
|
+
style=f"on {CHIP_BG}",
|
|
67
|
+
padding=(0, 1),
|
|
68
|
+
expand=False,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def render_chips(attachments: list[Attachment]) -> Columns:
|
|
73
|
+
"""Render queued attachments as a row of bordered pill chips."""
|
|
74
|
+
return Columns([_chip_panel(a) for a in attachments], padding=(0, 1), expand=False)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def render_attachments(
|
|
78
|
+
attachments: list[Attachment], *, warning: str | None = None
|
|
79
|
+
) -> RenderableType:
|
|
80
|
+
"""Render the attachment block: an optional amber warning line, then chips."""
|
|
81
|
+
chips = render_chips(attachments)
|
|
82
|
+
if warning:
|
|
83
|
+
return Group(Text(f"⚠ {warning}", style=WARN_COLOR), chips)
|
|
84
|
+
return chips
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AttachmentBadge(Static):
|
|
88
|
+
"""Bordered pill chips shown under a sent user message."""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self, attachments: list[Attachment], *, warning: str | None = None, **kwargs
|
|
92
|
+
) -> None:
|
|
93
|
+
super().__init__(render_attachments(attachments, warning=warning), **kwargs)
|
|
94
|
+
|
|
20
95
|
|
|
21
96
|
# Colors for batch job states
|
|
22
97
|
STATE_COLORS = {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "padwan-cli"
|
|
3
|
+
version = "0.5.0"
|
|
4
|
+
description = "CLI and TUI for padwan-llm"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"padwan-llm[realtime]>=0.8.0",
|
|
9
|
+
"piou[tui]>=0.36.0",
|
|
10
|
+
"tracktolib>=0.74.0",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[[project.authors]]
|
|
14
|
+
email = "julien.brayere@polarsen.com"
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
voice = ["sounddevice>=0.5"]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
padwan-cli = "padwan_cli:main_tui"
|
|
21
|
+
padwan-talk = "padwan_cli.talk:main"
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["uv_build>=0.9.28,<0.10.0"]
|
|
25
|
+
build-backend = "uv_build"
|
|
26
|
+
|
|
27
|
+
[tool.uv.build-backend]
|
|
28
|
+
module-root = ""
|
|
29
|
+
|
|
30
|
+
[tool.commitizen]
|
|
31
|
+
name = "cz_conventional_commits"
|
|
32
|
+
version_provider = "pep621"
|
|
33
|
+
tag_format = "$version"
|
|
34
|
+
update_changelog_on_bump = true
|
|
35
|
+
bump_message = "chore(release): release $current_version → $new_version [skip ci]"
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
asyncio_mode = "auto"
|
|
39
|
+
|
|
40
|
+
[dependency-groups]
|
|
41
|
+
dev = [
|
|
42
|
+
"piou[tui-reload]>=0.36.0",
|
|
43
|
+
"pyright>=1.1.408",
|
|
44
|
+
"pytest>=8.0.0",
|
|
45
|
+
"pytest-asyncio>=1.3.0",
|
|
46
|
+
"pytest-cov>=7.0.0",
|
|
47
|
+
"ruff>=0.11.0",
|
|
48
|
+
]
|
|
49
|
+
docs = ["zensical>=0.0.31"]
|
|
50
|
+
bump = ["commitizen>=4.0.0"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "padwan-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.0"
|
|
4
4
|
description = "CLI and TUI for padwan-llm"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -8,12 +8,18 @@ authors = [
|
|
|
8
8
|
]
|
|
9
9
|
requires-python = ">=3.14"
|
|
10
10
|
dependencies = [
|
|
11
|
-
"padwan-llm>=0.
|
|
12
|
-
"piou[tui]>=0.
|
|
11
|
+
"padwan-llm[realtime]>=0.8.0",
|
|
12
|
+
"piou[tui]>=0.36.0",
|
|
13
|
+
"tracktolib>=0.74.0",
|
|
13
14
|
]
|
|
14
15
|
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
# `talk` command audio I/O; also needs the PortAudio system lib (libportaudio2).
|
|
18
|
+
voice = ["sounddevice>=0.5"]
|
|
19
|
+
|
|
15
20
|
[project.scripts]
|
|
16
21
|
padwan-cli = "padwan_cli:main_tui"
|
|
22
|
+
padwan-talk = "padwan_cli.talk:main"
|
|
17
23
|
|
|
18
24
|
[build-system]
|
|
19
25
|
requires = ["uv_build>=0.9.28,<0.10.0"]
|
|
@@ -22,12 +28,9 @@ build-backend = "uv_build"
|
|
|
22
28
|
[tool.uv.build-backend]
|
|
23
29
|
module-root = ""
|
|
24
30
|
|
|
25
|
-
#[tool.uv.sources]
|
|
26
|
-
#padwan-llm = { path = "../padwan-llm", editable = true }
|
|
27
|
-
|
|
28
31
|
[dependency-groups]
|
|
29
32
|
dev = [
|
|
30
|
-
"piou[tui-reload]>=0.
|
|
33
|
+
"piou[tui-reload]>=0.36.0",
|
|
31
34
|
"pyright>=1.1.408",
|
|
32
35
|
"pytest>=8.0.0",
|
|
33
36
|
"pytest-asyncio>=1.3.0",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|