Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 57 additions & 12 deletions reai_toolkit/app/components/tabs/chat_tab.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,48 @@ def keyPressEvent(self, event) -> None:
super().keyPressEvent(event)


class _PanelBridge(QtCore.QObject):
def __init__(self, panel: "ChatPanel") -> None:
super().__init__()
self._panel = panel

@Slot()
def history(self) -> None:
self._panel._on_history_clicked()

@Slot()
def new_chat(self) -> None:
self._panel._on_new_chat_clicked()

@Slot()
def approve(self) -> None:
self._panel._on_confirm_clicked(True)

@Slot()
def reject(self) -> None:
self._panel._on_confirm_clicked(False)

@Slot()
def send(self) -> None:
self._panel._on_send_clicked()

@Slot()
def stop(self) -> None:
self._panel._on_stop_clicked()

@Slot()
def flush(self) -> None:
self._panel._flush_render()

@Slot(QtCore.QUrl)
def anchor(self, url) -> None:
self._panel._on_anchor_clicked(url)

@Slot(QtWidgets.QListWidgetItem)
def history_item(self, item) -> None:
self._panel._on_history_item(item)


class ChatPanel(kw.PluginForm):
TITLE = "RevEng.AI — Agent Chat"

Expand Down Expand Up @@ -208,6 +250,7 @@ def __init__(self, on_closed: Optional[Callable[[], None]] = None) -> None:
self._send_btn: Optional[QtWidgets.QPushButton] = None
self._stop_btn: Optional[QtWidgets.QPushButton] = None

self._bridge: Optional[_PanelBridge] = None
self._relay: Optional[_StreamRelay] = None
self._thread: Optional[QtCore.QThread] = None
self._worker: Optional[ChatStreamWorker] = None
Expand Down Expand Up @@ -266,7 +309,6 @@ def OnCreate(self, form) -> None:
self._transcript = QtWidgets.QTextBrowser()
self._transcript.setOpenLinks(False)
self._transcript.setOpenExternalLinks(False)
self._transcript.anchorClicked.connect(self._on_anchor_clicked)
root.addWidget(self._transcript, 1)

self._confirm_bar = QtWidgets.QWidget()
Expand Down Expand Up @@ -296,27 +338,29 @@ def OnCreate(self, form) -> None:
input_row.addLayout(btn_col)
root.addLayout(input_row)

history_btn.clicked.connect(self._on_history_clicked)
new_btn.clicked.connect(self._on_new_chat_clicked)
approve_btn.clicked.connect(lambda: self._on_confirm_clicked(True))
reject_btn.clicked.connect(lambda: self._on_confirm_clicked(False))
self._send_btn.clicked.connect(self._on_send_clicked)
self._stop_btn.clicked.connect(self._on_stop_clicked)
self._input.submit.connect(self._on_send_clicked)
self._history_list.itemActivated.connect(self._on_history_item)
self._history_list.itemClicked.connect(self._on_history_item)

self._render_timer = QtCore.QTimer(self._parent_window)
self._render_timer.setSingleShot(True)
self._render_timer.setInterval(100)
self._render_timer.timeout.connect(self._flush_render)

self._relay = _StreamRelay()
self._relay.on_event = self._handle_stream_event
self._relay.on_conversation_created = self._handle_conversation_created
self._relay.on_error = self._handle_stream_error
self._relay.on_finished = self._handle_stream_finished

self._bridge = _PanelBridge(self)
self._transcript.anchorClicked.connect(self._bridge.anchor)
history_btn.clicked.connect(self._bridge.history)
new_btn.clicked.connect(self._bridge.new_chat)
approve_btn.clicked.connect(self._bridge.approve)
reject_btn.clicked.connect(self._bridge.reject)
self._send_btn.clicked.connect(self._bridge.send)
self._stop_btn.clicked.connect(self._bridge.stop)
self._input.submit.connect(self._bridge.send)
self._history_list.itemActivated.connect(self._bridge.history_item)
self._history_list.itemClicked.connect(self._bridge.history_item)
self._render_timer.timeout.connect(self._bridge.flush)

def OnClose(self, form) -> None:
self.stop_stream_worker()
if self._render_timer is not None:
Expand All @@ -333,6 +377,7 @@ def OnClose(self, form) -> None:
self._input = None
self._parent_window = None
self._render_timer = None
self._bridge = None

def focus(self) -> None:
if self._parent_window:
Expand Down
14 changes: 13 additions & 1 deletion reai_toolkit/app/coordinators/ai_decomp_coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
from revengai.models.decompilation_data import DecompilationData
from revengai.models.summary_data import SummaryData
from revengai.models.tokenised_data import TokenisedData
from revengai.models.workflow_progress import WorkflowProgress

from reai_toolkit.app.app import App
from reai_toolkit.app.components.tabs.ai_decomp_tab import AIDecompView
from reai_toolkit.app.coordinators.ai_decomp_render import (
RenderModel,
index_of_identifier,
render_progress,
render_view_with_map,
resolve_token,
)
Expand Down Expand Up @@ -116,8 +118,18 @@ def _dispatch_task(self, ea: int) -> None:
on_summary=lambda response: self._on_summary_complete(ea, response),
on_comments=lambda response: self._on_comments_complete(ea, response),
on_tokenised=lambda response: self._on_tokenised_complete(ea, response),
on_progress=lambda progress: self._on_progress(ea, progress),
)

def _on_progress(self, ea: int, progress: WorkflowProgress) -> None:
if ea != self._current_func_vaddr:
return
if self._current_decomp is not None:
return
if self._decomp_view is None:
return
self._decomp_view.update_view_content(render_progress(progress))

def _on_decomp_complete(
self, ea: int, response: GenericApiReturn[DecompilationData]
) -> None:
Expand Down Expand Up @@ -307,4 +319,4 @@ def _rerender(self) -> None:
def _on_pane_closed(self) -> None:
self._decomp_view = None
self.disable_function_tracking()
self.log.info("AI Decomp view closed, reference cleared.")
self.log.debug("AI Decomp view closed, reference cleared.")
42 changes: 42 additions & 0 deletions reai_toolkit/app/coordinators/ai_decomp_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
from revengai.models.ai_decomp_function_mapping import AIDecompFunctionMapping
from revengai.models.comments_data import CommentsData
from revengai.models.decompilation_data import DecompilationData
from revengai.models.progress_message import ProgressMessage
from revengai.models.summary_data import SummaryData
from revengai.models.tokenised_data import TokenisedData
from revengai.models.workflow_progress import WorkflowProgress


_IDENT_RE = re.compile(r"[A-Za-z_]\w*")
Expand Down Expand Up @@ -92,6 +94,46 @@ def render_view_with_map(
)


def render_progress(progress: "WorkflowProgress") -> str:
lines: list[str] = ["// RevEng.AI — AI decompilation in progress…", "//"]

steps_total = progress.steps_total or 0
step_index = progress.step_index or 0
step_name = progress.step or ""
status = progress.status or ""

if steps_total > 0:
current = min(step_index + 1, steps_total)
lines.append(f"// Step {current}/{steps_total}: {step_name} [{status}]")
elif step_name:
lines.append(f"// {step_name} [{status}]")
else:
lines.append(f"// {status}")

messages = progress.messages or []
if messages:
lines.append("//")
for message in messages:
lines.append(f"// {_format_progress_message(message)}")

return "\n".join(lines)


def _format_progress_message(message: "ProgressMessage") -> str:
stamp = _format_progress_time(getattr(message, "timestamp", None))
prefix = f"{stamp} " if stamp else ""
return f"{prefix}[{message.level}] {message.text}"


def _format_progress_time(timestamp) -> str:
if timestamp is None:
return ""
try:
return timestamp.strftime("%H:%M:%S")
except (AttributeError, ValueError):
return ""


def _format_summary_as_comment(summary: str) -> str:
prefix = " * "
max_comment_width: int = 100
Expand Down
14 changes: 12 additions & 2 deletions reai_toolkit/app/services/ai_decomp/ai_decomp_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ def start_ai_decomp_task(
on_summary: Callable[[GenericApiReturn[SummaryData]], None],
on_comments: Callable[[GenericApiReturn[CommentsData]], None],
on_tokenised: Callable[[GenericApiReturn[TokenisedData]], None] | None = None,
on_progress: Callable[[WorkflowProgress], None] | None = None,
) -> None:
function_id = self._get_function_id(start_ea=ea)
if function_id is None:
Expand All @@ -107,6 +108,7 @@ def start_ai_decomp_task(
on_summary,
on_comments,
on_tokenised,
on_progress,
),
name=f"reai-aidecomp-{function_id}",
daemon=True,
Expand Down Expand Up @@ -303,11 +305,14 @@ def _run_task(
on_summary: Callable[[GenericApiReturn[SummaryData]], None],
on_comments: Callable[[GenericApiReturn[CommentsData]], None],
on_tokenised: Callable[[GenericApiReturn[TokenisedData]], None] | None = None,
on_progress: Callable[[WorkflowProgress], None] | None = None,
) -> None:
try:
if stop_event.is_set():
return
decomp = self._run_decomp_phase(function_id, stop_event, on_decomp)
decomp = self._run_decomp_phase(
function_id, stop_event, on_decomp, on_progress
)
if decomp is None:
return
self._run_summary_phase(function_id, stop_event, on_summary)
Expand Down Expand Up @@ -388,6 +393,7 @@ def _run_decomp_phase(
function_id: int,
stop_event: threading.Event,
on_decomp: Callable[[GenericApiReturn[DecompilationData]], None],
on_progress: Callable[[WorkflowProgress], None] | None = None,
) -> DecompilationData | None:
cached = self._decomp_cache.get(function_id)
if cached is not None:
Expand Down Expand Up @@ -436,6 +442,7 @@ def _run_decomp_phase(
status_fn=self._fetch_decomp_status,
requeue_fn=self._queue_decompilation,
label="AI Decompilation",
on_progress=on_progress,
)
if not polled_ok:
if poll_err is not None:
Expand Down Expand Up @@ -782,17 +789,20 @@ def _poll_workflow(
status_fn: Callable[[int], tuple[WorkflowProgress | None, str | None]],
requeue_fn: Callable[[int], tuple[bool, str | None]],
label: str,
on_progress: Callable[[WorkflowProgress], None] | None = None,
) -> tuple[bool, str | None]:
requeue_attempts = 0
while not stop_event.is_set():
progress, status_err = status_fn(function_id)
if status_err is not None or progress is None:
return False, status_err or f"{label} status fetch returned nothing."

logger.info(
logger.debug(
f"RevEng.AI: {label} progress for function id {function_id}: {progress.status}"
)

self._safe_dispatch(stop_event, on_progress, progress)

if progress.status == TaskStatus.COMPLETED:
return True, None

Expand Down
4 changes: 2 additions & 2 deletions reai_toolkit/hooks/reactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ def hook(self) -> bool:
if func is not None:
self._last_func_start = func.start_ea
self.coordinator.start_decompilation(ea=func.start_ea)
logger.info("[FunctionViewHooks] Hook registered.")
logger.debug("[FunctionViewHooks] Hook registered.")
else:
logger.error("[FunctionViewHooks] Failed to register.")
return ok
Expand All @@ -115,7 +115,7 @@ def unhook(self) -> None:
if self._is_hooked:
super().unhook()
self._is_hooked = False
logger.info("[FunctionViewHooks] Hook removed.")
logger.debug("[FunctionViewHooks] Hook removed.")
self._pending_ea = None
self._last_func_start = None

Expand Down
38 changes: 38 additions & 0 deletions tests/unit/ai_decomp/test_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@
from revengai.models.comments_data import CommentsData
from revengai.models.decompilation_data import DecompilationData
from revengai.models.inline_comment import InlineComment
from revengai.models.progress_message import ProgressMessage
from revengai.models.replacement_value import ReplacementValue
from revengai.models.summary_data import SummaryData
from revengai.models.tokenised_data import TokenisedData
from revengai.models.workflow_progress import WorkflowProgress

from reai_toolkit.app.coordinators.ai_decomp_render import (
index_of_identifier,
render_progress,
render_view,
render_view_with_map,
resolve_token,
Expand Down Expand Up @@ -152,3 +155,38 @@ def test_resolve_token_unknown_returns_none():
def test_resolve_token_value_fallback_when_line_unaligned():
tokd = _tokenised(tok="", mapping=_mapping(unmatched_vars={"@@V_v5@@": _rv("v5")}))
assert resolve_token(tokd, 1, 1, "v5") == ("@@V_v5@@", "variable")


def _pm(text, level="INFO", step="DECOMPILING", timestamp=None):
return ProgressMessage.model_construct(
level=level, step=step, text=text, timestamp=timestamp
)


def _wp(status="RUNNING", step="DECOMPILING", step_index=1, steps_total=3, messages=None):
return WorkflowProgress.model_construct(
status=status,
step=step,
step_index=step_index,
steps_total=steps_total,
messages=messages or [],
)


def test_render_progress_shows_step_and_status():
text = render_progress(_wp())
lines = text.split("\n")
assert lines[0].startswith("// RevEng.AI")
assert "// Step 2/3: DECOMPILING [RUNNING]" in lines


def test_render_progress_lists_messages_as_comment_lines():
text = render_progress(_wp(messages=[_pm("fetching bytes"), _pm("done", level="WARN")]))
assert "// [INFO] fetching bytes" in text
assert "// [WARN] done" in text
assert all(line.startswith("//") for line in text.split("\n"))


def test_render_progress_without_steps_falls_back_to_status():
text = render_progress(_wp(step="", step_index=0, steps_total=0, status="PENDING"))
assert "// PENDING" in text.split("\n")
Loading