feat: 任务工作区可配置跳转按钮(按钮管理) + 底部快捷跳转栏 + 目的树收起/空自动问答

- 新增「按钮管理」admin 页(/admin/task-buttons):按业务(rwfx/3qfx/xdfx)配置跳转按钮,
  支持改名称/类型(外链·切换业务·目的树弹框)/目标/追加参数名(id·task_id)/任务id·行动id/启停/排序
- 跳转按钮从左侧栏移到工作区底部「快捷跳转」栏:红底白字、靠右、变大;rwfx 历史 4 按钮纳入配置
- 后端新增全局共享 store /api/task-buttons (table task_buttons,admin 整体替换)
- xdfx 行动id/任务id:登录暂存原始行动id,按钮可选追加任一;id 参数名可自定义
- rwfx 清除记录移到「新建对话」下、去掉任务操作框;查询任务目的为空时自动发第一次问答
- 目的树面板默认收起;「弹框查看」抽成可复用 TaskPurposeDialog,可作为按钮配置到任意页面
- 文档(根/后端 CLAUDE.md) + 后端测试 tests/test_task_buttons.py

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
AdministratorandClaude Opus 4.8 committed 2026-06-18 15:41:05 +08:00
1 parent ecb79ce49c
commit 16afa9bcbb
22 files changed
+1341 -130

No files matched your search

@@ -254,6 +254,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **Light Apps** (`/api/light-apps`) | Light-app registry (????? / ????), **global/shared** (no user scoping; `created_by`/`updated_by` audit-only). Two kinds: `window_open` (launched in a new tab from the ???? card grid) and `iframe` (embedded under a parent sidebar menu via `mount_location` and dynamically injected into the sidebar). Both carry `route_params` ? a list of `{key, paramType: login\|theme, source, customValue, themeValue}` descriptors the **frontend** resolves at launch time (login params read from `localStorage.userInfo`; theme params use a literal value) and appends to `url` as query string. `GET /` - list (filters `app_type`/`mount_location`/`enabled_only`/`search`); `GET /{app_id}` - read one (both open to any authenticated user); `POST /`, `PUT /{app_id}`, `DELETE /{app_id}` - **admin only**; iframe apps must carry a `mount_location` (400 otherwise); the unique `name` collides as HTTP 409. Backed by `deerflow.persistence.light_apps` (table `light_apps`, auto-created by `create_all`); store wired in `deps.py` as `app.state.light_app_store`. Tests: `tests/test_light_apps.py`. |
| **Menu Overrides** (`/api/menu-overrides`) | Sidebar-menu overrides (菜单管理), **global/shared** (no user scoping; `updated_by` audit-only). The static menu tree (icons/routes/`adminOnly`) lives in the **frontend** code (`core/page-layout/sidebar-menu.ts`); iframe light-apps are injected at render time. This router persists a per-node **overlay** the frontend merges onto that tree: `parent_id` (re-parent, moves the whole subtree; NULL = top level), `sort_order` (reorder among siblings), `label` (rename; NULL keeps code label), `disabled` (停用 — hides the node **and its subtree**). A node with no row renders exactly as code defines it, so new code menus / newly-registered iframe apps appear automatically and are immediately configurable. `GET /` - list all overrides (open to any authenticated user); `PUT /` - **admin only**, atomically **replaces the whole set** (`{overrides: [...]}`); rejects re-parenting that would exceed **3 levels** or form a cycle (`_validate_depth`, HTTP 400). Backed by `deerflow.persistence.menu_overrides` (table `menu_overrides`, auto-created by `create_all`); store wired in `deps.py` as `app.state.menu_override_store`. Frontend: merge logic `core/page-layout/menu-overrides.ts` (`applyMenuOverrides` in `use-sidebar-menu.ts`), client `strategy-components/api/menu-overrides.ts`, admin page `pages/MenuManagementPage.tsx` (@dnd-kit drag-reorder/re-parent + 「移动到」parent selector + ↑↓ arrows + 改名/停用), entry in the bottom-left settings dropdown (`components/page-sidebar.tsx`, admin-only). Tests: `tests/test_menu_overrides.py`, `frontend-web/src/core/page-layout/menu-overrides.test.ts`. |
| **Sensitive Words** (`/api/sensitive-words`) | Sensitive-word / desensitize map (敏感词管理 / 脱敏词), **global/shared** (no user scoping; `updated_by` audit-only). Each row maps an original `word` → `replacement`, with `enabled` (停用 parks a word without deleting) + `sort_order`. The **frontend** ships a seed map in code (`src/lib/desensitize.ts` `SEED_DESENSITIZE_MAP`) used as the default on a fresh deployment; once an admin saves, this table is the source of truth and the frontend loads it at startup (`DesensitizeWordsLoader` → `replaceDesensitizeMap`, **enabled** rows only). `GET /` - list all words (open to any authenticated user — every page desensitizes); `PUT /` - **admin OR the `lqq` account** (`_require_manager`: `system_role == "admin"` or email `@`-prefix `== "lqq"`), atomically **replaces the whole set** (`{words: [...]}`, de-duped by `word`, last wins). Backed by `deerflow.persistence.sensitive_words` (table `sensitive_words`, PK `word` `String(191)`, auto-created by `create_all`); store wired in `deps.py` as `app.state.sensitive_word_store`. Frontend: client `strategy-components/api/sensitive-words.ts`, page `pages/SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, entry in the bottom-left settings dropdown — **gated on username `lqq`, not admin role**, present in both sidebar modes; 「导入种子词」 merges missing seed entries). Tests: `tests/test_sensitive_words.py`. |
| **Task Buttons** (`/api/task-buttons`) | Task-workspace configurable jump buttons (按钮管理), **global/shared** (no user scoping; `updated_by` audit-only). Each row = one jump button in a task deep-link workspace's bottom 快捷跳转 bar: `{id, business (rwfx/3qfx/xdfx), label, link_type (url/business), target, append_task_id, id_param (editable query-param name, e.g. id/task_id), id_kind (task/action — only xdfx differs, its deep-link id is an 行动id), enabled, sort_order}`. The **frontend** holds code-seed defaults (rwfx's 4 historical buttons built from the live `task_deeplink` config); only when this table is empty do the workspace + admin page fall back to them, else this table is the sole source of truth. `GET /` - list all (open to any authenticated user — the workspace renders them); `PUT /` - **admin only** (`_require_admin`), atomically **replaces the whole set** (`{buttons: [...]}`, de-duped by `id`, last wins). Backed by `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`); store wired in `deps.py` as `app.state.task_button_store`. Frontend: helpers `core/auth/task-buttons.ts`, client `strategy-components/api/task-buttons.ts`, admin page `pages/TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, entry「按钮管理」in the bottom-left settings dropdown, admin-only, both sidebar modes; per-business tabs + 「导入默认」). Tests: `tests/test_task_buttons.py`. |
| **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Defaults to `background=true`**: the slow per-thread extraction loop is deferred to a FastAPI background task and the endpoint returns `{queued:true, processed:N}` immediately (each thread's transcript is truncated to `max_input_chars` before the LLM call). Running the loop inline for a large batch outlives the nginx gateway timeout (504), which the frontend's `parseResponse` surfaces as a generic "Request failed"; `background=false` keeps the blocking path (full per-thread `created/skipped/failed` tally) for small batches/scripts. Shared loop: `_run_batch_ingest`. **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` ? `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "???????". The "??????" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector?keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[?]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). `GET/POST /folders` + `PUT/DELETE /folders/{id}` - user-managed directories (notes carry a `folder` path; `GET /notes?folder=` filters a dir + sub-dirs); `GET/POST /extract-templates` + `GET/PUT/DELETE /extract-templates/{id}` - editable extraction-prompt templates (thread/document ingest accept `template_id`; capture/PUT-note accept `folder`). Backed by the `deerflow.knowledge` module (see below). |
Proxied through nginx: `/api/langgraph/*` ? LangGraph, all other `/api/*` ? Gateway.
@@ -69,6 +69,7 @@ from app.gateway.routers import (
suggestions,
system_settings,
tags,
task_buttons,
thread_runs,
tool_metrics,
thread_shares,
@@ -1348,6 +1349,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Sensitive-word map (敏感词管理 / 脱敏词) at /api/sensitive-words
app.include_router(sensitive_words.router)
# Task-workspace configurable jump buttons (按钮管理) at /api/task-buttons
app.include_router(task_buttons.router)
# Standalone parallel multi-agent panel: fan-out streaming + saved tasks
app.include_router(parallel_agents.router)
app.include_router(parallel_agent_tasks.router)
@@ -247,6 +247,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.sensitive_word_store = make_sensitive_word_store(sf)
from deerflow.persistence.task_buttons import make_task_button_store
app.state.task_button_store = make_task_button_store(sf)
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf)
@@ -0,0 +1,104 @@
"""Task-workspace configurable jump-button API (按钮管理).
The task deep-link workspaces (rwfx / 3qfx / xdfx, see the frontend
``TaskFxWorkspace``) render configurable jump buttons in their left task panel.
Their default targets historically lived in ``config.yaml`` ``task_deeplink`` and
their names were hardcoded; this router persists a **global / shared** button
set the frontend renders instead, so an admin can rename / retarget / add /
remove jump buttons per business.
When the table is empty (fresh deployment, never saved) the frontend falls back
to code-defined seed defaults built from the live task-deeplink config; once
saved, this table is the source of truth.
Reads are open to any authenticated user (the workspace needs them); the whole
button set is replaced atomically and only by an admin.
Routes (prefix ``/api/task-buttons``):
GET "" list all buttons
PUT "" replace the entire button set (admin)
"""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_current_user, get_optional_user_from_request
from deerflow.persistence.task_buttons import TaskButtonStore
router = APIRouter(prefix="/api/task-buttons", tags=["task-buttons"])
# ── schemas ──────────────────────────────────────────────────────────────
class TaskButton(BaseModel):
id: str = Field(..., min_length=1, max_length=128)
business: str = Field(..., min_length=1, max_length=32)
label: str = Field(default="", max_length=255)
link_type: str = Field(default="url", max_length=16)
target: str = Field(default="", max_length=1024)
append_task_id: bool = True
# 追加的查询参数名(如 id / task_id)。
id_param: str = Field(default="id", max_length=64)
# task=任务id(默认)/ action=行动id(仅 xdfx 二者不同)。
id_kind: str = Field(default="task", max_length=16)
enabled: bool = True
sort_order: int = 0
class TaskButtonResponse(TaskButton):
updated_by: str | None = None
updated_at: datetime | str | None = None
class TaskButtonListResponse(BaseModel):
buttons: list[TaskButtonResponse]
class TaskButtonReplaceRequest(BaseModel):
buttons: list[TaskButton] = Field(default_factory=list)
# ── helpers ──────────────────────────────────────────────────────────────
def _get_store(request: Request) -> TaskButtonStore:
store = getattr(request.app.state, "task_button_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Task button store not available")
return store
async def _require_admin(request: Request) -> None:
"""Reject non-admins. Lets the call through when auth is disabled."""
user = await get_optional_user_from_request(request)
if user is None:
return
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="按钮管理仅限管理员")
# ── routes ───────────────────────────────────────────────────────────────
@router.get("", response_model=TaskButtonListResponse)
async def list_task_buttons(request: Request) -> TaskButtonListResponse:
store = _get_store(request)
rows = await store.list_buttons()
return TaskButtonListResponse(buttons=[TaskButtonResponse(**r) for r in rows])
@router.put("", response_model=TaskButtonListResponse)
async def replace_task_buttons(request: Request, body: TaskButtonReplaceRequest) -> TaskButtonListResponse:
await _require_admin(request)
store = _get_store(request)
user_id = await get_current_user(request)
rows = await store.replace_all(
[b.model_dump() for b in body.buttons],
updated_by=user_id,
)
return TaskButtonListResponse(buttons=[TaskButtonResponse(**r) for r in rows])
@@ -64,6 +64,7 @@ from deerflow.persistence.scheduled_tasks.model import (
SchedulerThreadRow,
)
from deerflow.persistence.skills.model import SkillDisplayAdapterRow, SkillFavoriteRow, SkillRow
from deerflow.persistence.task_buttons.model import TaskButtonRow
from deerflow.persistence.tags.model import TagAssignmentRow, TagRow
from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.embed_sessions.model import EmbedSessionRow
@@ -119,6 +120,7 @@ __all__ = [
"SkillFavoriteRow",
"TagAssignmentRow",
"TagRow",
"TaskButtonRow",
"EmbedSessionRow",
"ThreadMetaRow",
"ThreadShareRow",
@@ -0,0 +1,22 @@
"""Task-workspace configurable jump-button persistence (按钮管理)."""
from __future__ import annotations
from deerflow.persistence.task_buttons.base import TaskButtonStore
from deerflow.persistence.task_buttons.memory import MemoryTaskButtonStore
from deerflow.persistence.task_buttons.model import TaskButtonRow
from deerflow.persistence.task_buttons.sql import TaskButtonRepository
__all__ = [
"MemoryTaskButtonStore",
"TaskButtonRepository",
"TaskButtonRow",
"TaskButtonStore",
"make_task_button_store",
]
def make_task_button_store(session_factory):
if session_factory is None:
return MemoryTaskButtonStore()
return TaskButtonRepository(session_factory)
@@ -0,0 +1,30 @@
"""Abstract task-workspace button persistence API.
Global / shared store: no ``user_id`` scoping. Reads are open to any caller; the
router gates writes behind admin. ``updated_by`` is audit-only.
The whole button set is treated as one document: the editor computes the full
desired layout and replaces every row atomically via :meth:`replace_all`, so the
stored set never drifts out of sync with what the admin sees.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class TaskButtonStore(ABC):
@abstractmethod
async def list_buttons(self) -> list[dict[str, Any]]:
"""Return every stored button row (order unspecified)."""
raise NotImplementedError
@abstractmethod
async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]:
"""Atomically replace the entire button set with ``rows``.
Each row is ``{id, business, label, link_type, target, append_task_id,
enabled, sort_order}``. Returns the persisted rows.
"""
raise NotImplementedError
@@ -0,0 +1,42 @@
"""In-memory task-button store (used when no DB is configured / tests)."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from deerflow.persistence.task_buttons.base import TaskButtonStore
def _normalize(data: dict[str, Any], updated_by: str | None) -> dict[str, Any]:
return {
"id": str(data["id"]),
"business": str(data.get("business") or ""),
"label": str(data.get("label") or ""),
"link_type": str(data.get("link_type") or "url"),
"target": str(data.get("target") or ""),
"append_task_id": bool(data.get("append_task_id", True)),
"id_param": str(data.get("id_param") or "id"),
"id_kind": str(data.get("id_kind") or "task"),
"enabled": bool(data.get("enabled", True)),
"sort_order": int(data.get("sort_order", 0)),
"updated_by": updated_by,
"updated_at": datetime.now(UTC).isoformat(),
}
class MemoryTaskButtonStore(TaskButtonStore):
def __init__(self) -> None:
self._rows: dict[str, dict[str, Any]] = {}
async def list_buttons(self) -> list[dict[str, Any]]:
return [dict(r) for r in sorted(self._rows.values(), key=lambda r: r.get("sort_order", 0))]
async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]:
self._rows = {}
for r in rows:
row_id = r.get("id")
if not row_id:
continue
self._rows[str(row_id)] = _normalize(r, updated_by)
return [dict(r) for r in self._rows.values()]
@@ -0,0 +1,54 @@
"""ORM model for task-workspace configurable jump buttons (按钮管理).
Global / shared — there is no ``user_id`` scoping; every account sees the same
buttons. The task deep-link workspaces (rwfx / 3qfx / xdfx, see
``TaskFxWorkspace``) render the enabled buttons for their ``business`` in the
left task panel. Each row is one button:
- ``business`` — which workspace the button belongs to (``rwfx`` / ``3qfx`` /
``xdfx``).
- ``label`` — display name.
- ``link_type`` — ``url`` (external, opened in a new tab) or ``business``
(internal switch to another deep-link business).
- ``target`` — the full URL (``url``) or the target business code (``business``).
- ``append_task_id`` — for ``url`` buttons, whether to append ``?id={taskId}``.
- ``enabled`` — 停用 parks a button without deleting it.
- ``sort_order`` — position among the business's buttons.
When the whole table is empty (fresh deployment, never saved) the frontend
falls back to code-defined seed defaults built from the live task-deeplink
config; once saved, this table is the source of truth. ``updated_by`` is
audit-only.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Boolean, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
class TaskButtonRow(Base):
__tablename__ = "task_buttons"
id: Mapped[str] = mapped_column(String(128), primary_key=True)
business: Mapped[str] = mapped_column(String(32), index=True)
label: Mapped[str] = mapped_column(String(255), default="")
link_type: Mapped[str] = mapped_column(String(16), default="url")
target: Mapped[str] = mapped_column(String(1024), default="")
append_task_id: Mapped[bool] = mapped_column(Boolean, default=True)
# 追加的查询参数名(如 ``id`` / ``task_id``)。可空,读时归一化为 ``id``。
id_param: Mapped[str | None] = mapped_column(String(64), default="id")
# 追加的 id 取哪种:``task``=任务id(默认)/ ``action``=行动id(仅 xdfx 二者不同:深链原始
# ?taskId= 实为行动id,任务id 由 action 接口解析)。可空,读时归一化为 ``task``。
id_kind: Mapped[str | None] = mapped_column(String(16), default="task")
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
updated_by: Mapped[str | None] = mapped_column(String(64))
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
)
@@ -0,0 +1,66 @@
"""SQLAlchemy-backed task-button store (global / shared)."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.task_buttons.base import TaskButtonStore
from deerflow.persistence.task_buttons.model import TaskButtonRow
def _to_dict(row: TaskButtonRow) -> dict[str, Any]:
data = row.to_dict()
value = data.get("updated_at")
if isinstance(value, datetime):
data["updated_at"] = value.isoformat()
# 旧行 / NULL 归一化为默认值。
data["id_param"] = data.get("id_param") or "id"
data["id_kind"] = data.get("id_kind") or "task"
return data
def _row_from(data: dict[str, Any], updated_by: str | None) -> TaskButtonRow:
return TaskButtonRow(
id=str(data["id"]),
business=str(data.get("business") or ""),
label=str(data.get("label") or ""),
link_type=str(data.get("link_type") or "url"),
target=str(data.get("target") or ""),
append_task_id=bool(data.get("append_task_id", True)),
id_param=str(data.get("id_param") or "id"),
id_kind=str(data.get("id_kind") or "task"),
enabled=bool(data.get("enabled", True)),
sort_order=int(data.get("sort_order", 0)),
updated_by=updated_by,
)
class TaskButtonRepository(TaskButtonStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def list_buttons(self) -> list[dict[str, Any]]:
stmt = select(TaskButtonRow).order_by(TaskButtonRow.sort_order.asc())
async with self._sf() as session:
result = await session.execute(stmt)
return [_to_dict(row) for row in result.scalars()]
async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]:
# De-dupe by id (last wins) so a malformed payload can't violate the PK.
by_id: dict[str, dict[str, Any]] = {}
for r in rows:
row_id = r.get("id")
if row_id:
by_id[str(row_id)] = r
async with self._sf() as session:
await session.execute(delete(TaskButtonRow))
new_rows = [_row_from(r, updated_by) for r in by_id.values()]
session.add_all(new_rows)
await session.commit()
for row in new_rows:
await session.refresh(row)
return [_to_dict(row) for row in new_rows]