# Conflicts:
#	frontend-web/src/roundtable-planning/hooks/useStep3Dashboard.ts
This commit is contained in:
fengxiangdebaozi committed 2026-06-18 15:18:41 +08:00
commit b04b830039
20 files changed
+1396 -271

No files matched your search

@@ -255,6 +255,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`. |
| **Dashboard Sessions** (`/api/dashboard-sessions`) | 「大屏绘制智能体」独立页面会话,**按外部 `task_id` 存取,不按 user 分权**(open read + shared writes;`user_id` audit-only)。一个 task 一行(upsert):`transcript`(聊天记录 JSON)+ `report_json`(最新结构化数据,可解析 = 「有效结果」)。`GET /by-task/{task_id}` - 取该 task 会话(无则 404);`PUT /by-task/{task_id}` - upsert(`{transcript?, report_json?}`,只覆盖传入项)。Backed by `deerflow.persistence.dashboard_sessions`(table `dashboard_sessions`, auto-created by `create_all`,无迁移);store wired in `deps.py` as `app.state.dashboard_session_store`。前端独立页 `roundtable-planning/pages/DashboardAgentPage.tsx`(路由 `/page/strategy/dashboard-agent?taskId=`,侧边栏「问答管理 → 大屏绘制」)让智能体分析登录拉取的业务 JSON → report-json → 渲染 `DashboardApp`;输入 seam `core/auth/dashboard-input.ts`(JSON 接口未就绪时回退 copTaskDetail 占位)。Tests: `tests/test_dashboard_sessions.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.
@@ -37,6 +37,7 @@ from app.gateway.routers import (
channels,
checkpoint_migration,
feedback,
dashboard_sessions,
html_page_favorites,
intent,
knowledge,
@@ -1356,6 +1357,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)
# 大屏绘制智能体独立页面会话(按 taskId 存取,不分权)at /api/dashboard-sessions
app.include_router(dashboard_sessions.router)
# Standalone parallel multi-agent panel: fan-out streaming + saved tasks
app.include_router(parallel_agents.router)
app.include_router(parallel_agent_tasks.router)
@@ -252,6 +252,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.sensitive_word_store = make_sensitive_word_store(sf)
from deerflow.persistence.dashboard_sessions import make_dashboard_session_store
app.state.dashboard_session_store = make_dashboard_session_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,84 @@
"""按 taskId 存取的「大屏绘制智能体」独立页面会话存储 API。
独立大屏页(前端 ``DashboardAgentPage``)按外部 ``task_id`` 存取:聊天记录
(``transcript``)+ 最新结构化数据(``report_json``)。**按 task 共享、不按 user
分权**(open read + 写放开),镜像 ``roundtable_drafts`` 的 by-task 语义。
Routes (prefix ``/api/dashboard-sessions``):
GET /by-task/{task_id} 取该 task 的会话(无则 404)
PUT /by-task/{task_id} upsert(body: {transcript?, report_json?})
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from deerflow.runtime.user_context import get_effective_user_id
router = APIRouter(prefix="/api/dashboard-sessions", tags=["dashboard-sessions"])
class DashboardSessionResponse(BaseModel):
task_id: str
transcript: Any | None = None
report_json: str | None = None
created_at: str | None = None
updated_at: str | None = None
class DashboardSessionUpsertRequest(BaseModel):
# 只传需要更新的字段:聊天记录 / 结构化数据,二者皆可单独更新。
transcript: Any | None = None
report_json: str | None = None
def _current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is not None:
return str(user.id)
return get_effective_user_id()
def _get_store(request: Request):
store = getattr(request.app.state, "dashboard_session_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Dashboard session store not available")
return store
def _project(row: dict[str, Any]) -> DashboardSessionResponse:
return DashboardSessionResponse(
task_id=row.get("task_id", ""),
transcript=row.get("transcript"),
report_json=row.get("report_json"),
created_at=row.get("created_at"),
updated_at=row.get("updated_at"),
)
@router.get("/by-task/{task_id}", response_model=DashboardSessionResponse)
async def get_dashboard_session(request: Request, task_id: str) -> DashboardSessionResponse:
"""取该 task 的大屏会话(**不按 user 分权**);无则 404。"""
store = _get_store(request)
row = await store.get_by_task(task_id)
if row is None:
raise HTTPException(status_code=404, detail="No dashboard session for this task")
return _project(row)
@router.put("/by-task/{task_id}", response_model=DashboardSessionResponse)
async def upsert_dashboard_session(
request: Request, task_id: str, body: DashboardSessionUpsertRequest
) -> DashboardSessionResponse:
"""按 task upsert 大屏会话(**不按 user 分权**),返回最新快照。"""
store = _get_store(request)
row = await store.upsert_by_task(
task_id,
transcript=body.transcript,
report_json=body.report_json,
user_id=_current_user_id(request),
)
return _project(row)
@@ -0,0 +1,18 @@
"""大屏绘制智能体 session persistence (按 taskId 存取,不分权)."""
from __future__ import annotations
from deerflow.persistence.dashboard_sessions.model import DashboardSessionRow
from deerflow.persistence.dashboard_sessions.sql import DashboardSessionRepository
__all__ = [
"DashboardSessionRepository",
"DashboardSessionRow",
"make_dashboard_session_store",
]
def make_dashboard_session_store(session_factory):
if session_factory is None:
return None
return DashboardSessionRepository(session_factory)
@@ -0,0 +1,49 @@
"""ORM model for the standalone 大屏绘制智能体 session (按 taskId 存取).
One row per external ``task_id`` — the standalone dashboard page persists its
chat transcript + the latest structured ``report_json`` so that returning to the
same task either shows the finished dashboard directly (valid report_json) or
restores the generation process.
**Global / shared by task** — like ``roundtable_drafts`` task-bound drafts, the
store keys on ``task_id`` and does **not** scope by ``user_id`` (open read +
shared writes). ``user_id`` is recorded for audit only.
``transcript`` / ``report_json`` are large JSON blobs (chat history + report
data) stored as serialized strings in ``PortableLongText`` (MySQL ``LONGTEXT``)
to dodge MySQL's 64 KB ``TEXT`` cap — same approach as ``roundtable_drafts``.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableLongText
class DashboardSessionRow(Base):
__tablename__ = "dashboard_sessions"
# One session per task — keep it unique so upsert-by-task is unambiguous.
__table_args__ = (UniqueConstraint("task_id", name="uq_dashboard_sessions_task_id"),)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
# External task id (deep-link). The session is shared by task, not by user.
task_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
# Audit only — who last wrote. Never used for access control.
user_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# Chat transcript JSON (the dashboard agent's dialogue bubbles).
transcript: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# Latest structured dashboard data (report-json string). Presence of a
# parseable value = "有效结果" → the page shows the dashboard directly.
report_json: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(),
nullable=False,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
@@ -0,0 +1,104 @@
"""SQLAlchemy-backed 大屏绘制智能体 session store (按 taskId,不分权).
``transcript`` / ``report_json`` are JSON serialized into ``PortableLongText``
and round-tripped here with ``json.dumps`` / ``json.loads`` — same approach as
``roundtable_drafts``. All access keys on ``task_id`` and ignores ``user_id``
(open read + shared writes); ``user_id`` is recorded for audit only.
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.dashboard_sessions.model import DashboardSessionRow
def _loads(value: Any) -> Any:
if not isinstance(value, str):
return value
if not value:
return None
try:
return json.loads(value)
except Exception:
return None
def _dumps(value: Any) -> str | None:
if value is None:
return None
if isinstance(value, str):
return value # already a JSON string (report_json) — store verbatim
return json.dumps(value, ensure_ascii=False)
class DashboardSessionRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _to_dict(row: DashboardSessionRow) -> dict[str, Any]:
return {
"id": row.id,
"task_id": row.task_id,
"user_id": row.user_id,
"transcript": _loads(row.transcript),
# report_json is a structured-data JSON string; return it as a string
# so the frontend parses it with the same robust parser it uses live.
"report_json": row.report_json,
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
}
async def get_by_task(self, task_id: str) -> dict[str, Any] | None:
"""取该 task 的会话(**忽略 user_id**,不分权)。无则 None。"""
stmt = select(DashboardSessionRow).where(DashboardSessionRow.task_id == task_id).limit(1)
async with self._sf() as session:
result = await session.execute(stmt)
row = result.scalars().first()
return self._to_dict(row) if row is not None else None
async def upsert_by_task(
self,
task_id: str,
*,
transcript: Any = None,
report_json: Any = None,
user_id: str | None = None,
) -> dict[str, Any]:
"""按 task_id upsert:无则建、有则更新(**不分权**)。
``transcript`` / ``report_json`` 仅在传入非 None 时覆盖,便于只更新其中一项。
"""
now = datetime.now(UTC)
async with self._sf() as session:
stmt = select(DashboardSessionRow).where(DashboardSessionRow.task_id == task_id).limit(1)
row = (await session.execute(stmt)).scalars().first()
if row is None:
row = DashboardSessionRow(
id=uuid4().hex,
task_id=task_id,
user_id=user_id,
transcript=_dumps(transcript),
report_json=_dumps(report_json),
created_at=now,
updated_at=now,
)
session.add(row)
else:
if transcript is not None:
row.transcript = _dumps(transcript)
if report_json is not None:
row.report_json = _dumps(report_json)
if user_id is not None:
row.user_id = user_id
row.updated_at = now
await session.commit()
await session.refresh(row)
return self._to_dict(row)
@@ -23,6 +23,7 @@ from deerflow.persistence.agents.model import (
)
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
from deerflow.persistence.business_mapping.model import BusinessMappingRow
from deerflow.persistence.dashboard_sessions.model import DashboardSessionRow
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.knowledge.models import (
@@ -82,6 +83,7 @@ __all__ = [
"AgentSkillRow",
"AdminLeaderboardDailyStatRow",
"BusinessMappingRow",
"DashboardSessionRow",
"KnowledgeEmbeddingRow",
"KnowledgeEntityRow",
"KnowledgeExtractTemplateRow",