the rest of the refactor
This commit is contained in:
35
cht/session.py
Normal file
35
cht/session.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""Session data loading — reads frame/transcript indexes, returns plain data."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def load_frame_index(frames_dir: Path) -> list[dict]:
|
||||||
|
"""Read frames/index.json and return list of {id, path, timestamp}.
|
||||||
|
|
||||||
|
Returns only entries where the image file exists on disk.
|
||||||
|
Paths are resolved relative to *frames_dir* if needed.
|
||||||
|
"""
|
||||||
|
index_path = frames_dir / "index.json"
|
||||||
|
if not index_path.exists():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
index = json.loads(index_path.read_text())
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
return []
|
||||||
|
result = []
|
||||||
|
for entry in index:
|
||||||
|
fpath = Path(entry["path"])
|
||||||
|
if not fpath.exists():
|
||||||
|
fpath = frames_dir / fpath.name
|
||||||
|
if not fpath.exists():
|
||||||
|
continue
|
||||||
|
result.append({
|
||||||
|
"id": entry["id"],
|
||||||
|
"path": fpath,
|
||||||
|
"timestamp": entry.get("timestamp", 0),
|
||||||
|
})
|
||||||
|
return result
|
||||||
179
cht/stream/lifecycle.py
Normal file
179
cht/stream/lifecycle.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
"""Stream lifecycle — manages recording, scene detection, audio extraction, and transcription buffering."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from threading import Thread
|
||||||
|
|
||||||
|
from gi.repository import GLib
|
||||||
|
|
||||||
|
from cht.config import TRANSCRIBE_MIN_CHUNK_S
|
||||||
|
from cht.stream.manager import StreamManager
|
||||||
|
from cht.stream.tracker import RecordingTracker
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class StreamLifecycle:
|
||||||
|
"""Owns the streaming process state and coordinates background tasks.
|
||||||
|
|
||||||
|
The window provides UI-facing callbacks; this class handles the
|
||||||
|
process-management side (recorder, tracker, scene detection, audio
|
||||||
|
extraction, transcription buffering).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *, timeline, waveform_engine, transcriber,
|
||||||
|
on_new_frames, on_waveform_update, on_transcript_ready,
|
||||||
|
on_scene_marker, on_recorder_restarted):
|
||||||
|
self._timeline = timeline
|
||||||
|
self._waveform_engine = waveform_engine
|
||||||
|
self._transcriber = transcriber
|
||||||
|
|
||||||
|
# Callbacks
|
||||||
|
self._on_new_frames = on_new_frames
|
||||||
|
self._on_waveform_update = on_waveform_update
|
||||||
|
self._on_transcript_ready = on_transcript_ready
|
||||||
|
self._on_scene_marker = on_scene_marker
|
||||||
|
self._on_recorder_restarted = on_recorder_restarted
|
||||||
|
|
||||||
|
# State
|
||||||
|
self._streaming = False
|
||||||
|
self._gone_live = False
|
||||||
|
self._stream_mgr: StreamManager | None = None
|
||||||
|
self._tracker: RecordingTracker | None = None
|
||||||
|
self._pending_transcript_audio: list[tuple] = []
|
||||||
|
self._pending_transcript_duration = 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_streaming(self) -> bool:
|
||||||
|
return self._streaming
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stream_mgr(self) -> StreamManager | None:
|
||||||
|
return self._stream_mgr
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tracker(self) -> RecordingTracker | None:
|
||||||
|
return self._tracker
|
||||||
|
|
||||||
|
def start(self, session_id=None) -> StreamManager:
|
||||||
|
"""Start recording and all background processes. Returns the StreamManager."""
|
||||||
|
self._streaming = True
|
||||||
|
self._gone_live = False
|
||||||
|
|
||||||
|
self._stream_mgr = StreamManager(session_id=session_id)
|
||||||
|
self._stream_mgr.setup_dirs()
|
||||||
|
self._stream_mgr.start_recorder()
|
||||||
|
|
||||||
|
self._tracker = RecordingTracker(
|
||||||
|
get_segments=lambda: self._stream_mgr.recording_segments if self._stream_mgr else [],
|
||||||
|
on_duration_update=self._on_duration_update,
|
||||||
|
)
|
||||||
|
self._tracker.start()
|
||||||
|
|
||||||
|
self._stream_mgr.start_scene_detector(on_new_frames=self._handle_new_scene_frames)
|
||||||
|
self._stream_mgr.start_audio_extractor(on_new_audio=self._handle_new_audio)
|
||||||
|
|
||||||
|
GLib.timeout_add(1000, self._tick_live)
|
||||||
|
GLib.timeout_add(2000, self._check_recorder)
|
||||||
|
|
||||||
|
return self._stream_mgr
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop all processes and reset state. Does NOT touch UI — caller handles that."""
|
||||||
|
if self._tracker:
|
||||||
|
self._tracker.stop()
|
||||||
|
self._tracker = None
|
||||||
|
|
||||||
|
readonly = self._stream_mgr.readonly if self._stream_mgr else True
|
||||||
|
if self._stream_mgr:
|
||||||
|
if not readonly:
|
||||||
|
self._stream_mgr.stop_all()
|
||||||
|
self._stream_mgr = None
|
||||||
|
|
||||||
|
self._streaming = False
|
||||||
|
self._gone_live = False
|
||||||
|
self._pending_transcript_audio.clear()
|
||||||
|
self._pending_transcript_duration = 0.0
|
||||||
|
|
||||||
|
def set_manager_readonly(self, mgr: StreamManager):
|
||||||
|
"""Set a read-only stream manager (for loaded sessions, no streaming)."""
|
||||||
|
self._stream_mgr = mgr
|
||||||
|
|
||||||
|
def clear_manager(self):
|
||||||
|
"""Clear the stream manager without stopping processes."""
|
||||||
|
self._stream_mgr = None
|
||||||
|
|
||||||
|
# -- Internal callbacks --
|
||||||
|
|
||||||
|
def _on_duration_update(self, duration):
|
||||||
|
GLib.idle_add(self._timeline.set_duration, duration)
|
||||||
|
if not self._gone_live:
|
||||||
|
self._gone_live = True
|
||||||
|
GLib.idle_add(self._go_live_once)
|
||||||
|
if self._stream_mgr:
|
||||||
|
self._stream_mgr.capture_now(on_new_frames=self._handle_new_scene_frames)
|
||||||
|
|
||||||
|
def _go_live_once(self):
|
||||||
|
if self._stream_mgr:
|
||||||
|
log.info("Going LIVE (startup delay elapsed)")
|
||||||
|
self._timeline.go_live()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _tick_live(self):
|
||||||
|
if not self._streaming:
|
||||||
|
return False
|
||||||
|
self._timeline.tick_live()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _handle_new_scene_frames(self, frames):
|
||||||
|
for f in frames:
|
||||||
|
GLib.idle_add(self._on_scene_marker, f["timestamp"])
|
||||||
|
self._on_new_frames(frames)
|
||||||
|
|
||||||
|
def _handle_new_audio(self, wav_path, start_time, duration):
|
||||||
|
if not self._stream_mgr:
|
||||||
|
return
|
||||||
|
self._waveform_engine.append_chunk(wav_path, start_time)
|
||||||
|
peaks = self._waveform_engine.peaks
|
||||||
|
bucket_dur = self._waveform_engine.bucket_duration
|
||||||
|
GLib.idle_add(self._on_waveform_update, peaks.copy(), bucket_dur)
|
||||||
|
|
||||||
|
self._pending_transcript_audio.append((wav_path, start_time, duration))
|
||||||
|
self._pending_transcript_duration += duration
|
||||||
|
if self._pending_transcript_duration < TRANSCRIBE_MIN_CHUNK_S:
|
||||||
|
return
|
||||||
|
|
||||||
|
first_start = self._pending_transcript_audio[0][1]
|
||||||
|
total_dur = self._pending_transcript_duration
|
||||||
|
self._pending_transcript_audio.clear()
|
||||||
|
self._pending_transcript_duration = 0.0
|
||||||
|
|
||||||
|
mgr = self._stream_mgr
|
||||||
|
chunk_wav = mgr.audio_dir / f"transcript_{int(first_start):06d}.wav"
|
||||||
|
|
||||||
|
def _transcribe():
|
||||||
|
from cht.stream import ffmpeg as ff
|
||||||
|
try:
|
||||||
|
ff.extract_audio_chunk(
|
||||||
|
mgr.recording_path, chunk_wav,
|
||||||
|
start_time=first_start, duration=total_dur,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Transcript audio extraction failed: %s", e)
|
||||||
|
return
|
||||||
|
if not chunk_wav.exists():
|
||||||
|
return
|
||||||
|
new_segs = self._transcriber.transcribe_chunk(chunk_wav, time_offset=first_start)
|
||||||
|
self._transcriber.save_index(mgr.transcript_dir / "index.json")
|
||||||
|
if new_segs:
|
||||||
|
GLib.idle_add(self._on_transcript_ready, new_segs)
|
||||||
|
|
||||||
|
Thread(target=_transcribe, daemon=True, name="transcriber").start()
|
||||||
|
|
||||||
|
def _check_recorder(self):
|
||||||
|
if not self._streaming or not self._stream_mgr:
|
||||||
|
return False
|
||||||
|
if not self._stream_mgr.recorder_alive():
|
||||||
|
log.warning("Recorder died — restarting into new segment")
|
||||||
|
self._stream_mgr.restart_recorder()
|
||||||
|
self._on_recorder_restarted(self._stream_mgr.recording_path)
|
||||||
|
return True
|
||||||
125
cht/ui/agent_input.py
Normal file
125
cht/ui/agent_input.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""Agent input panel — entry, action buttons, model/language dropdowns."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import gi
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gtk, GObject
|
||||||
|
|
||||||
|
from cht.agent.runner import ACTIONS
|
||||||
|
from cht.transcriber.engine import LANGUAGES
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentInputPanel(Gtk.Frame):
|
||||||
|
"""Input bar with action buttons, model/lang selectors, and text entry."""
|
||||||
|
|
||||||
|
__gsignals__ = {
|
||||||
|
"send-requested": (GObject.SignalFlags.RUN_FIRST, None, (str,)),
|
||||||
|
"action-requested": (GObject.SignalFlags.RUN_FIRST, None, (str,)),
|
||||||
|
"model-changed": (GObject.SignalFlags.RUN_FIRST, None, (str,)),
|
||||||
|
"lang-changed": (GObject.SignalFlags.RUN_FIRST, None, (str,)),
|
||||||
|
"history-toggled": (GObject.SignalFlags.RUN_FIRST, None, (bool,)),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||||
|
outer.set_margin_start(4)
|
||||||
|
outer.set_margin_end(4)
|
||||||
|
outer.set_margin_top(4)
|
||||||
|
outer.set_margin_bottom(4)
|
||||||
|
|
||||||
|
actions_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
|
||||||
|
for label, verb in ACTIONS.items():
|
||||||
|
btn = Gtk.Button(label=label)
|
||||||
|
btn.add_css_class("flat")
|
||||||
|
btn.connect("clicked", lambda b, v=verb: self.emit("action-requested", v))
|
||||||
|
actions_box.append(btn)
|
||||||
|
|
||||||
|
spacer = Gtk.Box()
|
||||||
|
spacer.set_hexpand(True)
|
||||||
|
actions_box.append(spacer)
|
||||||
|
|
||||||
|
model_label = Gtk.Label(label="Model:")
|
||||||
|
model_label.add_css_class("dim-label")
|
||||||
|
actions_box.append(model_label)
|
||||||
|
|
||||||
|
self._model_dropdown = Gtk.DropDown.new_from_strings([])
|
||||||
|
self._model_dropdown.set_size_request(200, -1)
|
||||||
|
self._model_dropdown.connect("notify::selected", self._on_model_changed)
|
||||||
|
actions_box.append(self._model_dropdown)
|
||||||
|
|
||||||
|
lang_label = Gtk.Label(label="Lang:")
|
||||||
|
lang_label.add_css_class("dim-label")
|
||||||
|
actions_box.append(lang_label)
|
||||||
|
|
||||||
|
lang_names = list(LANGUAGES.keys())
|
||||||
|
self._lang_names = lang_names
|
||||||
|
self._lang_dropdown = Gtk.DropDown.new_from_strings(lang_names)
|
||||||
|
self._lang_dropdown.set_selected(0)
|
||||||
|
self._lang_dropdown.connect("notify::selected", self._on_lang_changed)
|
||||||
|
actions_box.append(self._lang_dropdown)
|
||||||
|
|
||||||
|
history_toggle = Gtk.CheckButton(label="Chat")
|
||||||
|
history_toggle.set_tooltip_text("Include conversation history in prompts")
|
||||||
|
history_toggle.connect("toggled", lambda b: self.emit("history-toggled", b.get_active()))
|
||||||
|
actions_box.append(history_toggle)
|
||||||
|
|
||||||
|
outer.append(actions_box)
|
||||||
|
|
||||||
|
input_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
|
||||||
|
self._entry = Gtk.Entry()
|
||||||
|
self._entry.set_hexpand(True)
|
||||||
|
self._entry.set_placeholder_text("Message agent... (@F1-3 frames, @T1-5 transcript)")
|
||||||
|
self._entry.connect("activate", lambda e: self._do_send())
|
||||||
|
input_row.append(self._entry)
|
||||||
|
|
||||||
|
send_btn = Gtk.Button(label="Send")
|
||||||
|
send_btn.add_css_class("suggested-action")
|
||||||
|
send_btn.connect("clicked", lambda b: self._do_send())
|
||||||
|
input_row.append(send_btn)
|
||||||
|
outer.append(input_row)
|
||||||
|
|
||||||
|
self.set_child(outer)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def entry(self) -> Gtk.Entry:
|
||||||
|
"""The text entry widget (for focus checks)."""
|
||||||
|
return self._entry
|
||||||
|
|
||||||
|
def get_text(self) -> str:
|
||||||
|
return self._entry.get_text().strip()
|
||||||
|
|
||||||
|
def clear_text(self) -> None:
|
||||||
|
self._entry.set_text("")
|
||||||
|
|
||||||
|
def populate_models(self, models: list[str], current: str | None = None) -> None:
|
||||||
|
if not models:
|
||||||
|
return
|
||||||
|
string_list = Gtk.StringList.new(models)
|
||||||
|
self._model_dropdown.set_model(string_list)
|
||||||
|
if current:
|
||||||
|
for i, m in enumerate(models):
|
||||||
|
if m == current:
|
||||||
|
self._model_dropdown.set_selected(i)
|
||||||
|
break
|
||||||
|
|
||||||
|
def _do_send(self):
|
||||||
|
text = self.get_text()
|
||||||
|
self.clear_text()
|
||||||
|
self.emit("send-requested", text)
|
||||||
|
|
||||||
|
def _on_model_changed(self, dropdown, _pspec):
|
||||||
|
idx = dropdown.get_selected()
|
||||||
|
model = dropdown.get_model()
|
||||||
|
if model and idx < model.get_n_items():
|
||||||
|
self.emit("model-changed", model.get_string(idx))
|
||||||
|
|
||||||
|
def _on_lang_changed(self, dropdown, _pspec):
|
||||||
|
idx = dropdown.get_selected()
|
||||||
|
if idx < len(self._lang_names):
|
||||||
|
lang_code = LANGUAGES[self._lang_names[idx]]
|
||||||
|
self.emit("lang-changed", lang_code or "")
|
||||||
106
cht/ui/session_dialog.py
Normal file
106
cht/ui/session_dialog.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""Session browser dialog — lists sessions, supports load and delete."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import gi
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
gi.require_version("Adw", "1")
|
||||||
|
from gi.repository import Gtk, Adw, GObject
|
||||||
|
|
||||||
|
from cht.stream.manager import list_sessions, delete_sessions
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionDialog(Adw.Window):
|
||||||
|
"""Modal dialog listing sessions. Emits 'session-selected' with session id."""
|
||||||
|
|
||||||
|
__gsignals__ = {
|
||||||
|
"session-selected": (GObject.SignalFlags.RUN_FIRST, None, (str,)),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, parent, **kwargs):
|
||||||
|
super().__init__(transient_for=parent, modal=True, **kwargs)
|
||||||
|
self.set_title("Load Session")
|
||||||
|
self.set_default_size(500, 400)
|
||||||
|
|
||||||
|
sessions = list_sessions()
|
||||||
|
|
||||||
|
toolbar = Adw.ToolbarView()
|
||||||
|
header = Adw.HeaderBar()
|
||||||
|
|
||||||
|
select_all_btn = Gtk.CheckButton(label="All")
|
||||||
|
header.pack_start(select_all_btn)
|
||||||
|
|
||||||
|
delete_btn = Gtk.Button(label="Delete")
|
||||||
|
delete_btn.add_css_class("destructive-action")
|
||||||
|
header.pack_end(delete_btn)
|
||||||
|
|
||||||
|
toolbar.add_top_bar(header)
|
||||||
|
|
||||||
|
scroll = Gtk.ScrolledWindow()
|
||||||
|
scroll.set_vexpand(True)
|
||||||
|
listbox = Gtk.ListBox()
|
||||||
|
listbox.set_selection_mode(Gtk.SelectionMode.NONE)
|
||||||
|
listbox.add_css_class("boxed-list")
|
||||||
|
|
||||||
|
checks: list[tuple[str, Gtk.CheckButton]] = []
|
||||||
|
|
||||||
|
for sid, sdir in sessions:
|
||||||
|
idx = sdir / "frames" / "index.json"
|
||||||
|
nframes = 0
|
||||||
|
try:
|
||||||
|
nframes = len(json.loads(idx.read_text()))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
nrec = len(list((sdir / "stream").glob("recording_*.mp4")))
|
||||||
|
|
||||||
|
check = Gtk.CheckButton()
|
||||||
|
checks.append((sid, check))
|
||||||
|
|
||||||
|
row = Adw.ActionRow()
|
||||||
|
row.set_title(sid)
|
||||||
|
row.set_subtitle(f"{nframes} frames, {nrec} segments")
|
||||||
|
row.set_activatable(True)
|
||||||
|
row.add_prefix(check)
|
||||||
|
|
||||||
|
def _on_row_activated(r, s=sid):
|
||||||
|
self.close()
|
||||||
|
self.emit("session-selected", s)
|
||||||
|
row.connect("activated", _on_row_activated)
|
||||||
|
listbox.append(row)
|
||||||
|
|
||||||
|
def _on_select_all(btn):
|
||||||
|
active = btn.get_active()
|
||||||
|
for _, cb in checks:
|
||||||
|
cb.set_active(active)
|
||||||
|
select_all_btn.connect("toggled", _on_select_all)
|
||||||
|
|
||||||
|
def _on_delete(btn):
|
||||||
|
to_delete = [sid for sid, cb in checks if cb.get_active()]
|
||||||
|
if not to_delete:
|
||||||
|
return
|
||||||
|
if self._current_session in to_delete:
|
||||||
|
to_delete.remove(self._current_session)
|
||||||
|
if to_delete:
|
||||||
|
delete_sessions(to_delete)
|
||||||
|
self.close()
|
||||||
|
# Re-open with updated list
|
||||||
|
new_dialog = SessionDialog(self.get_transient_for())
|
||||||
|
new_dialog._current_session = self._current_session
|
||||||
|
# Forward the signal
|
||||||
|
new_dialog.connect("session-selected",
|
||||||
|
lambda d, s: self.emit("session-selected", s))
|
||||||
|
new_dialog.present()
|
||||||
|
delete_btn.connect("clicked", _on_delete)
|
||||||
|
|
||||||
|
scroll.set_child(listbox)
|
||||||
|
toolbar.set_content(scroll)
|
||||||
|
self.set_content(toolbar)
|
||||||
|
|
||||||
|
self._current_session = None
|
||||||
|
|
||||||
|
def set_current_session(self, session_id: str | None) -> None:
|
||||||
|
"""Set the active session id so it won't be deleted."""
|
||||||
|
self._current_session = session_id
|
||||||
407
cht/window.py
407
cht/window.py
@@ -1,18 +1,16 @@
|
|||||||
"""Main application window — wires Timeline to all components."""
|
"""Main application window — wires Timeline to all components."""
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import gi
|
import gi
|
||||||
gi.require_version("Gtk", "4.0")
|
gi.require_version("Gtk", "4.0")
|
||||||
gi.require_version("Adw", "1")
|
gi.require_version("Adw", "1")
|
||||||
gi.require_version("GdkPixbuf", "2.0")
|
gi.require_version("GdkPixbuf", "2.0")
|
||||||
from gi.repository import Gtk, Gdk, Adw, GLib, Pango, GdkPixbuf
|
from gi.repository import Gtk, Gdk, Adw, GLib, GdkPixbuf
|
||||||
|
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
|
|
||||||
from cht.config import APP_NAME, SCENE_THRESHOLD, TRANSCRIBE_MIN_CHUNK_S
|
from cht.config import APP_NAME
|
||||||
from cht.ui.timeline import Timeline, TimelineControls
|
from cht.ui.timeline import Timeline, TimelineControls
|
||||||
from cht.ui.monitor import MonitorWidget
|
from cht.ui.monitor import MonitorWidget
|
||||||
from cht.ui.waveform import WaveformWidget
|
from cht.ui.waveform import WaveformWidget
|
||||||
@@ -20,11 +18,14 @@ from cht.ui.frames_panel import FramesPanel
|
|||||||
from cht.ui.transcript_panel import TranscriptPanel
|
from cht.ui.transcript_panel import TranscriptPanel
|
||||||
from cht.ui.keyboard import KeyboardManager, KEY_LEFT, KEY_RIGHT, KEY_UP, KEY_DOWN, KEY_RETURN, KEY_KP_ENTER, KEY_ESCAPE, KEY_DELETE
|
from cht.ui.keyboard import KeyboardManager, KEY_LEFT, KEY_RIGHT, KEY_UP, KEY_DOWN, KEY_RETURN, KEY_KP_ENTER, KEY_ESCAPE, KEY_DELETE
|
||||||
from cht.ui.agent_output import AgentOutputPanel
|
from cht.ui.agent_output import AgentOutputPanel
|
||||||
|
from cht.ui.agent_input import AgentInputPanel
|
||||||
from cht.audio.waveform import WaveformEngine
|
from cht.audio.waveform import WaveformEngine
|
||||||
from cht.transcriber.engine import TranscriberEngine, LANGUAGES
|
from cht.transcriber.engine import TranscriberEngine
|
||||||
from cht.stream.manager import StreamManager, list_sessions, delete_sessions
|
from cht.stream.manager import StreamManager, list_sessions
|
||||||
from cht.stream.tracker import RecordingTracker
|
from cht.stream.lifecycle import StreamLifecycle
|
||||||
from cht.agent.runner import AgentRunner, ACTIONS, check_claude_cli
|
from cht.ui.session_dialog import SessionDialog
|
||||||
|
from cht.session import load_frame_index
|
||||||
|
from cht.agent.runner import AgentRunner, check_claude_cli
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -34,10 +35,6 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.set_title(APP_NAME)
|
self.set_title(APP_NAME)
|
||||||
self.set_default_size(1400, 900)
|
self.set_default_size(1400, 900)
|
||||||
self._streaming = False
|
|
||||||
self._gone_live = False
|
|
||||||
self._stream_mgr = None
|
|
||||||
self._tracker = None
|
|
||||||
self._known_frames = set()
|
self._known_frames = set()
|
||||||
|
|
||||||
# Core components
|
# Core components
|
||||||
@@ -45,8 +42,19 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
self._agent = AgentRunner()
|
self._agent = AgentRunner()
|
||||||
self._waveform_engine = WaveformEngine()
|
self._waveform_engine = WaveformEngine()
|
||||||
self._transcriber = TranscriberEngine()
|
self._transcriber = TranscriberEngine()
|
||||||
self._pending_transcript_audio = []
|
|
||||||
self._pending_transcript_duration = 0.0
|
# Stream lifecycle (owns streaming state, recorder, tracker, audio buffering)
|
||||||
|
# Lambdas used because panels/widgets aren't created yet at this point.
|
||||||
|
self._lifecycle = StreamLifecycle(
|
||||||
|
timeline=self._timeline,
|
||||||
|
waveform_engine=self._waveform_engine,
|
||||||
|
transcriber=self._transcriber,
|
||||||
|
on_new_frames=lambda frames: None, # frame polling handles new frames
|
||||||
|
on_waveform_update=lambda peaks, bd: self._waveform_widget.set_peaks(peaks, bd),
|
||||||
|
on_transcript_ready=lambda segs: self._transcript_panel.add_items(segs),
|
||||||
|
on_scene_marker=lambda ts: self._timeline.add_scene_marker(ts),
|
||||||
|
on_recorder_restarted=lambda path: self._monitor.set_recording(path),
|
||||||
|
)
|
||||||
|
|
||||||
# Panels (own their selection state)
|
# Panels (own their selection state)
|
||||||
self._frames_panel = FramesPanel()
|
self._frames_panel = FramesPanel()
|
||||||
@@ -112,21 +120,23 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
# -- Connect / Disconnect --
|
# -- Connect / Disconnect --
|
||||||
|
|
||||||
def _on_connect_clicked(self, button):
|
def _on_connect_clicked(self, button):
|
||||||
if self._streaming:
|
if self._lifecycle.is_streaming:
|
||||||
self._stop_stream(reload_session=True)
|
self._stop_stream(reload_session=True)
|
||||||
else:
|
else:
|
||||||
session_id = self._stream_mgr.session_id if self._stream_mgr else None
|
session_id = self._lifecycle.stream_mgr.session_id if self._lifecycle.stream_mgr else None
|
||||||
if self._stream_mgr:
|
if self._lifecycle.stream_mgr:
|
||||||
self._stop_stream()
|
self._stop_stream()
|
||||||
self._start_stream(session_id=session_id)
|
self._start_stream(session_id=session_id)
|
||||||
|
|
||||||
def _on_capture_clicked(self):
|
def _on_capture_clicked(self):
|
||||||
if self._stream_mgr:
|
if self._lifecycle.stream_mgr:
|
||||||
self._stream_mgr.capture_now(on_new_frames=self._on_new_scene_frames)
|
self._lifecycle.stream_mgr.capture_now(
|
||||||
|
on_new_frames=self._lifecycle._handle_new_scene_frames
|
||||||
|
)
|
||||||
|
|
||||||
def _on_scene_threshold(self, val):
|
def _on_scene_threshold(self, val):
|
||||||
if self._stream_mgr:
|
if self._lifecycle.stream_mgr:
|
||||||
self._stream_mgr.scene_threshold = val
|
self._lifecycle.stream_mgr.scene_threshold = val
|
||||||
|
|
||||||
def _on_min_chunk_changed(self, panel, val):
|
def _on_min_chunk_changed(self, panel, val):
|
||||||
import cht.config
|
import cht.config
|
||||||
@@ -143,97 +153,33 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
if not sessions:
|
if not sessions:
|
||||||
self._agent_output.append("No previous sessions found.\n")
|
self._agent_output.append("No previous sessions found.\n")
|
||||||
return
|
return
|
||||||
|
dialog = SessionDialog(self)
|
||||||
dialog = Adw.Window(transient_for=self, modal=True)
|
dialog.set_current_session(
|
||||||
dialog.set_title("Load Session")
|
self._lifecycle.stream_mgr.session_id if self._lifecycle.stream_mgr else None
|
||||||
dialog.set_default_size(500, 400)
|
)
|
||||||
|
dialog.connect("session-selected", lambda d, sid: self._load_session(sid))
|
||||||
toolbar = Adw.ToolbarView()
|
|
||||||
header = Adw.HeaderBar()
|
|
||||||
|
|
||||||
select_all_btn = Gtk.CheckButton(label="All")
|
|
||||||
header.pack_start(select_all_btn)
|
|
||||||
|
|
||||||
delete_btn = Gtk.Button(label="Delete")
|
|
||||||
delete_btn.add_css_class("destructive-action")
|
|
||||||
header.pack_end(delete_btn)
|
|
||||||
|
|
||||||
toolbar.add_top_bar(header)
|
|
||||||
|
|
||||||
scroll = Gtk.ScrolledWindow()
|
|
||||||
scroll.set_vexpand(True)
|
|
||||||
listbox = Gtk.ListBox()
|
|
||||||
listbox.set_selection_mode(Gtk.SelectionMode.NONE)
|
|
||||||
listbox.add_css_class("boxed-list")
|
|
||||||
|
|
||||||
checks: list[tuple[str, Gtk.CheckButton]] = []
|
|
||||||
|
|
||||||
for sid, sdir in sessions:
|
|
||||||
idx = sdir / "frames" / "index.json"
|
|
||||||
nframes = 0
|
|
||||||
try:
|
|
||||||
nframes = len(json.loads(idx.read_text()))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
nrec = len(list((sdir / "stream").glob("recording_*.mp4")))
|
|
||||||
|
|
||||||
check = Gtk.CheckButton()
|
|
||||||
checks.append((sid, check))
|
|
||||||
|
|
||||||
row = Adw.ActionRow()
|
|
||||||
row.set_title(sid)
|
|
||||||
row.set_subtitle(f"{nframes} frames, {nrec} segments")
|
|
||||||
row.set_activatable(True)
|
|
||||||
row.add_prefix(check)
|
|
||||||
|
|
||||||
def _on_row_activated(r, s=sid, d=dialog):
|
|
||||||
d.close()
|
|
||||||
self._load_session(s)
|
|
||||||
row.connect("activated", _on_row_activated)
|
|
||||||
listbox.append(row)
|
|
||||||
|
|
||||||
def _on_select_all(btn):
|
|
||||||
active = btn.get_active()
|
|
||||||
for _, cb in checks:
|
|
||||||
cb.set_active(active)
|
|
||||||
select_all_btn.connect("toggled", _on_select_all)
|
|
||||||
|
|
||||||
def _on_delete(btn):
|
|
||||||
to_delete = [sid for sid, cb in checks if cb.get_active()]
|
|
||||||
if not to_delete:
|
|
||||||
return
|
|
||||||
current = self._stream_mgr.session_id if self._stream_mgr else None
|
|
||||||
if current in to_delete:
|
|
||||||
to_delete.remove(current)
|
|
||||||
if to_delete:
|
|
||||||
delete_sessions(to_delete)
|
|
||||||
dialog.close()
|
|
||||||
self._on_load_session_clicked(None)
|
|
||||||
delete_btn.connect("clicked", _on_delete)
|
|
||||||
|
|
||||||
scroll.set_child(listbox)
|
|
||||||
toolbar.set_content(scroll)
|
|
||||||
dialog.set_content(toolbar)
|
|
||||||
dialog.present()
|
dialog.present()
|
||||||
|
|
||||||
def _load_session(self, session_id):
|
def _load_session(self, session_id):
|
||||||
"""Load an existing session for review (no streaming)."""
|
"""Load an existing session for review (no streaming)."""
|
||||||
if self._streaming or self._stream_mgr:
|
if self._lifecycle.is_streaming or self._lifecycle.stream_mgr:
|
||||||
self._stop_stream()
|
self._stop_stream()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._stream_mgr = StreamManager.from_existing(session_id)
|
mgr = StreamManager.from_existing(session_id)
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
self._agent_output.append(f"Error: {e}\n")
|
self._agent_output.append(f"Error: {e}\n")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
self._lifecycle.set_manager_readonly(mgr)
|
||||||
|
|
||||||
self.set_title(f"{APP_NAME} — {session_id}")
|
self.set_title(f"{APP_NAME} — {session_id}")
|
||||||
self._agent_output.append(f"Loaded session: {session_id}\n")
|
self._agent_output.append(f"Loaded session: {session_id}\n")
|
||||||
|
|
||||||
segments = self._stream_mgr.recording_segments
|
segments = mgr.recording_segments
|
||||||
if segments:
|
if segments:
|
||||||
self._monitor.set_recording(segments[0])
|
self._monitor.set_recording(segments[0])
|
||||||
duration = self._stream_mgr.total_duration()
|
duration = mgr.total_duration()
|
||||||
if duration > 0:
|
if duration > 0:
|
||||||
self._timeline.set_duration(duration)
|
self._timeline.set_duration(duration)
|
||||||
self._timeline.seek(0)
|
self._timeline.seek(0)
|
||||||
@@ -252,7 +198,7 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
from cht.stream import ffmpeg as ff
|
from cht.stream import ffmpeg as ff
|
||||||
|
|
||||||
def _compute_waveform():
|
def _compute_waveform():
|
||||||
audio_dir = self._stream_mgr.audio_dir
|
audio_dir = mgr.audio_dir
|
||||||
audio_dir.mkdir(parents=True, exist_ok=True)
|
audio_dir.mkdir(parents=True, exist_ok=True)
|
||||||
full_wav = audio_dir / "full.wav"
|
full_wav = audio_dir / "full.wav"
|
||||||
try:
|
try:
|
||||||
@@ -275,125 +221,32 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
self._connect_btn.set_label("Disconnect")
|
self._connect_btn.set_label("Disconnect")
|
||||||
self._connect_btn.remove_css_class("suggested-action")
|
self._connect_btn.remove_css_class("suggested-action")
|
||||||
self._connect_btn.add_css_class("destructive-action")
|
self._connect_btn.add_css_class("destructive-action")
|
||||||
self._streaming = True
|
|
||||||
self._gone_live = False
|
|
||||||
|
|
||||||
self._stream_mgr = StreamManager(session_id=session_id)
|
mgr = self._lifecycle.start(session_id=session_id)
|
||||||
self._stream_mgr.setup_dirs()
|
|
||||||
self._stream_mgr.start_recorder()
|
|
||||||
|
|
||||||
self._monitor.set_recording(self._stream_mgr.recording_path)
|
self._monitor.set_recording(mgr.recording_path)
|
||||||
self._monitor.set_live_source(self._stream_mgr.relay_url)
|
self._monitor.set_live_source(mgr.relay_url)
|
||||||
|
|
||||||
self._tracker = RecordingTracker(
|
|
||||||
get_segments=lambda: self._stream_mgr.recording_segments if self._stream_mgr else [],
|
|
||||||
on_duration_update=self._on_duration_update,
|
|
||||||
)
|
|
||||||
self._tracker.start()
|
|
||||||
|
|
||||||
self._stream_mgr.start_scene_detector(on_new_frames=self._on_new_scene_frames)
|
|
||||||
self._stream_mgr.start_audio_extractor(on_new_audio=self._on_new_audio)
|
|
||||||
|
|
||||||
GLib.timeout_add(1000, self._poll_frames)
|
GLib.timeout_add(1000, self._poll_frames)
|
||||||
GLib.timeout_add(1000, self._tick_live)
|
|
||||||
GLib.timeout_add(2000, self._check_recorder)
|
|
||||||
|
|
||||||
# Reload existing data if resuming
|
# Reload existing data if resuming
|
||||||
if session_id:
|
if session_id:
|
||||||
self._load_existing_frames()
|
self._load_existing_frames()
|
||||||
self._load_existing_transcript()
|
self._load_existing_transcript()
|
||||||
|
|
||||||
self.set_title(f"{APP_NAME} — {self._stream_mgr.session_id}")
|
self.set_title(f"{APP_NAME} — {mgr.session_id}")
|
||||||
log.info("Waiting for sender...")
|
log.info("Waiting for sender...")
|
||||||
|
|
||||||
def _go_live_once(self):
|
|
||||||
if self._stream_mgr:
|
|
||||||
log.info("Going LIVE (startup delay elapsed)")
|
|
||||||
self._timeline.go_live()
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _tick_live(self):
|
|
||||||
if not self._streaming:
|
|
||||||
return False
|
|
||||||
self._timeline.tick_live()
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _on_duration_update(self, duration):
|
|
||||||
GLib.idle_add(self._timeline.set_duration, duration)
|
|
||||||
if not self._gone_live:
|
|
||||||
self._gone_live = True
|
|
||||||
GLib.idle_add(self._go_live_once)
|
|
||||||
if self._stream_mgr:
|
|
||||||
self._stream_mgr.capture_now(on_new_frames=self._on_new_scene_frames)
|
|
||||||
|
|
||||||
def _on_new_scene_frames(self, frames):
|
|
||||||
for f in frames:
|
|
||||||
GLib.idle_add(self._timeline.add_scene_marker, f["timestamp"])
|
|
||||||
|
|
||||||
def _on_new_audio(self, wav_path, start_time, duration):
|
|
||||||
if not self._stream_mgr:
|
|
||||||
return
|
|
||||||
self._waveform_engine.append_chunk(wav_path, start_time)
|
|
||||||
peaks = self._waveform_engine.peaks
|
|
||||||
bucket_dur = self._waveform_engine.bucket_duration
|
|
||||||
GLib.idle_add(self._waveform_widget.set_peaks, peaks.copy(), bucket_dur)
|
|
||||||
|
|
||||||
self._pending_transcript_audio.append((wav_path, start_time, duration))
|
|
||||||
self._pending_transcript_duration += duration
|
|
||||||
if self._pending_transcript_duration < TRANSCRIBE_MIN_CHUNK_S:
|
|
||||||
return
|
|
||||||
|
|
||||||
first_start = self._pending_transcript_audio[0][1]
|
|
||||||
total_dur = self._pending_transcript_duration
|
|
||||||
self._pending_transcript_audio.clear()
|
|
||||||
self._pending_transcript_duration = 0.0
|
|
||||||
|
|
||||||
mgr = self._stream_mgr
|
|
||||||
chunk_wav = mgr.audio_dir / f"transcript_{int(first_start):06d}.wav"
|
|
||||||
|
|
||||||
def _transcribe():
|
|
||||||
from cht.stream import ffmpeg as ff
|
|
||||||
try:
|
|
||||||
ff.extract_audio_chunk(
|
|
||||||
mgr.recording_path, chunk_wav,
|
|
||||||
start_time=first_start, duration=total_dur,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
log.error("Transcript audio extraction failed: %s", e)
|
|
||||||
return
|
|
||||||
if not chunk_wav.exists():
|
|
||||||
return
|
|
||||||
new_segs = self._transcriber.transcribe_chunk(chunk_wav, time_offset=first_start)
|
|
||||||
self._transcriber.save_index(mgr.transcript_dir / "index.json")
|
|
||||||
if new_segs:
|
|
||||||
GLib.idle_add(self._transcript_panel.add_items, new_segs)
|
|
||||||
|
|
||||||
Thread(target=_transcribe, daemon=True, name="transcriber").start()
|
|
||||||
|
|
||||||
def _check_recorder(self):
|
|
||||||
if not self._streaming or not self._stream_mgr:
|
|
||||||
return False
|
|
||||||
if not self._stream_mgr.recorder_alive():
|
|
||||||
log.warning("Recorder died — restarting into new segment")
|
|
||||||
self._stream_mgr.restart_recorder()
|
|
||||||
self._monitor.set_recording(self._stream_mgr.recording_path)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _on_live_toggle(self):
|
def _on_live_toggle(self):
|
||||||
pos = self._monitor.get_live_position()
|
pos = self._monitor.get_live_position()
|
||||||
self._timeline.toggle_live(live_player_pos=pos)
|
self._timeline.toggle_live(live_player_pos=pos)
|
||||||
|
|
||||||
def _stop_stream(self, reload_session=False):
|
def _stop_stream(self, reload_session=False):
|
||||||
log.info("Stopping stream...")
|
log.info("Stopping stream...")
|
||||||
last_session_id = self._stream_mgr.session_id if self._stream_mgr and not self._stream_mgr.readonly else None
|
mgr = self._lifecycle.stream_mgr
|
||||||
|
last_session_id = mgr.session_id if mgr and not mgr.readonly else None
|
||||||
|
|
||||||
if self._tracker:
|
self._lifecycle.stop()
|
||||||
self._tracker.stop()
|
|
||||||
self._tracker = None
|
|
||||||
if self._stream_mgr:
|
|
||||||
if not self._stream_mgr.readonly:
|
|
||||||
self._stream_mgr.stop_all()
|
|
||||||
self._stream_mgr = None
|
|
||||||
|
|
||||||
self._timeline.reset()
|
self._timeline.reset()
|
||||||
self._monitor.reset()
|
self._monitor.reset()
|
||||||
@@ -401,8 +254,6 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
self._waveform_widget.set_peaks(None, 0.05)
|
self._waveform_widget.set_peaks(None, 0.05)
|
||||||
self._transcriber.reset()
|
self._transcriber.reset()
|
||||||
self._agent.clear_history()
|
self._agent.clear_history()
|
||||||
self._pending_transcript_audio.clear()
|
|
||||||
self._pending_transcript_duration = 0.0
|
|
||||||
self._known_frames = set()
|
self._known_frames = set()
|
||||||
|
|
||||||
self._frames_panel.clear()
|
self._frames_panel.clear()
|
||||||
@@ -411,7 +262,6 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
self._connect_btn.set_label("Connect")
|
self._connect_btn.set_label("Connect")
|
||||||
self._connect_btn.remove_css_class("destructive-action")
|
self._connect_btn.remove_css_class("destructive-action")
|
||||||
self._connect_btn.add_css_class("suggested-action")
|
self._connect_btn.add_css_class("suggested-action")
|
||||||
self._streaming = False
|
|
||||||
self.set_title(APP_NAME)
|
self.set_title(APP_NAME)
|
||||||
|
|
||||||
if reload_session and last_session_id:
|
if reload_session and last_session_id:
|
||||||
@@ -422,7 +272,7 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
|
|
||||||
def teardown(self):
|
def teardown(self):
|
||||||
"""Full cleanup for app exit — safe to call multiple times."""
|
"""Full cleanup for app exit — safe to call multiple times."""
|
||||||
if self._stream_mgr or self._streaming:
|
if self._lifecycle.stream_mgr or self._lifecycle.is_streaming:
|
||||||
self._stop_stream()
|
self._stop_stream()
|
||||||
self._monitor.stop()
|
self._monitor.stop()
|
||||||
|
|
||||||
@@ -465,72 +315,16 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
right_box.append(transcript_frame)
|
right_box.append(transcript_frame)
|
||||||
|
|
||||||
# Agent input
|
# Agent input
|
||||||
self._agent_input = self._build_agent_input()
|
self._agent_input = AgentInputPanel()
|
||||||
|
self._agent_input.connect("send-requested", lambda p, text: self._send_message(text or None))
|
||||||
|
self._agent_input.connect("action-requested", lambda p, verb: self._send_action(verb))
|
||||||
|
self._agent_input.connect("model-changed", self._on_model_changed)
|
||||||
|
self._agent_input.connect("lang-changed", self._on_lang_changed)
|
||||||
|
self._agent_input.connect("history-toggled", lambda p, v: setattr(self._agent, "include_history", v))
|
||||||
right_box.append(self._agent_input)
|
right_box.append(self._agent_input)
|
||||||
|
|
||||||
return right_box
|
return right_box
|
||||||
|
|
||||||
def _build_agent_input(self):
|
|
||||||
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
|
||||||
outer.set_margin_start(4)
|
|
||||||
outer.set_margin_end(4)
|
|
||||||
outer.set_margin_top(4)
|
|
||||||
outer.set_margin_bottom(4)
|
|
||||||
|
|
||||||
actions_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
|
|
||||||
for label, verb in ACTIONS.items():
|
|
||||||
btn = Gtk.Button(label=label)
|
|
||||||
btn.add_css_class("flat")
|
|
||||||
btn.connect("clicked", lambda b, v=verb: self._send_action(v))
|
|
||||||
actions_box.append(btn)
|
|
||||||
|
|
||||||
spacer = Gtk.Box()
|
|
||||||
spacer.set_hexpand(True)
|
|
||||||
actions_box.append(spacer)
|
|
||||||
|
|
||||||
model_label = Gtk.Label(label="Model:")
|
|
||||||
model_label.add_css_class("dim-label")
|
|
||||||
actions_box.append(model_label)
|
|
||||||
|
|
||||||
self._model_dropdown = Gtk.DropDown.new_from_strings([])
|
|
||||||
self._model_dropdown.set_size_request(200, -1)
|
|
||||||
self._model_dropdown.connect("notify::selected", self._on_model_changed)
|
|
||||||
actions_box.append(self._model_dropdown)
|
|
||||||
|
|
||||||
lang_label = Gtk.Label(label="Lang:")
|
|
||||||
lang_label.add_css_class("dim-label")
|
|
||||||
actions_box.append(lang_label)
|
|
||||||
|
|
||||||
lang_names = list(LANGUAGES.keys())
|
|
||||||
self._lang_dropdown = Gtk.DropDown.new_from_strings(lang_names)
|
|
||||||
self._lang_dropdown.set_selected(0)
|
|
||||||
self._lang_dropdown.connect("notify::selected", self._on_lang_changed)
|
|
||||||
actions_box.append(self._lang_dropdown)
|
|
||||||
|
|
||||||
self._history_toggle = Gtk.CheckButton(label="Chat")
|
|
||||||
self._history_toggle.set_tooltip_text("Include conversation history in prompts")
|
|
||||||
self._history_toggle.connect("toggled", lambda b: setattr(self._agent, "include_history", b.get_active()))
|
|
||||||
actions_box.append(self._history_toggle)
|
|
||||||
|
|
||||||
outer.append(actions_box)
|
|
||||||
|
|
||||||
input_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
|
|
||||||
self._input_entry = Gtk.Entry()
|
|
||||||
self._input_entry.set_hexpand(True)
|
|
||||||
self._input_entry.set_placeholder_text("Message agent... (@F1-3 frames, @T1-5 transcript)")
|
|
||||||
self._input_entry.connect("activate", lambda e: self._send_message())
|
|
||||||
input_row.append(self._input_entry)
|
|
||||||
|
|
||||||
send_btn = Gtk.Button(label="Send")
|
|
||||||
send_btn.add_css_class("suggested-action")
|
|
||||||
send_btn.connect("clicked", lambda b: self._send_message())
|
|
||||||
input_row.append(send_btn)
|
|
||||||
outer.append(input_row)
|
|
||||||
|
|
||||||
frame = Gtk.Frame()
|
|
||||||
frame.set_child(outer)
|
|
||||||
return frame
|
|
||||||
|
|
||||||
# -- Keyboard --
|
# -- Keyboard --
|
||||||
|
|
||||||
def _setup_keyboard(self):
|
def _setup_keyboard(self):
|
||||||
@@ -541,7 +335,7 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
return False
|
return False
|
||||||
w = focus
|
w = focus
|
||||||
while w is not None:
|
while w is not None:
|
||||||
if w is self._input_entry:
|
if w is self._agent_input.entry:
|
||||||
return True
|
return True
|
||||||
w = w.get_parent()
|
w = w.get_parent()
|
||||||
return False
|
return False
|
||||||
@@ -575,14 +369,11 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
self._send_message(msg)
|
self._send_message(msg)
|
||||||
|
|
||||||
def _send_message(self, text: str | None = None):
|
def _send_message(self, text: str | None = None):
|
||||||
if text is None:
|
|
||||||
text = self._input_entry.get_text().strip()
|
|
||||||
self._input_entry.set_text("")
|
|
||||||
if not text:
|
if not text:
|
||||||
text = self._build_selection_message("answer")
|
text = self._build_selection_message("answer")
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
if not self._stream_mgr:
|
if not self._lifecycle.stream_mgr:
|
||||||
self._agent_output.append("No active session.\n")
|
self._agent_output.append("No active session.\n")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -591,40 +382,26 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
self._agent_output.begin_response()
|
self._agent_output.begin_response()
|
||||||
self._agent.send(
|
self._agent.send(
|
||||||
message=text,
|
message=text,
|
||||||
stream_mgr=self._stream_mgr,
|
stream_mgr=self._lifecycle.stream_mgr,
|
||||||
tracker=self._tracker,
|
tracker=self._lifecycle.tracker,
|
||||||
on_chunk=lambda chunk: GLib.idle_add(self._agent_output.replace_thinking, chunk),
|
on_chunk=lambda chunk: GLib.idle_add(self._agent_output.replace_thinking, chunk),
|
||||||
on_done=lambda err: GLib.idle_add(self._agent_output.finish_response, err),
|
on_done=lambda err: GLib.idle_add(self._agent_output.finish_response, err),
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- Settings callbacks --
|
# -- Settings callbacks --
|
||||||
|
|
||||||
def _on_lang_changed(self, dropdown, _pspec):
|
def _on_lang_changed(self, _panel, lang_code):
|
||||||
idx = dropdown.get_selected()
|
self._transcriber.language = lang_code or None
|
||||||
lang_names = list(LANGUAGES.keys())
|
log.info("Transcript language: %s", lang_code or "auto")
|
||||||
if idx < len(lang_names):
|
|
||||||
lang_code = LANGUAGES[lang_names[idx]]
|
|
||||||
self._transcriber.language = lang_code
|
|
||||||
log.info("Transcript language: %s (%s)", lang_names[idx], lang_code or "auto")
|
|
||||||
|
|
||||||
def _on_model_changed(self, dropdown, _pspec):
|
def _on_model_changed(self, _panel, model):
|
||||||
idx = dropdown.get_selected()
|
self._agent.model = model
|
||||||
model = self._agent.available_models[idx] if idx < len(self._agent.available_models) else None
|
log.info("Model switched to %s", model)
|
||||||
if model:
|
|
||||||
self._agent.model = model
|
|
||||||
log.info("Model switched to %s", model)
|
|
||||||
|
|
||||||
def _populate_model_dropdown(self):
|
def _populate_model_dropdown(self):
|
||||||
models = self._agent.available_models
|
self._agent_input.populate_models(
|
||||||
if not models:
|
self._agent.available_models, self._agent.model
|
||||||
return
|
)
|
||||||
string_list = Gtk.StringList.new(models)
|
|
||||||
self._model_dropdown.set_model(string_list)
|
|
||||||
current = self._agent.model
|
|
||||||
for i, m in enumerate(models):
|
|
||||||
if m == current:
|
|
||||||
self._model_dropdown.set_selected(i)
|
|
||||||
break
|
|
||||||
|
|
||||||
def _check_agent_auth(self):
|
def _check_agent_auth(self):
|
||||||
import os
|
import os
|
||||||
@@ -641,26 +418,17 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
# -- Data loading --
|
# -- Data loading --
|
||||||
|
|
||||||
def _load_existing_frames(self):
|
def _load_existing_frames(self):
|
||||||
if not self._stream_mgr:
|
if not self._lifecycle.stream_mgr:
|
||||||
return
|
return
|
||||||
index_path = self._stream_mgr.frames_dir / "index.json"
|
entries = load_frame_index(self._lifecycle.stream_mgr.frames_dir)
|
||||||
if not index_path.exists():
|
if not entries:
|
||||||
self._agent_output.append(" No frames found.\n")
|
self._agent_output.append(" No frames found.\n")
|
||||||
return
|
return
|
||||||
try:
|
|
||||||
index = json.loads(index_path.read_text())
|
|
||||||
except (json.JSONDecodeError, IOError):
|
|
||||||
return
|
|
||||||
items = []
|
items = []
|
||||||
for entry in index:
|
for entry in entries:
|
||||||
fpath = Path(entry["path"])
|
|
||||||
if not fpath.exists():
|
|
||||||
fpath = self._stream_mgr.frames_dir / fpath.name
|
|
||||||
if not fpath.exists():
|
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(fpath), 256, 144, True)
|
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(entry["path"]), 256, 144, True)
|
||||||
items.append({"id": entry["id"], "pixbuf": pixbuf, "timestamp": entry.get("timestamp", 0)})
|
items.append({"id": entry["id"], "pixbuf": pixbuf, "timestamp": entry["timestamp"]})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning("Thumbnail load failed for %s: %s", entry["id"], e)
|
log.warning("Thumbnail load failed for %s: %s", entry["id"], e)
|
||||||
if items:
|
if items:
|
||||||
@@ -669,9 +437,9 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
self._agent_output.append(f" Loaded {len(items)} frame thumbnails.\n")
|
self._agent_output.append(f" Loaded {len(items)} frame thumbnails.\n")
|
||||||
|
|
||||||
def _load_existing_transcript(self):
|
def _load_existing_transcript(self):
|
||||||
if not self._stream_mgr:
|
if not self._lifecycle.stream_mgr:
|
||||||
return
|
return
|
||||||
transcript_index = self._stream_mgr.transcript_dir / "index.json"
|
transcript_index = self._lifecycle.stream_mgr.transcript_dir / "index.json"
|
||||||
if not transcript_index.exists():
|
if not transcript_index.exists():
|
||||||
return
|
return
|
||||||
self._transcriber.load_index(transcript_index)
|
self._transcriber.load_index(transcript_index)
|
||||||
@@ -681,28 +449,17 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
self._agent_output.append(f" Loaded {len(segs)} transcript segments.\n")
|
self._agent_output.append(f" Loaded {len(segs)} transcript segments.\n")
|
||||||
|
|
||||||
def _poll_frames(self):
|
def _poll_frames(self):
|
||||||
if not self._stream_mgr:
|
if not self._lifecycle.stream_mgr:
|
||||||
return False
|
return False
|
||||||
index_path = self._stream_mgr.frames_dir / "index.json"
|
for entry in load_frame_index(self._lifecycle.stream_mgr.frames_dir):
|
||||||
if not index_path.exists():
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
index = json.loads(index_path.read_text())
|
|
||||||
except (json.JSONDecodeError, IOError):
|
|
||||||
return True
|
|
||||||
for entry in index:
|
|
||||||
fid = entry["id"]
|
fid = entry["id"]
|
||||||
if fid in self._known_frames:
|
if fid in self._known_frames:
|
||||||
continue
|
continue
|
||||||
fpath = Path(entry["path"])
|
|
||||||
if not fpath.exists():
|
|
||||||
continue
|
|
||||||
self._known_frames.add(fid)
|
self._known_frames.add(fid)
|
||||||
timestamp = entry.get("timestamp", 0)
|
|
||||||
try:
|
try:
|
||||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(fpath), 256, 144, True)
|
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(entry["path"]), 256, 144, True)
|
||||||
auto = not self._transcript_panel.has_selection
|
auto = not self._transcript_panel.has_selection
|
||||||
self._frames_panel.add_item(fid, pixbuf, timestamp, auto_select=auto)
|
self._frames_panel.add_item(fid, pixbuf, entry["timestamp"], auto_select=auto)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning("Thumbnail load failed for %s: %s", fid, e)
|
log.warning("Thumbnail load failed for %s: %s", fid, e)
|
||||||
return True
|
return True
|
||||||
|
|||||||
Reference in New Issue
Block a user