Totoro c994826d06
feat(channels): generalize group notifications and add opt-in task/goal IM notices (#6903)
* feat(channels): generalize IM group targets and add task/goal run notifications

- Generalize proactive group notification capability (proactive_group_notifications), binding validation, and scheduled task group selection across Feishu, QQ, and WeCom while keeping WeChat personal-only.
- Add QQ group active notification delivery via POST /v2/groups/{group_openid}/messages (msg_type=2) and WeCom group active notification delivery via WS Client.sendMessage(chatid, body) with bot_id:group:chatid workspace isolation.
- Generalize notification outbox sources via migration 0043_runtime_im_notices (source_kind, source_key, run_id, nullable task_id/task_run_id) with backward-compatible SQLite/PostgreSQL upgrades.
- Add opt-in ordinary task & goal run IM notifications (run_completed, run_failed, human_input_requested) on the shared outbox, delivery worker, renderer, and settings UI.

* fix(notifications): address review feedback and group/runtime notification edge cases

* test(migrations): update CURRENT_HEAD to 0043_runtime_im_notices across migration chain tests

* fix(channels): preserve group binding names and legacy authorization

Carry owner-chosen group names through existing connect state, keep private bindings out of legacy group fallback, and restore completion metadata retries. Cover real binding and worker finalization paths and synchronize notification docs.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-15 10:30:55 +08:00

164 lines
7.9 KiB
Python

"""Read-only feature-flag endpoint for the frontend bootstrap.
Reports which optional features are exposed over HTTP so the frontend can gate
UI and avoid firing requests that the backend would reject. Config-only flags
read through ``get_config`` so edits to ``config.yaml`` take effect on the next
request, while startup-scoped capabilities report the runtime that actually
started.
"""
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, Field
from app.gateway.browser_capability import browser_capability
from app.gateway.conversation_access import conversation_references_enabled
from app.gateway.deps import get_config
from app.gateway.knowledge_scope_admission import RAGFLOW_KNOWLEDGE_SEARCH_PROVIDER
from app.gateway.routers.thread_activity import thread_activity_available
from app.gateway.run_models import MAX_CONVERSATION_REFERENCES
from deerflow.config.app_config import AppConfig
from deerflow.scheduler.runtime import scheduler_tools_enabled
from deerflow.subagents.capacity import configured_subagent_max_running
router = APIRouter(prefix="/api", tags=["features"])
class AgentsApiFeature(BaseModel):
"""Availability of the custom-agent management API."""
enabled: bool = Field(..., description="Whether the agents_api routes are exposed over HTTP")
class BrowserControlFeature(BaseModel):
"""Availability of live agentic browser control."""
enabled: bool = Field(..., description="Whether the live browser routes and UI are available")
class McpTasksFeature(BaseModel):
"""Availability of the durable MCP task runtime."""
enabled: bool = Field(..., description="Whether durable MCP task APIs and UI are available")
class SubagentBatchesFeature(BaseModel):
"""Persistence, worker, and process capacity for native-subagent batches."""
enabled: bool = Field(..., description="Compatibility alias for worker_running")
repository_available: bool = Field(..., description="Whether durable batch history APIs are available")
worker_running: bool = Field(..., description="Whether this Gateway process is executing durable batch work")
max_running: int = Field(..., description="Native subagent execution slots in this Gateway process")
class ConversationReferencesFeature(BaseModel):
"""Availability of explicit conversation references on run requests."""
enabled: bool = Field(..., description="Whether the opt-in read_conversation tool is configured, so run requests may carry conversation_references")
max_references: int = Field(..., description="Maximum conversation references accepted on one run request")
class KnowledgeBaseFeature(BaseModel):
"""Availability of RAGFlow retrieval scope selection in chat."""
scope_selection_enabled: bool = Field(
...,
description="Whether chat may select a per-message RAGFlow retrieval scope",
)
class ScheduledTasksFeature(BaseModel):
"""Availability of scheduled tasks in this Gateway process."""
available: bool = Field(..., description="Whether the scheduled-task APIs have persistence (tasks can be listed and managed)")
running: bool = Field(..., description="Whether this process's scheduler poller is running, so tasks run on schedule and new tasks can be created")
tool_enabled: bool = Field(..., description="Whether chats can create and manage tasks and scheduled runs can stop their own schedule")
min_interval_seconds: int = Field(..., description="Shortest interval and earliest one-time delay, in seconds")
group_notifications: bool = Field(..., description="Whether scheduled tasks support explicitly selected group notifications")
class ThreadActivityFeature(BaseModel):
"""Availability of the activity feed and per-user read markers."""
available: bool = Field(..., description="Whether GET /api/thread-activity, POST /api/threads/{thread_id}/read and the thread search `unread` field work (SQL persistence)")
class FeaturesResponse(BaseModel):
"""Frontend-facing feature availability flags."""
agents_api: AgentsApiFeature
browser_control: BrowserControlFeature
mcp_tasks: McpTasksFeature
subagent_batches: SubagentBatchesFeature
conversation_references: ConversationReferencesFeature
knowledge_base: KnowledgeBaseFeature
scheduled_tasks: ScheduledTasksFeature
thread_activity: ThreadActivityFeature
@router.get(
"/features",
response_model=FeaturesResponse,
summary="List Feature Flags",
description="Report which optional features are available, so the frontend can gate UI before issuing requests.",
)
async def list_features(request: Request, config: AppConfig = Depends(get_config)) -> FeaturesResponse:
"""Return availability of optional frontend features."""
browser = browser_capability(config)
subagent_batch_worker_running = bool(getattr(request.app.state, "subagent_batches_available", False))
return FeaturesResponse(
agents_api=AgentsApiFeature(enabled=config.agents_api.enabled),
browser_control=BrowserControlFeature(enabled=browser.available),
# MCP task bindings and the submitter are startup-scoped. Report the
# capability that actually started rather than a hot-reloaded config
# value that would require a Gateway restart to take effect.
mcp_tasks=McpTasksFeature(enabled=bool(getattr(request.app.state, "mcp_tasks_available", False))),
subagent_batches=SubagentBatchesFeature(
# Keep the historical `enabled` field as a compatibility alias
# while exposing read persistence independently from execution.
# A stopped/disabled worker must not hide durable history/export.
enabled=subagent_batch_worker_running,
repository_available=getattr(request.app.state, "subagent_batch_repo", None) is not None,
worker_running=subagent_batch_worker_running,
max_running=configured_subagent_max_running(),
),
# Same predicate as run admission (``prepare_conversation_reader``), read
# through ``get_config`` so enabling the tool in config.yaml shows up
# without a restart. A UI with no entry point still needs no change here.
conversation_references=ConversationReferencesFeature(
enabled=conversation_references_enabled(config),
max_references=MAX_CONVERSATION_REFERENCES,
),
knowledge_base=KnowledgeBaseFeature(
scope_selection_enabled=_knowledge_scope_selection_enabled(config),
),
scheduled_tasks=_scheduled_tasks_feature(request, config),
# Startup-scoped: the read repository exists only with SQL persistence.
thread_activity=ThreadActivityFeature(available=thread_activity_available(request.app.state)),
)
def _scheduled_tasks_feature(request: Request, config: AppConfig) -> ScheduledTasksFeature:
# Repository and service are startup-scoped (same rationale as mcp_tasks):
# report what this process actually started.
state = request.app.state
service = getattr(state, "scheduled_task_service", None)
running = bool(getattr(service, "is_running", False))
scheduler = getattr(config, "scheduler", None)
repository_available = getattr(state, "scheduled_task_repo", None) is not None
return ScheduledTasksFeature(
available=repository_available,
running=running,
tool_enabled=running and scheduler_tools_enabled(config),
min_interval_seconds=int(getattr(scheduler, "min_once_delay_seconds", 60)),
group_notifications=repository_available and bool(getattr(getattr(config, "channel_connections", None), "enabled", False)),
)
def _knowledge_scope_selection_enabled(config: AppConfig) -> bool:
"""Fail closed unless the effective knowledge_search entry is RAGFlow."""
settings = config.knowledge_base
if not settings.enabled or not settings.scope_selection_enabled:
return False
tool = config.get_tool_config("knowledge_search")
return tool is not None and tool.use == RAGFLOW_KNOWLEDGE_SEARCH_PROVIDER