mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-13 10:55:59 +00:00
aa015462a7
* Add user-owned IM channel connections * Fix dev startup and channel connect popup * Use async channel connect flow * Harden dev service daemon startup * Support local IM channel connections * Align IM connections with local channels * Fix safe user id digest algorithm * Address Copilot IM channel feedback * Address IM channel review comments * Support all integrated IM channel connections * Format additional channel connection tests * Keep unavailable channel connect buttons clickable * Fix IM channel provider icons * Add runtime setup for enabled IM channels * Guard global shortcut key handling * Keep configured IM channels editable * Avoid password autofill for channel secrets * Make channel threads visible to connection owners * Persist IM runtime config locally * Allow disconnecting runtime IM channels * Route no-auth channel sessions to local user * Use default user for auth-disabled local mode * Show IM channel source on threads * Prefill IM channel runtime config * Reflect IM channel runtime health * Ignore Feishu message read events * Ignore Feishu non-content message events * Let setup wizard enable IM channels * Fix frontend formatting after merge * Stabilize backend tests without local config * Isolate channel runtime config tests * Address channel connection review comments * Use sha256 user buckets with legacy migration * Ensure runtime IM channels are ready after restart * Persist disconnected IM channel state * Address channel connection review comments * Address channel connection review findings Frontend connect flow: - Open the runtime-config dialog only when a provider still needs credentials; configured providers go straight to the connect flow, so the binding-code/deep-link path is reachable from the UI again. - After saving credentials, continue into the connect flow when a user binding is still required (multi-user mode) instead of stopping at a "Connected" toast. - Extract shared provider-state helpers to core/channels/provider-state and add unit + e2e coverage for the direct-connect and configure-then-connect paths. Provider status semantics: - Report connection_status from the user's newest connection row; with no binding it is not_connected, except in auth-disabled local mode where a configured running channel is effectively connected. Concurrency and event-loop correctness: - Offload ChannelRuntimeConfigStore construction and writes, channel service construction, and Slack connection replies to threads; add a tests/blocking_io/ anchor for the runtime-config handlers. - Consume binding codes with a conditional UPDATE so a code can only be used once under concurrent workers; retry upsert_connection as an update when a concurrent insert wins the unique constraint. - Serialize ensure_channel_ready per channel so concurrent provider polls cannot double-start a channel worker. Config and migration hardening: - Stop mutating the get_app_config()-cached Telegram provider config; the runtime store now owns the UI-entered bot username. - Register channel_connections in STARTUP_ONLY_FIELDS with the standardized startup-only Field description. - Match the legacy unsafe-id bucket by recomputing its exact SHA-1 name so another user's same-prefix bucket can never be migrated. - Remove the unused Telegram process_webhook_update path and document src/core/channels in the frontend docs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address PR review comments on authz scoping and channel runtime Security (review feedback from ShenAC-SAC): - Scope internal-token callers to the connection owner carried in X-DeerFlow-Owner-User-Id instead of bypassing owner checks outright, in both require_permission(owner_check=True) and the stateless run endpoints. Internal callers keep access to their own and shared/legacy threads, and may claim a default-owned channel thread for its real owner, but a leaked internal token no longer grants cross-user thread access. - Require admin privileges for POST/DELETE /api/channels/{provider}/ runtime-config: runtime credentials and channel workers are instance-wide shared state (same model as the MCP config API). Read-only provider listing stays available to all users. Performance (review feedback from willem-bd): - Skip the redundant thread channel-metadata PATCH after the first successful backfill per thread. - Reuse the per-connection Slack WebClient until its token changes instead of constructing one per outbound message. - Reconcile channel readiness for all providers concurrently in GET /api/channels/providers. Also resolve the code-quality unused-import flag in the blocking-io anchor by pre-importing the channel service via importlib. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix prettier formatting in provider-state test Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Reconcile UI runtime channel config with config reload on restart Main now reloads a channel's config.yaml entry on restart_channel() (#3514, issue #3497). Adapt the user-owned connection flow to coexist: - configure_channel() restarts with reload_config=False — the caller just supplied the authoritative config (browser-entered credentials that are never written to config.yaml), so a file reload must not clobber it with the stale on-disk entry. - _load_channel_config() re-applies the UI runtime-store overlay used at startup, so an operator-triggered restart keeps browser-entered credentials for channels without a config.yaml entry and does not resurrect a channel disconnected from the UI. - Offload the reload's disk IO (config.yaml + runtime store) with asyncio.to_thread, matching the blocking-IO policy on this branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
305 lines
8.6 KiB
Python
305 lines
8.6 KiB
Python
"""Terminal UI helpers for the Setup Wizard."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import getpass
|
||
import shutil
|
||
import sys
|
||
|
||
try:
|
||
import termios
|
||
import tty
|
||
except ImportError: # pragma: no cover - non-Unix fallback
|
||
termios = None
|
||
tty = None
|
||
|
||
# ── ANSI colours ──────────────────────────────────────────────────────────────
|
||
|
||
def _supports_color() -> bool:
|
||
return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
|
||
|
||
|
||
def _c(text: str, code: str) -> str:
|
||
if _supports_color():
|
||
return f"\033[{code}m{text}\033[0m"
|
||
return text
|
||
|
||
|
||
def green(text: str) -> str:
|
||
return _c(text, "32")
|
||
|
||
|
||
def red(text: str) -> str:
|
||
return _c(text, "31")
|
||
|
||
|
||
def yellow(text: str) -> str:
|
||
return _c(text, "33")
|
||
|
||
|
||
def cyan(text: str) -> str:
|
||
return _c(text, "36")
|
||
|
||
|
||
def bold(text: str) -> str:
|
||
return _c(text, "1")
|
||
|
||
|
||
def inverse(text: str) -> str:
|
||
return _c(text, "7")
|
||
|
||
|
||
# ── UI primitives ─────────────────────────────────────────────────────────────
|
||
|
||
def print_header(title: str) -> None:
|
||
width = max(len(title) + 4, 44)
|
||
bar = "═" * width
|
||
print()
|
||
print(f"╔{bar}╗")
|
||
print(f"║ {title.ljust(width - 2)}║")
|
||
print(f"╚{bar}╝")
|
||
print()
|
||
|
||
|
||
def print_section(title: str) -> None:
|
||
print()
|
||
print(bold(f"── {title} ──"))
|
||
print()
|
||
|
||
|
||
def print_success(message: str) -> None:
|
||
print(f" {green('✓')} {message}")
|
||
|
||
|
||
def print_warning(message: str) -> None:
|
||
print(f" {yellow('!')} {message}")
|
||
|
||
|
||
def print_error(message: str) -> None:
|
||
print(f" {red('✗')} {message}")
|
||
|
||
|
||
def print_info(message: str) -> None:
|
||
print(f" {cyan('→')} {message}")
|
||
|
||
|
||
def _ask_choice_with_numbers(prompt: str, options: list[str], default: int | None = None) -> int:
|
||
for i, opt in enumerate(options, 1):
|
||
marker = f" {green('*')}" if default is not None and i - 1 == default else " "
|
||
print(f"{marker} {i}. {opt}")
|
||
print()
|
||
|
||
while True:
|
||
suffix = f" [{default + 1}]" if default is not None else ""
|
||
raw = input(f"{prompt}{suffix}: ").strip()
|
||
if raw == "" and default is not None:
|
||
return default
|
||
if raw.isdigit():
|
||
idx = int(raw) - 1
|
||
if 0 <= idx < len(options):
|
||
return idx
|
||
print(f" Please enter a number between 1 and {len(options)}.")
|
||
|
||
|
||
def _supports_arrow_menu() -> bool:
|
||
return (
|
||
termios is not None
|
||
and tty is not None
|
||
and hasattr(sys.stdin, "isatty")
|
||
and hasattr(sys.stdout, "isatty")
|
||
and sys.stdin.isatty()
|
||
and sys.stdout.isatty()
|
||
and sys.stderr.isatty()
|
||
)
|
||
|
||
|
||
def _clear_rendered_lines(count: int) -> None:
|
||
if count <= 0:
|
||
return
|
||
sys.stdout.write("\x1b[2K\r")
|
||
for _ in range(count):
|
||
sys.stdout.write("\x1b[1A\x1b[2K\r")
|
||
|
||
|
||
def _read_key(fd: int) -> str:
|
||
first = sys.stdin.read(1)
|
||
if first != "\x1b":
|
||
return first
|
||
|
||
second = sys.stdin.read(1)
|
||
if second != "[":
|
||
return first
|
||
|
||
third = sys.stdin.read(1)
|
||
return f"\x1b[{third}"
|
||
|
||
|
||
def _terminal_width() -> int:
|
||
return max(shutil.get_terminal_size(fallback=(80, 24)).columns, 40)
|
||
|
||
|
||
def _truncate_line(text: str, max_width: int) -> str:
|
||
if len(text) <= max_width:
|
||
return text
|
||
if max_width <= 1:
|
||
return text[:max_width]
|
||
return f"{text[: max_width - 1]}…"
|
||
|
||
|
||
def _render_choice_menu(options: list[str], selected: int) -> int:
|
||
number_width = len(str(len(options)))
|
||
menu_width = _terminal_width()
|
||
content_width = max(menu_width - 3, 20)
|
||
for i, opt in enumerate(options, 1):
|
||
line = _truncate_line(f"{i:>{number_width}}. {opt}", content_width)
|
||
if i - 1 == selected:
|
||
print(f"{green('›')} {inverse(bold(line))}")
|
||
else:
|
||
print(f" {line}")
|
||
sys.stdout.flush()
|
||
return len(options)
|
||
|
||
|
||
def _ask_choice_with_arrows(prompt: str, options: list[str], default: int | None = None) -> int:
|
||
selected = default if default is not None else 0
|
||
typed = ""
|
||
fd = sys.stdin.fileno()
|
||
original_settings = termios.tcgetattr(fd)
|
||
rendered_lines = 0
|
||
|
||
try:
|
||
sys.stdout.write("\x1b[?25l")
|
||
sys.stdout.flush()
|
||
tty.setcbreak(fd)
|
||
prompt_help = f"{prompt} (↑/↓ move, Enter confirm, number quick-select)"
|
||
print(cyan(_truncate_line(prompt_help, max(_terminal_width() - 2, 20))))
|
||
|
||
while True:
|
||
if rendered_lines:
|
||
_clear_rendered_lines(rendered_lines)
|
||
rendered_lines = _render_choice_menu(options, selected)
|
||
|
||
key = _read_key(fd)
|
||
|
||
if key == "\x03":
|
||
raise KeyboardInterrupt
|
||
|
||
if key in ("\r", "\n"):
|
||
if typed:
|
||
idx = int(typed) - 1
|
||
if 0 <= idx < len(options):
|
||
selected = idx
|
||
typed = ""
|
||
break
|
||
|
||
if key == "\x1b[A":
|
||
selected = (selected - 1) % len(options)
|
||
typed = ""
|
||
continue
|
||
if key == "\x1b[B":
|
||
selected = (selected + 1) % len(options)
|
||
typed = ""
|
||
continue
|
||
if key in ("\x7f", "\b"):
|
||
typed = typed[:-1]
|
||
continue
|
||
if key.isdigit():
|
||
typed += key
|
||
continue
|
||
|
||
if rendered_lines:
|
||
_clear_rendered_lines(rendered_lines)
|
||
print(f"{prompt}: {options[selected]}")
|
||
return selected
|
||
finally:
|
||
termios.tcsetattr(fd, termios.TCSADRAIN, original_settings)
|
||
sys.stdout.write("\x1b[?25h")
|
||
sys.stdout.flush()
|
||
|
||
|
||
def ask_choice(prompt: str, options: list[str], default: int | None = None) -> int:
|
||
"""Present a menu and return the 0-based index of the selected option."""
|
||
if _supports_arrow_menu():
|
||
return _ask_choice_with_arrows(prompt, options, default=default)
|
||
return _ask_choice_with_numbers(prompt, options, default=default)
|
||
|
||
|
||
def ask_multi_choice(prompt: str, options: list[str], default: list[int] | None = None) -> list[int]:
|
||
"""Present a numbered multi-select menu and return 0-based indexes."""
|
||
has_default = default is not None
|
||
default_indexes = list(default or [])
|
||
for i, opt in enumerate(options, 1):
|
||
marker = f" {green('*')}" if has_default and i - 1 in default_indexes else " "
|
||
print(f"{marker} {i}. {opt}")
|
||
print()
|
||
|
||
suffix = ""
|
||
if default_indexes:
|
||
suffix = f" [{','.join(str(idx + 1) for idx in default_indexes)}]"
|
||
elif has_default:
|
||
suffix = " [none]"
|
||
|
||
while True:
|
||
raw = input(f"{prompt}{suffix}: ").strip().lower()
|
||
if raw == "" and has_default:
|
||
return default_indexes
|
||
if raw in {"none", "no", "n", "skip"}:
|
||
return []
|
||
if raw == "all":
|
||
return list(range(len(options)))
|
||
|
||
parts = [part.strip() for part in raw.replace(" ", ",").split(",") if part.strip()]
|
||
selected: list[int] = []
|
||
valid = bool(parts)
|
||
for part in parts:
|
||
if not part.isdigit():
|
||
valid = False
|
||
break
|
||
idx = int(part) - 1
|
||
if not 0 <= idx < len(options):
|
||
valid = False
|
||
break
|
||
if idx not in selected:
|
||
selected.append(idx)
|
||
if valid:
|
||
return selected
|
||
|
||
print(f" Enter comma-separated numbers between 1 and {len(options)}, 'all', or 'none'.")
|
||
|
||
|
||
def ask_text(prompt: str, default: str = "", required: bool = False) -> str:
|
||
"""Ask for a text value, returning default if the user presses Enter."""
|
||
suffix = f" [{default}]" if default else ""
|
||
while True:
|
||
value = input(f"{prompt}{suffix}: ").strip()
|
||
if value:
|
||
return value
|
||
if default:
|
||
return default
|
||
if not required:
|
||
return ""
|
||
print(" This field is required.")
|
||
|
||
|
||
def ask_secret(prompt: str) -> str:
|
||
"""Ask for a secret value (hidden input)."""
|
||
while True:
|
||
value = getpass.getpass(f"{prompt}: ").strip()
|
||
if value:
|
||
return value
|
||
print(" API key cannot be empty.")
|
||
|
||
|
||
def ask_yes_no(prompt: str, default: bool = True) -> bool:
|
||
"""Ask a yes/no question."""
|
||
suffix = "[Y/N]"
|
||
while True:
|
||
raw = input(f"{prompt} {suffix}: ").strip().lower()
|
||
if raw == "":
|
||
return default
|
||
if raw in ("y", "yes"):
|
||
return True
|
||
if raw in ("n", "no"):
|
||
return False
|
||
print(" Please enter y or n.")
|