mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-09 16:08:31 +00:00
* feat(tui): add Hermes-like terminal workbench backed by DeerFlowClient Implements the `deerflow` TUI from RFC #3540: a terminal-native, embedded workbench over the existing harness (no Gateway/frontend/nginx/Docker), built Python-native with Textual and learning UX patterns from tao-pi. Architecture — every layer except the Textual app is pure and unit-tested: - view_state.py: ViewState + reduce(state, action), the testable heart - runtime.py: StreamEvent -> reducer actions (pure translate + threaded driver) - message_format / command_registry / input_history / render / theme: pure - app.py: Textual App; runs the sync DeerFlowClient.stream() on a worker thread and marshals actions back to the UI thread. Slash command palette, model and thread modal pickers, ↑/↓ history, Ctrl+C interrupt, TTY-aware fallback. - cli.py: pure launch-mode planning + headless --print/--json + `deerflow` console script (textual is an optional [tui] extra; degrades to headless help) Web UI visibility (the RFC's key decision): persistence.py writes a threads_meta row under the local default user into the same DB the Gateway reads, so terminal sessions appear in the Web UI sidebar without running the Gateway. Best-effort, no-op on the memory backend; all DB work on one long-lived background loop. Tests: 95 TUI tests — pure layers via pytest, app/palette/overlays via Textual's pilot harness with a fake session, and a threads_meta read/write round-trip. ruff clean; respects the harness->app import boundary. Docs: backend/docs/TUI.md plus CLAUDE.md/README updates and preview screenshots. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tui): de-duplicate streamed assistant text and tool cards; keep Tab in composer Self-test surfaced three issues, all root-caused to consuming non-strict streaming from DeerFlowClient (proven by the client's own test_dedup_requires_messages_before_values_invariant, which shows the client can re-emit a message id's full content twice): - Assistant text was doubled (e.g. "answer answer") because the reducer blindly concatenated same-id deltas. Now merges by content: a re-send or cumulative snapshot replaces; only genuine increments append. - Tool activity showed duplicate and empty "gear" cards from partial/re-emitted tool-call chunks. ToolStarted now de-dupes by tool_call_id, drops id-less noise chunks, and fills the name on a later chunk; a tool result with no prior card still surfaces as a completed card. - Tab moved focus off the composer to the scroll region (felt like broken cursor logic). Tab is now consumed by the composer (completes a command when the palette is open, no-op otherwise). Adds reducer tests for each case plus a Tab-focus test; 102 TUI tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tui): make Esc interrupt an active run (matches the status hint) The status line advertised "esc interrupt" but Esc was only wired to close the slash palette, so it did nothing during a run. Esc now: closes the palette when open, interrupts the active run when streaming, and is a no-op when idle. The interrupt logic is shared with Ctrl+C via _interrupt_run(). Adds a regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tui): stop prior answers duplicating on threads with history On a thread with history, DeerFlowClient re-emits every prior message on each new turn (its streamed_ids dedup is per-stream-call), and a re-emitted older message can arrive after a newer message has already started. The reducer only matched the *most recent* assistant row by id and otherwise appended, so each re-emitted older answer was duplicated verbatim at the end of the transcript. Match an assistant row by id anywhere in the transcript and merge in place. Tool cards already de-dupe by call id globally, so they were unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tui): correct CJK cursor drift in the composer Confirmed a Textual Input bug (latest 8.2.7): Input._cursor_offset adds an unconditional +1 at the end of the value, overshooting by one cell after double-width (CJK) characters. That misplaces the hardware/IME cursor — the drift seen when typing Chinese in iTerm2 (the on-screen block cursor, drawn separately in render_line, is fine; English doesn't use an IME so it looks correct). Reproduced with a bare Input, so it's upstream, not our layout. Add ComposerInput(Input) overriding _cursor_offset to the true cell position and use it for the composer. Numeric tests pin the CJK end/mid and ASCII cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(tui): render finalized assistant messages as Markdown The transcript showed raw Markdown (literal **bold**, ## headings, - lists, links). Finalized assistant messages now render as Rich Markdown — headings, bold/italic, lists, inline code + code blocks, blockquotes, horizontal rules and links — with the ● speaker marker aligned to the top of the body. The actively-streaming message stays plain text so partial Markdown doesn't reflow/jump, then snaps to its rendered form when the run ends. Transcript re-renders are coalesced on a ~60ms timer (dirty flag) so per-token Markdown re-parsing stays smooth on long threads. Tests cover both the rendered and the streaming-plain paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * style(tui): apply ruff format CI lint runs `ruff format --check` via uvx (latest ruff); apply the formatter so the lint-backend job passes. No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(tui): address code-quality review comments From github-code-quality[bot] on #3760: - runtime.py: give the `_ClientLike` Protocol method a docstring body instead of a bare `...` (flagged as a no-effect statement), matching the harness convention for Protocol stubs (e.g. SafetyTerminationDetector). - test_tui_cli_main.py: drop the unnecessary `lambda: _FakeSession()` wrappers in monkeypatch.setattr; pass `_FakeSession` directly (same behavior). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tui): keep history Markdown-rendered when a follow-up run starts Previously the transcript rendered "the last assistant row" as plain text while streaming. But when a follow-up turn starts, the last assistant row is the *previous, finalized* answer until the new message begins — and the client re-emits prior messages early in the turn — so sending a follow-up reverted the previous answer from rendered Markdown back to raw text. Track the actively-streaming message id in ViewState instead: it's reset on RunStarted, set only when an AssistantDelta actually adds new content (history re-emits are no-ops and don't mark it), and cleared on RunEnded. The renderer keeps only that one message plain; all history stays Markdown. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(readme): add Terminal Workbench (TUI) section to root README Mention the new `deerflow` TUI alongside the Embedded Python Client in the root README.md and README_zh.md (install, launch/headless commands, feature summary, Web UI visibility), with a ToC entry and a preview screenshot. Links to backend/docs/TUI.md for the full guide. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tui): address review feedback (willem-bd) Ten findings from the TUI code review: 1. /resume was dead-ended — registered + in /help + tested as a builtin, but no dispatch branch. Wired it to thread resolution / the switcher. 2. --resume <title> was forwarded raw into the checkpointer (blank thread). Added Session.resolve_ref() to resolve id-or-title via list_threads; used by --resume and /resume. 3. str(get("id","")) returned "None" for an explicit id:None (truthy), defeating the empty-id guard so unrelated null-id tool calls collapsed into one card. Coerce via a None-safe helper. 4. Headless --print/--json no longer spin up the persistence loop/engine/pool (open_session(persistence=False)). 5. _LoopThread + engine are now closed: Session.close() (dispose engine + stop loop) called from a try/finally around app.run(). 6. --cli --continue (and piped --cli) now run headless instead of erroring. 7. Cancelled runs no longer persist a truncated title (guard on _cancelled). 8. Palette highlight resets to the top when the filter set changes. 9. Dropped the never-populated tools count from the header. 10. Documented the `not row.error` merge guard. Adds regression tests for each; 126 TUI tests pass, ruff check + format clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
130 lines
4.4 KiB
Python
130 lines
4.4 KiB
Python
"""Runtime bridge between ``DeerFlowClient`` streaming and the view-state reducer.
|
|
|
|
Two layers, both kept free of Textual:
|
|
|
|
* :func:`translate` — pure: one ``StreamEvent`` -> zero or more reducer actions.
|
|
* :func:`stream_actions` — drives ``client.stream()`` and yields a bracketed
|
|
action sequence (``RunStarted`` … translated actions … ``RunEnded``), turning
|
|
model errors into an ``AssistantError`` row instead of crashing.
|
|
|
|
The Textual app runs :func:`stream_actions` in a worker thread and applies each
|
|
yielded action to the reducer on the UI thread.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Iterator
|
|
from typing import Any, Protocol
|
|
|
|
from .view_state import (
|
|
Action,
|
|
AssistantDelta,
|
|
AssistantError,
|
|
RunEnded,
|
|
RunStarted,
|
|
ThreadTitle,
|
|
ToolResult,
|
|
ToolStarted,
|
|
)
|
|
|
|
|
|
class _StreamEventLike(Protocol):
|
|
type: str
|
|
data: dict
|
|
|
|
|
|
class _ClientLike(Protocol):
|
|
def stream(self, message: str, *, thread_id: str | None = None, **kwargs: Any) -> Iterator[Any]:
|
|
"""Yield streaming events for *message* (see ``DeerFlowClient.stream``)."""
|
|
|
|
|
|
def translate(event: _StreamEventLike) -> list[Action]:
|
|
"""Map a single ``StreamEvent`` to reducer actions. Pure."""
|
|
if event.type == "messages-tuple":
|
|
return _translate_message(event.data)
|
|
if event.type == "end":
|
|
usage = event.data.get("usage") if isinstance(event.data, dict) else None
|
|
return [RunEnded(usage=usage)]
|
|
if event.type == "values" and isinstance(event.data, dict):
|
|
title = event.data.get("title")
|
|
if isinstance(title, str) and title.strip():
|
|
return [ThreadTitle(title=title.strip())]
|
|
return []
|
|
# "custom" events are not rendered incrementally.
|
|
return []
|
|
|
|
|
|
def _translate_message(data: Any) -> list[Action]:
|
|
if not isinstance(data, dict):
|
|
return []
|
|
|
|
message_type = data.get("type")
|
|
actions: list[Action] = []
|
|
|
|
if message_type == "ai":
|
|
text = _extract_text(data.get("content"))
|
|
if text:
|
|
actions.append(AssistantDelta(id=_as_str(data.get("id")), text=text))
|
|
for tool_call in data.get("tool_calls") or []:
|
|
if not isinstance(tool_call, dict):
|
|
continue
|
|
actions.append(
|
|
ToolStarted(
|
|
tool_call_id=_as_str(tool_call.get("id")),
|
|
tool_name=_as_str(tool_call.get("name")),
|
|
args=tool_call.get("args") or {},
|
|
)
|
|
)
|
|
elif message_type == "tool":
|
|
is_error = bool(data.get("is_error")) or data.get("status") == "error"
|
|
actions.append(
|
|
ToolResult(
|
|
tool_call_id=_as_str(data.get("tool_call_id")),
|
|
content=_extract_text(data.get("content")),
|
|
is_error=is_error,
|
|
tool_name=_as_str(data.get("name")),
|
|
)
|
|
)
|
|
|
|
return actions
|
|
|
|
|
|
def _as_str(value: Any) -> str:
|
|
# Provider stream chunks can carry an explicit ``None`` id/name (the key is
|
|
# present, so ``.get(k, "")`` would return None, and ``str(None) == "None"``
|
|
# — a truthy value that would defeat the empty-id guard downstream).
|
|
return "" if value is None else str(value)
|
|
|
|
|
|
def stream_actions(client: _ClientLike, message: str, *, thread_id: str | None = None, **kwargs: Any) -> Iterator[Action]:
|
|
"""Yield a bracketed action stream for one agent run.
|
|
|
|
Always begins with ``RunStarted`` and ends with ``RunEnded`` (even on error,
|
|
where an ``AssistantError`` row is emitted first).
|
|
"""
|
|
yield RunStarted()
|
|
try:
|
|
for event in client.stream(message, thread_id=thread_id, **kwargs):
|
|
yield from translate(event)
|
|
if event.type == "end":
|
|
return # RunEnded already emitted by translate()
|
|
yield RunEnded()
|
|
except Exception as exc: # noqa: BLE001 - surface any model/runtime error in-UI
|
|
yield AssistantError(str(exc) or exc.__class__.__name__)
|
|
yield RunEnded()
|
|
|
|
|
|
def _extract_text(content: Any) -> str:
|
|
if content is None:
|
|
return ""
|
|
if isinstance(content, str):
|
|
return content
|
|
if isinstance(content, list):
|
|
parts: list[str] = []
|
|
for block in content:
|
|
if isinstance(block, str):
|
|
parts.append(block)
|
|
elif isinstance(block, dict) and block.get("type") == "text" and isinstance(block.get("text"), str):
|
|
parts.append(block["text"])
|
|
return "".join(parts)
|
|
return str(content)
|