{chain.title || "未命名链条"}
@@ -279,7 +355,26 @@ function ChainRow({
公共
)}
+ {!enabled && (
+
+ 已停用
+
+ )}
+ {/* 启停:本人的链条或管理员可操作。 */}
+ {canManage && (
+
+ )}
{/* 发布 / 取消发布:本人的链条或管理员可操作。 */}
{canManage && (
- {ownerName && (
-
-
-
配置人:{ownerName}
+ {(ownerName || updatedLabel) && (
+
+ {ownerName && (
+
+
+ 配置人:{ownerName}
+
+ )}
+ {updatedLabel && (
+
+
+ 最后修改:{updatedLabel}
+
+ )}
)}
@@ -401,3 +503,21 @@ function ChainSeatsFlow({
);
}
+
+/** 后端 `updated_at` 形如 `2026-08-19T17:50:00+08:00`;展示到分钟。 */
+function formatUpdatedAt(iso: string | undefined): string {
+ if (!iso) return "";
+ const date = new Date(iso);
+ if (!Number.isNaN(date.getTime())) {
+ return date.toLocaleString("zh-CN", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ });
+ }
+ const s = iso.replace("T", " ");
+ return s.length >= 16 ? s.slice(0, 16) : s;
+}
diff --git a/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx b/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx
index 11498d84..af4baaff 100644
--- a/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx
+++ b/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx
@@ -34,6 +34,7 @@ import { listChains, type ChainScope } from "../api/chains";
import {
CHAIN_MIN_SEATS,
chainTagClasses,
+ isChainEnabled,
type BusinessChain,
type SeatSelectionResult,
} from "../lib/business-chain";
@@ -77,7 +78,7 @@ export function BusinessChainPicker({
listAgents().catch(() => null), // agent 列表失败不阻断链条展示
]);
if (cancelled) return;
- setChains(chainList);
+ setChains(chainList.filter(isChainEnabled));
if (agents) {
const candidates = filterRecommendCandidates(agents);
setExistingIds(new Set(candidates.map((c) => c.agent_id)));
diff --git a/frontend-web/src/roundtable-planning/lib/business-chain.ts b/frontend-web/src/roundtable-planning/lib/business-chain.ts
index 18d4e953..3f29f2d0 100644
--- a/frontend-web/src/roundtable-planning/lib/business-chain.ts
+++ b/frontend-web/src/roundtable-planning/lib/business-chain.ts
@@ -95,6 +95,11 @@ export interface BusinessChain {
gatherFirst?: boolean;
/** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */
isPublic?: boolean;
+ /**
+ * 启停状态。缺省 / undefined = 启用(存量链条无该字段时按启用处理)。
+ * 停用后管理弹窗仍可见,选链弹窗 / 公共入口不再选用。
+ */
+ enabled?: boolean;
/** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */
isMine?: boolean;
/** 配置者(owner)的可读用户名(后端解析的 email);解析失败时为空。 */
@@ -151,6 +156,11 @@ export interface SeatSelectionResult {
export const CHAIN_MIN_SEATS = 2;
export const CHAIN_MAX_SEATS = 20;
+/** 启停状态:缺省 / undefined 视为启用(存量链条无该字段)。 */
+export function isChainEnabled(chain: Pick
): boolean {
+ return chain.enabled !== false;
+}
+
/**
* 由一条业务链条 + 当前候选智能体,构造 `SeatSelectionResult`。
*
diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
index e3ea2bfe..831da692 100644
--- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
+++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
@@ -134,6 +134,7 @@ import {
} from "@/roundtable-planning/lib/seat-filter";
import {
buildChainSeatSelection,
+ isChainEnabled,
type BusinessChain,
type ChainSeat,
type SeatSelectionResult,
@@ -3395,7 +3396,8 @@ function RoundtableEntryRail({
void listChains("public")
.then((all) => {
if (!cancelled) {
- setChains(lockBusinessChain ? all.filter((chain) => chain.id === lockedChainId) : all);
+ const usable = all.filter(isChainEnabled);
+ setChains(lockBusinessChain ? usable.filter((chain) => chain.id === lockedChainId) : usable);
}
})
.catch(() => {
diff --git a/frontend-web/src/strategy-components/api/report-structures.ts b/frontend-web/src/strategy-components/api/report-structures.ts
new file mode 100644
index 00000000..4a129f30
--- /dev/null
+++ b/frontend-web/src/strategy-components/api/report-structures.ts
@@ -0,0 +1,180 @@
+/**
+ * 深度研究「工作流配置 / 报告结构」API 客户端 + React Query hooks。
+ *
+ * 后端:`/api/report-structures`(见 app/gateway/routers/report_structures.py)。
+ * 全局共享、无用户隔离;新增永远默认启用(服务端强制,不收 enabled 字段)。
+ * snake_case ⇄ camelCase 在此边界收敛。
+ */
+
+import {
+ keepPreviousData,
+ useMutation,
+ useQuery,
+ useQueryClient,
+} from "@tanstack/react-query";
+
+import { apiFetch } from "@/core/api/fetch-client";
+import { getBackendBaseURL } from "@/core/config";
+
+export interface ReportStructure {
+ id: string;
+ /** 标题:卡片主标签,也是列表「标题筛选」的目标。 */
+ title: string;
+ /** 描述:一句话说明这套结构的适用场景(存储列沿用 name)。 */
+ name: string;
+ /** 接口内容:选中卡片后应用到报告结构(custom_outline)的 Markdown 文本。 */
+ content: string;
+ enabled: boolean;
+ /** 创建用户名(本部署为邮箱;系统内置种子为“系统内置”)。 */
+ createdBy: string | null;
+ createdAt: string | null;
+ /** 最后修改时间(编辑、启停都会刷新)。 */
+ updatedAt: string | null;
+}
+
+export type ReportStructureInput = {
+ title: string;
+ name: string;
+ content: string;
+};
+
+export type ReportStructurePatch = Partial;
+
+export type ReportStructureStatusFilter = "all" | "enabled" | "disabled";
+
+const BASE = () => `${getBackendBaseURL()}/api/report-structures`;
+
+interface ServerStructure {
+ id: string;
+ title: string;
+ name: string;
+ content: string;
+ enabled: boolean;
+ created_by: string | null;
+ created_at: string | null;
+ updated_at: string | null;
+}
+
+function toStructure(s: ServerStructure): ReportStructure {
+ return {
+ id: s.id,
+ title: s.title,
+ name: s.name ?? "",
+ content: s.content,
+ enabled: s.enabled ?? true,
+ createdBy: s.created_by ?? null,
+ createdAt: s.created_at ?? null,
+ updatedAt: s.updated_at ?? null,
+ };
+}
+
+async function readJson(res: Response, ctx: string): Promise {
+ if (!res.ok) {
+ const data = await res.json().catch(() => null as unknown);
+ const detail =
+ data && typeof data === "object" && "detail" in data
+ ? String((data as { detail: unknown }).detail)
+ : `${ctx} failed (${res.status})`;
+ throw new Error(detail);
+ }
+ return (await res.json()) as T;
+}
+
+export async function listReportStructures(params: {
+ status?: ReportStructureStatusFilter;
+ title?: string;
+} = {}): Promise {
+ const qs = new URLSearchParams();
+ if (params.status === "enabled") qs.set("enabled", "true");
+ if (params.status === "disabled") qs.set("enabled", "false");
+ const title = params.title?.trim();
+ if (title) qs.set("title", title);
+ const query = qs.toString();
+ const res = await apiFetch(`${BASE()}${query ? `?${query}` : ""}`, { method: "GET" });
+ const data = await readJson<{ structures: ServerStructure[] }>(res, "list report structures");
+ return (data.structures ?? []).map(toStructure);
+}
+
+export async function createReportStructure(input: ReportStructureInput): Promise {
+ const res = await apiFetch(BASE(), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: input.title, name: input.name, content: input.content }),
+ });
+ return toStructure(await readJson(res, "create report structure"));
+}
+
+export async function updateReportStructure(
+ id: string,
+ patch: ReportStructurePatch,
+): Promise {
+ const body: Record = {};
+ if (patch.title !== undefined) body.title = patch.title;
+ if (patch.name !== undefined) body.name = patch.name;
+ if (patch.content !== undefined) body.content = patch.content;
+ const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ return toStructure(await readJson(res, "update report structure"));
+}
+
+export async function setReportStructureEnabled(id: string, enabled: boolean): Promise {
+ const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}/enabled`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ enabled }),
+ });
+ return toStructure(await readJson(res, "toggle report structure"));
+}
+
+export async function deleteReportStructure(id: string): Promise {
+ const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "DELETE" });
+ // 204 无响应体,不能再走 readJson 的 res.json()。
+ if (!res.ok) {
+ const data = (await res.json().catch(() => null)) as { detail?: unknown } | null;
+ throw new Error(
+ data && typeof data.detail === "string" ? data.detail : `delete report structure failed (${res.status})`,
+ );
+ }
+}
+
+// ── React Query hooks(工作流配置弹窗内各视图共用缓存) ──────────────────────
+
+const REPORT_STRUCTURES_KEY = "report-structures";
+
+export function useReportStructures(params: { status?: ReportStructureStatusFilter; title?: string } = {}) {
+ return useQuery({
+ queryKey: [REPORT_STRUCTURES_KEY, "list", params.status ?? "all", params.title ?? ""],
+ queryFn: () => listReportStructures(params),
+ placeholderData: keepPreviousData,
+ refetchOnWindowFocus: false,
+ });
+}
+
+export function useReportStructureMutations() {
+ const queryClient = useQueryClient();
+ const invalidate = () => queryClient.invalidateQueries({ queryKey: [REPORT_STRUCTURES_KEY] });
+
+ const create = useMutation({
+ mutationFn: (input: ReportStructureInput) => createReportStructure(input),
+ onSuccess: invalidate,
+ });
+ const update = useMutation({
+ mutationFn: ({ id, patch }: { id: string; patch: ReportStructurePatch }) =>
+ updateReportStructure(id, patch),
+ onSuccess: invalidate,
+ });
+ const toggleEnabled = useMutation({
+ mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) =>
+ setReportStructureEnabled(id, enabled),
+ onSuccess: invalidate,
+ });
+ const remove = useMutation({
+ mutationFn: (id: string) => deleteReportStructure(id),
+ onSuccess: invalidate,
+ });
+
+ return { create, update, toggleEnabled, remove };
+}
diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md
index 58883967..eb21ba3b 100644
--- a/offline-backend-20260512/backend/CLAUDE.md
+++ b/offline-backend-20260512/backend/CLAUDE.md
@@ -482,6 +482,16 @@ Backs the zzJC / zzXD agent-chat left sidebar (「按类型聚合智能体 + 聊
### Position-collaboration foundation
+### Roundtable business-chain enable/disable
+
+`roundtable_chains.enabled` (BOOLEAN NOT NULL DEFAULT 1, migration `20260819_02`)
+is the per-chain 启停 switch. New and existing chains default to **enabled**.
+Owner or admin can flip it via `PUT /api/roundtable-chains/{id}` `{enabled}`.
+Disabled chains stay visible in the management list (with an 启用/停用 filter)
+and still show `updated_at`; the frontend hides them from the picker / public
+entry rail so they cannot be selected into a roundtable. Tests:
+`tests/test_roundtable_chain_stages.py` (`test_create_defaults_enabled_then_toggle`).
+
### Roundtable business-chain human validation
The existing `/api/roundtable-chains` `seats` JSON also accepts the optional
diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py
index b1316762..1bb4e1b5 100644
--- a/offline-backend-20260512/backend/app/gateway/app.py
+++ b/offline-backend-20260512/backend/app/gateway/app.py
@@ -1,1526 +1,1527 @@
-import asyncio
-import logging
-import os
-import queue
-import sys
-import threading
-from collections.abc import AsyncGenerator
-from contextlib import asynccontextmanager
-from datetime import datetime
-from time import perf_counter
-
-from dotenv import load_dotenv
-from fastapi import FastAPI
-from fastapi.middleware.cors import CORSMiddleware
-
-# Load .env BEFORE any module that reads os.environ at import time.
-# Without this, GATEWAY_CORS_ORIGINS / AUTH_JWT_SECRET / DEER_FLOW_AUTH_DISABLED
-# placed in /workspace/.env are silently ignored when the container is started
-# without explicit `-e` flags, leading to ephemeral JWT secrets, missing CORS
-# origins, and "Failed to fetch" in the browser.
-load_dotenv()
-
-from app.gateway.auth.disabled_mode import is_auth_disabled # noqa: E402
-from app.gateway.auth_middleware import AuthMiddleware
-from app.gateway.config import get_gateway_config
-from app.gateway.csrf_middleware import CSRFMiddleware
-from app.gateway.deps import langgraph_runtime
-from app.gateway.routers import (
- ai_writing,
- admin_active_runs,
- admin_users,
- agents,
- artifacts,
- assistants_compat,
- auth,
- browser_context,
- business_mapping,
+import asyncio
+import logging
+import os
+import queue
+import sys
+import threading
+from collections.abc import AsyncGenerator
+from contextlib import asynccontextmanager
+from datetime import datetime
+from time import perf_counter
+
+from dotenv import load_dotenv
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+
+# Load .env BEFORE any module that reads os.environ at import time.
+# Without this, GATEWAY_CORS_ORIGINS / AUTH_JWT_SECRET / DEER_FLOW_AUTH_DISABLED
+# placed in /workspace/.env are silently ignored when the container is started
+# without explicit `-e` flags, leading to ephemeral JWT secrets, missing CORS
+# origins, and "Failed to fetch" in the browser.
+load_dotenv()
+
+from app.gateway.auth.disabled_mode import is_auth_disabled # noqa: E402
+from app.gateway.auth_middleware import AuthMiddleware
+from app.gateway.config import get_gateway_config
+from app.gateway.csrf_middleware import CSRFMiddleware
+from app.gateway.deps import langgraph_runtime
+from app.gateway.routers import (
+ ai_writing,
+ admin_active_runs,
+ admin_users,
+ agents,
+ artifacts,
+ assistants_compat,
+ auth,
+ browser_context,
+ business_mapping,
channels,
checkpoint_migration,
compact_menu_overrides,
feedback,
- dashboard_sessions,
- deep_research,
- html_page_favorites,
- intent,
- knowledge,
- knowledge_ingest,
- light_apps,
- llm_metrics,
- llmwiki,
- mcp,
- memory,
- menu_overrides,
- model_management,
- models,
- multi_agent,
- notifications,
- open_3qfx,
- open_chat,
- parallel_agent_tasks,
- parallel_agents,
- position_roundtable,
- positions,
- position_roles,
- public_config,
- public_embed,
- public_leaderboard,
- public_scheduled_tasks,
- recommend,
- flow_extract,
- recommended_questions,
- roundtable_chains,
- roundtable_artifact_submissions,
- roundtable_diagnostics,
- roundtable_draft_shares,
- roundtable_drafts,
- roundtable_jobs,
- roundtable_task_drafts,
- runs,
- scheduled_tasks,
- sensitive_words,
- skill_addresses,
- skills,
- suggestions,
- system_settings,
- tags,
- task_buttons,
- taskcop_tasks,
- task_reports,
- thread_runs,
- tool_metrics,
- thread_shares,
- threads,
- uploads,
- user_preferences,
- user_prompts,
- writing,
-)
-from deerflow.config import app_config as deerflow_app_config
-from deerflow.config.app_config import apply_logging_level
-from deerflow.runtime.scheduler import ScheduledTaskService, set_scheduled_task_service
-
-AppConfig = deerflow_app_config.AppConfig
-get_app_config = deerflow_app_config.get_app_config
-
-# Default logging; lifespan overrides from config.yaml log_level.
-logging.basicConfig(
- level=logging.INFO,
- format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
- datefmt="%Y-%m-%d %H:%M:%S",
-)
-
-logger = logging.getLogger(__name__)
-
-# Upper bound (seconds) each lifespan shutdown hook is allowed to run.
-# Bounds worker exit time so uvicorn's reload supervisor does not keep
-# firing signals into a worker that is stuck waiting for shutdown cleanup.
-_SHUTDOWN_HOOK_TIMEOUT_SECONDS = 5.0
-
-
-async def _ensure_admin_user(app: FastAPI) -> None:
- """Startup hook: handle first boot and migrate orphan threads otherwise.
-
- After admin creation, migrate orphan threads from the LangGraph
- store (metadata.user_id unset) to the admin account. This is the
- "no-auth ? with-auth" upgrade path: users who ran DeerFlow without
- authentication have existing LangGraph thread data that needs an
- owner assigned.
- First boot (no admin exists):
- - Does NOT create any user accounts automatically.
- - The operator must visit ``/setup`` to create the first admin.
-
- Subsequent boots (admin already exists):
- - Runs the one-time "no-auth ? with-auth" orphan thread migration for
- existing LangGraph thread metadata that has no owner_id.
-
- No SQL persistence migration is needed: the four user_id columns
- (threads_meta, runs, run_events, feedback) only come into existence
- alongside the auth module via create_all, so freshly created tables
- never contain NULL-owner rows.
- """
- from sqlalchemy import select
-
- from app.gateway.deps import get_local_provider
- from deerflow.persistence.engine import get_session_factory
- from deerflow.persistence.user.model import UserRow
-
- try:
- provider = get_local_provider()
- except RuntimeError:
- # Auth persistence may not be initialized in some test/boot paths.
- # Skip admin migration work rather than failing gateway startup.
- logger.warning("Auth persistence not ready; skipping admin bootstrap check")
- return
-
- sf = get_session_factory()
- if sf is None:
- return
-
- admin_count = await provider.count_admin_users()
-
- if admin_count == 0:
- logger.info("=" * 60)
- logger.info(" First boot detected ? no admin account exists.")
- logger.info(" Visit /setup to complete admin account creation.")
- logger.info("=" * 60)
- return
-
- # Admin already exists ? run orphan thread migration for any
- # LangGraph thread metadata that pre-dates the auth module.
- async with sf() as session:
- stmt = select(UserRow).where(UserRow.system_role == "admin").limit(1)
- row = (await session.execute(stmt)).scalar_one_or_none()
-
- if row is None:
- return # Should not happen (admin_count > 0 above), but be safe.
-
- admin_id = str(row.id)
-
- # LangGraph store orphan migration ? non-fatal.
- # This covers the "no-auth ? with-auth" upgrade path for users
- # whose existing LangGraph thread metadata has no user_id set.
- store = getattr(app.state, "store", None)
- if store is not None:
- try:
- migrated = await _migrate_orphaned_threads(store, admin_id)
- if migrated:
- logger.info("Migrated %d orphan LangGraph thread(s) to admin", migrated)
- except Exception:
- logger.exception("LangGraph thread migration failed (non-fatal)")
-
-
-async def _iter_store_items(store, namespace, *, page_size: int = 500):
- """Paginated async iterator over a LangGraph store namespace.
-
- Replaces the old hardcoded ``limit=1000`` call with a cursor-style
- loop so that environments with more than one page of orphans do
- not silently lose data. Terminates when a page is empty OR when a
- short page arrives (indicating the last page).
- """
- offset = 0
- while True:
- batch = await store.asearch(namespace, limit=page_size, offset=offset)
- if not batch:
- return
- for item in batch:
- yield item
- if len(batch) < page_size:
- return
- offset += page_size
-
-
-async def _sync_legacy_skills(app: FastAPI) -> None:
- """Idempotently register existing on-disk ``skills/custom/*`` directories
- in the ``skills`` DB table, owned by the first admin and published=True.
-
- Without this step, after upgrading to per-user skill ownership the existing
- custom skill folders would not be visible to anyone (the SkillStore
- visibility filter relies on a DB row). Running this once on every boot is
- cheap (folder scan + one ``ensure_legacy`` per name) and idempotent.
-
- Non-fatal: a failure here MUST NOT block server startup.
- """
- from sqlalchemy import select
-
- from deerflow.persistence.engine import get_session_factory
- from deerflow.persistence.user.model import UserRow
- from deerflow.skills.storage import get_or_new_skill_storage
- from deerflow.skills.types import SkillCategory
-
- sf = get_session_factory()
- if sf is None:
- return
-
- skill_store = getattr(app.state, "skill_store", None)
- if skill_store is None:
- return
-
- async with sf() as session:
- stmt = select(UserRow).where(UserRow.system_role == "admin").limit(1)
- row = (await session.execute(stmt)).scalar_one_or_none()
- if row is None:
- # No admin yet ? leave ownership null; first admin is responsible for
- # claiming via the UI once they exist.
- admin_id: str | None = None
- else:
- admin_id = str(row.id)
-
- config = getattr(app.state, "config", None)
- storage = get_or_new_skill_storage(app_config=config)
- try:
- skills = storage.load_skills(enabled_only=False)
- except Exception:
- logger.exception("_sync_legacy_skills: could not list on-disk skills")
- return
-
- inserted = 0
- for skill in skills:
- if skill.category != SkillCategory.CUSTOM:
- continue
- try:
- existing = await skill_store.get_any(skill.name)
- if existing is not None:
- continue
- await skill_store.ensure_legacy(
- {"name": skill.name, "owner_user_id": admin_id, "published": True}
- )
- inserted += 1
- except Exception:
- logger.warning("_sync_legacy_skills: could not register %s", skill.name, exc_info=True)
- if inserted:
- logger.info("Skill ownership migration: registered %d legacy custom skill(s)", inserted)
-
-
-async def _migrate_orphaned_threads(store, admin_user_id: str) -> int:
- """Migrate LangGraph store threads with no user_id to the given admin.
-
- Uses cursor pagination so all orphans are migrated regardless of
- count. Returns the number of rows migrated.
- """
- migrated = 0
- async for item in _iter_store_items(store, ("threads",)):
- metadata = item.value.get("metadata", {})
- if not metadata.get("user_id"):
- metadata["user_id"] = admin_user_id
- item.value["metadata"] = metadata
- await store.aput(("threads",), item.key, item.value)
- migrated += 1
- return migrated
-
-
-async def _run_db_migrations() -> None:
- """Run Alembic migrations at startup so schema changes are applied automatically."""
- from pathlib import Path
-
- from alembic import command
- from alembic.config import Config
-
- import deerflow.persistence as _persistence_pkg
- from deerflow.persistence.engine import get_engine
-
- engine = get_engine()
- if engine is None:
- logger.info("Persistence backend=memory ? skipping Alembic migrations")
- return
-
- if os.getenv("DEER_FLOW_SKIP_ALEMBIC", "").lower() in {"1", "true", "yes"}:
- logger.warning("DEER_FLOW_SKIP_ALEMBIC is set; skipping Alembic migrations")
- return
-
- db_url = engine.url.render_as_string(hide_password=False).replace("%", "%%")
- migrations_dir = str(Path(_persistence_pkg.__file__).parent / "migrations")
- result_queue: queue.Queue[BaseException | None] = queue.Queue(maxsize=1)
-
- def _run_sync() -> None:
- try:
- cfg = Config()
- cfg.set_main_option("script_location", migrations_dir)
- cfg.set_main_option("sqlalchemy.url", db_url)
- command.upgrade(cfg, "head")
- except BaseException as exc:
- result_queue.put(exc)
- else:
- result_queue.put(None)
-
- try:
- try:
- timeout_s = float(os.getenv("DEER_FLOW_ALEMBIC_TIMEOUT", "60"))
- except ValueError:
- timeout_s = 60.0
-
- worker = threading.Thread(target=_run_sync, name="deerflow-alembic-upgrade", daemon=True)
- worker.start()
- try:
- deadline = asyncio.get_running_loop().time() + timeout_s
- while result_queue.empty():
- if asyncio.get_running_loop().time() >= deadline:
- raise asyncio.TimeoutError
- await asyncio.sleep(0.2)
- result = result_queue.get_nowait()
- except asyncio.TimeoutError:
- logger.error(
- "Database migration did not finish within %.0fs; continuing startup on create_all schema. "
- "Likely cause: MySQL metadata-lock wait or a slow DDL. Set DEER_FLOW_SKIP_ALEMBIC=1 "
- "to skip startup migrations, or inspect MySQL with SHOW FULL PROCESSLIST.",
- timeout_s,
- )
- return
- if result is not None:
- raise result
- logger.info("Database migrations applied (head)")
- except Exception:
- logger.exception("Database migration failed ? schema may be out of date")
-
-
-# Skill curator ?????
-# - ????:?????????"??????";??????? should_run_now() ???
-# - ????:?????????????,???????
-_CURATOR_CHECK_INTERVAL_SECONDS = 3600 # 1 ??
-_CURATOR_STARTUP_DELAY_SECONDS = 300 # 5 ??
-
-
-def _acquire_singleton_lock(lock_path):
- """???????????,??? worker ?? leader ???
-
- ?? -> ??????????(?????????,??????/????);
- ????(???? worker ??)????? -> ?? None?
- ??????? OS ????????
- """
- try:
- lock_path.parent.mkdir(parents=True, exist_ok=True)
- handle = open(lock_path, "a+")
- except OSError:
- logger.exception("Curator scheduler: ???????")
- return None
- try:
- if sys.platform == "win32":
- import msvcrt
-
- handle.seek(0)
- msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
- else:
- import fcntl
-
- fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
- except OSError:
- handle.close()
- return None
- return handle
-
-
-def _release_singleton_lock(handle) -> None:
- """????? _acquire_singleton_lock ?????????"""
- try:
- if sys.platform == "win32":
- import msvcrt
-
- handle.seek(0)
- msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
- else:
- import fcntl
-
- fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
- except OSError:
- pass
- finally:
- try:
- handle.close()
- except OSError:
- pass
-
-
-async def _curator_scheduler_loop() -> None:
- """??????:?? _CURATOR_CHECK_INTERVAL_SECONDS ???????????
-
- ? worker ???????? leader ?? ?? ???????? worker ????
- ????,?? worker ????,?? curator ?????????? LLM?
- ? cancel ? CancelledError ??? ``except Exception``(?? BaseException
- ??,?????),?????????? finally ?????
- """
- from deerflow.config.runtime_paths import runtime_home
- from deerflow.skills.curator import maybe_run_curator
-
- lock_handle = _acquire_singleton_lock(runtime_home() / ".curator_scheduler.lock")
- if lock_handle is None:
- logger.info("Curator scheduler: ??? worker ??????,? worker ??")
- return
-
- logger.info("Curator scheduler: ?????,? worker ?? curator ??")
- try:
- # ?????????????,??????
- await asyncio.sleep(_CURATOR_STARTUP_DELAY_SECONDS)
- while True:
- try:
- await maybe_run_curator()
- except Exception:
- logger.exception("Curator scheduler tick failed (non-fatal)")
- await asyncio.sleep(_CURATOR_CHECK_INTERVAL_SECONDS)
- finally:
- _release_singleton_lock(lock_handle)
-
-
-def _env_bool(name: str, default: bool) -> bool:
- raw = os.getenv(name)
- if raw is None:
- return default
- return raw.strip().lower() not in {"0", "false", "no", "off"}
-
-
-def _env_int(name: str, default: int, *, alias: str | None = None, minimum: int = 1) -> int:
- raw = os.getenv(name)
- if raw is None and alias:
- raw = os.getenv(alias)
- if raw is None:
- raw = str(default)
- try:
- value = int(raw)
- except ValueError:
- return default
- return max(minimum, value)
-
-
-def _env_float(name: str, default: float, *, alias: str | None = None, minimum: float = 0.0) -> float:
- raw = os.getenv(name)
- if raw is None and alias:
- raw = os.getenv(alias)
- if raw is None:
- raw = str(default)
- try:
- value = float(raw)
- except ValueError:
- return default
- return max(minimum, value)
-
-
-_LEADERBOARD_SNAPSHOT_BACKGROUND_ENABLED = _env_bool("ADMIN_LEADERBOARD_BACKGROUND_ENABLED", True)
-_LEADERBOARD_SNAPSHOT_PREWARM_DAYS = _env_int(
- "ADMIN_LEADERBOARD_BACKGROUND_PREWARM_DAYS",
- 30,
-)
-_LEADERBOARD_SNAPSHOT_BATCH_SIZE = _env_int(
- "ADMIN_LEADERBOARD_BACKGROUND_BATCH_SIZE",
- 1,
-)
-_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS = _env_float(
- "ADMIN_LEADERBOARD_BACKGROUND_INTERVAL_SECONDS",
- 300.0,
-)
-_LEADERBOARD_SNAPSHOT_SLEEP_SECONDS = _env_float(
- "ADMIN_LEADERBOARD_BACKGROUND_SLEEP_SECONDS",
- 2.0,
-)
-_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS = _env_int(
- "ADMIN_LEADERBOARD_BACKGROUND_RUNNING_TTL_SECONDS",
- 1800,
-)
-_LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS = _env_float(
- "ADMIN_LEADERBOARD_BACKGROUND_STARTUP_DELAY_SECONDS",
- 60.0,
-)
-# Concurrency (system-pressure) sampler: how often to snapshot the in-flight run
-# count, and how long to keep samples (drives the admin 并发量 line chart, which
-# aggregates per minute/hour). Sampling is in-memory cheap (one tiny INSERT).
-_CONCURRENCY_SAMPLE_ENABLED = _env_bool("CONCURRENCY_SAMPLE_ENABLED", True)
-_CONCURRENCY_SAMPLE_INTERVAL_SECONDS = _env_float(
- "CONCURRENCY_SAMPLE_INTERVAL_SECONDS",
- 15.0,
-)
-_CONCURRENCY_SAMPLE_RETENTION_HOURS = _env_float(
- "CONCURRENCY_SAMPLE_RETENTION_HOURS",
- 168.0, # 7 days
-)
-# Hour (local Beijing time, 0-23) after which the scheduler force-recomputes the
-# *previous* day's snapshot once, so records that landed near midnight are fully
-# captured. Default 1 ? "???? 1 ????????".
-_LEADERBOARD_FINALIZE_HOUR = min(
- 23,
- _env_int("ADMIN_LEADERBOARD_FINALIZE_HOUR", 1, minimum=0),
-)
-
-
-def _leaderboard_scheduler_sql_for_log(stmt) -> str:
- try:
- return str(stmt.compile(compile_kwargs={"literal_binds": True}))
- except Exception:
- return str(stmt)
-
-
-async def _execute_leaderboard_scheduler_query(session, step: str, stmt, **context):
- sql = _leaderboard_scheduler_sql_for_log(stmt)
- logger.info(
- "Leaderboard snapshot DB query start: step=%s stat_date=%s job=%s settings_hash=%s started_at=%s sql=%s",
- step,
- context.get("stat_date") or context.get("stat_dates") or "",
- context.get("job_key") or "",
- context.get("settings_hash") or "",
- datetime.now().isoformat(),
- sql,
- )
- start = perf_counter()
- try:
- result = await session.execute(stmt)
- except Exception:
- logger.exception(
- "Leaderboard snapshot DB query failed: step=%s stat_date=%s job=%s settings_hash=%s elapsed_ms=%.2f",
- step,
- context.get("stat_date") or context.get("stat_dates") or "",
- context.get("job_key") or "",
- context.get("settings_hash") or "",
- (perf_counter() - start) * 1000,
- )
- raise
- logger.info(
- "Leaderboard snapshot DB query done: step=%s stat_date=%s job=%s settings_hash=%s finished_at=%s elapsed_ms=%.2f",
- step,
- context.get("stat_date") or context.get("stat_dates") or "",
- context.get("job_key") or "",
- context.get("settings_hash") or "",
- datetime.now().isoformat(),
- (perf_counter() - start) * 1000,
- )
- return result
-
-
-async def _time_leaderboard_scheduler_db_operation(step: str, operation, *, sql: str = "", **context):
- logger.info(
- "Leaderboard snapshot DB query start: step=%s stat_date=%s job=%s settings_hash=%s started_at=%s sql=%s",
- step,
- context.get("stat_date") or context.get("stat_dates") or "",
- context.get("job_key") or "",
- context.get("settings_hash") or "",
- datetime.now().isoformat(),
- sql,
- )
- start = perf_counter()
- try:
- result = await operation()
- except Exception:
- logger.exception(
- "Leaderboard snapshot DB query failed: step=%s stat_date=%s job=%s settings_hash=%s elapsed_ms=%.2f",
- step,
- context.get("stat_date") or context.get("stat_dates") or "",
- context.get("job_key") or "",
- context.get("settings_hash") or "",
- (perf_counter() - start) * 1000,
- )
- raise
- logger.info(
- "Leaderboard snapshot DB query done: step=%s stat_date=%s job=%s settings_hash=%s finished_at=%s elapsed_ms=%.2f",
- step,
- context.get("stat_date") or context.get("stat_dates") or "",
- context.get("job_key") or "",
- context.get("settings_hash") or "",
- datetime.now().isoformat(),
- (perf_counter() - start) * 1000,
- )
- return result
-
-
-def _recent_leaderboard_stat_dates(days: int) -> list[str]:
- from datetime import datetime, timedelta
-
- from deerflow.persistence.types import BEIJING_TZ
-
- today = datetime.now(BEIJING_TZ).date()
- start = today - timedelta(days=max(1, days) - 1)
- return [(start + timedelta(days=i)).isoformat() for i in range((today - start).days + 1)]
-
-
-async def _leaderboard_snapshot_due_dates(
- session_factory,
- *,
- settings_hash: str,
- limit: int,
- running_ttl_seconds: int,
-) -> list[str]:
- from datetime import UTC, datetime, timedelta
-
- from sqlalchemy import and_, or_, select
-
- from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
-
- cutoff = datetime.now(UTC) - timedelta(seconds=running_ttl_seconds)
- async with session_factory() as session:
- stmt = (
- select(AdminLeaderboardDailyStatRow.stat_date)
- .where(
- AdminLeaderboardDailyStatRow.settings_hash == settings_hash,
- or_(
- AdminLeaderboardDailyStatRow.status.in_(["queued", "error"]),
- and_(
- AdminLeaderboardDailyStatRow.status == "running",
- AdminLeaderboardDailyStatRow.updated_at < cutoff,
- ),
- ),
- )
- .order_by(AdminLeaderboardDailyStatRow.stat_date.desc())
- .limit(limit)
- )
- rows = (
- await _execute_leaderboard_scheduler_query(
- session,
- "scheduler.due_dates",
- stmt,
- settings_hash=settings_hash,
- )
- ).all()
- return [str(row[0]) for row in rows]
-
-
-async def _claim_leaderboard_snapshot(
- session_factory,
- *,
- stat_date: str,
- settings_hash: str,
- running_ttl_seconds: int,
-) -> bool:
- from datetime import UTC, datetime
-
- from app.gateway.routers.admin_users import _snapshot_is_fresh
- from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
- from deerflow.persistence.types import BEIJING_TZ
-
- async with session_factory() as session:
- row = await _time_leaderboard_scheduler_db_operation(
- "scheduler.claim.get",
- lambda: session.get(
- AdminLeaderboardDailyStatRow,
- {"stat_date": stat_date, "settings_hash": settings_hash},
- ),
- sql="SELECT admin_leaderboard_daily_stats by primary key",
- stat_date=stat_date,
- settings_hash=settings_hash,
- )
- if row is None or _snapshot_is_fresh(row, stat_date):
- return False
- if row.status == "running":
- updated_at = row.updated_at
- if isinstance(updated_at, datetime):
- if updated_at.tzinfo is None:
- updated_at = updated_at.replace(tzinfo=BEIJING_TZ)
- age = (datetime.now(BEIJING_TZ) - updated_at.astimezone(BEIJING_TZ)).total_seconds()
- if age < running_ttl_seconds:
- return False
- row.status = "running"
- row.message = "?????????????"
- row.error = None
- row.updated_at = datetime.now(UTC)
- await _time_leaderboard_scheduler_db_operation(
- "scheduler.claim.commit",
- lambda: session.commit(),
- sql="COMMIT",
- stat_date=stat_date,
- settings_hash=settings_hash,
- )
- return True
-
-
-async def _maybe_finalize_previous_leaderboard_day(session_factory, settings_hash: str) -> bool:
- """Once per day after ``_LEADERBOARD_FINALIZE_HOUR``, force-recompute yesterday.
-
- A ``ready`` past-day snapshot is otherwise treated as fresh forever and never
- rebuilt. But the previous day's snapshot is usually generated before midnight,
- so it can miss runs/tool-calls that only land in the DB around the day
- boundary. This re-queues yesterday (``force=True``) a single time; the rebuild
- stamps ``generated_at`` to today, which stops it from re-firing for the rest of
- the day.
- """
- from datetime import timedelta
-
- from app.gateway.routers.admin_users import (
- _as_beijing,
- _queue_daily_leaderboard_snapshots,
- )
- from deerflow.persistence.admin_stats.finalize import should_finalize_previous_day
- from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
- from deerflow.persistence.types import BEIJING_TZ
-
- now = datetime.now(BEIJING_TZ)
- yesterday = (now.date() - timedelta(days=1)).isoformat()
- async with session_factory() as session:
- row = await session.get(
- AdminLeaderboardDailyStatRow,
- {"stat_date": yesterday, "settings_hash": settings_hash},
- )
- generated_at = _as_beijing(row.generated_at) if (row is not None and row.generated_at is not None) else None
- if not should_finalize_previous_day(
- status=(row.status if row is not None else None),
- generated_at=generated_at,
- now=now,
- finalize_hour=_LEADERBOARD_FINALIZE_HOUR,
- ):
- return False
- queued = await _queue_daily_leaderboard_snapshots(
- session_factory,
- [yesterday],
- settings_hash,
- force=True,
- message="???????????????????????????",
- )
- if queued:
- logger.info(
- "Leaderboard snapshot finalize: queued previous day %s for full recompute (hash=%s)",
- yesterday,
- settings_hash[:10],
- )
- return True
- return False
-
-
-async def _run_leaderboard_snapshot_tick() -> tuple[int, int]:
- from app.gateway.routers.admin_users import (
- _daily_job_key,
- _queue_daily_leaderboard_snapshots,
- _refresh_daily_leaderboard_snapshot,
- _resolve_leaderboard_snapshot_scope,
- )
- from deerflow.config.system_settings import load_system_settings
- from deerflow.persistence.engine import get_session_factory
-
- session_factory = get_session_factory()
- if session_factory is None:
- return 0, 0
-
- settings = load_system_settings().leaderboard
- excluded, include_scheduled, include_admins, include_failed, settings_hash = _resolve_leaderboard_snapshot_scope(settings)
- stat_dates = _recent_leaderboard_stat_dates(_LEADERBOARD_SNAPSHOT_PREWARM_DAYS)
- queued_dates = await _queue_daily_leaderboard_snapshots(
- session_factory,
- stat_dates,
- settings_hash,
- message="?????????????/?????",
- )
-
- # ??????? 1 ???????"???"????????????????????
- try:
- await _maybe_finalize_previous_leaderboard_day(session_factory, settings_hash)
- except Exception:
- logger.exception("Leaderboard snapshot finalize check failed (non-fatal)")
-
- due_dates = await _leaderboard_snapshot_due_dates(
- session_factory,
- settings_hash=settings_hash,
- limit=_LEADERBOARD_SNAPSHOT_BATCH_SIZE,
- running_ttl_seconds=_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS,
- )
- processed = 0
- for stat_date in due_dates:
- claimed = await _claim_leaderboard_snapshot(
- session_factory,
- stat_date=stat_date,
- settings_hash=settings_hash,
- running_ttl_seconds=_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS,
- )
- if not claimed:
- continue
- logger.info("Leaderboard snapshot scheduler: building date=%s hash=%s", stat_date, settings_hash[:10])
- await _refresh_daily_leaderboard_snapshot(
- _daily_job_key(stat_date, settings_hash),
- session_factory=session_factory,
- settings=settings,
- stat_date=stat_date,
- settings_hash=settings_hash,
- excluded=excluded,
- include_scheduled=include_scheduled,
- include_admins=include_admins,
- include_failed=include_failed,
- )
- processed += 1
- if _LEADERBOARD_SNAPSHOT_SLEEP_SECONDS > 0 and processed < len(due_dates):
- await asyncio.sleep(_LEADERBOARD_SNAPSHOT_SLEEP_SECONDS)
- return processed, len(queued_dates)
-
-
-async def _leaderboard_snapshot_scheduler_loop() -> None:
- """Background task inside the existing gateway process for daily snapshots."""
- if not _LEADERBOARD_SNAPSHOT_BACKGROUND_ENABLED:
- logger.info("Leaderboard snapshot scheduler disabled by ADMIN_LEADERBOARD_BACKGROUND_ENABLED")
- return
-
- from deerflow.config.runtime_paths import runtime_home
-
- lock_handle = _acquire_singleton_lock(runtime_home() / ".leaderboard_snapshot_scheduler.lock")
- if lock_handle is None:
- logger.info("Leaderboard snapshot scheduler: another gateway worker holds the lock; this worker skips")
- return
-
- logger.info(
- "Leaderboard snapshot scheduler started: prewarm_days=%d batch_size=%d interval=%.1fs",
- _LEADERBOARD_SNAPSHOT_PREWARM_DAYS,
- _LEADERBOARD_SNAPSHOT_BATCH_SIZE,
- _LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS,
- )
- try:
- if _LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS > 0:
- await asyncio.sleep(_LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS)
- while True:
- try:
- processed, queued = await _run_leaderboard_snapshot_tick()
- if processed or queued:
- logger.info(
- "Leaderboard snapshot scheduler tick: queued=%d processed=%d",
- queued,
- processed,
- )
- except Exception:
- logger.exception("Leaderboard snapshot scheduler tick failed (non-fatal)")
- await asyncio.sleep(_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS)
- finally:
- _release_singleton_lock(lock_handle)
-
-
-async def _concurrency_sampler_loop(app: FastAPI) -> None:
- """Periodically record the in-flight run count for the admin pressure chart.
-
- Runs in the gateway process, singleton-locked so only one worker samples.
- Each tick records ``len(run_manager.list_active())`` and, roughly hourly,
- purges samples older than the retention window. Best-effort throughout — a
- failed tick is logged and the loop continues.
- """
- if not _CONCURRENCY_SAMPLE_ENABLED:
- logger.info("Concurrency sampler disabled by CONCURRENCY_SAMPLE_ENABLED")
- return
-
- store = getattr(app.state, "concurrency_sample_store", None)
- run_mgr = getattr(app.state, "run_manager", None)
- concurrency_gate = getattr(app.state, "concurrency_gate", None)
- if store is None or run_mgr is None:
- logger.info("Concurrency sampler not started (no store / run manager — memory backend?)")
- return
-
- from deerflow.config.runtime_paths import runtime_home
-
- lock_handle = _acquire_singleton_lock(runtime_home() / ".concurrency_sampler.lock")
- if lock_handle is None:
- logger.info("Concurrency sampler: another gateway worker holds the lock; this worker skips")
- return
-
- interval = max(1.0, _CONCURRENCY_SAMPLE_INTERVAL_SECONDS)
- purge_every_ticks = max(1, int(3600 / interval)) # ~once per hour
- logger.info("Concurrency sampler started: interval=%.1fs retention=%.1fh", interval, _CONCURRENCY_SAMPLE_RETENTION_HOURS)
- ticks = 0
- try:
- while True:
- try:
- # Runtime toggle (admin can turn sampling off without a restart).
- # mtime-cached read, so this is cheap every tick.
- from deerflow.config.system_settings import get_concurrency_monitor_settings
-
- if get_concurrency_monitor_settings().enabled:
- active_count = None
- if concurrency_gate is not None:
- active_count = await concurrency_gate.count_active()
- if active_count is None:
- active = await run_mgr.list_active()
- active_count = len(active)
- await store.record(active_count)
- ticks += 1
- if ticks % purge_every_ticks == 0:
- from datetime import UTC, datetime, timedelta
-
- cutoff = datetime.now(UTC) - timedelta(hours=_CONCURRENCY_SAMPLE_RETENTION_HOURS)
- await store.purge_older_than(cutoff)
- except Exception:
- logger.exception("Concurrency sampler tick failed (non-fatal)")
- await asyncio.sleep(interval)
- finally:
- _release_singleton_lock(lock_handle)
-
-
-def _warn_if_multi_worker() -> None:
- """?? uvicorn / gunicorn ? worker ???? ERROR ???
-
- ? agent / canvas / AI ??????? Gateway ????? ``StreamBridge``
- ?? run_id ? SSE ?????? map?? worker ????? worker A ??
- ``/runs/stream``???? worker B ? join/resume??? in-process map ??
- ???? 404 / ???
-
- ????? startup ? ERROR ????**???? fail-fast** ?? ??
- ???????????????? sticky session??????????
- """
- # uvicorn / gunicorn ??????? worker ?????????????
- # ?????? sibling ??????????? WEB_CONCURRENCY?gunicorn
- # / uvicorn ????
- web_concurrency = os.environ.get("WEB_CONCURRENCY", "").strip()
- if web_concurrency and web_concurrency.isdigit() and int(web_concurrency) > 1:
- logger.error(
- "? ??? WEB_CONCURRENCY=%s?? worker ????AI ?? / ? agent "
- "??????????????? worker ????????? / "
- "SSE ?? / resume 400??????????? WEB_CONCURRENCY=1 "
- "????????? sticky session??? AIWritingPage.tsx ????",
- web_concurrency,
- )
- return
-
- # ???? /proc ??? uvicorn ?????? Linux ??????
- if not sys.platform.startswith("linux"):
- return
- try:
- from pathlib import Path
-
- peers = 0
- for entry in Path("/proc").iterdir():
- if not entry.name.isdigit():
- continue
- try:
- cmdline = (entry / "cmdline").read_bytes().replace(b"\x00", b" ").decode("utf-8", "ignore")
- except OSError:
- continue
- if "uvicorn" in cmdline and "app.gateway.app" in cmdline:
- peers += 1
- if peers > 1:
- logger.error(
- "? ??? %d ??? uvicorn ???? worker ????? AI ?? "
- "????? / SSE ??????????? worker ??????? "
- "?? sticky session?",
- peers,
- )
- except Exception:
- # ????????? ?? ??????
- pass
-
-
-@asynccontextmanager
-async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
- """Application lifespan handler."""
-
- # ?????? worker ?? ?? ?? AI ?? / ? agent ???????
- # ??????? worker ???????? ERROR ?????
- _warn_if_multi_worker()
-
- # Load config and check necessary environment variables at startup
- try:
- app.state.config = get_app_config()
- apply_logging_level(app.state.config.log_level)
- logger.info("Configuration loaded successfully")
- except Exception as e:
- error_msg = f"Failed to load configuration during gateway startup: {e}"
- logger.exception(error_msg)
- raise RuntimeError(error_msg) from e
- config = get_gateway_config()
- logger.info(f"Starting API Gateway on {config.host}:{config.port}")
-
- # Initialize LangGraph runtime components (StreamBridge, RunManager, checkpointer, store)
- async with langgraph_runtime(app):
- logger.info("LangGraph runtime initialised")
-
- # Run Alembic migrations before anything else touches the schema.
- await _run_db_migrations()
-
- # ?? recall_budget cache?? DB ????,? get_effective_memory_config ?????
- try:
- from deerflow.config.memory_config import load_recall_budget_cache
-
- _mc_store = getattr(app.state, "memory_config_store", None)
- if _mc_store is not None:
- _recall_budgets = await _mc_store.load_all()
- load_recall_budget_cache(_recall_budgets)
- logger.info("Memory config cache warmed: %d user override(s)", len(_recall_budgets))
- except Exception:
- logger.exception("Memory config cache warm-up failed (non-fatal)")
-
- # Ensure admin user exists (auto-create on first boot)
- # Must run AFTER langgraph_runtime so app.state.store is available for thread migration
- await _ensure_admin_user(app)
-
- # Migrate "ownerless" on-disk skills into the new SkillStore table ?
- # makes the multitenancy switch transparent for existing deployments.
- try:
- await _sync_legacy_skills(app)
- except Exception:
- logger.exception("Skill ownership migration failed (non-fatal)")
-
- # ????? agent(intent / recommender / coordinator)???????
- # ??? _sync_legacy_agents **??**??????????????????
- # ??? upsert ? agents ?,?????? /api/agents ?????,
- # ????????????????.deer-flow/ ? gitignore,??? clone
- # ????????,seeder ????? _roundtable_seed_assets/ ??????
- try:
- from app.gateway.routers._roundtable_seed import (
- ensure_roundtable_functional_agents,
- )
-
- ensure_roundtable_functional_agents()
- except Exception:
- logger.exception(
- "Roundtable functional agent seeding failed at startup (non-fatal)"
- )
-
- # AI ????? agent(researcher / outliner / writer / editor)???????
- # ???:??? _sync_legacy_agents ??,?????????????
- # upsert ? agents ?,admin ?????????????????? agent?
- try:
- from app.gateway.routers._ai_writing_seed import (
- ensure_ai_writing_functional_agents,
- )
-
- ensure_ai_writing_functional_agents()
- except Exception:
- logger.exception(
- "AI-writing functional agent seeding failed at startup (non-fatal)"
- )
-
- # 内置「模板数据转换器」agent(JSON → HTML 模板填充)。同样必须在
- # _sync_legacy_agents **之前**补建目录,后者把它 upsert 进 agents 表,
- # 第一次启动 /api/agents 与定时任务下拉框里就有它。
- try:
- from app.gateway.routers._template_builder_seed import (
- ensure_template_builder_agent,
- )
-
- ensure_template_builder_agent()
- except Exception:
- logger.exception(
- "Template-builder agent seeding failed at startup (non-fatal)"
- )
-
- # 深度研究「收集员」内置 agent(对话式信息收集)。同样必须在
- # _sync_legacy_agents 之前补建目录,由后者 upsert 进 agents 表,
- # 深度研究页的收集对话与智能体管理页即可直接使用。
- try:
- from app.gateway.routers._deep_research_seed import (
- ensure_deep_research_collector_agent,
- )
-
- ensure_deep_research_collector_agent()
- except Exception:
- logger.exception(
- "Deep-research collector agent seeding failed at startup (non-fatal)"
- )
-
- # ?????????????????3Q/6BF/7BF/8BF??agent/chain ?????
- try:
- business_mapping_store = getattr(app.state, "business_mapping_store", None)
- if business_mapping_store is not None:
- inserted = await business_mapping_store.ensure_seed_defaults()
- if inserted:
- logger.info("Seeded %d business mapping rows", inserted)
- except Exception:
- logger.exception("Business mapping seeding failed at startup (non-fatal)")
-
- # ???? .deer-flow/agents/{id}/ ???????? agent ?? agents ??
- # ??????????????? sync(????? + N ? agent ??? DB
- # SELECT/UPDATE),??????? + ?????? TTL ??,????
- # /api/agents ?????
- try:
- from app.gateway.routers.agents import (
- _mark_legacy_sync_fresh,
- _sync_legacy_agents,
- )
- from app.gateway.services import _mark_legacy_sync_fresh as _services_mark_sync_fresh
-
- agent_store = getattr(app.state, "agent_store", None)
- if agent_store is not None:
- await _sync_legacy_agents(agent_store)
- _mark_legacy_sync_fresh()
- # ``services.py`` keeps an independent TTL gate (used by
- # ``start_run`` ? ``_enforce_agent_access``). Without this
- # ack, the first ``/runs/stream`` after startup would
- # re-run the heavy sync even though we just did it.
- _services_mark_sync_fresh()
- logger.info("Legacy folder-backed agents synced at startup")
- # 内置功能型 agent(roundtable-* / ai-writing-*)按用途默认打标签,
- # 供管理员「内置智能体」筛选按用途归类。幂等、只追加、失败不阻塞启动。
- try:
- from app.gateway.routers._builtin_agent_tag_seed import (
- ensure_builtin_agent_purpose_tags,
- )
-
- tag_store = getattr(app.state, "tag_store", None)
- if tag_store is not None:
- await ensure_builtin_agent_purpose_tags(agent_store, tag_store)
- except Exception:
- logger.exception("Built-in agent purpose-tag seeding failed at startup (non-fatal)")
- except Exception:
- logger.exception("Legacy agent sync failed at startup (non-fatal)")
-
- # ?? skill curator ???:??????,?????????????
- # (per-user interval_hours ??????????????)?
- try:
- app.state.curator_scheduler_task = asyncio.create_task(_curator_scheduler_loop())
- except Exception:
- logger.exception("Skill curator scheduler failed to start (non-fatal)")
-
- # ?? AI ????????:? config.yaml ? ai_writing.cleanup ???,
- # ?????? 3 ????,? retention_days(?? 7 ?)?????,??
- # ?? MySQL ??? + SQLite checkpointer ?? thread ???? worker
- # ?????? leader,?????? worker ??????
- try:
- from app.gateway.ai_writing_cleanup import cleanup_scheduler_loop
-
- app.state.ai_writing_cleanup_task = asyncio.create_task(cleanup_scheduler_loop(app))
- except Exception:
- logger.exception("AI writing cleanup scheduler failed to start (non-fatal)")
-
- try:
- app.state.leaderboard_snapshot_scheduler_task = asyncio.create_task(_leaderboard_snapshot_scheduler_loop())
- except Exception:
- logger.exception("Leaderboard snapshot scheduler failed to start (non-fatal)")
-
- # Concurrency (system-pressure) sampler — lightweight, singleton-locked,
- # runtime-toggleable via system_settings.concurrency_monitor.enabled.
- try:
- app.state.concurrency_sampler_task = asyncio.create_task(_concurrency_sampler_loop(app))
- except Exception:
- logger.exception("Concurrency sampler failed to start (non-fatal)")
-
- # Start IM channel service if any channels are configured
- try:
- from app.channels.service import start_channel_service
-
- channel_service = await start_channel_service(app.state.config)
- logger.info("Channel service started: %s", channel_service.get_status())
- except Exception:
- logger.exception("No IM channels configured or channel service failed to start")
-
- scheduled_tasks_enabled = os.environ.get("SCHEDULED_TASKS_ENABLED", "true").strip().lower()
- if scheduled_tasks_enabled in {"0", "false", "no", "off"}:
- logger.info("Scheduled task service disabled by SCHEDULED_TASKS_ENABLED=%s", scheduled_tasks_enabled)
- else:
- try:
- scheduled_task_store = getattr(app.state, "scheduled_task_store", None)
- if scheduled_task_store is not None:
- scheduler_service = ScheduledTaskService(app, scheduled_task_store)
- app.state.scheduled_task_service = scheduler_service
- set_scheduled_task_service(scheduler_service)
- await scheduler_service.start()
- logger.info("Scheduled task service started")
- except Exception:
- logger.exception("Scheduled task service failed to start")
-
- yield
-
- # ?? curator ???
- curator_task = getattr(app.state, "curator_scheduler_task", None)
- if curator_task is not None:
- curator_task.cancel()
- try:
- await curator_task
- except asyncio.CancelledError:
- pass
- except Exception:
- logger.exception("Curator scheduler shutdown error")
-
- # ?? AI ???????
- cleanup_task = getattr(app.state, "ai_writing_cleanup_task", None)
- if cleanup_task is not None:
- cleanup_task.cancel()
- try:
- await cleanup_task
- except asyncio.CancelledError:
- pass
- except Exception:
- logger.exception("AI writing cleanup scheduler shutdown error")
-
- leaderboard_snapshot_task = getattr(app.state, "leaderboard_snapshot_scheduler_task", None)
- if leaderboard_snapshot_task is not None:
- leaderboard_snapshot_task.cancel()
- try:
- await leaderboard_snapshot_task
- except asyncio.CancelledError:
- pass
- except Exception:
- logger.exception("Leaderboard snapshot scheduler shutdown error")
-
- concurrency_sampler_task = getattr(app.state, "concurrency_sampler_task", None)
- if concurrency_sampler_task is not None:
- concurrency_sampler_task.cancel()
- try:
- await concurrency_sampler_task
- except asyncio.CancelledError:
- pass
- except Exception:
- logger.exception("Concurrency sampler shutdown error")
-
- # ?? 7 ????AI ?? graph ???? checkpointer / in-flight task?
- # thread ??? LangGraph Server ? checkpointer ???lifespan ???
- # ``langgraph_runtime()`` ? contextmanager ???????????????
-
- try:
- scheduler_service = getattr(app.state, "scheduled_task_service", None)
- if scheduler_service is not None:
- await scheduler_service.stop()
- except Exception:
- logger.exception("Failed to stop scheduled task service")
- finally:
- set_scheduled_task_service(None)
-
- # Stop channel service on shutdown (bounded to prevent worker hang)
- try:
- from app.channels.service import stop_channel_service
-
- await asyncio.wait_for(
- stop_channel_service(),
- timeout=_SHUTDOWN_HOOK_TIMEOUT_SECONDS,
- )
- except TimeoutError:
- logger.warning(
- "Channel service shutdown exceeded %.1fs; proceeding with worker exit.",
- _SHUTDOWN_HOOK_TIMEOUT_SECONDS,
- )
- except Exception:
- logger.exception("Failed to stop channel service")
-
- logger.info("Shutting down API Gateway")
-
-
-def create_app() -> FastAPI:
- """Create and configure the FastAPI application.
-
- Returns:
- Configured FastAPI application instance.
- """
- config = get_gateway_config()
- docs_kwargs = {"docs_url": "/docs", "redoc_url": "/redoc", "openapi_url": "/openapi.json"} if config.enable_docs else {"docs_url": None, "redoc_url": None, "openapi_url": None}
-
- app = FastAPI(
- title="DeerFlow API Gateway",
- description="""
-## DeerFlow API Gateway
-
-API Gateway for DeerFlow - A LangGraph-based AI agent backend with sandbox execution capabilities.
-
-### Features
-
-- **Models Management**: Query and retrieve available AI models
-- **MCP Configuration**: Manage Model Context Protocol (MCP) server configurations
-- **Memory Management**: Access and manage global memory data for personalized conversations
-- **Skills Management**: Query and manage skills and their enabled status
-- **Artifacts**: Access thread artifacts and generated files
-- **Health Monitoring**: System health check endpoints
-
-### Architecture
-
-LangGraph requests are handled by nginx reverse proxy.
-This gateway provides custom endpoints for models, MCP configuration, skills, and artifacts.
- """,
- version="0.1.0",
- lifespan=lifespan,
- **docs_kwargs,
- openapi_tags=[
- {
- "name": "models",
- "description": "Operations for querying available AI models and their configurations",
- },
- {
- "name": "mcp",
- "description": "Manage Model Context Protocol (MCP) server configurations",
- },
- {
- "name": "memory",
- "description": "Access and manage global memory data for personalized conversations",
- },
- {
- "name": "skills",
- "description": "Manage skills and their configurations",
- },
- {
- "name": "artifacts",
- "description": "Access and download thread artifacts and generated files",
- },
- {
- "name": "uploads",
- "description": "Upload and manage user files for threads",
- },
- {
- "name": "threads",
- "description": "Manage DeerFlow thread-local filesystem data",
- },
- {
- "name": "agents",
- "description": "Create and manage custom agents with per-agent config and prompts",
- },
- {
- "name": "suggestions",
- "description": "Generate follow-up question suggestions for conversations",
- },
- {
- "name": "channels",
- "description": "Manage IM channel integrations (Feishu, Slack, Telegram)",
- },
- {
- "name": "assistants-compat",
- "description": "LangGraph Platform-compatible assistants API (stub)",
- },
- {
- "name": "runs",
- "description": "LangGraph Platform-compatible runs lifecycle (create, stream, cancel)",
- },
- {
- "name": "scheduled-tasks",
- "description": "Create, manage, and execute user scheduled agent tasks",
- },
- {
- "name": "health",
- "description": "Health check and system status endpoints",
- },
- ],
- )
-
- # Auth: reject unauthenticated requests to non-public paths (fail-closed safety net)
- app.add_middleware(AuthMiddleware)
-
- # CSRF: Double Submit Cookie pattern for state-changing requests
- app.add_middleware(CSRFMiddleware)
-
- # CORS
- if is_auth_disabled():
- logger.warning(
- "DEER_FLOW_AUTH_DISABLED is set ? API authentication is bypassed. "
- "Do not use this in production."
- )
-
- def _cors_allow_any_origin() -> bool:
- """Mirror any browser Origin (works with credentials); cannot use literal '*'."""
- v = os.environ.get("GATEWAY_CORS_ALLOW_ALL", "").strip().lower()
- return v in ("1", "true", "yes", "on") or is_auth_disabled()
-
- # Keep standard local frontend origins available even when the startup
- # script supplies an explicit production/development allow-list. Vite can
- # choose 5173 or move to 5174 when that port is already occupied; excluding
- # the former makes every browser preflight fail after a backend restart.
- local_vite_origins = [
- "http://localhost:5173",
- "http://127.0.0.1:5173",
- "http://localhost:5174",
- "http://127.0.0.1:5174",
- "http://localhost:3000",
- "http://127.0.0.1:3000",
- ]
-
- # The legacy TaskCOP Vue application uses its own Vite development port.
- taskcop_vite_origins = [
- "http://localhost:8080",
- "http://127.0.0.1:8080",
- ]
- local_dev_origins = [*local_vite_origins, *taskcop_vite_origins]
-
- cors_origins_env = os.environ.get("GATEWAY_CORS_ORIGINS", "").strip()
- cors_origins: list[str] = [o.strip() for o in cors_origins_env.split(",") if o.strip()] if cors_origins_env else []
- if cors_origins:
- cors_origins = list(dict.fromkeys([*cors_origins, *local_dev_origins]))
-
- # Headers the LangGraph SDK / browser clients need to read from cross-origin
- # responses. Without exposing them, the SDK's `response.headers.get(...)` returns
- # null on cross-origin streaming responses, breaking run-id extraction and reconnect.
- cors_expose_headers = [
- "Content-Disposition",
- "Content-Location",
- "Location",
- "X-Run-Id",
- "X-Thread-Id",
- ]
-
- # Public pages (e.g. http://47.x.x.x:7010) calling this Gateway on localhost
- # trigger Chrome/Edge Private Network Access preflights. Without
- # allow_private_network=True the browser reports a generic CORS failure even
- # when Access-Control-Allow-Origin is correct.
- cors_kwargs = {
- "allow_credentials": True,
- "allow_methods": ["*"],
- "allow_headers": ["*"],
- "expose_headers": cors_expose_headers,
- "allow_private_network": True,
- }
-
- if _cors_allow_any_origin():
- logger.warning(
- "Permissive CORS enabled ? any Origin is echoed back (GATEWAY_CORS_ALLOW_ALL or "
- "DEER_FLOW_AUTH_DISABLED). Do not expose publicly."
- )
- app.add_middleware(
- CORSMiddleware,
- allow_origins=[],
- # fullmatch() on the Origin header; browsers send scheme://host[:port]
- allow_origin_regex=r".+",
- **cors_kwargs,
- )
- elif cors_origins:
- if "*" in cors_origins:
- logger.error(
- "GATEWAY_CORS_ORIGINS contains '*' with allow_credentials=True ? invalid. "
- "Set GATEWAY_CORS_ALLOW_ALL=1 instead, or list explicit origins."
- )
- cors_origins = [o for o in cors_origins if o != "*"]
- if cors_origins:
- app.add_middleware(
- CORSMiddleware,
- allow_origins=cors_origins,
- **cors_kwargs,
- )
- else:
- # Safe-by-default fallback for local frontend development. Without any CORS
- # middleware, browser preflight OPTIONS requests will hit routes directly and
- # fail with 405, which blocks login and all JSON POST APIs.
- dev_origins = local_dev_origins
- logger.warning(
- "No CORS configuration found; enabling local dev origins only: %s. "
- "Set GATEWAY_CORS_ORIGINS or GATEWAY_CORS_ALLOW_ALL for deployment.",
- ", ".join(dev_origins),
- )
- app.add_middleware(
- CORSMiddleware,
- allow_origins=dev_origins,
- **cors_kwargs,
- )
-
- # Include routers
- # Models API is mounted at /api/models
- app.include_router(models.router)
- # Administrator-managed config.yaml model settings and connection tests.
- app.include_router(model_management.router)
-
- # MCP API is mounted at /api/mcp
- app.include_router(mcp.router)
-
- # DeerFlow-native LLMWiki management/search BFF (conditionally backed by WeKnora).
- app.include_router(llmwiki.router)
-
- # Memory API is mounted at /api/memory
- app.include_router(memory.router)
-
- # Skills API is mounted at /api/skills
- app.include_router(skills.router)
-
- # Artifacts API is mounted at /api/threads/{thread_id}/artifacts
- app.include_router(artifacts.router)
-
- # Uploads API is mounted at /api/threads/{thread_id}/uploads
- app.include_router(uploads.router)
-
- # Optional Chrome-extension page capture API is mounted at /api/browser-context
- app.include_router(browser_context.router)
-
- # Thread cleanup API is mounted at /api/threads/{thread_id}
- app.include_router(threads.router)
-
- # Agents API is mounted at /api/agents
- app.include_router(agents.router)
-
- # Recommended Questions API is mounted at /api/recommended-questions
- app.include_router(recommended_questions.router)
-
- app.include_router(user_prompts.router)
-
- # Per-user UI preferences at /api/user-preferences
- app.include_router(user_preferences.router)
-
- # Roundtable-planning drafts + recommendation history at /api/roundtable-drafts
- app.include_router(roundtable_drafts.router)
-
- # Task-scoped roundtable drafts (taskId 深链聊天记录,独立存、不分权) at /api/roundtable-task-drafts
- app.include_router(roundtable_task_drafts.router)
-
- # Task-scoped artifact submission status; stores references only, never artifact content.
- app.include_router(roundtable_artifact_submissions.router)
-
- # Step3 流程图结构化抽取 + 校验 (SSE) at /api/flow-extract
- app.include_router(flow_extract.router)
-
- # Roundtable-planning business chains at /api/roundtable-chains
- app.include_router(roundtable_chains.router)
-
- # 岗位协同独立会话 / 节点状态(不使用原圆桌总控或后台作业)
- # Canonical position-workspace persistence API.
- app.include_router(position_roundtable.router, prefix="/api")
- # Compatibility route for intranet gateways whose allowlist only exposes
- # the established `/api/multi-agent/*` namespace.
- app.include_router(
- position_roundtable.router,
- prefix="/api/multi-agent",
- include_in_schema=False,
- )
-
- # Global business ? agent / chain mapping at /api/business-mapping
- app.include_router(business_mapping.router)
-
- # Position-roundtable work-view role configuration at /api/position-roles.
- app.include_router(position_roles.router)
-
- # Light-app registry (?????) at /api/light-apps
- app.include_router(light_apps.router)
-
+ dashboard_sessions,
+ deep_research,
+ html_page_favorites,
+ intent,
+ knowledge,
+ knowledge_ingest,
+ light_apps,
+ llm_metrics,
+ llmwiki,
+ mcp,
+ memory,
+ menu_overrides,
+ model_management,
+ models,
+ multi_agent,
+ notifications,
+ open_3qfx,
+ open_chat,
+ parallel_agent_tasks,
+ parallel_agents,
+ position_roundtable,
+ positions,
+ position_roles,
+ public_config,
+ public_embed,
+ public_leaderboard,
+ public_scheduled_tasks,
+ recommend,
+ flow_extract,
+ recommended_questions,
+ roundtable_chains,
+ roundtable_artifact_submissions,
+ roundtable_diagnostics,
+ roundtable_draft_shares,
+ roundtable_drafts,
+ roundtable_jobs,
+ roundtable_task_drafts,
+ runs,
+ scheduled_tasks,
+ sensitive_words,
+ skill_addresses,
+ skills,
+ suggestions,
+ system_settings,
+ tags,
+ task_buttons,
+ taskcop_tasks,
+ report_structures,
+ task_reports,
+ thread_runs,
+ tool_metrics,
+ thread_shares,
+ threads,
+ uploads,
+ user_preferences,
+ user_prompts,
+ writing,
+)
+from deerflow.config import app_config as deerflow_app_config
+from deerflow.config.app_config import apply_logging_level
+from deerflow.runtime.scheduler import ScheduledTaskService, set_scheduled_task_service
+
+AppConfig = deerflow_app_config.AppConfig
+get_app_config = deerflow_app_config.get_app_config
+
+# Default logging; lifespan overrides from config.yaml log_level.
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+)
+
+logger = logging.getLogger(__name__)
+
+# Upper bound (seconds) each lifespan shutdown hook is allowed to run.
+# Bounds worker exit time so uvicorn's reload supervisor does not keep
+# firing signals into a worker that is stuck waiting for shutdown cleanup.
+_SHUTDOWN_HOOK_TIMEOUT_SECONDS = 5.0
+
+
+async def _ensure_admin_user(app: FastAPI) -> None:
+ """Startup hook: handle first boot and migrate orphan threads otherwise.
+
+ After admin creation, migrate orphan threads from the LangGraph
+ store (metadata.user_id unset) to the admin account. This is the
+ "no-auth ? with-auth" upgrade path: users who ran DeerFlow without
+ authentication have existing LangGraph thread data that needs an
+ owner assigned.
+ First boot (no admin exists):
+ - Does NOT create any user accounts automatically.
+ - The operator must visit ``/setup`` to create the first admin.
+
+ Subsequent boots (admin already exists):
+ - Runs the one-time "no-auth ? with-auth" orphan thread migration for
+ existing LangGraph thread metadata that has no owner_id.
+
+ No SQL persistence migration is needed: the four user_id columns
+ (threads_meta, runs, run_events, feedback) only come into existence
+ alongside the auth module via create_all, so freshly created tables
+ never contain NULL-owner rows.
+ """
+ from sqlalchemy import select
+
+ from app.gateway.deps import get_local_provider
+ from deerflow.persistence.engine import get_session_factory
+ from deerflow.persistence.user.model import UserRow
+
+ try:
+ provider = get_local_provider()
+ except RuntimeError:
+ # Auth persistence may not be initialized in some test/boot paths.
+ # Skip admin migration work rather than failing gateway startup.
+ logger.warning("Auth persistence not ready; skipping admin bootstrap check")
+ return
+
+ sf = get_session_factory()
+ if sf is None:
+ return
+
+ admin_count = await provider.count_admin_users()
+
+ if admin_count == 0:
+ logger.info("=" * 60)
+ logger.info(" First boot detected ? no admin account exists.")
+ logger.info(" Visit /setup to complete admin account creation.")
+ logger.info("=" * 60)
+ return
+
+ # Admin already exists ? run orphan thread migration for any
+ # LangGraph thread metadata that pre-dates the auth module.
+ async with sf() as session:
+ stmt = select(UserRow).where(UserRow.system_role == "admin").limit(1)
+ row = (await session.execute(stmt)).scalar_one_or_none()
+
+ if row is None:
+ return # Should not happen (admin_count > 0 above), but be safe.
+
+ admin_id = str(row.id)
+
+ # LangGraph store orphan migration ? non-fatal.
+ # This covers the "no-auth ? with-auth" upgrade path for users
+ # whose existing LangGraph thread metadata has no user_id set.
+ store = getattr(app.state, "store", None)
+ if store is not None:
+ try:
+ migrated = await _migrate_orphaned_threads(store, admin_id)
+ if migrated:
+ logger.info("Migrated %d orphan LangGraph thread(s) to admin", migrated)
+ except Exception:
+ logger.exception("LangGraph thread migration failed (non-fatal)")
+
+
+async def _iter_store_items(store, namespace, *, page_size: int = 500):
+ """Paginated async iterator over a LangGraph store namespace.
+
+ Replaces the old hardcoded ``limit=1000`` call with a cursor-style
+ loop so that environments with more than one page of orphans do
+ not silently lose data. Terminates when a page is empty OR when a
+ short page arrives (indicating the last page).
+ """
+ offset = 0
+ while True:
+ batch = await store.asearch(namespace, limit=page_size, offset=offset)
+ if not batch:
+ return
+ for item in batch:
+ yield item
+ if len(batch) < page_size:
+ return
+ offset += page_size
+
+
+async def _sync_legacy_skills(app: FastAPI) -> None:
+ """Idempotently register existing on-disk ``skills/custom/*`` directories
+ in the ``skills`` DB table, owned by the first admin and published=True.
+
+ Without this step, after upgrading to per-user skill ownership the existing
+ custom skill folders would not be visible to anyone (the SkillStore
+ visibility filter relies on a DB row). Running this once on every boot is
+ cheap (folder scan + one ``ensure_legacy`` per name) and idempotent.
+
+ Non-fatal: a failure here MUST NOT block server startup.
+ """
+ from sqlalchemy import select
+
+ from deerflow.persistence.engine import get_session_factory
+ from deerflow.persistence.user.model import UserRow
+ from deerflow.skills.storage import get_or_new_skill_storage
+ from deerflow.skills.types import SkillCategory
+
+ sf = get_session_factory()
+ if sf is None:
+ return
+
+ skill_store = getattr(app.state, "skill_store", None)
+ if skill_store is None:
+ return
+
+ async with sf() as session:
+ stmt = select(UserRow).where(UserRow.system_role == "admin").limit(1)
+ row = (await session.execute(stmt)).scalar_one_or_none()
+ if row is None:
+ # No admin yet ? leave ownership null; first admin is responsible for
+ # claiming via the UI once they exist.
+ admin_id: str | None = None
+ else:
+ admin_id = str(row.id)
+
+ config = getattr(app.state, "config", None)
+ storage = get_or_new_skill_storage(app_config=config)
+ try:
+ skills = storage.load_skills(enabled_only=False)
+ except Exception:
+ logger.exception("_sync_legacy_skills: could not list on-disk skills")
+ return
+
+ inserted = 0
+ for skill in skills:
+ if skill.category != SkillCategory.CUSTOM:
+ continue
+ try:
+ existing = await skill_store.get_any(skill.name)
+ if existing is not None:
+ continue
+ await skill_store.ensure_legacy(
+ {"name": skill.name, "owner_user_id": admin_id, "published": True}
+ )
+ inserted += 1
+ except Exception:
+ logger.warning("_sync_legacy_skills: could not register %s", skill.name, exc_info=True)
+ if inserted:
+ logger.info("Skill ownership migration: registered %d legacy custom skill(s)", inserted)
+
+
+async def _migrate_orphaned_threads(store, admin_user_id: str) -> int:
+ """Migrate LangGraph store threads with no user_id to the given admin.
+
+ Uses cursor pagination so all orphans are migrated regardless of
+ count. Returns the number of rows migrated.
+ """
+ migrated = 0
+ async for item in _iter_store_items(store, ("threads",)):
+ metadata = item.value.get("metadata", {})
+ if not metadata.get("user_id"):
+ metadata["user_id"] = admin_user_id
+ item.value["metadata"] = metadata
+ await store.aput(("threads",), item.key, item.value)
+ migrated += 1
+ return migrated
+
+
+async def _run_db_migrations() -> None:
+ """Run Alembic migrations at startup so schema changes are applied automatically."""
+ from pathlib import Path
+
+ from alembic import command
+ from alembic.config import Config
+
+ import deerflow.persistence as _persistence_pkg
+ from deerflow.persistence.engine import get_engine
+
+ engine = get_engine()
+ if engine is None:
+ logger.info("Persistence backend=memory ? skipping Alembic migrations")
+ return
+
+ if os.getenv("DEER_FLOW_SKIP_ALEMBIC", "").lower() in {"1", "true", "yes"}:
+ logger.warning("DEER_FLOW_SKIP_ALEMBIC is set; skipping Alembic migrations")
+ return
+
+ db_url = engine.url.render_as_string(hide_password=False).replace("%", "%%")
+ migrations_dir = str(Path(_persistence_pkg.__file__).parent / "migrations")
+ result_queue: queue.Queue[BaseException | None] = queue.Queue(maxsize=1)
+
+ def _run_sync() -> None:
+ try:
+ cfg = Config()
+ cfg.set_main_option("script_location", migrations_dir)
+ cfg.set_main_option("sqlalchemy.url", db_url)
+ command.upgrade(cfg, "head")
+ except BaseException as exc:
+ result_queue.put(exc)
+ else:
+ result_queue.put(None)
+
+ try:
+ try:
+ timeout_s = float(os.getenv("DEER_FLOW_ALEMBIC_TIMEOUT", "60"))
+ except ValueError:
+ timeout_s = 60.0
+
+ worker = threading.Thread(target=_run_sync, name="deerflow-alembic-upgrade", daemon=True)
+ worker.start()
+ try:
+ deadline = asyncio.get_running_loop().time() + timeout_s
+ while result_queue.empty():
+ if asyncio.get_running_loop().time() >= deadline:
+ raise asyncio.TimeoutError
+ await asyncio.sleep(0.2)
+ result = result_queue.get_nowait()
+ except asyncio.TimeoutError:
+ logger.error(
+ "Database migration did not finish within %.0fs; continuing startup on create_all schema. "
+ "Likely cause: MySQL metadata-lock wait or a slow DDL. Set DEER_FLOW_SKIP_ALEMBIC=1 "
+ "to skip startup migrations, or inspect MySQL with SHOW FULL PROCESSLIST.",
+ timeout_s,
+ )
+ return
+ if result is not None:
+ raise result
+ logger.info("Database migrations applied (head)")
+ except Exception:
+ logger.exception("Database migration failed ? schema may be out of date")
+
+
+# Skill curator ?????
+# - ????:?????????"??????";??????? should_run_now() ???
+# - ????:?????????????,???????
+_CURATOR_CHECK_INTERVAL_SECONDS = 3600 # 1 ??
+_CURATOR_STARTUP_DELAY_SECONDS = 300 # 5 ??
+
+
+def _acquire_singleton_lock(lock_path):
+ """???????????,??? worker ?? leader ???
+
+ ?? -> ??????????(?????????,??????/????);
+ ????(???? worker ??)????? -> ?? None?
+ ??????? OS ????????
+ """
+ try:
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
+ handle = open(lock_path, "a+")
+ except OSError:
+ logger.exception("Curator scheduler: ???????")
+ return None
+ try:
+ if sys.platform == "win32":
+ import msvcrt
+
+ handle.seek(0)
+ msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
+ else:
+ import fcntl
+
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except OSError:
+ handle.close()
+ return None
+ return handle
+
+
+def _release_singleton_lock(handle) -> None:
+ """????? _acquire_singleton_lock ?????????"""
+ try:
+ if sys.platform == "win32":
+ import msvcrt
+
+ handle.seek(0)
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
+ else:
+ import fcntl
+
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
+ except OSError:
+ pass
+ finally:
+ try:
+ handle.close()
+ except OSError:
+ pass
+
+
+async def _curator_scheduler_loop() -> None:
+ """??????:?? _CURATOR_CHECK_INTERVAL_SECONDS ???????????
+
+ ? worker ???????? leader ?? ?? ???????? worker ????
+ ????,?? worker ????,?? curator ?????????? LLM?
+ ? cancel ? CancelledError ??? ``except Exception``(?? BaseException
+ ??,?????),?????????? finally ?????
+ """
+ from deerflow.config.runtime_paths import runtime_home
+ from deerflow.skills.curator import maybe_run_curator
+
+ lock_handle = _acquire_singleton_lock(runtime_home() / ".curator_scheduler.lock")
+ if lock_handle is None:
+ logger.info("Curator scheduler: ??? worker ??????,? worker ??")
+ return
+
+ logger.info("Curator scheduler: ?????,? worker ?? curator ??")
+ try:
+ # ?????????????,??????
+ await asyncio.sleep(_CURATOR_STARTUP_DELAY_SECONDS)
+ while True:
+ try:
+ await maybe_run_curator()
+ except Exception:
+ logger.exception("Curator scheduler tick failed (non-fatal)")
+ await asyncio.sleep(_CURATOR_CHECK_INTERVAL_SECONDS)
+ finally:
+ _release_singleton_lock(lock_handle)
+
+
+def _env_bool(name: str, default: bool) -> bool:
+ raw = os.getenv(name)
+ if raw is None:
+ return default
+ return raw.strip().lower() not in {"0", "false", "no", "off"}
+
+
+def _env_int(name: str, default: int, *, alias: str | None = None, minimum: int = 1) -> int:
+ raw = os.getenv(name)
+ if raw is None and alias:
+ raw = os.getenv(alias)
+ if raw is None:
+ raw = str(default)
+ try:
+ value = int(raw)
+ except ValueError:
+ return default
+ return max(minimum, value)
+
+
+def _env_float(name: str, default: float, *, alias: str | None = None, minimum: float = 0.0) -> float:
+ raw = os.getenv(name)
+ if raw is None and alias:
+ raw = os.getenv(alias)
+ if raw is None:
+ raw = str(default)
+ try:
+ value = float(raw)
+ except ValueError:
+ return default
+ return max(minimum, value)
+
+
+_LEADERBOARD_SNAPSHOT_BACKGROUND_ENABLED = _env_bool("ADMIN_LEADERBOARD_BACKGROUND_ENABLED", True)
+_LEADERBOARD_SNAPSHOT_PREWARM_DAYS = _env_int(
+ "ADMIN_LEADERBOARD_BACKGROUND_PREWARM_DAYS",
+ 30,
+)
+_LEADERBOARD_SNAPSHOT_BATCH_SIZE = _env_int(
+ "ADMIN_LEADERBOARD_BACKGROUND_BATCH_SIZE",
+ 1,
+)
+_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS = _env_float(
+ "ADMIN_LEADERBOARD_BACKGROUND_INTERVAL_SECONDS",
+ 300.0,
+)
+_LEADERBOARD_SNAPSHOT_SLEEP_SECONDS = _env_float(
+ "ADMIN_LEADERBOARD_BACKGROUND_SLEEP_SECONDS",
+ 2.0,
+)
+_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS = _env_int(
+ "ADMIN_LEADERBOARD_BACKGROUND_RUNNING_TTL_SECONDS",
+ 1800,
+)
+_LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS = _env_float(
+ "ADMIN_LEADERBOARD_BACKGROUND_STARTUP_DELAY_SECONDS",
+ 60.0,
+)
+# Concurrency (system-pressure) sampler: how often to snapshot the in-flight run
+# count, and how long to keep samples (drives the admin 并发量 line chart, which
+# aggregates per minute/hour). Sampling is in-memory cheap (one tiny INSERT).
+_CONCURRENCY_SAMPLE_ENABLED = _env_bool("CONCURRENCY_SAMPLE_ENABLED", True)
+_CONCURRENCY_SAMPLE_INTERVAL_SECONDS = _env_float(
+ "CONCURRENCY_SAMPLE_INTERVAL_SECONDS",
+ 15.0,
+)
+_CONCURRENCY_SAMPLE_RETENTION_HOURS = _env_float(
+ "CONCURRENCY_SAMPLE_RETENTION_HOURS",
+ 168.0, # 7 days
+)
+# Hour (local Beijing time, 0-23) after which the scheduler force-recomputes the
+# *previous* day's snapshot once, so records that landed near midnight are fully
+# captured. Default 1 ? "???? 1 ????????".
+_LEADERBOARD_FINALIZE_HOUR = min(
+ 23,
+ _env_int("ADMIN_LEADERBOARD_FINALIZE_HOUR", 1, minimum=0),
+)
+
+
+def _leaderboard_scheduler_sql_for_log(stmt) -> str:
+ try:
+ return str(stmt.compile(compile_kwargs={"literal_binds": True}))
+ except Exception:
+ return str(stmt)
+
+
+async def _execute_leaderboard_scheduler_query(session, step: str, stmt, **context):
+ sql = _leaderboard_scheduler_sql_for_log(stmt)
+ logger.info(
+ "Leaderboard snapshot DB query start: step=%s stat_date=%s job=%s settings_hash=%s started_at=%s sql=%s",
+ step,
+ context.get("stat_date") or context.get("stat_dates") or "",
+ context.get("job_key") or "",
+ context.get("settings_hash") or "",
+ datetime.now().isoformat(),
+ sql,
+ )
+ start = perf_counter()
+ try:
+ result = await session.execute(stmt)
+ except Exception:
+ logger.exception(
+ "Leaderboard snapshot DB query failed: step=%s stat_date=%s job=%s settings_hash=%s elapsed_ms=%.2f",
+ step,
+ context.get("stat_date") or context.get("stat_dates") or "",
+ context.get("job_key") or "",
+ context.get("settings_hash") or "",
+ (perf_counter() - start) * 1000,
+ )
+ raise
+ logger.info(
+ "Leaderboard snapshot DB query done: step=%s stat_date=%s job=%s settings_hash=%s finished_at=%s elapsed_ms=%.2f",
+ step,
+ context.get("stat_date") or context.get("stat_dates") or "",
+ context.get("job_key") or "",
+ context.get("settings_hash") or "",
+ datetime.now().isoformat(),
+ (perf_counter() - start) * 1000,
+ )
+ return result
+
+
+async def _time_leaderboard_scheduler_db_operation(step: str, operation, *, sql: str = "", **context):
+ logger.info(
+ "Leaderboard snapshot DB query start: step=%s stat_date=%s job=%s settings_hash=%s started_at=%s sql=%s",
+ step,
+ context.get("stat_date") or context.get("stat_dates") or "",
+ context.get("job_key") or "",
+ context.get("settings_hash") or "",
+ datetime.now().isoformat(),
+ sql,
+ )
+ start = perf_counter()
+ try:
+ result = await operation()
+ except Exception:
+ logger.exception(
+ "Leaderboard snapshot DB query failed: step=%s stat_date=%s job=%s settings_hash=%s elapsed_ms=%.2f",
+ step,
+ context.get("stat_date") or context.get("stat_dates") or "",
+ context.get("job_key") or "",
+ context.get("settings_hash") or "",
+ (perf_counter() - start) * 1000,
+ )
+ raise
+ logger.info(
+ "Leaderboard snapshot DB query done: step=%s stat_date=%s job=%s settings_hash=%s finished_at=%s elapsed_ms=%.2f",
+ step,
+ context.get("stat_date") or context.get("stat_dates") or "",
+ context.get("job_key") or "",
+ context.get("settings_hash") or "",
+ datetime.now().isoformat(),
+ (perf_counter() - start) * 1000,
+ )
+ return result
+
+
+def _recent_leaderboard_stat_dates(days: int) -> list[str]:
+ from datetime import datetime, timedelta
+
+ from deerflow.persistence.types import BEIJING_TZ
+
+ today = datetime.now(BEIJING_TZ).date()
+ start = today - timedelta(days=max(1, days) - 1)
+ return [(start + timedelta(days=i)).isoformat() for i in range((today - start).days + 1)]
+
+
+async def _leaderboard_snapshot_due_dates(
+ session_factory,
+ *,
+ settings_hash: str,
+ limit: int,
+ running_ttl_seconds: int,
+) -> list[str]:
+ from datetime import UTC, datetime, timedelta
+
+ from sqlalchemy import and_, or_, select
+
+ from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
+
+ cutoff = datetime.now(UTC) - timedelta(seconds=running_ttl_seconds)
+ async with session_factory() as session:
+ stmt = (
+ select(AdminLeaderboardDailyStatRow.stat_date)
+ .where(
+ AdminLeaderboardDailyStatRow.settings_hash == settings_hash,
+ or_(
+ AdminLeaderboardDailyStatRow.status.in_(["queued", "error"]),
+ and_(
+ AdminLeaderboardDailyStatRow.status == "running",
+ AdminLeaderboardDailyStatRow.updated_at < cutoff,
+ ),
+ ),
+ )
+ .order_by(AdminLeaderboardDailyStatRow.stat_date.desc())
+ .limit(limit)
+ )
+ rows = (
+ await _execute_leaderboard_scheduler_query(
+ session,
+ "scheduler.due_dates",
+ stmt,
+ settings_hash=settings_hash,
+ )
+ ).all()
+ return [str(row[0]) for row in rows]
+
+
+async def _claim_leaderboard_snapshot(
+ session_factory,
+ *,
+ stat_date: str,
+ settings_hash: str,
+ running_ttl_seconds: int,
+) -> bool:
+ from datetime import UTC, datetime
+
+ from app.gateway.routers.admin_users import _snapshot_is_fresh
+ from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
+ from deerflow.persistence.types import BEIJING_TZ
+
+ async with session_factory() as session:
+ row = await _time_leaderboard_scheduler_db_operation(
+ "scheduler.claim.get",
+ lambda: session.get(
+ AdminLeaderboardDailyStatRow,
+ {"stat_date": stat_date, "settings_hash": settings_hash},
+ ),
+ sql="SELECT admin_leaderboard_daily_stats by primary key",
+ stat_date=stat_date,
+ settings_hash=settings_hash,
+ )
+ if row is None or _snapshot_is_fresh(row, stat_date):
+ return False
+ if row.status == "running":
+ updated_at = row.updated_at
+ if isinstance(updated_at, datetime):
+ if updated_at.tzinfo is None:
+ updated_at = updated_at.replace(tzinfo=BEIJING_TZ)
+ age = (datetime.now(BEIJING_TZ) - updated_at.astimezone(BEIJING_TZ)).total_seconds()
+ if age < running_ttl_seconds:
+ return False
+ row.status = "running"
+ row.message = "?????????????"
+ row.error = None
+ row.updated_at = datetime.now(UTC)
+ await _time_leaderboard_scheduler_db_operation(
+ "scheduler.claim.commit",
+ lambda: session.commit(),
+ sql="COMMIT",
+ stat_date=stat_date,
+ settings_hash=settings_hash,
+ )
+ return True
+
+
+async def _maybe_finalize_previous_leaderboard_day(session_factory, settings_hash: str) -> bool:
+ """Once per day after ``_LEADERBOARD_FINALIZE_HOUR``, force-recompute yesterday.
+
+ A ``ready`` past-day snapshot is otherwise treated as fresh forever and never
+ rebuilt. But the previous day's snapshot is usually generated before midnight,
+ so it can miss runs/tool-calls that only land in the DB around the day
+ boundary. This re-queues yesterday (``force=True``) a single time; the rebuild
+ stamps ``generated_at`` to today, which stops it from re-firing for the rest of
+ the day.
+ """
+ from datetime import timedelta
+
+ from app.gateway.routers.admin_users import (
+ _as_beijing,
+ _queue_daily_leaderboard_snapshots,
+ )
+ from deerflow.persistence.admin_stats.finalize import should_finalize_previous_day
+ from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
+ from deerflow.persistence.types import BEIJING_TZ
+
+ now = datetime.now(BEIJING_TZ)
+ yesterday = (now.date() - timedelta(days=1)).isoformat()
+ async with session_factory() as session:
+ row = await session.get(
+ AdminLeaderboardDailyStatRow,
+ {"stat_date": yesterday, "settings_hash": settings_hash},
+ )
+ generated_at = _as_beijing(row.generated_at) if (row is not None and row.generated_at is not None) else None
+ if not should_finalize_previous_day(
+ status=(row.status if row is not None else None),
+ generated_at=generated_at,
+ now=now,
+ finalize_hour=_LEADERBOARD_FINALIZE_HOUR,
+ ):
+ return False
+ queued = await _queue_daily_leaderboard_snapshots(
+ session_factory,
+ [yesterday],
+ settings_hash,
+ force=True,
+ message="???????????????????????????",
+ )
+ if queued:
+ logger.info(
+ "Leaderboard snapshot finalize: queued previous day %s for full recompute (hash=%s)",
+ yesterday,
+ settings_hash[:10],
+ )
+ return True
+ return False
+
+
+async def _run_leaderboard_snapshot_tick() -> tuple[int, int]:
+ from app.gateway.routers.admin_users import (
+ _daily_job_key,
+ _queue_daily_leaderboard_snapshots,
+ _refresh_daily_leaderboard_snapshot,
+ _resolve_leaderboard_snapshot_scope,
+ )
+ from deerflow.config.system_settings import load_system_settings
+ from deerflow.persistence.engine import get_session_factory
+
+ session_factory = get_session_factory()
+ if session_factory is None:
+ return 0, 0
+
+ settings = load_system_settings().leaderboard
+ excluded, include_scheduled, include_admins, include_failed, settings_hash = _resolve_leaderboard_snapshot_scope(settings)
+ stat_dates = _recent_leaderboard_stat_dates(_LEADERBOARD_SNAPSHOT_PREWARM_DAYS)
+ queued_dates = await _queue_daily_leaderboard_snapshots(
+ session_factory,
+ stat_dates,
+ settings_hash,
+ message="?????????????/?????",
+ )
+
+ # ??????? 1 ???????"???"????????????????????
+ try:
+ await _maybe_finalize_previous_leaderboard_day(session_factory, settings_hash)
+ except Exception:
+ logger.exception("Leaderboard snapshot finalize check failed (non-fatal)")
+
+ due_dates = await _leaderboard_snapshot_due_dates(
+ session_factory,
+ settings_hash=settings_hash,
+ limit=_LEADERBOARD_SNAPSHOT_BATCH_SIZE,
+ running_ttl_seconds=_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS,
+ )
+ processed = 0
+ for stat_date in due_dates:
+ claimed = await _claim_leaderboard_snapshot(
+ session_factory,
+ stat_date=stat_date,
+ settings_hash=settings_hash,
+ running_ttl_seconds=_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS,
+ )
+ if not claimed:
+ continue
+ logger.info("Leaderboard snapshot scheduler: building date=%s hash=%s", stat_date, settings_hash[:10])
+ await _refresh_daily_leaderboard_snapshot(
+ _daily_job_key(stat_date, settings_hash),
+ session_factory=session_factory,
+ settings=settings,
+ stat_date=stat_date,
+ settings_hash=settings_hash,
+ excluded=excluded,
+ include_scheduled=include_scheduled,
+ include_admins=include_admins,
+ include_failed=include_failed,
+ )
+ processed += 1
+ if _LEADERBOARD_SNAPSHOT_SLEEP_SECONDS > 0 and processed < len(due_dates):
+ await asyncio.sleep(_LEADERBOARD_SNAPSHOT_SLEEP_SECONDS)
+ return processed, len(queued_dates)
+
+
+async def _leaderboard_snapshot_scheduler_loop() -> None:
+ """Background task inside the existing gateway process for daily snapshots."""
+ if not _LEADERBOARD_SNAPSHOT_BACKGROUND_ENABLED:
+ logger.info("Leaderboard snapshot scheduler disabled by ADMIN_LEADERBOARD_BACKGROUND_ENABLED")
+ return
+
+ from deerflow.config.runtime_paths import runtime_home
+
+ lock_handle = _acquire_singleton_lock(runtime_home() / ".leaderboard_snapshot_scheduler.lock")
+ if lock_handle is None:
+ logger.info("Leaderboard snapshot scheduler: another gateway worker holds the lock; this worker skips")
+ return
+
+ logger.info(
+ "Leaderboard snapshot scheduler started: prewarm_days=%d batch_size=%d interval=%.1fs",
+ _LEADERBOARD_SNAPSHOT_PREWARM_DAYS,
+ _LEADERBOARD_SNAPSHOT_BATCH_SIZE,
+ _LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS,
+ )
+ try:
+ if _LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS > 0:
+ await asyncio.sleep(_LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS)
+ while True:
+ try:
+ processed, queued = await _run_leaderboard_snapshot_tick()
+ if processed or queued:
+ logger.info(
+ "Leaderboard snapshot scheduler tick: queued=%d processed=%d",
+ queued,
+ processed,
+ )
+ except Exception:
+ logger.exception("Leaderboard snapshot scheduler tick failed (non-fatal)")
+ await asyncio.sleep(_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS)
+ finally:
+ _release_singleton_lock(lock_handle)
+
+
+async def _concurrency_sampler_loop(app: FastAPI) -> None:
+ """Periodically record the in-flight run count for the admin pressure chart.
+
+ Runs in the gateway process, singleton-locked so only one worker samples.
+ Each tick records ``len(run_manager.list_active())`` and, roughly hourly,
+ purges samples older than the retention window. Best-effort throughout — a
+ failed tick is logged and the loop continues.
+ """
+ if not _CONCURRENCY_SAMPLE_ENABLED:
+ logger.info("Concurrency sampler disabled by CONCURRENCY_SAMPLE_ENABLED")
+ return
+
+ store = getattr(app.state, "concurrency_sample_store", None)
+ run_mgr = getattr(app.state, "run_manager", None)
+ concurrency_gate = getattr(app.state, "concurrency_gate", None)
+ if store is None or run_mgr is None:
+ logger.info("Concurrency sampler not started (no store / run manager — memory backend?)")
+ return
+
+ from deerflow.config.runtime_paths import runtime_home
+
+ lock_handle = _acquire_singleton_lock(runtime_home() / ".concurrency_sampler.lock")
+ if lock_handle is None:
+ logger.info("Concurrency sampler: another gateway worker holds the lock; this worker skips")
+ return
+
+ interval = max(1.0, _CONCURRENCY_SAMPLE_INTERVAL_SECONDS)
+ purge_every_ticks = max(1, int(3600 / interval)) # ~once per hour
+ logger.info("Concurrency sampler started: interval=%.1fs retention=%.1fh", interval, _CONCURRENCY_SAMPLE_RETENTION_HOURS)
+ ticks = 0
+ try:
+ while True:
+ try:
+ # Runtime toggle (admin can turn sampling off without a restart).
+ # mtime-cached read, so this is cheap every tick.
+ from deerflow.config.system_settings import get_concurrency_monitor_settings
+
+ if get_concurrency_monitor_settings().enabled:
+ active_count = None
+ if concurrency_gate is not None:
+ active_count = await concurrency_gate.count_active()
+ if active_count is None:
+ active = await run_mgr.list_active()
+ active_count = len(active)
+ await store.record(active_count)
+ ticks += 1
+ if ticks % purge_every_ticks == 0:
+ from datetime import UTC, datetime, timedelta
+
+ cutoff = datetime.now(UTC) - timedelta(hours=_CONCURRENCY_SAMPLE_RETENTION_HOURS)
+ await store.purge_older_than(cutoff)
+ except Exception:
+ logger.exception("Concurrency sampler tick failed (non-fatal)")
+ await asyncio.sleep(interval)
+ finally:
+ _release_singleton_lock(lock_handle)
+
+
+def _warn_if_multi_worker() -> None:
+ """?? uvicorn / gunicorn ? worker ???? ERROR ???
+
+ ? agent / canvas / AI ??????? Gateway ????? ``StreamBridge``
+ ?? run_id ? SSE ?????? map?? worker ????? worker A ??
+ ``/runs/stream``???? worker B ? join/resume??? in-process map ??
+ ???? 404 / ???
+
+ ????? startup ? ERROR ????**???? fail-fast** ?? ??
+ ???????????????? sticky session??????????
+ """
+ # uvicorn / gunicorn ??????? worker ?????????????
+ # ?????? sibling ??????????? WEB_CONCURRENCY?gunicorn
+ # / uvicorn ????
+ web_concurrency = os.environ.get("WEB_CONCURRENCY", "").strip()
+ if web_concurrency and web_concurrency.isdigit() and int(web_concurrency) > 1:
+ logger.error(
+ "? ??? WEB_CONCURRENCY=%s?? worker ????AI ?? / ? agent "
+ "??????????????? worker ????????? / "
+ "SSE ?? / resume 400??????????? WEB_CONCURRENCY=1 "
+ "????????? sticky session??? AIWritingPage.tsx ????",
+ web_concurrency,
+ )
+ return
+
+ # ???? /proc ??? uvicorn ?????? Linux ??????
+ if not sys.platform.startswith("linux"):
+ return
+ try:
+ from pathlib import Path
+
+ peers = 0
+ for entry in Path("/proc").iterdir():
+ if not entry.name.isdigit():
+ continue
+ try:
+ cmdline = (entry / "cmdline").read_bytes().replace(b"\x00", b" ").decode("utf-8", "ignore")
+ except OSError:
+ continue
+ if "uvicorn" in cmdline and "app.gateway.app" in cmdline:
+ peers += 1
+ if peers > 1:
+ logger.error(
+ "? ??? %d ??? uvicorn ???? worker ????? AI ?? "
+ "????? / SSE ??????????? worker ??????? "
+ "?? sticky session?",
+ peers,
+ )
+ except Exception:
+ # ????????? ?? ??????
+ pass
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
+ """Application lifespan handler."""
+
+ # ?????? worker ?? ?? ?? AI ?? / ? agent ???????
+ # ??????? worker ???????? ERROR ?????
+ _warn_if_multi_worker()
+
+ # Load config and check necessary environment variables at startup
+ try:
+ app.state.config = get_app_config()
+ apply_logging_level(app.state.config.log_level)
+ logger.info("Configuration loaded successfully")
+ except Exception as e:
+ error_msg = f"Failed to load configuration during gateway startup: {e}"
+ logger.exception(error_msg)
+ raise RuntimeError(error_msg) from e
+ config = get_gateway_config()
+ logger.info(f"Starting API Gateway on {config.host}:{config.port}")
+
+ # Initialize LangGraph runtime components (StreamBridge, RunManager, checkpointer, store)
+ async with langgraph_runtime(app):
+ logger.info("LangGraph runtime initialised")
+
+ # Run Alembic migrations before anything else touches the schema.
+ await _run_db_migrations()
+
+ # ?? recall_budget cache?? DB ????,? get_effective_memory_config ?????
+ try:
+ from deerflow.config.memory_config import load_recall_budget_cache
+
+ _mc_store = getattr(app.state, "memory_config_store", None)
+ if _mc_store is not None:
+ _recall_budgets = await _mc_store.load_all()
+ load_recall_budget_cache(_recall_budgets)
+ logger.info("Memory config cache warmed: %d user override(s)", len(_recall_budgets))
+ except Exception:
+ logger.exception("Memory config cache warm-up failed (non-fatal)")
+
+ # Ensure admin user exists (auto-create on first boot)
+ # Must run AFTER langgraph_runtime so app.state.store is available for thread migration
+ await _ensure_admin_user(app)
+
+ # Migrate "ownerless" on-disk skills into the new SkillStore table ?
+ # makes the multitenancy switch transparent for existing deployments.
+ try:
+ await _sync_legacy_skills(app)
+ except Exception:
+ logger.exception("Skill ownership migration failed (non-fatal)")
+
+ # ????? agent(intent / recommender / coordinator)???????
+ # ??? _sync_legacy_agents **??**??????????????????
+ # ??? upsert ? agents ?,?????? /api/agents ?????,
+ # ????????????????.deer-flow/ ? gitignore,??? clone
+ # ????????,seeder ????? _roundtable_seed_assets/ ??????
+ try:
+ from app.gateway.routers._roundtable_seed import (
+ ensure_roundtable_functional_agents,
+ )
+
+ ensure_roundtable_functional_agents()
+ except Exception:
+ logger.exception(
+ "Roundtable functional agent seeding failed at startup (non-fatal)"
+ )
+
+ # AI ????? agent(researcher / outliner / writer / editor)???????
+ # ???:??? _sync_legacy_agents ??,?????????????
+ # upsert ? agents ?,admin ?????????????????? agent?
+ try:
+ from app.gateway.routers._ai_writing_seed import (
+ ensure_ai_writing_functional_agents,
+ )
+
+ ensure_ai_writing_functional_agents()
+ except Exception:
+ logger.exception(
+ "AI-writing functional agent seeding failed at startup (non-fatal)"
+ )
+
+ # 内置「模板数据转换器」agent(JSON → HTML 模板填充)。同样必须在
+ # _sync_legacy_agents **之前**补建目录,后者把它 upsert 进 agents 表,
+ # 第一次启动 /api/agents 与定时任务下拉框里就有它。
+ try:
+ from app.gateway.routers._template_builder_seed import (
+ ensure_template_builder_agent,
+ )
+
+ ensure_template_builder_agent()
+ except Exception:
+ logger.exception(
+ "Template-builder agent seeding failed at startup (non-fatal)"
+ )
+
+ # 深度研究「收集员」内置 agent(对话式信息收集)。同样必须在
+ # _sync_legacy_agents 之前补建目录,由后者 upsert 进 agents 表,
+ # 深度研究页的收集对话与智能体管理页即可直接使用。
+ try:
+ from app.gateway.routers._deep_research_seed import (
+ ensure_deep_research_collector_agent,
+ )
+
+ ensure_deep_research_collector_agent()
+ except Exception:
+ logger.exception(
+ "Deep-research collector agent seeding failed at startup (non-fatal)"
+ )
+
+ # ?????????????????3Q/6BF/7BF/8BF??agent/chain ?????
+ try:
+ business_mapping_store = getattr(app.state, "business_mapping_store", None)
+ if business_mapping_store is not None:
+ inserted = await business_mapping_store.ensure_seed_defaults()
+ if inserted:
+ logger.info("Seeded %d business mapping rows", inserted)
+ except Exception:
+ logger.exception("Business mapping seeding failed at startup (non-fatal)")
+
+ # ???? .deer-flow/agents/{id}/ ???????? agent ?? agents ??
+ # ??????????????? sync(????? + N ? agent ??? DB
+ # SELECT/UPDATE),??????? + ?????? TTL ??,????
+ # /api/agents ?????
+ try:
+ from app.gateway.routers.agents import (
+ _mark_legacy_sync_fresh,
+ _sync_legacy_agents,
+ )
+ from app.gateway.services import _mark_legacy_sync_fresh as _services_mark_sync_fresh
+
+ agent_store = getattr(app.state, "agent_store", None)
+ if agent_store is not None:
+ await _sync_legacy_agents(agent_store)
+ _mark_legacy_sync_fresh()
+ # ``services.py`` keeps an independent TTL gate (used by
+ # ``start_run`` ? ``_enforce_agent_access``). Without this
+ # ack, the first ``/runs/stream`` after startup would
+ # re-run the heavy sync even though we just did it.
+ _services_mark_sync_fresh()
+ logger.info("Legacy folder-backed agents synced at startup")
+ # 内置功能型 agent(roundtable-* / ai-writing-*)按用途默认打标签,
+ # 供管理员「内置智能体」筛选按用途归类。幂等、只追加、失败不阻塞启动。
+ try:
+ from app.gateway.routers._builtin_agent_tag_seed import (
+ ensure_builtin_agent_purpose_tags,
+ )
+
+ tag_store = getattr(app.state, "tag_store", None)
+ if tag_store is not None:
+ await ensure_builtin_agent_purpose_tags(agent_store, tag_store)
+ except Exception:
+ logger.exception("Built-in agent purpose-tag seeding failed at startup (non-fatal)")
+ except Exception:
+ logger.exception("Legacy agent sync failed at startup (non-fatal)")
+
+ # ?? skill curator ???:??????,?????????????
+ # (per-user interval_hours ??????????????)?
+ try:
+ app.state.curator_scheduler_task = asyncio.create_task(_curator_scheduler_loop())
+ except Exception:
+ logger.exception("Skill curator scheduler failed to start (non-fatal)")
+
+ # ?? AI ????????:? config.yaml ? ai_writing.cleanup ???,
+ # ?????? 3 ????,? retention_days(?? 7 ?)?????,??
+ # ?? MySQL ??? + SQLite checkpointer ?? thread ???? worker
+ # ?????? leader,?????? worker ??????
+ try:
+ from app.gateway.ai_writing_cleanup import cleanup_scheduler_loop
+
+ app.state.ai_writing_cleanup_task = asyncio.create_task(cleanup_scheduler_loop(app))
+ except Exception:
+ logger.exception("AI writing cleanup scheduler failed to start (non-fatal)")
+
+ try:
+ app.state.leaderboard_snapshot_scheduler_task = asyncio.create_task(_leaderboard_snapshot_scheduler_loop())
+ except Exception:
+ logger.exception("Leaderboard snapshot scheduler failed to start (non-fatal)")
+
+ # Concurrency (system-pressure) sampler — lightweight, singleton-locked,
+ # runtime-toggleable via system_settings.concurrency_monitor.enabled.
+ try:
+ app.state.concurrency_sampler_task = asyncio.create_task(_concurrency_sampler_loop(app))
+ except Exception:
+ logger.exception("Concurrency sampler failed to start (non-fatal)")
+
+ # Start IM channel service if any channels are configured
+ try:
+ from app.channels.service import start_channel_service
+
+ channel_service = await start_channel_service(app.state.config)
+ logger.info("Channel service started: %s", channel_service.get_status())
+ except Exception:
+ logger.exception("No IM channels configured or channel service failed to start")
+
+ scheduled_tasks_enabled = os.environ.get("SCHEDULED_TASKS_ENABLED", "true").strip().lower()
+ if scheduled_tasks_enabled in {"0", "false", "no", "off"}:
+ logger.info("Scheduled task service disabled by SCHEDULED_TASKS_ENABLED=%s", scheduled_tasks_enabled)
+ else:
+ try:
+ scheduled_task_store = getattr(app.state, "scheduled_task_store", None)
+ if scheduled_task_store is not None:
+ scheduler_service = ScheduledTaskService(app, scheduled_task_store)
+ app.state.scheduled_task_service = scheduler_service
+ set_scheduled_task_service(scheduler_service)
+ await scheduler_service.start()
+ logger.info("Scheduled task service started")
+ except Exception:
+ logger.exception("Scheduled task service failed to start")
+
+ yield
+
+ # ?? curator ???
+ curator_task = getattr(app.state, "curator_scheduler_task", None)
+ if curator_task is not None:
+ curator_task.cancel()
+ try:
+ await curator_task
+ except asyncio.CancelledError:
+ pass
+ except Exception:
+ logger.exception("Curator scheduler shutdown error")
+
+ # ?? AI ???????
+ cleanup_task = getattr(app.state, "ai_writing_cleanup_task", None)
+ if cleanup_task is not None:
+ cleanup_task.cancel()
+ try:
+ await cleanup_task
+ except asyncio.CancelledError:
+ pass
+ except Exception:
+ logger.exception("AI writing cleanup scheduler shutdown error")
+
+ leaderboard_snapshot_task = getattr(app.state, "leaderboard_snapshot_scheduler_task", None)
+ if leaderboard_snapshot_task is not None:
+ leaderboard_snapshot_task.cancel()
+ try:
+ await leaderboard_snapshot_task
+ except asyncio.CancelledError:
+ pass
+ except Exception:
+ logger.exception("Leaderboard snapshot scheduler shutdown error")
+
+ concurrency_sampler_task = getattr(app.state, "concurrency_sampler_task", None)
+ if concurrency_sampler_task is not None:
+ concurrency_sampler_task.cancel()
+ try:
+ await concurrency_sampler_task
+ except asyncio.CancelledError:
+ pass
+ except Exception:
+ logger.exception("Concurrency sampler shutdown error")
+
+ # ?? 7 ????AI ?? graph ???? checkpointer / in-flight task?
+ # thread ??? LangGraph Server ? checkpointer ???lifespan ???
+ # ``langgraph_runtime()`` ? contextmanager ???????????????
+
+ try:
+ scheduler_service = getattr(app.state, "scheduled_task_service", None)
+ if scheduler_service is not None:
+ await scheduler_service.stop()
+ except Exception:
+ logger.exception("Failed to stop scheduled task service")
+ finally:
+ set_scheduled_task_service(None)
+
+ # Stop channel service on shutdown (bounded to prevent worker hang)
+ try:
+ from app.channels.service import stop_channel_service
+
+ await asyncio.wait_for(
+ stop_channel_service(),
+ timeout=_SHUTDOWN_HOOK_TIMEOUT_SECONDS,
+ )
+ except TimeoutError:
+ logger.warning(
+ "Channel service shutdown exceeded %.1fs; proceeding with worker exit.",
+ _SHUTDOWN_HOOK_TIMEOUT_SECONDS,
+ )
+ except Exception:
+ logger.exception("Failed to stop channel service")
+
+ logger.info("Shutting down API Gateway")
+
+
+def create_app() -> FastAPI:
+ """Create and configure the FastAPI application.
+
+ Returns:
+ Configured FastAPI application instance.
+ """
+ config = get_gateway_config()
+ docs_kwargs = {"docs_url": "/docs", "redoc_url": "/redoc", "openapi_url": "/openapi.json"} if config.enable_docs else {"docs_url": None, "redoc_url": None, "openapi_url": None}
+
+ app = FastAPI(
+ title="DeerFlow API Gateway",
+ description="""
+## DeerFlow API Gateway
+
+API Gateway for DeerFlow - A LangGraph-based AI agent backend with sandbox execution capabilities.
+
+### Features
+
+- **Models Management**: Query and retrieve available AI models
+- **MCP Configuration**: Manage Model Context Protocol (MCP) server configurations
+- **Memory Management**: Access and manage global memory data for personalized conversations
+- **Skills Management**: Query and manage skills and their enabled status
+- **Artifacts**: Access thread artifacts and generated files
+- **Health Monitoring**: System health check endpoints
+
+### Architecture
+
+LangGraph requests are handled by nginx reverse proxy.
+This gateway provides custom endpoints for models, MCP configuration, skills, and artifacts.
+ """,
+ version="0.1.0",
+ lifespan=lifespan,
+ **docs_kwargs,
+ openapi_tags=[
+ {
+ "name": "models",
+ "description": "Operations for querying available AI models and their configurations",
+ },
+ {
+ "name": "mcp",
+ "description": "Manage Model Context Protocol (MCP) server configurations",
+ },
+ {
+ "name": "memory",
+ "description": "Access and manage global memory data for personalized conversations",
+ },
+ {
+ "name": "skills",
+ "description": "Manage skills and their configurations",
+ },
+ {
+ "name": "artifacts",
+ "description": "Access and download thread artifacts and generated files",
+ },
+ {
+ "name": "uploads",
+ "description": "Upload and manage user files for threads",
+ },
+ {
+ "name": "threads",
+ "description": "Manage DeerFlow thread-local filesystem data",
+ },
+ {
+ "name": "agents",
+ "description": "Create and manage custom agents with per-agent config and prompts",
+ },
+ {
+ "name": "suggestions",
+ "description": "Generate follow-up question suggestions for conversations",
+ },
+ {
+ "name": "channels",
+ "description": "Manage IM channel integrations (Feishu, Slack, Telegram)",
+ },
+ {
+ "name": "assistants-compat",
+ "description": "LangGraph Platform-compatible assistants API (stub)",
+ },
+ {
+ "name": "runs",
+ "description": "LangGraph Platform-compatible runs lifecycle (create, stream, cancel)",
+ },
+ {
+ "name": "scheduled-tasks",
+ "description": "Create, manage, and execute user scheduled agent tasks",
+ },
+ {
+ "name": "health",
+ "description": "Health check and system status endpoints",
+ },
+ ],
+ )
+
+ # Auth: reject unauthenticated requests to non-public paths (fail-closed safety net)
+ app.add_middleware(AuthMiddleware)
+
+ # CSRF: Double Submit Cookie pattern for state-changing requests
+ app.add_middleware(CSRFMiddleware)
+
+ # CORS
+ if is_auth_disabled():
+ logger.warning(
+ "DEER_FLOW_AUTH_DISABLED is set ? API authentication is bypassed. "
+ "Do not use this in production."
+ )
+
+ def _cors_allow_any_origin() -> bool:
+ """Mirror any browser Origin (works with credentials); cannot use literal '*'."""
+ v = os.environ.get("GATEWAY_CORS_ALLOW_ALL", "").strip().lower()
+ return v in ("1", "true", "yes", "on") or is_auth_disabled()
+
+ # Keep standard local frontend origins available even when the startup
+ # script supplies an explicit production/development allow-list. Vite can
+ # choose 5173 or move to 5174 when that port is already occupied; excluding
+ # the former makes every browser preflight fail after a backend restart.
+ local_vite_origins = [
+ "http://localhost:5173",
+ "http://127.0.0.1:5173",
+ "http://localhost:5174",
+ "http://127.0.0.1:5174",
+ "http://localhost:3000",
+ "http://127.0.0.1:3000",
+ ]
+
+ # The legacy TaskCOP Vue application uses its own Vite development port.
+ taskcop_vite_origins = [
+ "http://localhost:8080",
+ "http://127.0.0.1:8080",
+ ]
+ local_dev_origins = [*local_vite_origins, *taskcop_vite_origins]
+
+ cors_origins_env = os.environ.get("GATEWAY_CORS_ORIGINS", "").strip()
+ cors_origins: list[str] = [o.strip() for o in cors_origins_env.split(",") if o.strip()] if cors_origins_env else []
+ if cors_origins:
+ cors_origins = list(dict.fromkeys([*cors_origins, *local_dev_origins]))
+
+ # Headers the LangGraph SDK / browser clients need to read from cross-origin
+ # responses. Without exposing them, the SDK's `response.headers.get(...)` returns
+ # null on cross-origin streaming responses, breaking run-id extraction and reconnect.
+ cors_expose_headers = [
+ "Content-Disposition",
+ "Content-Location",
+ "Location",
+ "X-Run-Id",
+ "X-Thread-Id",
+ ]
+
+ # Public pages (e.g. http://47.x.x.x:7010) calling this Gateway on localhost
+ # trigger Chrome/Edge Private Network Access preflights. Without
+ # allow_private_network=True the browser reports a generic CORS failure even
+ # when Access-Control-Allow-Origin is correct.
+ cors_kwargs = {
+ "allow_credentials": True,
+ "allow_methods": ["*"],
+ "allow_headers": ["*"],
+ "expose_headers": cors_expose_headers,
+ "allow_private_network": True,
+ }
+
+ if _cors_allow_any_origin():
+ logger.warning(
+ "Permissive CORS enabled ? any Origin is echoed back (GATEWAY_CORS_ALLOW_ALL or "
+ "DEER_FLOW_AUTH_DISABLED). Do not expose publicly."
+ )
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=[],
+ # fullmatch() on the Origin header; browsers send scheme://host[:port]
+ allow_origin_regex=r".+",
+ **cors_kwargs,
+ )
+ elif cors_origins:
+ if "*" in cors_origins:
+ logger.error(
+ "GATEWAY_CORS_ORIGINS contains '*' with allow_credentials=True ? invalid. "
+ "Set GATEWAY_CORS_ALLOW_ALL=1 instead, or list explicit origins."
+ )
+ cors_origins = [o for o in cors_origins if o != "*"]
+ if cors_origins:
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=cors_origins,
+ **cors_kwargs,
+ )
+ else:
+ # Safe-by-default fallback for local frontend development. Without any CORS
+ # middleware, browser preflight OPTIONS requests will hit routes directly and
+ # fail with 405, which blocks login and all JSON POST APIs.
+ dev_origins = local_dev_origins
+ logger.warning(
+ "No CORS configuration found; enabling local dev origins only: %s. "
+ "Set GATEWAY_CORS_ORIGINS or GATEWAY_CORS_ALLOW_ALL for deployment.",
+ ", ".join(dev_origins),
+ )
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=dev_origins,
+ **cors_kwargs,
+ )
+
+ # Include routers
+ # Models API is mounted at /api/models
+ app.include_router(models.router)
+ # Administrator-managed config.yaml model settings and connection tests.
+ app.include_router(model_management.router)
+
+ # MCP API is mounted at /api/mcp
+ app.include_router(mcp.router)
+
+ # DeerFlow-native LLMWiki management/search BFF (conditionally backed by WeKnora).
+ app.include_router(llmwiki.router)
+
+ # Memory API is mounted at /api/memory
+ app.include_router(memory.router)
+
+ # Skills API is mounted at /api/skills
+ app.include_router(skills.router)
+
+ # Artifacts API is mounted at /api/threads/{thread_id}/artifacts
+ app.include_router(artifacts.router)
+
+ # Uploads API is mounted at /api/threads/{thread_id}/uploads
+ app.include_router(uploads.router)
+
+ # Optional Chrome-extension page capture API is mounted at /api/browser-context
+ app.include_router(browser_context.router)
+
+ # Thread cleanup API is mounted at /api/threads/{thread_id}
+ app.include_router(threads.router)
+
+ # Agents API is mounted at /api/agents
+ app.include_router(agents.router)
+
+ # Recommended Questions API is mounted at /api/recommended-questions
+ app.include_router(recommended_questions.router)
+
+ app.include_router(user_prompts.router)
+
+ # Per-user UI preferences at /api/user-preferences
+ app.include_router(user_preferences.router)
+
+ # Roundtable-planning drafts + recommendation history at /api/roundtable-drafts
+ app.include_router(roundtable_drafts.router)
+
+ # Task-scoped roundtable drafts (taskId 深链聊天记录,独立存、不分权) at /api/roundtable-task-drafts
+ app.include_router(roundtable_task_drafts.router)
+
+ # Task-scoped artifact submission status; stores references only, never artifact content.
+ app.include_router(roundtable_artifact_submissions.router)
+
+ # Step3 流程图结构化抽取 + 校验 (SSE) at /api/flow-extract
+ app.include_router(flow_extract.router)
+
+ # Roundtable-planning business chains at /api/roundtable-chains
+ app.include_router(roundtable_chains.router)
+
+ # 岗位协同独立会话 / 节点状态(不使用原圆桌总控或后台作业)
+ # Canonical position-workspace persistence API.
+ app.include_router(position_roundtable.router, prefix="/api")
+ # Compatibility route for intranet gateways whose allowlist only exposes
+ # the established `/api/multi-agent/*` namespace.
+ app.include_router(
+ position_roundtable.router,
+ prefix="/api/multi-agent",
+ include_in_schema=False,
+ )
+
+ # Global business ? agent / chain mapping at /api/business-mapping
+ app.include_router(business_mapping.router)
+
+ # Position-roundtable work-view role configuration at /api/position-roles.
+ app.include_router(position_roles.router)
+
+ # Light-app registry (?????) at /api/light-apps
+ app.include_router(light_apps.router)
+
# Sidebar-menu overrides (菜单管理) at /api/menu-overrides
app.include_router(menu_overrides.router)
@@ -1528,158 +1529,161 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
app.include_router(compact_menu_overrides.router)
# Sensitive-word map (敏感词管理 / 脱敏词) at /api/sensitive-words
- app.include_router(sensitive_words.router)
-
- # Skill URL/IP batch replace (技能地址管理) at /api/skill-addresses
- app.include_router(skill_addresses.router)
-
- # 大屏绘制智能体独立页面会话(按 taskId 存取,不分权)at /api/dashboard-sessions
- app.include_router(dashboard_sessions.router)
-
- # Task-workspace configurable jump buttons (按钮管理) at /api/task-buttons
- app.include_router(task_buttons.router)
-
- # Temporary compatibility bridge for the TaskCOP situation-overview frontend.
- app.include_router(taskcop_tasks.router)
-
- # Task-scoped situation-report details used by dashboard drawing / task gates.
- app.include_router(task_reports.router)
-
- # Standalone parallel multi-agent panel: fan-out streaming + saved tasks
- app.include_router(parallel_agents.router)
- app.include_router(parallel_agent_tasks.router)
-
- # Roundtable-planning background jobs at /api/roundtable-jobs
- app.include_router(roundtable_jobs.router)
- app.include_router(roundtable_diagnostics.router)
-
- # Deep Research (深度研究) API at /api/deep-research
- app.include_router(deep_research.router)
-
- # Suggestions API is mounted at /api/threads/{thread_id}/suggestions
- app.include_router(suggestions.router)
-
- # Writing rewrite API is mounted at /api/writing
- app.include_router(writing.router)
-
- # AI Writing multi-agent API is mounted at /api/ai-writing
- app.include_router(ai_writing.router)
-
- # Scheduled task API is mounted at /api/scheduled-tasks
- app.include_router(scheduled_tasks.router)
-
- # Public, unauthenticated read-only scheduled task API
- # (/api/public/scheduled-tasks/*). Lets external systems query tasks and
- # surface results without a DeerFlow login.
- app.include_router(public_scheduled_tasks.router)
-
- # Public read-only user leaderboard publish page (/api/public/leaderboard)
- app.include_router(public_leaderboard.router)
-
- # Public embed Q&A status polling (/api/public/embed/*)
- app.include_router(public_embed.router)
- app.include_router(public_config.router)
- app.include_router(html_page_favorites.router)
-
- # Product knowledge base API is mounted at /api/knowledge
- app.include_router(knowledge.router)
-
- # External knowledge ingest (publish md artifacts) at /api/knowledge-ingest
- app.include_router(knowledge_ingest.router)
-
- # System settings API is mounted at /api/system-settings
- app.include_router(system_settings.router)
-
- # Admin LLM metrics audit + export
- app.include_router(llm_metrics.router)
-
- # Admin window into other users' threads / messages / memory
- app.include_router(admin_users.router)
- app.include_router(admin_users.self_router)
-
- app.include_router(admin_active_runs.router)
-
- # Admin tool / skill call metrics audit + export
- app.include_router(tool_metrics.router)
-
- # Channels API is mounted at /api/channels
- app.include_router(channels.router)
-
- # Admin checkpoint migration API: SQLite legacy checkpoints -> Postgres.
- app.include_router(checkpoint_migration.router)
-
- # Assistants compatibility API (LangGraph Platform stub)
- app.include_router(assistants_compat.router)
-
- # Auth API is mounted at /api/v1/auth
- app.include_router(auth.router)
-
- # Feedback API is mounted at /api/threads/{thread_id}/runs/{run_id}/feedback
- app.include_router(feedback.router)
-
- # Notifications API is mounted at /api/notifications
- app.include_router(notifications.router)
-
- # Tags API is mounted at /api/tags
- app.include_router(tags.router)
-
- # 岗位 (position) management API is mounted at /api/positions
- app.include_router(positions.router)
-
- # Thread Runs API (LangGraph Platform-compatible runs lifecycle)
- app.include_router(thread_runs.router)
-
- app.include_router(thread_shares.router)
- # Public, unauthenticated read-only share snapshots (/api/public/shares/*).
- app.include_router(thread_shares.public_router)
-
- # ?????????import ???? / view ????????
- app.include_router(roundtable_draft_shares.router)
- # Public, unauthenticated read-only roundtable result (/api/public/roundtable-shares/*).
- app.include_router(roundtable_draft_shares.public_router)
-
- # Stateless Runs API (stream/wait without a pre-existing thread)
- app.include_router(runs.router)
-
- # 开放问答接口 /api/open/chat —— 无需登录,指定模型 + 智能体做问答,可关闭思考模式
- app.include_router(open_chat.router)
-
- # 开放接口 /api/open/3qfx/ask —— 无需登录,按 taskId 直接发起 3qfx 第一次问答(后台运行,落成真实会话)
- app.include_router(open_3qfx.router)
-
- # --- ?????????? /page/workspace/roundtable/planning?---
- # ??? app/gateway/routers/{multi_agent,intent,recommend}.py?
- # ??? frontend-web/docs/multi-agent-backend-dev.md
- app.include_router(multi_agent.router) # Step 2?init(SSE) + run/stream(leader|special)
- app.include_router(intent.router) # Step 1?/api/intent/init + /stream
- app.include_router(recommend.router) # Step 1?2?/api/recommend/stream?? init?
- # Step 3 ????/?????? artifacts.router?/api/threads/{id}/artifacts?
-
- # Health probes are always unauthenticated (see
- # auth_middleware.PUBLIC_HEALTH_PATHS). Support the bare Gateway path,
- # API base URL, and LangGraph proxy base URL for deployment monitors.
- @app.api_route("/health", methods=["GET", "HEAD"], tags=["health"])
- @app.api_route("/api/health", methods=["GET", "HEAD"], tags=["health"])
- @app.api_route("/api/langgraph/health", methods=["GET", "HEAD"], tags=["health"])
- async def health_check() -> dict:
- """Health check endpoint.
-
- Returns:
- Service health status information.
- """
- return {"status": "healthy", "service": "deer-flow-gateway"}
-
- # WeKnora's embedded frontend uses root-relative routes such as /platform,
- # /assets, /locales and /api/v1/*. Register that proxy last so it stays a
- # true fallback and cannot intercept DeerFlow APIs.
- # Register the public-prefix routes before the unprefixed static fallback.
- # Otherwise proxy_router's /{proxied_path:path} route consumes every
- # /deerflow/* request before the prefixed platform/assets routes can match.
- app.include_router(llmwiki.proxy_router, prefix="/deerflow")
- app.include_router(llmwiki.proxy_router)
-
- return app
-
-
-# Create app instance for uvicorn
-app = create_app()
+ app.include_router(sensitive_words.router)
+
+ # Skill URL/IP batch replace (技能地址管理) at /api/skill-addresses
+ app.include_router(skill_addresses.router)
+
+ # 大屏绘制智能体独立页面会话(按 taskId 存取,不分权)at /api/dashboard-sessions
+ app.include_router(dashboard_sessions.router)
+
+ # Task-workspace configurable jump buttons (按钮管理) at /api/task-buttons
+ app.include_router(task_buttons.router)
+
+ # Deep-research report structures (工作流配置 / 报告结构) at /api/report-structures
+ app.include_router(report_structures.router)
+
+ # Temporary compatibility bridge for the TaskCOP situation-overview frontend.
+ app.include_router(taskcop_tasks.router)
+
+ # Task-scoped situation-report details used by dashboard drawing / task gates.
+ app.include_router(task_reports.router)
+
+ # Standalone parallel multi-agent panel: fan-out streaming + saved tasks
+ app.include_router(parallel_agents.router)
+ app.include_router(parallel_agent_tasks.router)
+
+ # Roundtable-planning background jobs at /api/roundtable-jobs
+ app.include_router(roundtable_jobs.router)
+ app.include_router(roundtable_diagnostics.router)
+
+ # Deep Research (深度研究) API at /api/deep-research
+ app.include_router(deep_research.router)
+
+ # Suggestions API is mounted at /api/threads/{thread_id}/suggestions
+ app.include_router(suggestions.router)
+
+ # Writing rewrite API is mounted at /api/writing
+ app.include_router(writing.router)
+
+ # AI Writing multi-agent API is mounted at /api/ai-writing
+ app.include_router(ai_writing.router)
+
+ # Scheduled task API is mounted at /api/scheduled-tasks
+ app.include_router(scheduled_tasks.router)
+
+ # Public, unauthenticated read-only scheduled task API
+ # (/api/public/scheduled-tasks/*). Lets external systems query tasks and
+ # surface results without a DeerFlow login.
+ app.include_router(public_scheduled_tasks.router)
+
+ # Public read-only user leaderboard publish page (/api/public/leaderboard)
+ app.include_router(public_leaderboard.router)
+
+ # Public embed Q&A status polling (/api/public/embed/*)
+ app.include_router(public_embed.router)
+ app.include_router(public_config.router)
+ app.include_router(html_page_favorites.router)
+
+ # Product knowledge base API is mounted at /api/knowledge
+ app.include_router(knowledge.router)
+
+ # External knowledge ingest (publish md artifacts) at /api/knowledge-ingest
+ app.include_router(knowledge_ingest.router)
+
+ # System settings API is mounted at /api/system-settings
+ app.include_router(system_settings.router)
+
+ # Admin LLM metrics audit + export
+ app.include_router(llm_metrics.router)
+
+ # Admin window into other users' threads / messages / memory
+ app.include_router(admin_users.router)
+ app.include_router(admin_users.self_router)
+
+ app.include_router(admin_active_runs.router)
+
+ # Admin tool / skill call metrics audit + export
+ app.include_router(tool_metrics.router)
+
+ # Channels API is mounted at /api/channels
+ app.include_router(channels.router)
+
+ # Admin checkpoint migration API: SQLite legacy checkpoints -> Postgres.
+ app.include_router(checkpoint_migration.router)
+
+ # Assistants compatibility API (LangGraph Platform stub)
+ app.include_router(assistants_compat.router)
+
+ # Auth API is mounted at /api/v1/auth
+ app.include_router(auth.router)
+
+ # Feedback API is mounted at /api/threads/{thread_id}/runs/{run_id}/feedback
+ app.include_router(feedback.router)
+
+ # Notifications API is mounted at /api/notifications
+ app.include_router(notifications.router)
+
+ # Tags API is mounted at /api/tags
+ app.include_router(tags.router)
+
+ # 岗位 (position) management API is mounted at /api/positions
+ app.include_router(positions.router)
+
+ # Thread Runs API (LangGraph Platform-compatible runs lifecycle)
+ app.include_router(thread_runs.router)
+
+ app.include_router(thread_shares.router)
+ # Public, unauthenticated read-only share snapshots (/api/public/shares/*).
+ app.include_router(thread_shares.public_router)
+
+ # ?????????import ???? / view ????????
+ app.include_router(roundtable_draft_shares.router)
+ # Public, unauthenticated read-only roundtable result (/api/public/roundtable-shares/*).
+ app.include_router(roundtable_draft_shares.public_router)
+
+ # Stateless Runs API (stream/wait without a pre-existing thread)
+ app.include_router(runs.router)
+
+ # 开放问答接口 /api/open/chat —— 无需登录,指定模型 + 智能体做问答,可关闭思考模式
+ app.include_router(open_chat.router)
+
+ # 开放接口 /api/open/3qfx/ask —— 无需登录,按 taskId 直接发起 3qfx 第一次问答(后台运行,落成真实会话)
+ app.include_router(open_3qfx.router)
+
+ # --- ?????????? /page/workspace/roundtable/planning?---
+ # ??? app/gateway/routers/{multi_agent,intent,recommend}.py?
+ # ??? frontend-web/docs/multi-agent-backend-dev.md
+ app.include_router(multi_agent.router) # Step 2?init(SSE) + run/stream(leader|special)
+ app.include_router(intent.router) # Step 1?/api/intent/init + /stream
+ app.include_router(recommend.router) # Step 1?2?/api/recommend/stream?? init?
+ # Step 3 ????/?????? artifacts.router?/api/threads/{id}/artifacts?
+
+ # Health probes are always unauthenticated (see
+ # auth_middleware.PUBLIC_HEALTH_PATHS). Support the bare Gateway path,
+ # API base URL, and LangGraph proxy base URL for deployment monitors.
+ @app.api_route("/health", methods=["GET", "HEAD"], tags=["health"])
+ @app.api_route("/api/health", methods=["GET", "HEAD"], tags=["health"])
+ @app.api_route("/api/langgraph/health", methods=["GET", "HEAD"], tags=["health"])
+ async def health_check() -> dict:
+ """Health check endpoint.
+
+ Returns:
+ Service health status information.
+ """
+ return {"status": "healthy", "service": "deer-flow-gateway"}
+
+ # WeKnora's embedded frontend uses root-relative routes such as /platform,
+ # /assets, /locales and /api/v1/*. Register that proxy last so it stays a
+ # true fallback and cannot intercept DeerFlow APIs.
+ # Register the public-prefix routes before the unprefixed static fallback.
+ # Otherwise proxy_router's /{proxied_path:path} route consumes every
+ # /deerflow/* request before the prefixed platform/assets routes can match.
+ app.include_router(llmwiki.proxy_router, prefix="/deerflow")
+ app.include_router(llmwiki.proxy_router)
+
+ return app
+
+
+# Create app instance for uvicorn
+app = create_app()
diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py
index b094f05e..5a10142f 100644
--- a/offline-backend-20260512/backend/app/gateway/deps.py
+++ b/offline-backend-20260512/backend/app/gateway/deps.py
@@ -1,286 +1,286 @@
-"""Centralized accessors for singleton objects stored on ``app.state``.
-
-**Getters** (used by routers): raise 503 when a required dependency is
-missing, except ``get_store`` which returns ``None``.
-
-Initialization is handled directly in ``app.py`` via :class:`AsyncExitStack`.
-"""
-
-from __future__ import annotations
-
-import asyncio
-import logging
-from collections.abc import AsyncGenerator, Callable
-from contextlib import AsyncExitStack, asynccontextmanager
-from typing import TYPE_CHECKING, TypeVar, cast
-
-from fastapi import FastAPI, HTTPException, Request
-from langgraph.types import Checkpointer
-
-from deerflow.config.app_config import AppConfig
-from deerflow.persistence.feedback import FeedbackRepository
-from deerflow.runtime import RunContext, RunManager, StreamBridge
-from deerflow.runtime.events.store.base import RunEventStore
-from deerflow.runtime.runs.store.base import RunStore
-
-logger = logging.getLogger(__name__)
-
-if TYPE_CHECKING:
- from app.gateway.auth.local_provider import LocalAuthProvider
- from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
- from deerflow.persistence.agents import AgentStore
- from deerflow.persistence.notifications import NotificationStore
- from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
- from deerflow.persistence.skills import SkillStore
- from deerflow.persistence.tags import TagStore
- from deerflow.persistence.thread_meta.base import ThreadMetaStore
-
-
-T = TypeVar("T")
-
-
-def resolve_roundtable_gateway_mode(raw: str | None) -> str:
- """解析 ``ROUNDTABLE_JOB_GATEWAY``:**默认 ``inprocess``(真模型后台挂起)**。
-
- 只有显式写 ``simulated`` 才用模拟网关(前端联调用:真实进度、不调真模型);
- 缺省 / 空串 / 其它值一律落到 ``inprocess`` —— 部署时无需在 .env 里配置任何值,
- 后台挂起开箱即用(Phase 3b 已在 Windows 本地与内网 Docker 验证)。
- """
- mode = (raw or "").strip().lower()
- return "simulated" if mode == "simulated" else "inprocess"
-
-
-def get_config(request: Request) -> AppConfig:
- """Return the app-scoped ``AppConfig`` stored on ``app.state``."""
- config = getattr(request.app.state, "config", None)
- if config is None:
- raise HTTPException(status_code=503, detail="Configuration not available")
- return config
-
-
-@asynccontextmanager
-async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
- """Bootstrap and tear down all LangGraph runtime singletons.
-
- Usage in ``app.py``::
-
- async with langgraph_runtime(app):
- yield
- """
- from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
- from deerflow.runtime import make_store, make_stream_bridge
- from deerflow.runtime.checkpointer.async_provider import make_checkpointer
- from deerflow.runtime.events.store import make_run_event_store
-
- async with AsyncExitStack() as stack:
- config = getattr(app.state, "config", None)
- if config is None:
- raise RuntimeError("langgraph_runtime() requires app.state.config to be initialized")
-
- app.state.stream_bridge = await stack.enter_async_context(make_stream_bridge(config))
-
- # Initialize persistence engine BEFORE checkpointer so that
- # auto-create-database logic runs first (postgres backend).
- await init_engine_from_config(config.database)
-
- app.state.checkpointer = await stack.enter_async_context(make_checkpointer(config))
- app.state.store = await stack.enter_async_context(make_store(config))
-
- # Initialize repositories — one get_session_factory() call for all.
- sf = get_session_factory()
- if sf is not None:
- from deerflow.persistence.feedback import FeedbackRepository
- from deerflow.persistence.run import RunRepository
-
- app.state.run_store = RunRepository(sf)
- app.state.feedback_repo = FeedbackRepository(sf)
- else:
- from deerflow.runtime.runs.store.memory import MemoryRunStore
-
- app.state.run_store = MemoryRunStore()
- app.state.feedback_repo = None
-
- from deerflow.persistence.thread_meta import make_thread_store
-
- app.state.thread_store = make_thread_store(sf, app.state.store)
-
- from deerflow.persistence.scheduled_tasks import make_scheduled_task_store
-
- app.state.scheduled_task_store = make_scheduled_task_store(sf)
-
- from deerflow.persistence.html_pages import make_html_page_store
-
- app.state.html_page_store = make_html_page_store(sf)
-
- from deerflow.persistence.llm_metrics import make_llm_metrics_store
-
- app.state.llm_metrics_store = make_llm_metrics_store(sf)
-
- from deerflow.persistence.concurrency import make_concurrency_sample_store
-
- app.state.concurrency_sample_store = make_concurrency_sample_store(sf)
-
- from deerflow.persistence.tool_metrics import make_tool_metrics_store
-
- app.state.tool_metrics_store = make_tool_metrics_store(sf)
-
- from deerflow.persistence.agents import make_agent_store
-
- app.state.agent_store = make_agent_store(sf)
-
- from deerflow.persistence.skills import make_skill_store
-
- app.state.skill_store = make_skill_store(sf)
-
- from deerflow.persistence.notifications import (
- make_notification_store,
- set_notification_store,
- )
-
- app.state.notification_store = make_notification_store(sf)
- # Make the sender helper (harness layer) able to write without going
- # through FastAPI request context — used by cascade.py.
- set_notification_store(app.state.notification_store)
- from deerflow.persistence.recommended_questions import make_recommended_question_store
-
- app.state.recommended_question_store = make_recommended_question_store(sf)
-
- from deerflow.persistence.tags import make_tag_store
-
- app.state.tag_store = make_tag_store(sf)
-
- from deerflow.persistence.positions import make_position_store
-
- app.state.position_store = make_position_store(sf)
-
- from deerflow.services.position_sync import PositionSyncService
-
- app.state.position_sync = PositionSyncService(sf, app.state.position_store, app.state.tag_store)
-
- from deerflow.persistence.thread_shares import make_thread_share_store
-
- app.state.thread_share_store = make_thread_share_store(sf)
-
- from deerflow.persistence.embed_sessions import make_embed_session_store
-
- app.state.embed_session_store = make_embed_session_store(sf)
-
- from deerflow.persistence.ai_writing_sessions import (
- make_ai_writing_session_store,
- )
- from deerflow.persistence.ai_writing_sessions import (
- register_default_store as _register_ai_writing_default_store,
- )
-
- app.state.ai_writing_session_store = make_ai_writing_session_store(sf)
- # 让 LangGraph Server 路径下的 AI 写作节点能跨 app/harness 边界拿到 repo。
- # 老 /start /stream /resume 路由仍走 request.app.state,互不影响。
- _register_ai_writing_default_store(app.state.ai_writing_session_store)
-
- # AI 写作「后台挂起」执行器:把写作图作为后台任务驱动到定稿(离页/刷新/切号不打断)。
- # 用 app.state.checkpointer(与运行时 worker 同一份 saver)续上线程 checkpoint。
- from app.gateway.ai_writing_job_executor import AIWritingJobExecutor
-
- app.state.ai_writing_job_executor = AIWritingJobExecutor(app)
- # 进程重启续跑:把仍标记为 background 的会话重新拉起驱动(best-effort,不阻塞启动)。
- if app.state.ai_writing_session_store is not None:
- from app.gateway.ai_writing_job_executor import STATUS_BACKGROUND
-
- async def _reconcile_ai_writing_background() -> None:
- try:
- sessions = await app.state.ai_writing_session_store.list_by_status(STATUS_BACKGROUND)
- app.state.ai_writing_job_executor.reconcile(sessions=sessions)
- except Exception: # noqa: BLE001
- logger.warning("[ai-writing-bg] startup reconcile failed", exc_info=True)
-
- asyncio.create_task(_reconcile_ai_writing_background())
-
- from deerflow.persistence.article_types import make_article_type_store
-
- app.state.article_type_store = make_article_type_store(sf)
-
- from deerflow.persistence.research_templates import make_research_template_store
-
- app.state.research_template_store = make_research_template_store(sf)
-
- from deerflow.persistence.memory_config import make_user_memory_config_store
-
- app.state.memory_config_store = make_user_memory_config_store(sf)
-
- from deerflow.knowledge import make_knowledge_service
-
- app.state.knowledge_service = make_knowledge_service(sf, config.knowledge)
-
- # Seed the built-in extraction prompts as editable templates so operators
- # can immediately view/edit them (idempotent; never overwrites edits).
- if app.state.knowledge_service is not None:
- try:
- await app.state.knowledge_service.seed_default_templates()
- except Exception:
- logger.exception("Failed to seed knowledge extraction templates")
-
- # Background auto-sedimentation queue (phase 3) — drains finished
- # conversations off the chat path. Worker only does work when
- # ``knowledge.auto_ingest_enabled`` is set; the queue is always wired so
- # the middleware can enqueue without a hard dependency.
- from deerflow.knowledge.auto_ingest import AutoIngestQueue, set_auto_ingest_queue
-
- if app.state.knowledge_service is not None:
- _auto_ingest_queue = AutoIngestQueue(
- lambda: getattr(app.state, "knowledge_service", None),
- min_chars=config.knowledge.auto_ingest_min_chars,
- )
- _auto_ingest_queue.start()
- set_auto_ingest_queue(_auto_ingest_queue)
- app.state.knowledge_auto_ingest_queue = _auto_ingest_queue
-
- from deerflow.persistence.user_prompts import make_user_prompt_store
-
- app.state.user_prompt_store = make_user_prompt_store(sf)
-
- from deerflow.persistence.user_preferences import make_user_preferences_store
-
- app.state.user_preferences_store = make_user_preferences_store(sf)
-
- from deerflow.persistence.roundtable_drafts import make_roundtable_draft_store
-
- app.state.roundtable_draft_store = make_roundtable_draft_store(sf)
-
- # Task-scoped roundtable drafts (taskId 深链聊天记录,独立存、不按 user 分权)。
- from deerflow.persistence.roundtable_task_drafts import make_roundtable_task_draft_store
-
- app.state.roundtable_task_draft_store = make_roundtable_task_draft_store(sf)
-
- from deerflow.persistence.roundtable_draft_shares import make_roundtable_draft_share_store
-
- app.state.roundtable_draft_share_store = make_roundtable_draft_share_store(sf)
-
- from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
-
- app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
-
- # 独立的岗位协同 Session / Node 存储。它只索引业务流程状态,真实消息和
- # 文件仍复用 LangGraph thread/checkpoint 与现有 artifact API。
- from deerflow.persistence.position_roundtable import make_position_roundtable_store
-
- app.state.position_roundtable_store = make_position_roundtable_store(sf)
-
- # 岗位会商工作视角(名称 / 顺序 / 特殊意图岗)是全局配置,和组织/RBAC
- # 岗位分开保存。启动只补齐缺失内置项,不覆盖管理员的既有修改。
- from app.gateway.position_role_defaults import POSITION_ROLE_SEED
- from deerflow.persistence.position_roles import make_position_role_store
-
- app.state.position_role_store = make_position_role_store(sf)
- await app.state.position_role_store.ensure_seed(POSITION_ROLE_SEED)
-
- from deerflow.persistence.business_mapping import make_business_mapping_store
-
- app.state.business_mapping_store = make_business_mapping_store(sf)
-
- from deerflow.persistence.light_apps import make_light_app_store
-
- app.state.light_app_store = make_light_app_store(sf)
-
+"""Centralized accessors for singleton objects stored on ``app.state``.
+
+**Getters** (used by routers): raise 503 when a required dependency is
+missing, except ``get_store`` which returns ``None``.
+
+Initialization is handled directly in ``app.py`` via :class:`AsyncExitStack`.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from collections.abc import AsyncGenerator, Callable
+from contextlib import AsyncExitStack, asynccontextmanager
+from typing import TYPE_CHECKING, TypeVar, cast
+
+from fastapi import FastAPI, HTTPException, Request
+from langgraph.types import Checkpointer
+
+from deerflow.config.app_config import AppConfig
+from deerflow.persistence.feedback import FeedbackRepository
+from deerflow.runtime import RunContext, RunManager, StreamBridge
+from deerflow.runtime.events.store.base import RunEventStore
+from deerflow.runtime.runs.store.base import RunStore
+
+logger = logging.getLogger(__name__)
+
+if TYPE_CHECKING:
+ from app.gateway.auth.local_provider import LocalAuthProvider
+ from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
+ from deerflow.persistence.agents import AgentStore
+ from deerflow.persistence.notifications import NotificationStore
+ from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
+ from deerflow.persistence.skills import SkillStore
+ from deerflow.persistence.tags import TagStore
+ from deerflow.persistence.thread_meta.base import ThreadMetaStore
+
+
+T = TypeVar("T")
+
+
+def resolve_roundtable_gateway_mode(raw: str | None) -> str:
+ """解析 ``ROUNDTABLE_JOB_GATEWAY``:**默认 ``inprocess``(真模型后台挂起)**。
+
+ 只有显式写 ``simulated`` 才用模拟网关(前端联调用:真实进度、不调真模型);
+ 缺省 / 空串 / 其它值一律落到 ``inprocess`` —— 部署时无需在 .env 里配置任何值,
+ 后台挂起开箱即用(Phase 3b 已在 Windows 本地与内网 Docker 验证)。
+ """
+ mode = (raw or "").strip().lower()
+ return "simulated" if mode == "simulated" else "inprocess"
+
+
+def get_config(request: Request) -> AppConfig:
+ """Return the app-scoped ``AppConfig`` stored on ``app.state``."""
+ config = getattr(request.app.state, "config", None)
+ if config is None:
+ raise HTTPException(status_code=503, detail="Configuration not available")
+ return config
+
+
+@asynccontextmanager
+async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
+ """Bootstrap and tear down all LangGraph runtime singletons.
+
+ Usage in ``app.py``::
+
+ async with langgraph_runtime(app):
+ yield
+ """
+ from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
+ from deerflow.runtime import make_store, make_stream_bridge
+ from deerflow.runtime.checkpointer.async_provider import make_checkpointer
+ from deerflow.runtime.events.store import make_run_event_store
+
+ async with AsyncExitStack() as stack:
+ config = getattr(app.state, "config", None)
+ if config is None:
+ raise RuntimeError("langgraph_runtime() requires app.state.config to be initialized")
+
+ app.state.stream_bridge = await stack.enter_async_context(make_stream_bridge(config))
+
+ # Initialize persistence engine BEFORE checkpointer so that
+ # auto-create-database logic runs first (postgres backend).
+ await init_engine_from_config(config.database)
+
+ app.state.checkpointer = await stack.enter_async_context(make_checkpointer(config))
+ app.state.store = await stack.enter_async_context(make_store(config))
+
+ # Initialize repositories — one get_session_factory() call for all.
+ sf = get_session_factory()
+ if sf is not None:
+ from deerflow.persistence.feedback import FeedbackRepository
+ from deerflow.persistence.run import RunRepository
+
+ app.state.run_store = RunRepository(sf)
+ app.state.feedback_repo = FeedbackRepository(sf)
+ else:
+ from deerflow.runtime.runs.store.memory import MemoryRunStore
+
+ app.state.run_store = MemoryRunStore()
+ app.state.feedback_repo = None
+
+ from deerflow.persistence.thread_meta import make_thread_store
+
+ app.state.thread_store = make_thread_store(sf, app.state.store)
+
+ from deerflow.persistence.scheduled_tasks import make_scheduled_task_store
+
+ app.state.scheduled_task_store = make_scheduled_task_store(sf)
+
+ from deerflow.persistence.html_pages import make_html_page_store
+
+ app.state.html_page_store = make_html_page_store(sf)
+
+ from deerflow.persistence.llm_metrics import make_llm_metrics_store
+
+ app.state.llm_metrics_store = make_llm_metrics_store(sf)
+
+ from deerflow.persistence.concurrency import make_concurrency_sample_store
+
+ app.state.concurrency_sample_store = make_concurrency_sample_store(sf)
+
+ from deerflow.persistence.tool_metrics import make_tool_metrics_store
+
+ app.state.tool_metrics_store = make_tool_metrics_store(sf)
+
+ from deerflow.persistence.agents import make_agent_store
+
+ app.state.agent_store = make_agent_store(sf)
+
+ from deerflow.persistence.skills import make_skill_store
+
+ app.state.skill_store = make_skill_store(sf)
+
+ from deerflow.persistence.notifications import (
+ make_notification_store,
+ set_notification_store,
+ )
+
+ app.state.notification_store = make_notification_store(sf)
+ # Make the sender helper (harness layer) able to write without going
+ # through FastAPI request context — used by cascade.py.
+ set_notification_store(app.state.notification_store)
+ from deerflow.persistence.recommended_questions import make_recommended_question_store
+
+ app.state.recommended_question_store = make_recommended_question_store(sf)
+
+ from deerflow.persistence.tags import make_tag_store
+
+ app.state.tag_store = make_tag_store(sf)
+
+ from deerflow.persistence.positions import make_position_store
+
+ app.state.position_store = make_position_store(sf)
+
+ from deerflow.services.position_sync import PositionSyncService
+
+ app.state.position_sync = PositionSyncService(sf, app.state.position_store, app.state.tag_store)
+
+ from deerflow.persistence.thread_shares import make_thread_share_store
+
+ app.state.thread_share_store = make_thread_share_store(sf)
+
+ from deerflow.persistence.embed_sessions import make_embed_session_store
+
+ app.state.embed_session_store = make_embed_session_store(sf)
+
+ from deerflow.persistence.ai_writing_sessions import (
+ make_ai_writing_session_store,
+ )
+ from deerflow.persistence.ai_writing_sessions import (
+ register_default_store as _register_ai_writing_default_store,
+ )
+
+ app.state.ai_writing_session_store = make_ai_writing_session_store(sf)
+ # 让 LangGraph Server 路径下的 AI 写作节点能跨 app/harness 边界拿到 repo。
+ # 老 /start /stream /resume 路由仍走 request.app.state,互不影响。
+ _register_ai_writing_default_store(app.state.ai_writing_session_store)
+
+ # AI 写作「后台挂起」执行器:把写作图作为后台任务驱动到定稿(离页/刷新/切号不打断)。
+ # 用 app.state.checkpointer(与运行时 worker 同一份 saver)续上线程 checkpoint。
+ from app.gateway.ai_writing_job_executor import AIWritingJobExecutor
+
+ app.state.ai_writing_job_executor = AIWritingJobExecutor(app)
+ # 进程重启续跑:把仍标记为 background 的会话重新拉起驱动(best-effort,不阻塞启动)。
+ if app.state.ai_writing_session_store is not None:
+ from app.gateway.ai_writing_job_executor import STATUS_BACKGROUND
+
+ async def _reconcile_ai_writing_background() -> None:
+ try:
+ sessions = await app.state.ai_writing_session_store.list_by_status(STATUS_BACKGROUND)
+ app.state.ai_writing_job_executor.reconcile(sessions=sessions)
+ except Exception: # noqa: BLE001
+ logger.warning("[ai-writing-bg] startup reconcile failed", exc_info=True)
+
+ asyncio.create_task(_reconcile_ai_writing_background())
+
+ from deerflow.persistence.article_types import make_article_type_store
+
+ app.state.article_type_store = make_article_type_store(sf)
+
+ from deerflow.persistence.research_templates import make_research_template_store
+
+ app.state.research_template_store = make_research_template_store(sf)
+
+ from deerflow.persistence.memory_config import make_user_memory_config_store
+
+ app.state.memory_config_store = make_user_memory_config_store(sf)
+
+ from deerflow.knowledge import make_knowledge_service
+
+ app.state.knowledge_service = make_knowledge_service(sf, config.knowledge)
+
+ # Seed the built-in extraction prompts as editable templates so operators
+ # can immediately view/edit them (idempotent; never overwrites edits).
+ if app.state.knowledge_service is not None:
+ try:
+ await app.state.knowledge_service.seed_default_templates()
+ except Exception:
+ logger.exception("Failed to seed knowledge extraction templates")
+
+ # Background auto-sedimentation queue (phase 3) — drains finished
+ # conversations off the chat path. Worker only does work when
+ # ``knowledge.auto_ingest_enabled`` is set; the queue is always wired so
+ # the middleware can enqueue without a hard dependency.
+ from deerflow.knowledge.auto_ingest import AutoIngestQueue, set_auto_ingest_queue
+
+ if app.state.knowledge_service is not None:
+ _auto_ingest_queue = AutoIngestQueue(
+ lambda: getattr(app.state, "knowledge_service", None),
+ min_chars=config.knowledge.auto_ingest_min_chars,
+ )
+ _auto_ingest_queue.start()
+ set_auto_ingest_queue(_auto_ingest_queue)
+ app.state.knowledge_auto_ingest_queue = _auto_ingest_queue
+
+ from deerflow.persistence.user_prompts import make_user_prompt_store
+
+ app.state.user_prompt_store = make_user_prompt_store(sf)
+
+ from deerflow.persistence.user_preferences import make_user_preferences_store
+
+ app.state.user_preferences_store = make_user_preferences_store(sf)
+
+ from deerflow.persistence.roundtable_drafts import make_roundtable_draft_store
+
+ app.state.roundtable_draft_store = make_roundtable_draft_store(sf)
+
+ # Task-scoped roundtable drafts (taskId 深链聊天记录,独立存、不按 user 分权)。
+ from deerflow.persistence.roundtable_task_drafts import make_roundtable_task_draft_store
+
+ app.state.roundtable_task_draft_store = make_roundtable_task_draft_store(sf)
+
+ from deerflow.persistence.roundtable_draft_shares import make_roundtable_draft_share_store
+
+ app.state.roundtable_draft_share_store = make_roundtable_draft_share_store(sf)
+
+ from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
+
+ app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
+
+ # 独立的岗位协同 Session / Node 存储。它只索引业务流程状态,真实消息和
+ # 文件仍复用 LangGraph thread/checkpoint 与现有 artifact API。
+ from deerflow.persistence.position_roundtable import make_position_roundtable_store
+
+ app.state.position_roundtable_store = make_position_roundtable_store(sf)
+
+ # 岗位会商工作视角(名称 / 顺序 / 特殊意图岗)是全局配置,和组织/RBAC
+ # 岗位分开保存。启动只补齐缺失内置项,不覆盖管理员的既有修改。
+ from app.gateway.position_role_defaults import POSITION_ROLE_SEED
+ from deerflow.persistence.position_roles import make_position_role_store
+
+ app.state.position_role_store = make_position_role_store(sf)
+ await app.state.position_role_store.ensure_seed(POSITION_ROLE_SEED)
+
+ from deerflow.persistence.business_mapping import make_business_mapping_store
+
+ app.state.business_mapping_store = make_business_mapping_store(sf)
+
+ from deerflow.persistence.light_apps import make_light_app_store
+
+ app.state.light_app_store = make_light_app_store(sf)
+
from deerflow.persistence.menu_overrides import make_menu_override_store
app.state.menu_override_store = make_menu_override_store(sf)
@@ -290,180 +290,184 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.compact_menu_override_store = make_compact_menu_override_store(sf)
from deerflow.persistence.sensitive_words import make_sensitive_word_store
-
- 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.task_buttons import make_task_button_store
-
- app.state.task_button_store = make_task_button_store(sf)
-
- from deerflow.integrations.weknora.runtime import register_llmwiki_store
- from deerflow.persistence.llmwiki import make_llmwiki_store
-
- app.state.llmwiki_store = make_llmwiki_store(sf)
- register_llmwiki_store(app.state.llmwiki_store)
-
- from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
-
- app.state.taskcop_task_store = make_taskcop_task_store(sf)
-
- from deerflow.persistence.task_reports import make_task_report_store
-
- app.state.task_report_store = make_task_report_store(sf)
-
- from deerflow.persistence.roundtable_artifact_submissions import make_roundtable_artifact_submission_store
-
- # Task-scoped artifact submission status: shared by task; actor is audit-only.
- app.state.roundtable_artifact_submission_store = make_roundtable_artifact_submission_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)
-
- from deerflow.persistence.roundtable_jobs import (
- make_roundtable_job_store,
- )
- from deerflow.persistence.roundtable_jobs import (
- register_default_store as _register_roundtable_job_default_store,
- )
-
- app.state.roundtable_job_store = make_roundtable_job_store(sf)
- # 让 LangGraph Server 路径下的圆桌编排图节点能跨 app/harness 边界拿到 repo
- # (Phase 2 后台编排图写进度用)。
- _register_roundtable_job_default_store(app.state.roundtable_job_store)
-
- # 意图回合单飞 store(Phase 4):``/api/intent/stream`` 据此对 (thread_id,
- # client_turn_id) 跨 worker 单飞 + 幂等回放,杜绝并发双发意图。sf 为 None(无 DB)
- # 时为 None,路由侧判空跳过单飞、走原流程。
- from deerflow.persistence.intent_turns import make_intent_turn_store
-
- app.state.intent_turn_store = make_intent_turn_store(sf)
-
- # 圆桌会商「诊断日志」store:会商全流程各处报错 / 关键事件落库,供管理员诊断页筛选。
- # 同样登记进程级默认仓储——harness 侧编排引擎 / 进程内网关跨边界写诊断用。
- from deerflow.persistence.roundtable_diagnostics import (
- make_roundtable_diagnostic_store,
- )
- from deerflow.persistence.roundtable_diagnostics import (
- register_default_store as _register_roundtable_diagnostic_default_store,
- )
-
- app.state.roundtable_diagnostic_store = make_roundtable_diagnostic_store(sf)
- _register_roundtable_diagnostic_default_store(app.state.roundtable_diagnostic_store)
-
- # 圆桌后台作业执行器:把编排引擎作为后台任务跑。
- # 网关由环境变量 ``ROUNDTABLE_JOB_GATEWAY`` 选择(见 resolve_roundtable_gateway_mode):
- # - 缺省 / "inprocess"(Phase 3b):进程内驱动真模型(leader/seat/report)——**默认**,
- # 部署时无需任何配置即可用后台挂起;
- # - 显式 "simulated"(Phase 3a):模拟网关,产生真实进度但不调真模型,供前端联调。
- if app.state.roundtable_job_store is not None:
- import os
-
- from app.gateway.roundtable_job_executor import RoundtableJobExecutor
-
- gateway_mode = resolve_roundtable_gateway_mode(os.getenv("ROUNDTABLE_JOB_GATEWAY"))
- if gateway_mode == "simulated":
- from deerflow.agents.roundtable_orchestrator import SimulatedRoundtableGateway
-
- # delay=3s/步:让模拟后台任务有可观察的进行中时长(切页/重连仍在跑)。
- gateway_factory = lambda p: SimulatedRoundtableGateway( # noqa: E731
- p.agents, mode=p.mode, delay=3.0
- )
- logger.info("[roundtable-jobs] gateway = simulated")
- else:
- from app.gateway.roundtable_inprocess_gateway import InProcessRoundtableGateway
-
- _app = app # 闭包捕获 app,供网关访问 app.state(checkpointer/thread_store)
- gateway_factory = lambda p: InProcessRoundtableGateway( # noqa: E731
- _app,
- user_id=p.user_id,
- agents=p.agents,
- model=p.model,
- # 席位执行模式沿用 Step2 选择,后台席位 run 据此覆盖 policy 的 thinking/reasoning + 开子代理。
- seat_thinking_enabled=p.seat_thinking_enabled,
- seat_reasoning_effort=p.seat_reasoning_effort,
- seat_subagent_enabled=p.seat_subagent_enabled,
- seat_skill_directives=p.seat_skill_directives,
- # 每席位推理深度覆盖(业务链条里各席位单独配):run_seat 命中即覆盖作业级 seatMode。
- seat_modes=p.seat_modes,
- # 诊断日志上下文:网关每轮 leader/seat/report/summary/dashboard 报错均带这些归属。
- job_id=p.job_id,
- draft_id=p.draft_id,
- task_id=p.task_id,
- )
- logger.info("[roundtable-jobs] gateway = inprocess (real models, default)")
-
- app.state.roundtable_job_executor = RoundtableJobExecutor(
- app.state.roundtable_job_store,
- gateway_factory=gateway_factory,
- draft_store=getattr(app.state, "roundtable_draft_store", None),
- # task 作业把最终报告写回**独立的** task-draft 存储(不分权)。
- task_draft_store=getattr(app.state, "roundtable_task_draft_store", None),
- )
-
- # Phase 3 dispatcher:跨 worker 领取 queued/租约过期作业 + outbox 补偿器。
- # 每个 worker 进程都跑一个;谁先 claim_job 成功谁执行(DB 条件 UPDATE 互斥),
- # 崩溃的 worker 租约过期后由其它 worker 接管。路由 start/resume 后 nudge 立即唤醒。
- from app.gateway.roundtable_job_dispatcher import RoundtableJobDispatcher
-
- app.state.roundtable_job_dispatcher = RoundtableJobDispatcher(
- app.state.roundtable_job_store,
- app.state.roundtable_job_executor,
- draft_store=getattr(app.state, "roundtable_draft_store", None),
- task_draft_store=getattr(app.state, "roundtable_task_draft_store", None),
- )
- app.state.roundtable_job_dispatcher.start()
- else:
- app.state.roundtable_job_executor = None
- app.state.roundtable_job_dispatcher = None
-
- # ── Deep Research (深度研究) stores + executor + dispatcher ──────────
- # Five tables: sessions / jobs / events / sources / messages.
- # When DB is available, the executor + dispatcher run background research
- # jobs (lease-based claim, crash-recoverable) mirroring the roundtable pattern.
- from deerflow.persistence.deep_research_events import (
- make_deep_research_event_store,
- )
- from deerflow.persistence.deep_research_jobs import make_deep_research_job_store
- from deerflow.persistence.deep_research_messages import (
- make_deep_research_message_store,
- )
- from deerflow.persistence.deep_research_sessions import (
- make_deep_research_session_store,
- )
+
+ 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.task_buttons import make_task_button_store
+
+ app.state.task_button_store = make_task_button_store(sf)
+
+ from deerflow.persistence.report_structures import make_report_structure_store
+
+ app.state.report_structure_store = make_report_structure_store(sf)
+
+ from deerflow.integrations.weknora.runtime import register_llmwiki_store
+ from deerflow.persistence.llmwiki import make_llmwiki_store
+
+ app.state.llmwiki_store = make_llmwiki_store(sf)
+ register_llmwiki_store(app.state.llmwiki_store)
+
+ from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
+
+ app.state.taskcop_task_store = make_taskcop_task_store(sf)
+
+ from deerflow.persistence.task_reports import make_task_report_store
+
+ app.state.task_report_store = make_task_report_store(sf)
+
+ from deerflow.persistence.roundtable_artifact_submissions import make_roundtable_artifact_submission_store
+
+ # Task-scoped artifact submission status: shared by task; actor is audit-only.
+ app.state.roundtable_artifact_submission_store = make_roundtable_artifact_submission_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)
+
+ from deerflow.persistence.roundtable_jobs import (
+ make_roundtable_job_store,
+ )
+ from deerflow.persistence.roundtable_jobs import (
+ register_default_store as _register_roundtable_job_default_store,
+ )
+
+ app.state.roundtable_job_store = make_roundtable_job_store(sf)
+ # 让 LangGraph Server 路径下的圆桌编排图节点能跨 app/harness 边界拿到 repo
+ # (Phase 2 后台编排图写进度用)。
+ _register_roundtable_job_default_store(app.state.roundtable_job_store)
+
+ # 意图回合单飞 store(Phase 4):``/api/intent/stream`` 据此对 (thread_id,
+ # client_turn_id) 跨 worker 单飞 + 幂等回放,杜绝并发双发意图。sf 为 None(无 DB)
+ # 时为 None,路由侧判空跳过单飞、走原流程。
+ from deerflow.persistence.intent_turns import make_intent_turn_store
+
+ app.state.intent_turn_store = make_intent_turn_store(sf)
+
+ # 圆桌会商「诊断日志」store:会商全流程各处报错 / 关键事件落库,供管理员诊断页筛选。
+ # 同样登记进程级默认仓储——harness 侧编排引擎 / 进程内网关跨边界写诊断用。
+ from deerflow.persistence.roundtable_diagnostics import (
+ make_roundtable_diagnostic_store,
+ )
+ from deerflow.persistence.roundtable_diagnostics import (
+ register_default_store as _register_roundtable_diagnostic_default_store,
+ )
+
+ app.state.roundtable_diagnostic_store = make_roundtable_diagnostic_store(sf)
+ _register_roundtable_diagnostic_default_store(app.state.roundtable_diagnostic_store)
+
+ # 圆桌后台作业执行器:把编排引擎作为后台任务跑。
+ # 网关由环境变量 ``ROUNDTABLE_JOB_GATEWAY`` 选择(见 resolve_roundtable_gateway_mode):
+ # - 缺省 / "inprocess"(Phase 3b):进程内驱动真模型(leader/seat/report)——**默认**,
+ # 部署时无需任何配置即可用后台挂起;
+ # - 显式 "simulated"(Phase 3a):模拟网关,产生真实进度但不调真模型,供前端联调。
+ if app.state.roundtable_job_store is not None:
+ import os
+
+ from app.gateway.roundtable_job_executor import RoundtableJobExecutor
+
+ gateway_mode = resolve_roundtable_gateway_mode(os.getenv("ROUNDTABLE_JOB_GATEWAY"))
+ if gateway_mode == "simulated":
+ from deerflow.agents.roundtable_orchestrator import SimulatedRoundtableGateway
+
+ # delay=3s/步:让模拟后台任务有可观察的进行中时长(切页/重连仍在跑)。
+ gateway_factory = lambda p: SimulatedRoundtableGateway( # noqa: E731
+ p.agents, mode=p.mode, delay=3.0
+ )
+ logger.info("[roundtable-jobs] gateway = simulated")
+ else:
+ from app.gateway.roundtable_inprocess_gateway import InProcessRoundtableGateway
+
+ _app = app # 闭包捕获 app,供网关访问 app.state(checkpointer/thread_store)
+ gateway_factory = lambda p: InProcessRoundtableGateway( # noqa: E731
+ _app,
+ user_id=p.user_id,
+ agents=p.agents,
+ model=p.model,
+ # 席位执行模式沿用 Step2 选择,后台席位 run 据此覆盖 policy 的 thinking/reasoning + 开子代理。
+ seat_thinking_enabled=p.seat_thinking_enabled,
+ seat_reasoning_effort=p.seat_reasoning_effort,
+ seat_subagent_enabled=p.seat_subagent_enabled,
+ seat_skill_directives=p.seat_skill_directives,
+ # 每席位推理深度覆盖(业务链条里各席位单独配):run_seat 命中即覆盖作业级 seatMode。
+ seat_modes=p.seat_modes,
+ # 诊断日志上下文:网关每轮 leader/seat/report/summary/dashboard 报错均带这些归属。
+ job_id=p.job_id,
+ draft_id=p.draft_id,
+ task_id=p.task_id,
+ )
+ logger.info("[roundtable-jobs] gateway = inprocess (real models, default)")
+
+ app.state.roundtable_job_executor = RoundtableJobExecutor(
+ app.state.roundtable_job_store,
+ gateway_factory=gateway_factory,
+ draft_store=getattr(app.state, "roundtable_draft_store", None),
+ # task 作业把最终报告写回**独立的** task-draft 存储(不分权)。
+ task_draft_store=getattr(app.state, "roundtable_task_draft_store", None),
+ )
+
+ # Phase 3 dispatcher:跨 worker 领取 queued/租约过期作业 + outbox 补偿器。
+ # 每个 worker 进程都跑一个;谁先 claim_job 成功谁执行(DB 条件 UPDATE 互斥),
+ # 崩溃的 worker 租约过期后由其它 worker 接管。路由 start/resume 后 nudge 立即唤醒。
+ from app.gateway.roundtable_job_dispatcher import RoundtableJobDispatcher
+
+ app.state.roundtable_job_dispatcher = RoundtableJobDispatcher(
+ app.state.roundtable_job_store,
+ app.state.roundtable_job_executor,
+ draft_store=getattr(app.state, "roundtable_draft_store", None),
+ task_draft_store=getattr(app.state, "roundtable_task_draft_store", None),
+ )
+ app.state.roundtable_job_dispatcher.start()
+ else:
+ app.state.roundtable_job_executor = None
+ app.state.roundtable_job_dispatcher = None
+
+ # ── Deep Research (深度研究) stores + executor + dispatcher ──────────
+ # Five tables: sessions / jobs / events / sources / messages.
+ # When DB is available, the executor + dispatcher run background research
+ # jobs (lease-based claim, crash-recoverable) mirroring the roundtable pattern.
+ from deerflow.persistence.deep_research_events import (
+ make_deep_research_event_store,
+ )
+ from deerflow.persistence.deep_research_jobs import make_deep_research_job_store
+ from deerflow.persistence.deep_research_messages import (
+ make_deep_research_message_store,
+ )
+ from deerflow.persistence.deep_research_sessions import (
+ make_deep_research_session_store,
+ )
from deerflow.persistence.deep_research_sources import (
make_deep_research_source_store,
)
from deerflow.persistence.document_rewrite_versions import (
make_document_rewrite_version_store,
)
-
- app.state.deep_research_session_store = make_deep_research_session_store(sf)
- app.state.deep_research_job_store = make_deep_research_job_store(sf)
- app.state.deep_research_event_store = make_deep_research_event_store(sf)
- app.state.deep_research_source_store = make_deep_research_source_store(sf)
+
+ app.state.deep_research_session_store = make_deep_research_session_store(sf)
+ app.state.deep_research_job_store = make_deep_research_job_store(sf)
+ app.state.deep_research_event_store = make_deep_research_event_store(sf)
+ app.state.deep_research_source_store = make_deep_research_source_store(sf)
app.state.deep_research_message_store = make_deep_research_message_store(sf)
# Whole-document AI rewrites retain a durable original snapshot so a
# completed replacement can be safely undone after a refresh.
app.state.document_rewrite_version_store = make_document_rewrite_version_store(sf)
- # Low-latency in-process SSE fan-out. The database event log remains
- # authoritative for reconnect replay and cross-worker recovery.
- from app.gateway.deep_research_live_hub import DeepResearchLiveHub
-
- app.state.deep_research_live_hub = DeepResearchLiveHub()
-
- if app.state.deep_research_job_store is not None:
- from app.gateway.deep_research_job_dispatcher import (
- DeepResearchJobDispatcher,
- )
- from app.gateway.deep_research_job_executor import (
- DeepResearchJobExecutor,
- )
-
+ # Low-latency in-process SSE fan-out. The database event log remains
+ # authoritative for reconnect replay and cross-worker recovery.
+ from app.gateway.deep_research_live_hub import DeepResearchLiveHub
+
+ app.state.deep_research_live_hub = DeepResearchLiveHub()
+
+ if app.state.deep_research_job_store is not None:
+ from app.gateway.deep_research_job_dispatcher import (
+ DeepResearchJobDispatcher,
+ )
+ from app.gateway.deep_research_job_executor import (
+ DeepResearchJobExecutor,
+ )
+
app.state.deep_research_executor = DeepResearchJobExecutor(
app.state.deep_research_job_store,
app.state.deep_research_session_store,
@@ -474,358 +478,358 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
document_rewrite_version_store=app.state.document_rewrite_version_store,
app_config=config,
)
- app.state.deep_research_dispatcher = DeepResearchJobDispatcher(
- app.state.deep_research_job_store,
- app.state.deep_research_executor,
- )
- app.state.deep_research_dispatcher.start()
- else:
- app.state.deep_research_executor = None
- app.state.deep_research_dispatcher = None
-
- # Run event store (has its own factory with config-driven backend selection)
- run_events_config = getattr(config, "run_events", None)
- app.state.run_event_store = make_run_event_store(run_events_config)
-
- # RunManager with store backing for persistence
- app.state.run_manager = RunManager(store=app.state.run_store)
- from deerflow.runtime import make_redis_concurrency_gate
-
- app.state.concurrency_gate = make_redis_concurrency_gate(config)
-
- try:
- yield
- finally:
- from deerflow.persistence.notifications import set_notification_store
-
- set_notification_store(None)
- dispatcher = getattr(app.state, "roundtable_job_dispatcher", None)
- if dispatcher is not None:
- await dispatcher.stop()
- dr_dispatcher = getattr(app.state, "deep_research_dispatcher", None)
- if dr_dispatcher is not None:
- await dr_dispatcher.stop()
- queue = getattr(app.state, "knowledge_auto_ingest_queue", None)
- if queue is not None:
- from deerflow.knowledge.auto_ingest import set_auto_ingest_queue
-
- await queue.stop()
- set_auto_ingest_queue(None)
- await close_engine()
- gate = getattr(app.state, "concurrency_gate", None)
- if gate is not None:
- await gate.aclose()
-
-
-# ---------------------------------------------------------------------------
-# Getters – called by routers per-request
-# ---------------------------------------------------------------------------
-
-
-def _require(attr: str, label: str) -> Callable[[Request], T]:
- """Create a FastAPI dependency that returns ``app.state.`` or 503."""
-
- def dep(request: Request) -> T:
- val = getattr(request.app.state, attr, None)
- if val is None:
- raise HTTPException(status_code=503, detail=f"{label} not available")
- return cast(T, val)
-
- dep.__name__ = dep.__qualname__ = f"get_{attr}"
- return dep
-
-
-get_stream_bridge: Callable[[Request], StreamBridge] = _require("stream_bridge", "Stream bridge")
-get_run_manager: Callable[[Request], RunManager] = _require("run_manager", "Run manager")
-get_checkpointer: Callable[[Request], Checkpointer] = _require("checkpointer", "Checkpointer")
-get_run_event_store: Callable[[Request], RunEventStore] = _require("run_event_store", "Run event store")
-get_feedback_repo: Callable[[Request], FeedbackRepository] = _require("feedback_repo", "Feedback")
-get_run_store: Callable[[Request], RunStore] = _require("run_store", "Run store")
-
-
-def get_concurrency_gate(request: Request):
- """Return the optional cross-worker concurrency gate."""
- return getattr(request.app.state, "concurrency_gate", None)
-
-
-def get_store(request: Request):
- """Return the global store (may be ``None`` if not configured)."""
- return getattr(request.app.state, "store", None)
-
-
-def get_thread_store(request: Request) -> ThreadMetaStore:
- """Return the thread metadata store (SQL or memory-backed)."""
- val = getattr(request.app.state, "thread_store", None)
- if val is None:
- raise HTTPException(status_code=503, detail="Thread metadata store not available")
- return val
-
-
-def get_scheduled_task_store(request: Request) -> ScheduledTaskStore:
- """Return the scheduled-task store."""
- val = getattr(request.app.state, "scheduled_task_store", None)
- if val is None:
- raise HTTPException(status_code=503, detail="Scheduled task store not available")
- return val
-
-
-def get_llm_metrics_store(request: Request):
- """Return the LLM metrics store, or None when not configured."""
- return getattr(request.app.state, "llm_metrics_store", None)
-
-
-def get_concurrency_sample_store(request: Request):
- """Return the concurrency-sample store, or None on the memory backend."""
- return getattr(request.app.state, "concurrency_sample_store", None)
-
-
-def get_tool_metrics_store(request: Request):
- """Return the tool-call metrics store, or None when not configured."""
- return getattr(request.app.state, "tool_metrics_store", None)
-
-
-def get_agent_store(request: Request) -> AgentStore:
- """Return the custom-agent store."""
- val = getattr(request.app.state, "agent_store", None)
- if val is None:
- raise HTTPException(status_code=503, detail="Agent store not available")
- return val
-
-
-def get_skill_store(request: Request) -> SkillStore:
- """Return the custom-skill store."""
- val = getattr(request.app.state, "skill_store", None)
- if val is None:
- raise HTTPException(status_code=503, detail="Skill store not available")
- return val
-
-
-def get_notification_store(request: Request) -> NotificationStore:
- """Return the in-app notification store."""
- val = getattr(request.app.state, "notification_store", None)
- if val is None:
- raise HTTPException(status_code=503, detail="Notification store not available")
- return val
-
-
-def get_tag_store(request: Request) -> TagStore:
- """Return the tag store."""
- val = getattr(request.app.state, "tag_store", None)
- if val is None:
- raise HTTPException(status_code=503, detail="Tag store not available")
- return val
-
-
-def get_position_store(request: Request):
- """Return the 岗位 (position) store."""
- val = getattr(request.app.state, "position_store", None)
- if val is None:
- raise HTTPException(status_code=503, detail="Position store not available")
- return val
-
-
-def get_position_sync(request: Request):
- """Return the position → subscription sync service."""
- val = getattr(request.app.state, "position_sync", None)
- if val is None:
- raise HTTPException(status_code=503, detail="Position sync service not available")
- return val
-
-
-def get_thread_share_store(request: Request):
- """Return the conversation share-link store."""
- val = getattr(request.app.state, "thread_share_store", None)
- if val is None:
- raise HTTPException(status_code=503, detail="Thread share store not available")
- return val
-
-
-def get_roundtable_draft_share_store(request: Request):
- """Return the roundtable-draft share-link store."""
- val = getattr(request.app.state, "roundtable_draft_share_store", None)
- if val is None:
- raise HTTPException(status_code=503, detail="Roundtable draft share store not available")
- return val
-
-
-def get_embed_session_store(request: Request):
- """Return the iframe embed session → thread mapping store."""
- val = getattr(request.app.state, "embed_session_store", None)
- if val is None:
- raise HTTPException(status_code=503, detail="Embed session store not available")
- return val
-
-
-def get_ai_writing_session_store(request: Request):
- """Return the AI writing session store, or None when not configured."""
- return getattr(request.app.state, "ai_writing_session_store", None)
-
-
-def get_ai_writing_job_executor(request: Request):
- """Return the AI writing background-suspend executor, or None when unavailable."""
- return getattr(request.app.state, "ai_writing_job_executor", None)
-
-
-def get_article_type_store(request: Request):
- """Return the article type store, or None when not configured."""
- return getattr(request.app.state, "article_type_store", None)
-
-
-def get_research_template_store(request: Request):
- """Return the research template store, or None when not configured."""
- return getattr(request.app.state, "research_template_store", None)
-
-
-def get_memory_config_store(request: Request):
- """Return the per-user memory config store."""
- val = getattr(request.app.state, "memory_config_store", None)
- if val is None:
- raise HTTPException(status_code=503, detail="Memory config store not available")
- return val
-
-
-def get_knowledge_service(request: Request):
- """Return the knowledge base service (503 when disabled / memory backend)."""
- val = getattr(request.app.state, "knowledge_service", None)
- if val is None:
- raise HTTPException(status_code=503, detail="Knowledge base not available")
- return val
-
-
-def get_run_context(request: Request) -> RunContext:
- """Build a :class:`RunContext` from ``app.state`` singletons.
-
- Returns a *base* context with infrastructure dependencies.
- """
- config = get_config(request)
- return RunContext(
- checkpointer=get_checkpointer(request),
- store=get_store(request),
- event_store=get_run_event_store(request),
- run_events_config=getattr(config, "run_events", None),
- thread_store=get_thread_store(request),
- app_config=config,
- )
-
-
-# ---------------------------------------------------------------------------
-# Auth helpers (used by authz.py and auth middleware)
-# ---------------------------------------------------------------------------
-
-# Cached singletons to avoid repeated instantiation per request
-_cached_local_provider: LocalAuthProvider | None = None
-_cached_repo: SQLiteUserRepository | None = None
-
-
-def get_request_access_token(request: Request) -> str | None:
- """Extract access token from cookie or Authorization header.
-
- Priority:
- 1. ``access_token`` cookie (browser session mode)
- 2. ``Authorization: Bearer `` header (API client mode)
- 3. ``access_token`` query parameter for the WeKnora iframe bootstrap only
- """
- cookie_token = request.cookies.get("access_token")
- if cookie_token:
- return cookie_token
-
- authorization = request.headers.get("authorization", "").strip()
- if authorization:
- scheme, _, token = authorization.partition(" ")
- if scheme.lower() == "bearer":
- token = token.strip()
- if token:
- return token
-
- from app.gateway.proxy_path import app_relative_path
-
- path = app_relative_path(request).rstrip("/")
- if path.startswith("/api/llmwiki/knowledge-bases/") and path.endswith("/weknora/frame"):
- query_token = (request.query_params.get("access_token") or "").strip()
- if query_token:
- return query_token
- return None
-
-
-def get_local_provider() -> LocalAuthProvider:
- """Get or create the cached LocalAuthProvider singleton.
-
- Must be called after ``init_engine_from_config()`` — the shared
- session factory is required to construct the user repository.
- """
- global _cached_local_provider, _cached_repo
- if _cached_repo is None:
- from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
- from deerflow.persistence.engine import get_session_factory
-
- sf = get_session_factory()
- if sf is None:
- raise RuntimeError("get_local_provider() called before init_engine_from_config(); cannot access users table")
- _cached_repo = SQLiteUserRepository(sf)
- if _cached_local_provider is None:
- from app.gateway.auth.local_provider import LocalAuthProvider
-
- _cached_local_provider = LocalAuthProvider(repository=_cached_repo)
- return _cached_local_provider
-
-
-async def get_current_user_from_request(request: Request):
- """Get the current authenticated user from request token.
-
- Raises HTTPException 401 if not authenticated.
- """
- from app.gateway.auth import decode_token
- from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse, TokenError, token_error_to_code
-
- access_token = get_request_access_token(request)
- if not access_token:
- raise HTTPException(
- status_code=401,
- detail=AuthErrorResponse(code=AuthErrorCode.NOT_AUTHENTICATED, message="Not authenticated").model_dump(),
- )
-
- payload = decode_token(access_token)
- if isinstance(payload, TokenError):
- raise HTTPException(
- status_code=401,
- detail=AuthErrorResponse(code=token_error_to_code(payload), message=f"Token error: {payload.value}").model_dump(),
- )
-
- provider = get_local_provider()
- user = await provider.get_user(payload.sub)
- if user is None:
- raise HTTPException(
- status_code=401,
- detail=AuthErrorResponse(code=AuthErrorCode.USER_NOT_FOUND, message="User not found").model_dump(),
- )
-
- # Token version mismatch → password was changed, token is stale
- if user.token_version != payload.ver:
- raise HTTPException(
- status_code=401,
- detail=AuthErrorResponse(code=AuthErrorCode.TOKEN_INVALID, message="Token revoked (password changed)").model_dump(),
- )
-
- return user
-
-
-async def get_optional_user_from_request(request: Request):
- """Get optional authenticated user from request.
-
- Returns None if not authenticated.
- """
- try:
- return await get_current_user_from_request(request)
- except HTTPException:
- return None
-
-
-async def get_current_user(request: Request) -> str | None:
- """Extract user_id from request cookie, or None if not authenticated.
-
- Thin adapter that returns the string id for callers that only need
- identification (e.g., ``feedback.py``). Full-user callers should use
- ``get_current_user_from_request`` or ``get_optional_user_from_request``.
- """
- user = await get_optional_user_from_request(request)
- return str(user.id) if user else None
+ app.state.deep_research_dispatcher = DeepResearchJobDispatcher(
+ app.state.deep_research_job_store,
+ app.state.deep_research_executor,
+ )
+ app.state.deep_research_dispatcher.start()
+ else:
+ app.state.deep_research_executor = None
+ app.state.deep_research_dispatcher = None
+
+ # Run event store (has its own factory with config-driven backend selection)
+ run_events_config = getattr(config, "run_events", None)
+ app.state.run_event_store = make_run_event_store(run_events_config)
+
+ # RunManager with store backing for persistence
+ app.state.run_manager = RunManager(store=app.state.run_store)
+ from deerflow.runtime import make_redis_concurrency_gate
+
+ app.state.concurrency_gate = make_redis_concurrency_gate(config)
+
+ try:
+ yield
+ finally:
+ from deerflow.persistence.notifications import set_notification_store
+
+ set_notification_store(None)
+ dispatcher = getattr(app.state, "roundtable_job_dispatcher", None)
+ if dispatcher is not None:
+ await dispatcher.stop()
+ dr_dispatcher = getattr(app.state, "deep_research_dispatcher", None)
+ if dr_dispatcher is not None:
+ await dr_dispatcher.stop()
+ queue = getattr(app.state, "knowledge_auto_ingest_queue", None)
+ if queue is not None:
+ from deerflow.knowledge.auto_ingest import set_auto_ingest_queue
+
+ await queue.stop()
+ set_auto_ingest_queue(None)
+ await close_engine()
+ gate = getattr(app.state, "concurrency_gate", None)
+ if gate is not None:
+ await gate.aclose()
+
+
+# ---------------------------------------------------------------------------
+# Getters – called by routers per-request
+# ---------------------------------------------------------------------------
+
+
+def _require(attr: str, label: str) -> Callable[[Request], T]:
+ """Create a FastAPI dependency that returns ``app.state.`` or 503."""
+
+ def dep(request: Request) -> T:
+ val = getattr(request.app.state, attr, None)
+ if val is None:
+ raise HTTPException(status_code=503, detail=f"{label} not available")
+ return cast(T, val)
+
+ dep.__name__ = dep.__qualname__ = f"get_{attr}"
+ return dep
+
+
+get_stream_bridge: Callable[[Request], StreamBridge] = _require("stream_bridge", "Stream bridge")
+get_run_manager: Callable[[Request], RunManager] = _require("run_manager", "Run manager")
+get_checkpointer: Callable[[Request], Checkpointer] = _require("checkpointer", "Checkpointer")
+get_run_event_store: Callable[[Request], RunEventStore] = _require("run_event_store", "Run event store")
+get_feedback_repo: Callable[[Request], FeedbackRepository] = _require("feedback_repo", "Feedback")
+get_run_store: Callable[[Request], RunStore] = _require("run_store", "Run store")
+
+
+def get_concurrency_gate(request: Request):
+ """Return the optional cross-worker concurrency gate."""
+ return getattr(request.app.state, "concurrency_gate", None)
+
+
+def get_store(request: Request):
+ """Return the global store (may be ``None`` if not configured)."""
+ return getattr(request.app.state, "store", None)
+
+
+def get_thread_store(request: Request) -> ThreadMetaStore:
+ """Return the thread metadata store (SQL or memory-backed)."""
+ val = getattr(request.app.state, "thread_store", None)
+ if val is None:
+ raise HTTPException(status_code=503, detail="Thread metadata store not available")
+ return val
+
+
+def get_scheduled_task_store(request: Request) -> ScheduledTaskStore:
+ """Return the scheduled-task store."""
+ val = getattr(request.app.state, "scheduled_task_store", None)
+ if val is None:
+ raise HTTPException(status_code=503, detail="Scheduled task store not available")
+ return val
+
+
+def get_llm_metrics_store(request: Request):
+ """Return the LLM metrics store, or None when not configured."""
+ return getattr(request.app.state, "llm_metrics_store", None)
+
+
+def get_concurrency_sample_store(request: Request):
+ """Return the concurrency-sample store, or None on the memory backend."""
+ return getattr(request.app.state, "concurrency_sample_store", None)
+
+
+def get_tool_metrics_store(request: Request):
+ """Return the tool-call metrics store, or None when not configured."""
+ return getattr(request.app.state, "tool_metrics_store", None)
+
+
+def get_agent_store(request: Request) -> AgentStore:
+ """Return the custom-agent store."""
+ val = getattr(request.app.state, "agent_store", None)
+ if val is None:
+ raise HTTPException(status_code=503, detail="Agent store not available")
+ return val
+
+
+def get_skill_store(request: Request) -> SkillStore:
+ """Return the custom-skill store."""
+ val = getattr(request.app.state, "skill_store", None)
+ if val is None:
+ raise HTTPException(status_code=503, detail="Skill store not available")
+ return val
+
+
+def get_notification_store(request: Request) -> NotificationStore:
+ """Return the in-app notification store."""
+ val = getattr(request.app.state, "notification_store", None)
+ if val is None:
+ raise HTTPException(status_code=503, detail="Notification store not available")
+ return val
+
+
+def get_tag_store(request: Request) -> TagStore:
+ """Return the tag store."""
+ val = getattr(request.app.state, "tag_store", None)
+ if val is None:
+ raise HTTPException(status_code=503, detail="Tag store not available")
+ return val
+
+
+def get_position_store(request: Request):
+ """Return the 岗位 (position) store."""
+ val = getattr(request.app.state, "position_store", None)
+ if val is None:
+ raise HTTPException(status_code=503, detail="Position store not available")
+ return val
+
+
+def get_position_sync(request: Request):
+ """Return the position → subscription sync service."""
+ val = getattr(request.app.state, "position_sync", None)
+ if val is None:
+ raise HTTPException(status_code=503, detail="Position sync service not available")
+ return val
+
+
+def get_thread_share_store(request: Request):
+ """Return the conversation share-link store."""
+ val = getattr(request.app.state, "thread_share_store", None)
+ if val is None:
+ raise HTTPException(status_code=503, detail="Thread share store not available")
+ return val
+
+
+def get_roundtable_draft_share_store(request: Request):
+ """Return the roundtable-draft share-link store."""
+ val = getattr(request.app.state, "roundtable_draft_share_store", None)
+ if val is None:
+ raise HTTPException(status_code=503, detail="Roundtable draft share store not available")
+ return val
+
+
+def get_embed_session_store(request: Request):
+ """Return the iframe embed session → thread mapping store."""
+ val = getattr(request.app.state, "embed_session_store", None)
+ if val is None:
+ raise HTTPException(status_code=503, detail="Embed session store not available")
+ return val
+
+
+def get_ai_writing_session_store(request: Request):
+ """Return the AI writing session store, or None when not configured."""
+ return getattr(request.app.state, "ai_writing_session_store", None)
+
+
+def get_ai_writing_job_executor(request: Request):
+ """Return the AI writing background-suspend executor, or None when unavailable."""
+ return getattr(request.app.state, "ai_writing_job_executor", None)
+
+
+def get_article_type_store(request: Request):
+ """Return the article type store, or None when not configured."""
+ return getattr(request.app.state, "article_type_store", None)
+
+
+def get_research_template_store(request: Request):
+ """Return the research template store, or None when not configured."""
+ return getattr(request.app.state, "research_template_store", None)
+
+
+def get_memory_config_store(request: Request):
+ """Return the per-user memory config store."""
+ val = getattr(request.app.state, "memory_config_store", None)
+ if val is None:
+ raise HTTPException(status_code=503, detail="Memory config store not available")
+ return val
+
+
+def get_knowledge_service(request: Request):
+ """Return the knowledge base service (503 when disabled / memory backend)."""
+ val = getattr(request.app.state, "knowledge_service", None)
+ if val is None:
+ raise HTTPException(status_code=503, detail="Knowledge base not available")
+ return val
+
+
+def get_run_context(request: Request) -> RunContext:
+ """Build a :class:`RunContext` from ``app.state`` singletons.
+
+ Returns a *base* context with infrastructure dependencies.
+ """
+ config = get_config(request)
+ return RunContext(
+ checkpointer=get_checkpointer(request),
+ store=get_store(request),
+ event_store=get_run_event_store(request),
+ run_events_config=getattr(config, "run_events", None),
+ thread_store=get_thread_store(request),
+ app_config=config,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Auth helpers (used by authz.py and auth middleware)
+# ---------------------------------------------------------------------------
+
+# Cached singletons to avoid repeated instantiation per request
+_cached_local_provider: LocalAuthProvider | None = None
+_cached_repo: SQLiteUserRepository | None = None
+
+
+def get_request_access_token(request: Request) -> str | None:
+ """Extract access token from cookie or Authorization header.
+
+ Priority:
+ 1. ``access_token`` cookie (browser session mode)
+ 2. ``Authorization: Bearer `` header (API client mode)
+ 3. ``access_token`` query parameter for the WeKnora iframe bootstrap only
+ """
+ cookie_token = request.cookies.get("access_token")
+ if cookie_token:
+ return cookie_token
+
+ authorization = request.headers.get("authorization", "").strip()
+ if authorization:
+ scheme, _, token = authorization.partition(" ")
+ if scheme.lower() == "bearer":
+ token = token.strip()
+ if token:
+ return token
+
+ from app.gateway.proxy_path import app_relative_path
+
+ path = app_relative_path(request).rstrip("/")
+ if path.startswith("/api/llmwiki/knowledge-bases/") and path.endswith("/weknora/frame"):
+ query_token = (request.query_params.get("access_token") or "").strip()
+ if query_token:
+ return query_token
+ return None
+
+
+def get_local_provider() -> LocalAuthProvider:
+ """Get or create the cached LocalAuthProvider singleton.
+
+ Must be called after ``init_engine_from_config()`` — the shared
+ session factory is required to construct the user repository.
+ """
+ global _cached_local_provider, _cached_repo
+ if _cached_repo is None:
+ from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
+ from deerflow.persistence.engine import get_session_factory
+
+ sf = get_session_factory()
+ if sf is None:
+ raise RuntimeError("get_local_provider() called before init_engine_from_config(); cannot access users table")
+ _cached_repo = SQLiteUserRepository(sf)
+ if _cached_local_provider is None:
+ from app.gateway.auth.local_provider import LocalAuthProvider
+
+ _cached_local_provider = LocalAuthProvider(repository=_cached_repo)
+ return _cached_local_provider
+
+
+async def get_current_user_from_request(request: Request):
+ """Get the current authenticated user from request token.
+
+ Raises HTTPException 401 if not authenticated.
+ """
+ from app.gateway.auth import decode_token
+ from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse, TokenError, token_error_to_code
+
+ access_token = get_request_access_token(request)
+ if not access_token:
+ raise HTTPException(
+ status_code=401,
+ detail=AuthErrorResponse(code=AuthErrorCode.NOT_AUTHENTICATED, message="Not authenticated").model_dump(),
+ )
+
+ payload = decode_token(access_token)
+ if isinstance(payload, TokenError):
+ raise HTTPException(
+ status_code=401,
+ detail=AuthErrorResponse(code=token_error_to_code(payload), message=f"Token error: {payload.value}").model_dump(),
+ )
+
+ provider = get_local_provider()
+ user = await provider.get_user(payload.sub)
+ if user is None:
+ raise HTTPException(
+ status_code=401,
+ detail=AuthErrorResponse(code=AuthErrorCode.USER_NOT_FOUND, message="User not found").model_dump(),
+ )
+
+ # Token version mismatch → password was changed, token is stale
+ if user.token_version != payload.ver:
+ raise HTTPException(
+ status_code=401,
+ detail=AuthErrorResponse(code=AuthErrorCode.TOKEN_INVALID, message="Token revoked (password changed)").model_dump(),
+ )
+
+ return user
+
+
+async def get_optional_user_from_request(request: Request):
+ """Get optional authenticated user from request.
+
+ Returns None if not authenticated.
+ """
+ try:
+ return await get_current_user_from_request(request)
+ except HTTPException:
+ return None
+
+
+async def get_current_user(request: Request) -> str | None:
+ """Extract user_id from request cookie, or None if not authenticated.
+
+ Thin adapter that returns the string id for callers that only need
+ identification (e.g., ``feedback.py``). Full-user callers should use
+ ``get_current_user_from_request`` or ``get_optional_user_from_request``.
+ """
+ user = await get_optional_user_from_request(request)
+ return str(user.id) if user else None
diff --git a/offline-backend-20260512/backend/app/gateway/routers/agents.py b/offline-backend-20260512/backend/app/gateway/routers/agents.py
index 589c2004..a60cfb1c 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/agents.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/agents.py
@@ -64,15 +64,20 @@ class AgentResponse(BaseModel):
description="入库模板名 (external knowledge-ingest templateName). When set, markdown artifacts produced in this agent's chats can be published to the external knowledge-ingest API.",
)
- seat_skill_directive: bool | None = Field(
+ seat_skill_directive: bool | None = Field(
default=None,
description="会商席位「技能识别强化」开关 (config.yaml). True = 本智能体作为会商席位时强化技能识别 (与业务链条同名开关取 OR). None/False = 关 (默认).",
- )
-
- llmwiki_knowledge_base_ids: list[str] | None = Field(
- default=None,
- description="DeerFlow LLMWiki mapping ids this agent may offer for conversation retrieval.",
- )
+ )
+
+ job_responsibilities: str | None = Field(
+ default=None,
+ description="岗位职责 (config.yaml). Shown above 常用问题 in the agent configuration panel. None/empty = not set.",
+ )
+
+ llmwiki_knowledge_base_ids: list[str] | None = Field(
+ default=None,
+ description="DeerFlow LLMWiki mapping ids this agent may offer for conversation retrieval.",
+ )
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this agent")
@@ -125,12 +130,13 @@ class AgentCreateRequest(BaseModel):
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist persisted to config.yaml")
common_questions: list[CommonQuestion] | None = Field(default=None, description="Recommended questions (常用问题) persisted to config.yaml. Each item is {title, prompt}; legacy strings are coerced.")
template_name: str | None = Field(default=None, description="入库模板名 persisted to config.yaml. Enables publishing markdown artifacts to the external knowledge-ingest API.")
- seat_skill_directive: bool | None = Field(default=None, description="会商席位「技能识别强化」开关 persisted to config.yaml. None/False = off (默认).")
- llmwiki_knowledge_base_ids: list[str] | None = Field(
- default=None,
- max_length=100,
- description="Allowed DeerFlow LLMWiki mapping ids persisted to config.yaml.",
- )
+ seat_skill_directive: bool | None = Field(default=None, description="会商席位「技能识别强化」开关 persisted to config.yaml. None/False = off (默认).")
+ job_responsibilities: str | None = Field(default=None, description="岗位职责 persisted to config.yaml. Empty/None = not set.")
+ llmwiki_knowledge_base_ids: list[str] | None = Field(
+ default=None,
+ max_length=100,
+ description="Allowed DeerFlow LLMWiki mapping ids persisted to config.yaml.",
+ )
class AgentUpdateRequest(BaseModel):
@@ -147,12 +153,13 @@ class AgentUpdateRequest(BaseModel):
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
common_questions: list[CommonQuestion] | None = Field(default=None, description="Updated recommended questions (常用问题, config.yaml). Each item is {title, prompt}; legacy strings are coerced.")
template_name: str | None = Field(default=None, description="Updated 入库模板名 (config.yaml). Empty string clears it.")
- seat_skill_directive: bool | None = Field(default=None, description="Updated 会商席位「技能识别强化」开关 (config.yaml).")
- llmwiki_knowledge_base_ids: list[str] | None = Field(
- default=None,
- max_length=100,
- description="Updated allowed DeerFlow LLMWiki mapping ids (config.yaml).",
- )
+ seat_skill_directive: bool | None = Field(default=None, description="Updated 会商席位「技能识别强化」开关 (config.yaml).")
+ job_responsibilities: str | None = Field(default=None, description="Updated 岗位职责 (config.yaml). Empty string clears it.")
+ llmwiki_knowledge_base_ids: list[str] | None = Field(
+ default=None,
+ max_length=100,
+ description="Updated allowed DeerFlow LLMWiki mapping ids (config.yaml).",
+ )
class AgentSelectorResponse(BaseModel):
@@ -193,38 +200,38 @@ def _current_user_id(request: Request) -> str:
return get_effective_user_id()
-def _is_admin_user(request: Request) -> bool:
+def _is_admin_user(request: Request) -> bool:
"""Return True when the request is authenticated as a system admin."""
user = getattr(request.state, "user", None)
- return getattr(user, "system_role", None) == "admin"
-
-
-async def _validate_llmwiki_bindings(
- request: Request,
- mapping_ids: list[str] | None,
-) -> list[str] | None:
- """Normalize bindings and require read access for the editing actor."""
-
- if mapping_ids is None:
- return None
- normalized = list(dict.fromkeys(item.strip() for item in mapping_ids if item.strip()))
- if not normalized:
- return []
- store = getattr(request.app.state, "llmwiki_store", None)
- if store is None:
- raise HTTPException(status_code=503, detail="LLMWiki metadata store is unavailable")
- user_id = _current_user_id(request)
- is_admin = _is_admin_user(request)
- for mapping_id in normalized:
- row = await store.get_authorized(
- mapping_id,
- user_id,
- write=False,
- is_admin=is_admin,
- )
- if row is None:
- raise HTTPException(status_code=422, detail=f"LLMWiki knowledge base '{mapping_id}' is unavailable")
- return normalized
+ return getattr(user, "system_role", None) == "admin"
+
+
+async def _validate_llmwiki_bindings(
+ request: Request,
+ mapping_ids: list[str] | None,
+) -> list[str] | None:
+ """Normalize bindings and require read access for the editing actor."""
+
+ if mapping_ids is None:
+ return None
+ normalized = list(dict.fromkeys(item.strip() for item in mapping_ids if item.strip()))
+ if not normalized:
+ return []
+ store = getattr(request.app.state, "llmwiki_store", None)
+ if store is None:
+ raise HTTPException(status_code=503, detail="LLMWiki metadata store is unavailable")
+ user_id = _current_user_id(request)
+ is_admin = _is_admin_user(request)
+ for mapping_id in normalized:
+ row = await store.get_authorized(
+ mapping_id,
+ user_id,
+ write=False,
+ is_admin=is_admin,
+ )
+ if row is None:
+ raise HTTPException(status_code=422, detail=f"LLMWiki knowledge base '{mapping_id}' is unavailable")
+ return normalized
def _email_local_part(email: str | None) -> str | None:
@@ -276,7 +283,7 @@ def _validate_storage_id(agent_id: str) -> str:
raise HTTPException(status_code=422, detail=str(exc)) from exc
-def _write_agent_config(
+def _write_agent_config(
agent_id: str,
*,
name: str,
@@ -286,9 +293,10 @@ def _write_agent_config(
tool_groups: list[str] | None = None,
common_questions: list[CommonQuestion] | None = None,
template_name: str | None = None,
- seat_skill_directive: bool | None = None,
- llmwiki_knowledge_base_ids: list[str] | None = None,
-) -> None:
+ seat_skill_directive: bool | None = None,
+ job_responsibilities: str | None = None,
+ llmwiki_knowledge_base_ids: list[str] | None = None,
+) -> None:
agent_dir = get_paths().agent_dir(agent_id)
agent_dir.mkdir(parents=True, exist_ok=True)
config_file = agent_dir / "config.yaml"
@@ -317,19 +325,30 @@ def _write_agent_config(
if template_name is not None and template_name.strip():
config_data["template_name"] = template_name.strip()
# 会商席位技能识别强化开关:仅当显式设置时写入(None = 不写, 由 AgentConfig 默认为关)。
- if seat_skill_directive is not None:
- config_data["seat_skill_directive"] = bool(seat_skill_directive)
- if llmwiki_knowledge_base_ids is not None:
- config_data["llmwiki_knowledge_base_ids"] = list(
- dict.fromkeys(item.strip() for item in llmwiki_knowledge_base_ids if item.strip())
- )
+ if seat_skill_directive is not None:
+ config_data["seat_skill_directive"] = bool(seat_skill_directive)
+ if job_responsibilities is not None and job_responsibilities.strip():
+ config_data["job_responsibilities"] = job_responsibilities.strip()
+ if llmwiki_knowledge_base_ids is not None:
+ config_data["llmwiki_knowledge_base_ids"] = list(
+ dict.fromkeys(item.strip() for item in llmwiki_knowledge_base_ids if item.strip())
+ )
with open(config_file, "w", encoding="utf-8") as f:
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
def _load_config_extras(agent_id: str) -> dict[str, Any]:
"""Read skills/model/tool_groups/common_questions from the agent's config.yaml, tolerant of missing files."""
- empty = {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None, "seat_skill_directive": None, "llmwiki_knowledge_base_ids": None}
+ empty = {
+ "skills": None,
+ "model": None,
+ "tool_groups": None,
+ "common_questions": None,
+ "template_name": None,
+ "seat_skill_directive": None,
+ "job_responsibilities": None,
+ "llmwiki_knowledge_base_ids": None,
+ }
try:
cfg = load_agent_config(agent_id)
except (FileNotFoundError, ValueError):
@@ -342,8 +361,9 @@ def _load_config_extras(agent_id: str) -> dict[str, Any]:
"tool_groups": cfg.tool_groups,
"common_questions": cfg.common_questions,
"template_name": cfg.template_name,
- "seat_skill_directive": cfg.seat_skill_directive,
- "llmwiki_knowledge_base_ids": cfg.llmwiki_knowledge_base_ids,
+ "seat_skill_directive": cfg.seat_skill_directive,
+ "job_responsibilities": cfg.job_responsibilities,
+ "llmwiki_knowledge_base_ids": cfg.llmwiki_knowledge_base_ids,
}
@@ -504,7 +524,16 @@ def _record_to_response(
can happen lazily when the agent is actually opened.
"""
soul: str | None = None
- extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None, "seat_skill_directive": None, "llmwiki_knowledge_base_ids": None}
+ extras: dict[str, Any] = {
+ "skills": None,
+ "model": None,
+ "tool_groups": None,
+ "common_questions": None,
+ "template_name": None,
+ "seat_skill_directive": None,
+ "job_responsibilities": None,
+ "llmwiki_knowledge_base_ids": None,
+ }
if include_extras:
_ensure_agent_dir_from_record(record)
extras = _load_config_extras(record["id"])
@@ -528,8 +557,9 @@ def _record_to_response(
skills=extras["skills"],
common_questions=extras.get("common_questions"),
template_name=extras.get("template_name"),
- seat_skill_directive=extras.get("seat_skill_directive"),
- llmwiki_knowledge_base_ids=extras.get("llmwiki_knowledge_base_ids"),
+ seat_skill_directive=extras.get("seat_skill_directive"),
+ job_responsibilities=extras.get("job_responsibilities"),
+ llmwiki_knowledge_base_ids=extras.get("llmwiki_knowledge_base_ids"),
featured_order=record.get("featured_order"),
)
@@ -830,7 +860,7 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse:
summary="Create Agent",
description="Create a new user-owned agent. The display name can be Chinese and may duplicate existing names.",
)
-async def create_agent_endpoint(request: AgentCreateRequest, http_request: Request) -> AgentResponse:
+async def create_agent_endpoint(request: AgentCreateRequest, http_request: Request) -> AgentResponse:
_require_agents_api_enabled()
name = _validate_display_name(request.name)
store = get_agent_store(http_request)
@@ -839,13 +869,13 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
agent_id = _validate_storage_id(request.id)
if await store.get_any(agent_id) is not None:
raise HTTPException(status_code=409, detail=f"Agent id '{agent_id}' already exists")
- else:
- agent_id = uuid4().hex
- agent_dir = get_paths().agent_dir(agent_id)
- llmwiki_ids = await _validate_llmwiki_bindings(
- http_request,
- request.llmwiki_knowledge_base_ids,
- )
+ else:
+ agent_id = uuid4().hex
+ agent_dir = get_paths().agent_dir(agent_id)
+ llmwiki_ids = await _validate_llmwiki_bindings(
+ http_request,
+ request.llmwiki_knowledge_base_ids,
+ )
try:
if agent_dir.exists():
@@ -860,8 +890,9 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
tool_groups=request.tool_groups,
common_questions=request.common_questions,
template_name=request.template_name,
- seat_skill_directive=request.seat_skill_directive,
- llmwiki_knowledge_base_ids=llmwiki_ids,
+ seat_skill_directive=request.seat_skill_directive,
+ job_responsibilities=request.job_responsibilities,
+ llmwiki_knowledge_base_ids=llmwiki_ids,
)
_write_agent_soul(agent_id, request.soul)
@@ -942,14 +973,15 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
next_tool_groups = request.tool_groups if "tool_groups" in fields_set else existing_extras["tool_groups"]
next_common_questions = request.common_questions if "common_questions" in fields_set else existing_extras["common_questions"]
next_template_name = request.template_name if "template_name" in fields_set else existing_extras["template_name"]
- next_seat_skill_directive = request.seat_skill_directive if "seat_skill_directive" in fields_set else existing_extras["seat_skill_directive"]
- next_llmwiki_ids = (
- await _validate_llmwiki_bindings(http_request, request.llmwiki_knowledge_base_ids)
- if "llmwiki_knowledge_base_ids" in fields_set
- else existing_extras["llmwiki_knowledge_base_ids"]
- )
-
- config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups", "common_questions", "template_name", "seat_skill_directive", "llmwiki_knowledge_base_ids"))
+ next_seat_skill_directive = request.seat_skill_directive if "seat_skill_directive" in fields_set else existing_extras["seat_skill_directive"]
+ next_job_responsibilities = request.job_responsibilities if "job_responsibilities" in fields_set else existing_extras["job_responsibilities"]
+ next_llmwiki_ids = (
+ await _validate_llmwiki_bindings(http_request, request.llmwiki_knowledge_base_ids)
+ if "llmwiki_knowledge_base_ids" in fields_set
+ else existing_extras["llmwiki_knowledge_base_ids"]
+ )
+
+ config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups", "common_questions", "template_name", "seat_skill_directive", "job_responsibilities", "llmwiki_knowledge_base_ids"))
if config_dirty:
_write_agent_config(
agent_id,
@@ -960,8 +992,9 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
tool_groups=next_tool_groups,
common_questions=next_common_questions,
template_name=next_template_name,
- seat_skill_directive=next_seat_skill_directive,
- llmwiki_knowledge_base_ids=next_llmwiki_ids,
+ seat_skill_directive=next_seat_skill_directive,
+ job_responsibilities=next_job_responsibilities,
+ llmwiki_knowledge_base_ids=next_llmwiki_ids,
)
# Keep the listing cache in lock-step with config.yaml, but only
# when the cached fields were actually touched — a partial update
diff --git a/offline-backend-20260512/backend/app/gateway/routers/report_structures.py b/offline-backend-20260512/backend/app/gateway/routers/report_structures.py
new file mode 100644
index 00000000..f69bccd7
--- /dev/null
+++ b/offline-backend-20260512/backend/app/gateway/routers/report_structures.py
@@ -0,0 +1,168 @@
+"""CRUD API for deep-research report structures (报告结构 / 工作流配置).
+
+The deep-research workbench's 高级配置 → 工作流配置 dialog renders a card per
+row; picking a card applies ``content`` to the research config's
+``custom_outline``. Global / shared — every account sees the same cards.
+
+Routes (prefix ``/api/report-structures``):
+ GET "" list (optional ``enabled`` / ``title`` filters)
+ POST "" create (title / name / content; enabled forced True)
+ GET "/{id}" get one
+ PUT "/{id}" update title / name / content
+ PUT "/{id}/enabled" toggle 启用/停用
+ DELETE "/{id}" delete
+
+The first list call seeds the two built-in outlines (通用分析结构 /
+递进式论证结构) when the table has never held a row — see
+``deerflow.persistence.report_structures.defaults``.
+"""
+
+from __future__ import annotations
+
+import logging
+from uuid import uuid4
+
+from fastapi import APIRouter, HTTPException, Query, Request
+from pydantic import BaseModel, Field
+
+from app.gateway.deps import get_optional_user_from_request
+from deerflow.persistence.report_structures import ReportStructureStore
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/api/report-structures", tags=["report-structures"])
+
+
+# ── schemas ──────────────────────────────────────────────────────────────
+
+
+class ReportStructureResponse(BaseModel):
+ id: str
+ title: str
+ name: str
+ content: str
+ enabled: bool = True
+ created_by: str | None = None
+ created_at: str | None = None
+ updated_at: str | None = None
+
+
+class ReportStructureListResponse(BaseModel):
+ structures: list[ReportStructureResponse]
+
+
+class ReportStructureCreateRequest(BaseModel):
+ title: str = Field(..., min_length=1, max_length=256)
+ name: str = Field(default="", max_length=256)
+ content: str = Field(..., min_length=1, max_length=20_000)
+
+
+class ReportStructureUpdateRequest(BaseModel):
+ title: str | None = Field(default=None, min_length=1, max_length=256)
+ name: str | None = Field(default=None, max_length=256)
+ content: str | None = Field(default=None, min_length=1, max_length=20_000)
+
+
+class ReportStructureEnabledRequest(BaseModel):
+ enabled: bool
+
+
+# ── helpers ──────────────────────────────────────────────────────────────
+
+
+def _get_store(request: Request) -> ReportStructureStore:
+ store = getattr(request.app.state, "report_structure_store", None)
+ if store is None:
+ raise HTTPException(status_code=503, detail="Report structure store not available")
+ return store
+
+
+async def _current_username(request: Request) -> str | None:
+ """创建用户名:邮箱(本部署的账号即邮箱),无认证时为 None。"""
+ user = await get_optional_user_from_request(request)
+ if user is None:
+ return None
+ return str(user.email) or str(user.id)
+
+
+# ── routes ───────────────────────────────────────────────────────────────
+
+
+@router.get("", response_model=ReportStructureListResponse)
+async def list_report_structures(
+ request: Request,
+ enabled: bool | None = Query(default=None, description="启用/停用筛选;缺省=全部"),
+ title: str | None = Query(default=None, max_length=256, description="标题模糊筛选(大小写不敏感)"),
+) -> ReportStructureListResponse:
+ store = _get_store(request)
+ await store.ensure_defaults()
+ rows = await store.list_structures()
+ if enabled is not None:
+ rows = [r for r in rows if bool(r.get("enabled", True)) is enabled]
+ if title:
+ needle = title.strip().lower()
+ if needle:
+ rows = [r for r in rows if needle in str(r.get("title", "")).lower()]
+ return ReportStructureListResponse(structures=[ReportStructureResponse(**r) for r in rows])
+
+
+@router.post("", response_model=ReportStructureResponse, status_code=201)
+async def create_report_structure(request: Request, body: ReportStructureCreateRequest) -> ReportStructureResponse:
+ store = _get_store(request)
+ row = await store.create_structure(
+ {
+ "id": str(uuid4()),
+ "title": body.title.strip(),
+ "name": body.name.strip(),
+ "content": body.content.strip(),
+ "created_by": await _current_username(request),
+ }
+ )
+ return ReportStructureResponse(**row)
+
+
+@router.get("/{structure_id}", response_model=ReportStructureResponse)
+async def get_report_structure(request: Request, structure_id: str) -> ReportStructureResponse:
+ store = _get_store(request)
+ row = await store.get_structure(structure_id)
+ if row is None:
+ raise HTTPException(status_code=404, detail="报告结构不存在")
+ return ReportStructureResponse(**row)
+
+
+@router.put("/{structure_id}", response_model=ReportStructureResponse)
+async def update_report_structure(
+ request: Request, structure_id: str, body: ReportStructureUpdateRequest
+) -> ReportStructureResponse:
+ store = _get_store(request)
+ data: dict = {}
+ if body.title is not None:
+ data["title"] = body.title.strip()
+ if body.name is not None:
+ data["name"] = body.name.strip()
+ if body.content is not None:
+ data["content"] = body.content.strip()
+ if not data:
+ raise HTTPException(status_code=400, detail="没有需要更新的字段")
+ row = await store.update_structure(structure_id, data)
+ if row is None:
+ raise HTTPException(status_code=404, detail="报告结构不存在")
+ return ReportStructureResponse(**row)
+
+
+@router.put("/{structure_id}/enabled", response_model=ReportStructureResponse)
+async def set_report_structure_enabled(
+ request: Request, structure_id: str, body: ReportStructureEnabledRequest
+) -> ReportStructureResponse:
+ store = _get_store(request)
+ row = await store.set_enabled(structure_id, body.enabled)
+ if row is None:
+ raise HTTPException(status_code=404, detail="报告结构不存在")
+ return ReportStructureResponse(**row)
+
+
+@router.delete("/{structure_id}", status_code=204)
+async def delete_report_structure(request: Request, structure_id: str) -> None:
+ store = _get_store(request)
+ deleted = await store.delete_structure(structure_id)
+ if not deleted:
+ raise HTTPException(status_code=404, detail="报告结构不存在")
diff --git a/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py b/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py
index b958dc6e..86dfec4b 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py
@@ -13,8 +13,9 @@ Routes (prefix ``/api/roundtable-chains``):
DELETE "/{chain_id}" delete
管理员权限:``system_role == "admin"`` 的用户(``_is_admin``)可 list ``scope=all``
-(所有账号的全部链条),并可 get / 编辑 / 发布 / 删除**任意** owner 的链条;非管理员
+(所有账号的全部链条),并可 get / 编辑 / 发布 / 启停 / 删除**任意** owner 的链条;非管理员
仍仅限本人链条(``scope=all`` 退化为 ``mine``)。编辑/删除不转移链条归属(``user_id`` 不变)。
+``enabled`` 默认 True:停用后管理列表仍可见,但选链弹窗 / 公共入口不再选用。
"""
from __future__ import annotations
@@ -90,6 +91,8 @@ class ChainResponse(BaseModel):
gather_first: bool = False
# 发布状态:True = 公共(所有账号可在「公共」筛选查看 / 选用)。
is_public: bool = False
+ # 启停状态:True = 启用(可选入研讨);False = 停用。缺省 True。
+ enabled: bool = True
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
is_mine: bool = True
# 配置者(owner)的 user_id 及解析后的可读用户名(email)。owner_name 由路由层
@@ -116,6 +119,7 @@ class ChainCreateRequest(BaseModel):
coordinator_prompt: str | None = Field(default=None, max_length=4000)
seat_mode: str | None = Field(default=None, max_length=16)
gather_first: bool = False
+ enabled: bool = True
class ChainUpdateRequest(BaseModel):
@@ -131,6 +135,8 @@ class ChainUpdateRequest(BaseModel):
gather_first: bool | None = None
# 发布开关(True 发布为公共 / False 取消发布);仅 owner 可改(update 本就 owner 限定)。
is_public: bool | None = None
+ # 启停开关(True 启用 / False 停用);仅 owner / 管理员可改。omit 不改。
+ enabled: bool | None = None
# ── helpers ──────────────────────────────────────────────────────────────
@@ -329,6 +335,7 @@ async def create_chain(request: Request, body: ChainCreateRequest) -> ChainRespo
"coordinator_prompt": body.coordinator_prompt,
"seat_mode": body.seat_mode,
"gather_first": body.gather_first,
+ "enabled": body.enabled,
},
)
await _enrich_owner_names([row])
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py
index 17a80a11..6fef1133 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py
@@ -7,7 +7,7 @@ import threading
from typing import Any
import yaml
-from pydantic import BaseModel, field_validator, model_validator
+from pydantic import BaseModel, field_validator, model_validator
from deerflow.config.paths import get_paths
@@ -82,7 +82,7 @@ class CommonQuestion(BaseModel):
return value
-class AgentConfig(BaseModel):
+class AgentConfig(BaseModel):
"""Configuration for a custom agent."""
id: str | None = None
@@ -108,20 +108,23 @@ class AgentConfig(BaseModel):
# 拼进本轮任务,避免弱模型无视系统提示里的技能块。None/False = 关(默认);True = 开。
# 与「业务链条配置」里的同名开关取 OR:任一打开即对该席位生效。见
# ``app/gateway/roundtable_seat_skills.py``。
- seat_skill_directive: bool | None = None
- # DeerFlow mapping ids this agent may offer in its new-conversation
- # knowledge selector. WeKnora ids and credentials never enter agent files.
- llmwiki_knowledge_base_ids: list[str] | None = None
-
- @field_validator("llmwiki_knowledge_base_ids")
- @classmethod
- def _normalize_llmwiki_ids(cls, value: list[str] | None) -> list[str] | None:
- if value is None:
- return None
- normalized = list(dict.fromkeys(str(item).strip() for item in value if str(item).strip()))
- if len(normalized) > 100:
- raise ValueError("An agent can bind at most 100 LLMWiki knowledge bases")
- return normalized
+ seat_skill_directive: bool | None = None
+ # 岗位职责:会商智能体配置面板展示用描述。Display/UX metadata only —
+ # the runtime never reads this. None/empty = not set.
+ job_responsibilities: str | None = None
+ # DeerFlow mapping ids this agent may offer in its new-conversation
+ # knowledge selector. WeKnora ids and credentials never enter agent files.
+ llmwiki_knowledge_base_ids: list[str] | None = None
+
+ @field_validator("llmwiki_knowledge_base_ids")
+ @classmethod
+ def _normalize_llmwiki_ids(cls, value: list[str] | None) -> list[str] | None:
+ if value is None:
+ return None
+ normalized = list(dict.fromkeys(str(item).strip() for item in value if str(item).strip()))
+ if len(normalized) > 100:
+ raise ValueError("An agent can bind at most 100 LLMWiki knowledge bases")
+ return normalized
def load_agent_config(agent_id: str | None) -> AgentConfig | None:
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260819_02_chain_enabled.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260819_02_chain_enabled.py
new file mode 100644
index 00000000..ba0e692e
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260819_02_chain_enabled.py
@@ -0,0 +1,51 @@
+"""Add enabled column to roundtable_chains.
+
+Per-chain 启停 switch: True = 可在选链弹窗 / 公共入口里选用;False = 停用(管理列表
+仍可见,但不可选入研讨)。Default True → existing chains stay enabled. BOOLEAN NOT
+NULL DEFAULT 1, matching ``RoundtableChainRow.enabled``.
+
+``create_all`` adds the column for brand-new DBs; the startup ``_ensure_orm_columns_sync``
+sweep adds it to already-existing tables (sqlite/mysql/postgres). This revision keeps the
+alembic graph consistent with the rest of the chain columns and documents the change.
+
+Revision ID: 20260819_02
+Revises: 20260819_01
+Create Date: 2026-08-19
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy import inspect
+
+
+revision = "20260819_02"
+down_revision = "20260819_01"
+branch_labels = None
+depends_on = None
+
+
+def _has_table(table_name: str) -> bool:
+ inspector = inspect(op.get_bind())
+ return table_name in inspector.get_table_names()
+
+
+def _has_column(table_name: str, column_name: str) -> bool:
+ if not _has_table(table_name):
+ return False
+ inspector = inspect(op.get_bind())
+ return any(col["name"] == column_name for col in inspector.get_columns(table_name))
+
+
+def upgrade() -> None:
+ if _has_table("roundtable_chains") and not _has_column("roundtable_chains", "enabled"):
+ op.add_column(
+ "roundtable_chains",
+ sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
+ )
+
+
+def downgrade() -> None:
+ if _has_column("roundtable_chains", "enabled"):
+ op.drop_column("roundtable_chains", "enabled")
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/report_structures/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/report_structures/__init__.py
new file mode 100644
index 00000000..5a3ad87f
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/report_structures/__init__.py
@@ -0,0 +1,22 @@
+"""Deep-research report-structure persistence (报告结构 / 工作流配置)."""
+
+from __future__ import annotations
+
+from deerflow.persistence.report_structures.base import ReportStructureStore
+from deerflow.persistence.report_structures.memory import MemoryReportStructureStore
+from deerflow.persistence.report_structures.model import ReportStructureRow
+from deerflow.persistence.report_structures.sql import ReportStructureRepository
+
+__all__ = [
+ "MemoryReportStructureStore",
+ "ReportStructureRepository",
+ "ReportStructureRow",
+ "ReportStructureStore",
+ "make_report_structure_store",
+]
+
+
+def make_report_structure_store(session_factory):
+ if session_factory is None:
+ return MemoryReportStructureStore()
+ return ReportStructureRepository(session_factory)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/report_structures/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/report_structures/base.py
new file mode 100644
index 00000000..2321a2e5
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/report_structures/base.py
@@ -0,0 +1,64 @@
+"""Abstract report-structure persistence API (报告结构 / 工作流配置).
+
+Global / shared store: no ``user_id`` scoping — the deep-research 工作流配置
+dialog shows the same cards to every account. ``created_by`` is a display-only
+audit field (创建用户名). Rows are managed per-item (create / update /
+toggle-enabled / delete); ``ensure_defaults`` lazily seeds the two built-in
+outlines the first time the table is empty so a fresh deployment renders the
+same 通用分析结构 / 递进式论证结构 cards the frontend used to hardcode.
+"""
+
+from __future__ import annotations
+
+import logging
+from abc import ABC, abstractmethod
+from typing import Any
+
+from deerflow.persistence.report_structures.defaults import DEFAULT_REPORT_STRUCTURE_ROWS
+
+logger = logging.getLogger(__name__)
+
+
+class ReportStructureStore(ABC):
+ @abstractmethod
+ async def list_structures(self) -> list[dict[str, Any]]:
+ """Return every stored structure, most recently modified first."""
+ raise NotImplementedError
+
+ @abstractmethod
+ async def get_structure(self, structure_id: str) -> dict[str, Any] | None:
+ raise NotImplementedError
+
+ @abstractmethod
+ async def create_structure(self, data: dict[str, Any]) -> dict[str, Any]:
+ """Insert ``{id, title, name, content, created_by}`` (enabled forced True)."""
+ raise NotImplementedError
+
+ @abstractmethod
+ async def update_structure(self, structure_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
+ """Update any of ``title`` / ``name`` / ``content``. None when missing."""
+ raise NotImplementedError
+
+ @abstractmethod
+ async def set_enabled(self, structure_id: str, enabled: bool) -> dict[str, Any] | None:
+ """Flip 启用/停用 (also bumps updated_at). None when missing."""
+ raise NotImplementedError
+
+ @abstractmethod
+ async def delete_structure(self, structure_id: str) -> bool:
+ raise NotImplementedError
+
+ @abstractmethod
+ async def count(self) -> int:
+ raise NotImplementedError
+
+ async def ensure_defaults(self) -> None:
+ """Seed the built-in outlines when the table has never held a row.
+
+ Concurrent first reads can race; seed ids are fixed so a losing insert
+ fails on the primary key and is simply skipped.
+ """
+ if await self.count() > 0:
+ return
+ for row in DEFAULT_REPORT_STRUCTURE_ROWS:
+ await self.create_structure(dict(row))
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/report_structures/defaults.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/report_structures/defaults.py
new file mode 100644
index 00000000..be0c3db7
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/report_structures/defaults.py
@@ -0,0 +1,269 @@
+"""深度研究「报告结构」默认种子(新机器部署开箱即有的内置结构库)。
+
+前两套(通用分析结构 / 递进式论证结构)逐字提取自前端
+``frontend-web/src/pages/deep-research/ReportConfigCard.tsx`` 的
+``DEFAULT_REPORT_OUTLINE`` / ``PROGRESSIVE_REPORT_OUTLINE``;其余三套为本仓库
+补充的常用研究结构(快速简报 / 事件分析 / 对象调查)。
+表为空(全新部署、从未写过数据)时 ``ensure_defaults`` 会把全部内置结构插入
+``report_structures``;已有数据的库不会追加。若调优了某套结构,请同步更新
+本文件,否则新机器补建出来的还是旧版。
+"""
+
+from __future__ import annotations
+
+GENERAL_OUTLINE = """# 报告标题
+
+# 引言
+用 1–2 段说明研究对象、提出本议题的背景、研究范围及资料边界;简要交代全文的分析路径,不单独拆分小节。
+
+# 一、研究对象与事实基础
+## 1.1 概念、范围与背景
+界定研究对象、关键概念、时间和空间范围;说明读者理解问题所需的背景。
+## 1.2 已有事实与证据
+按时间或逻辑顺序梳理已知事实、数据、案例或材料发现;优先使用可核验的来源。
+## 1.3 当前状态与主要特征
+归纳研究对象目前呈现的状态、关键变化、共性与差异;没有数据支撑时明确说明。
+
+# 二、问题形成与作用机制
+## 2.1 直接原因与触发条件
+分析近期事件、条件变化或关键节点如何推动问题出现或演变。
+## 2.2 深层影响因素
+从制度、技术、资源、行为、环境或历史条件中选择适用维度,分析长期影响。
+## 2.3 因果关系与相互作用
+说明不同因素如何共同作用;区分来源已证实的事实、分析判断和仍待验证的推断。
+
+# 三、影响、问题与不同观点
+## 3.1 对研究对象的直接影响
+从结果、效率、质量、风险、成本、体验或其他适用维度,分析当前影响。
+## 3.2 对相关主体与外部环境的影响
+说明影响如何传导到相关群体、组织、地区、系统或后续事项;不适用的维度可省略。
+## 3.3 主要争议与解释差异
+并列材料支持的不同观点、解释或结论,并说明各自的证据基础与局限。
+
+# 四、比较、趋势与不确定性
+## 4.1 可比案例或横向比较
+在材料允许时选择历史案例、不同地区、不同方案或不同阶段进行比较,说明异同与启示。
+## 4.2 发展趋势与关键变量
+识别后续可能变化、决定性变量、观察指标和潜在转折点;不把预测写成既成事实。
+## 4.3 风险情景与不确定性
+说明不同情景的触发条件、可能影响和证据不足之处;对没有依据的推测明确标注。
+
+# 五、结论、建议与后续关注
+## 5.1 核心结论
+用编号或分点方式收束全文;每条结论都应能回溯到正文中的事实和分析。
+## 5.2 可行建议或应对方向
+仅在研究材料支持时提出建议;说明适用对象、前提条件、优先级和预期作用。
+## 5.3 资料局限与后续关注
+列出证据缺口、数据口径差异、尚不能下结论的问题,以及值得持续跟踪的事项。
+
+# 参考资料
+仅列出本报告实际使用的来源,并与正文引用保持一致。
+
+> 写作要求:引言保持简洁;每个二级小节按材料充分程度写 2–5 段完整分析,每段围绕一个明确观点展开,采用“事实/证据 → 分析 → 影响或小结”的顺序。章节可根据研究主题合并、增删或调整,但不要为了套模板而编造不适用的主体、数据、案例或引用。"""
+
+PROGRESSIVE_OUTLINE = """# 报告标题
+
+# 引言
+用 1–2 段说明研究对象、核心问题、研究范围和资料边界;简要说明本文将按“事实基线 → 成因机制 → 影响判断 → 应对方向 → 结论”的递进逻辑展开。
+
+# 一、事实基线:问题是什么
+## 1.1 概念界定与研究范围
+明确关键概念、时间/对象边界和判断口径,避免后续讨论混用概念。
+## 1.2 关键事实、数据与证据
+按时间或逻辑顺序列出已经发生的事实、可核验数据、案例和来源。
+## 1.3 基线判断与待解问题
+基于前两节归纳当前状态、主要矛盾和需要解释的核心问题。
+
+> 本章产出:后续分析必须以本章已经确认的事实和问题为基础,不得在后文补写没有来源支撑的新事实。
+
+# 二、形成机制:为什么会这样
+## 2.1 直接原因与触发条件
+从第一章识别的问题出发,分析近期事件、条件变化或关键节点如何导致当前结果。
+## 2.2 深层条件与关键约束
+结合第一章的证据,说明制度、技术、资源、行为、环境或历史条件等长期影响。
+## 2.3 因果链条与关键变量
+把前两节串成清晰的“条件 → 机制 → 结果”链条,标出决定性变量及仍待验证的环节。
+
+> 本章产出:第三章只能沿用本章已说明的机制评估影响,不能把相关性直接写成因果关系。
+
+# 三、结果与影响:会带来什么
+## 3.1 直接结果与短期影响
+依据第二章的因果链条,分析对研究对象最直接、最先出现的影响。
+## 3.2 传导效应与中长期影响
+继续说明影响如何传导至相关主体、外部环境或后续事项;不适用的维度可省略。
+## 3.3 权衡、分歧与边界
+比较不同影响之间的取舍、材料支持的不同观点,以及结论适用的边界。
+
+> 本章产出:第四章的趋势判断、情景和建议应以本章已评估的影响、风险和权衡为依据。
+
+# 四、趋势与应对:接下来怎么办
+## 4.1 未来走势与观察指标
+从第三章的影响出发,识别可能变化、关键观察指标和潜在转折点;不把预测写成既成事实。
+## 4.2 可选方案与适用条件
+针对第三章发现的问题和风险,提出可行的应对方向、前提条件与优先级。
+## 4.3 风险情景与验证重点
+说明不同情景的触发条件、可能后果,以及下一步需要补充验证的数据或材料。
+
+> 本章产出:第五章只综合前四章已经建立的事实、机制、影响和建议,不新增独立论点。
+
+# 五、综合结论与后续关注
+## 5.1 核心结论
+按“事实基线 → 原因机制 → 主要影响 → 应对方向”逐项收束,每条结论都可回溯到前文证据。
+## 5.2 局限与后续研究
+列出资料缺口、数据口径差异、尚不能下结论的问题和后续跟踪清单。
+
+# 参考资料
+仅列出本报告实际使用的来源,并与正文引用保持一致。
+
+> 写作要求:除引言外,每个二级小节写 2–5 段完整分析。后章必须明确承接前章的结论;若材料无法支持递进关系,应说明资料限制而非编造衔接。"""
+
+BRIEF_OUTLINE = """# 报告标题
+
+# 核心结论
+用 3–5 条要点直接给出最重要的结论与判断,每条一两句话并可回溯到后文依据;先说结论,再列支撑。
+
+# 关键事实
+## 事实清单
+按重要性列出已核验的关键事实、数据与出处;不确定的信息单独注明置信度。
+## 时间与背景
+用最短的篇幅交代理解结论所需的事件顺序与背景,不展开细节。
+
+# 风险与不确定性
+列出可能推翻或削弱上述结论的因素、证据缺口和正在变化中的变量。
+
+# 建议行动
+按优先级给出可执行的建议或下一步关注点,说明适用前提与预期作用;材料不足时明确说明证据尚不支持。
+
+# 参考资料
+仅列出本简报实际使用的来源,并与正文引用保持一致。
+
+> 写作要求:全文控制在较短篇幅(建议 800–1500 字),信息密度优先;不写套话与背景铺陈,所有论断要么有来源支撑,要么明确标注为分析判断。"""
+
+
+EVENT_OUTLINE = """# 报告标题
+
+# 引言
+用 1–2 段说明事件的性质、发生时间地点、当前状态与本报告要回答的核心问题。
+
+# 一、事件概述
+## 1.1 事件是什么
+客观描述发生了什么:主体、行为、规模、直接结果;只写已核验事实。
+## 1.2 各方说法
+并列当事方、官方与媒体的不同表述,注明各自的信息来源与立场。
+
+# 二、时间线还原
+## 2.1 起因与前情
+梳理事件发生的背景、前置条件与诱发节点。
+## 2.2 关键节点
+按时间顺序还原事件演进的关键节点,每个节点注明时间与来源;存在争议的时间点单独标注。
+## 2.3 当前状态
+说明事件目前的进展、处置情况与未决事项。
+
+# 三、原因与责任分析
+## 3.1 直接原因
+分析触发事件的直接因素与机制。
+## 3.2 深层原因
+从制度、管理、技术、环境等维度分析更深层的促成条件。
+## 3.3 责任与归因
+基于事实分析各方责任,区分已认定的结论与仍在调查的部分。
+
+# 四、影响与反应
+## 4.1 直接影响
+评估事件对当事方与相关群体已造成的影响。
+## 4.2 各方反应与后续动作
+梳理官方处置、当事方回应、舆论反应与衍生事件。
+## 4.3 潜在延伸影响
+分析可能的中长期影响与传导路径,注明判断依据。
+
+# 五、结论与后续关注
+## 5.1 核心结论
+收束事件性质、原因与影响的核心判断。
+## 5.2 待验证问题与观察点
+列出尚未证实的关键问题与值得持续跟踪的信号。
+
+# 参考资料
+仅列出本报告实际使用的来源,并与正文引用保持一致。
+
+> 写作要求:严格区分“已证实事实—各方声称—分析推断”三个层次并分别标注;时间线每个节点须有来源;对争议点呈现多方说法,不单方采信。"""
+
+PROFILE_OUTLINE = """# 报告标题
+
+# 引言
+说明调查对象的界定范围(个人、组织或产品)、研究目的与资料边界(公开信息的时间范围与覆盖面)。
+
+# 一、基本情况
+## 1.1 基本信息与背景
+梳理对象的公认基本信息与来历,注明信息时效。
+## 1.2 发展沿革
+按时间梳理关键成长节点、转折与现状。
+
+# 二、能力与表现
+## 2.1 核心能力或竞争力
+分析对象的核心能力、资源禀赋或产品力,给出可观察的证据。
+## 2.2 代表性成果与表现
+列举代表性成果、业绩或事件表现,并评估其含金量与可持续性。
+## 2.3 短板与争议
+并列材料中的短板、批评与争议点,说明各方依据。
+
+# 三、关系网络与生态
+## 3.1 关联方与合作关系
+梳理重要的合作关系、关联方与利益结构。
+## 3.2 立场与倾向
+基于言行记录分析对象的立场、风格与决策倾向。
+
+# 四、风险评估
+## 4.1 已知风险
+汇总已暴露的风险点、负面记录与可靠性问题。
+## 4.2 潜在风险与信息缺口
+指出资料不足导致的盲区与需要进一步核实的问题。
+
+# 五、综合评估
+## 5.1 画像结论
+用几句话给出对象的综合画像与可信度评估。
+## 5.2 交往或合作建议
+给出与对象打交道的注意事项与建议,说明前提。
+
+# 参考资料
+仅列出本报告实际使用的来源,并与正文引用保持一致。
+
+> 写作要求:只依据公开可核验信息,猜测与推断必须明确标注;负面信息须注明来源与时间,区分“事实—评价—传闻”;信息不足时如实说明,不为了完整而编造。"""
+
+
+# 固定 id:并发首查时重复插入会撞主键而被吞掉,不会产生重复行。
+DEFAULT_REPORT_STRUCTURE_ROWS: list[dict] = [
+ {
+ "id": "seed-report-outline-general",
+ "title": "通用分析结构",
+ "name": "通用分析结构",
+ "content": GENERAL_OUTLINE,
+ "created_by": "系统内置",
+ },
+ {
+ "id": "seed-report-outline-progressive",
+ "title": "递进式论证结构",
+ "name": "递进式论证结构",
+ "content": PROGRESSIVE_OUTLINE,
+ "created_by": "系统内置",
+ },
+ {
+ "id": "seed-report-outline-brief",
+ "title": "快速简报结构",
+ "name": "快速简报结构",
+ "content": BRIEF_OUTLINE,
+ "created_by": "系统内置",
+ },
+ {
+ "id": "seed-report-outline-event",
+ "title": "事件分析结构",
+ "name": "事件分析结构",
+ "content": EVENT_OUTLINE,
+ "created_by": "系统内置",
+ },
+ {
+ "id": "seed-report-outline-profile",
+ "title": "对象调查结构",
+ "name": "对象调查结构",
+ "content": PROFILE_OUTLINE,
+ "created_by": "系统内置",
+ },
+]
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/report_structures/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/report_structures/memory.py
new file mode 100644
index 00000000..ee383721
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/report_structures/memory.py
@@ -0,0 +1,66 @@
+"""In-memory report-structure store (auth-less tests / no-DB fallback)."""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from typing import Any
+
+from deerflow.persistence.report_structures.base import ReportStructureStore
+
+
+def _now_iso() -> str:
+ return datetime.now(UTC).isoformat()
+
+
+def _sort_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ return sorted(rows, key=lambda r: (r.get("updated_at") or "", r.get("created_at") or ""), reverse=True)
+
+
+class MemoryReportStructureStore(ReportStructureStore):
+ def __init__(self) -> None:
+ self._rows: dict[str, dict[str, Any]] = {}
+
+ async def list_structures(self) -> list[dict[str, Any]]:
+ return _sort_rows(list(self._rows.values()))
+
+ async def get_structure(self, structure_id: str) -> dict[str, Any] | None:
+ row = self._rows.get(structure_id)
+ return dict(row) if row else None
+
+ async def create_structure(self, data: dict[str, Any]) -> dict[str, Any]:
+ row = {
+ "id": str(data["id"]),
+ "title": str(data.get("title") or ""),
+ "name": str(data.get("name") or ""),
+ "content": str(data.get("content") or ""),
+ "enabled": True,
+ "created_by": data.get("created_by"),
+ "created_at": _now_iso(),
+ "updated_at": _now_iso(),
+ }
+ self._rows[row["id"]] = row
+ return dict(row)
+
+ async def update_structure(self, structure_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
+ row = self._rows.get(structure_id)
+ if row is None:
+ return None
+ for key in ("title", "name", "content"):
+ if key in data:
+ row[key] = str(data[key])
+ row["updated_at"] = _now_iso()
+ return dict(row)
+
+ async def set_enabled(self, structure_id: str, enabled: bool) -> dict[str, Any] | None:
+ row = self._rows.get(structure_id)
+ if row is None:
+ return None
+ row["enabled"] = bool(enabled)
+ row["updated_at"] = _now_iso()
+ return dict(row)
+
+ async def delete_structure(self, structure_id: str) -> bool:
+ return self._rows.pop(structure_id, None) is not None
+
+ async def count(self) -> int:
+ return len(self._rows)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/report_structures/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/report_structures/model.py
new file mode 100644
index 00000000..ba24105b
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/report_structures/model.py
@@ -0,0 +1,45 @@
+"""ORM model for deep-research report structures (报告结构 / 工作流配置).
+
+Global / shared — every account sees the same structure cards in the deep
+research workbench's 工作流配置 dialog. Each row is one reusable report
+structure the user can pick to fill ``custom_outline``:
+
+- ``title`` — 标题, the card's primary label and the list's 标题筛选 target.
+- ``name`` — 名称, a secondary display label.
+- ``content`` — 接口内容, the Markdown outline text applied to the research
+ config's ``custom_outline`` when the card is selected.
+- ``enabled`` — 启用/停用; creation always defaults to True (the caller cannot
+ choose it) and a dedicated toggle flips it later.
+- ``created_by`` — 创建用户名 (audit-only display field).
+
+Fresh deployments get the two built-in outlines (通用分析结构 / 递进式论证结构)
+seeded by ``ensure_defaults`` — see ``defaults.py``.
+"""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+from sqlalchemy import Boolean, String, Text
+from sqlalchemy.orm import Mapped, mapped_column
+
+from deerflow.persistence.base import Base
+from deerflow.persistence.types import BeijingDateTime
+
+
+class ReportStructureRow(Base):
+ __tablename__ = "report_structures"
+
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
+ title: Mapped[str] = mapped_column(String(256), nullable=False)
+ name: Mapped[str] = mapped_column(String(256), default="", nullable=False)
+ content: Mapped[str] = mapped_column(Text, nullable=False)
+ # ``server_default`` 必须显式声明:否则在已存在该表但缺列的旧库上,启动期
+ # ``_ensure_orm_columns_sync`` 会跳过「NOT NULL 且无 server_default」的列(见
+ # engine.py),该列永远补不上 → 查询报 Unknown column → 500。
+ enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="1")
+ created_by: Mapped[str | None] = mapped_column(String(128), 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)
+ )
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/report_structures/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/report_structures/sql.py
new file mode 100644
index 00000000..18cb3872
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/report_structures/sql.py
@@ -0,0 +1,108 @@
+"""SQLAlchemy-backed report-structure store (global / shared)."""
+
+from __future__ import annotations
+
+import logging
+from datetime import UTC, datetime
+from typing import Any
+
+from sqlalchemy import delete, func, select
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from deerflow.persistence.report_structures.base import ReportStructureStore
+from deerflow.persistence.report_structures.model import ReportStructureRow
+
+logger = logging.getLogger(__name__)
+
+
+def _to_dict(row: ReportStructureRow) -> dict[str, Any]:
+ data = row.to_dict()
+ for key in ("created_at", "updated_at"):
+ value = data.get(key)
+ if isinstance(value, datetime):
+ data[key] = value.isoformat()
+ return data
+
+
+class ReportStructureRepository(ReportStructureStore):
+ def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
+ self._sf = session_factory
+
+ async def list_structures(self) -> list[dict[str, Any]]:
+ stmt = select(ReportStructureRow).order_by(
+ ReportStructureRow.updated_at.desc(), ReportStructureRow.created_at.asc()
+ )
+ async with self._sf() as session:
+ result = await session.execute(stmt)
+ return [_to_dict(row) for row in result.scalars()]
+
+ async def get_structure(self, structure_id: str) -> dict[str, Any] | None:
+ async with self._sf() as session:
+ row = await session.get(ReportStructureRow, structure_id)
+ return _to_dict(row) if row is not None else None
+
+ async def create_structure(self, data: dict[str, Any]) -> dict[str, Any]:
+ now = datetime.now(UTC)
+ row = ReportStructureRow(
+ id=str(data["id"]),
+ title=str(data.get("title") or ""),
+ name=str(data.get("name") or ""),
+ content=str(data.get("content") or ""),
+ # 创建永远默认启用(前端不提供该项填写)。
+ enabled=True,
+ created_by=data.get("created_by"),
+ created_at=now,
+ updated_at=now,
+ )
+ async with self._sf() as session:
+ session.add(row)
+ await session.commit()
+ await session.refresh(row)
+ return _to_dict(row)
+
+ async def update_structure(self, structure_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
+ async with self._sf() as session:
+ row = await session.get(ReportStructureRow, structure_id)
+ if row is None:
+ return None
+ for key in ("title", "name", "content"):
+ if key in data:
+ setattr(row, key, str(data[key]))
+ row.updated_at = datetime.now(UTC)
+ await session.commit()
+ await session.refresh(row)
+ return _to_dict(row)
+
+ async def set_enabled(self, structure_id: str, enabled: bool) -> dict[str, Any] | None:
+ async with self._sf() as session:
+ row = await session.get(ReportStructureRow, structure_id)
+ if row is None:
+ return None
+ row.enabled = bool(enabled)
+ row.updated_at = datetime.now(UTC)
+ await session.commit()
+ await session.refresh(row)
+ return _to_dict(row)
+
+ async def delete_structure(self, structure_id: str) -> bool:
+ async with self._sf() as session:
+ result = await session.execute(delete(ReportStructureRow).where(ReportStructureRow.id == structure_id))
+ await session.commit()
+ return bool(result.rowcount)
+
+ async def count(self) -> int:
+ async with self._sf() as session:
+ return int(await session.scalar(select(func.count()).select_from(ReportStructureRow)) or 0)
+
+ async def ensure_defaults(self) -> None:
+ """Seed when empty; a concurrent first-read loses on the fixed PK silently."""
+ if await self.count() > 0:
+ return
+ from deerflow.persistence.report_structures.defaults import DEFAULT_REPORT_STRUCTURE_ROWS
+
+ for row in DEFAULT_REPORT_STRUCTURE_ROWS:
+ try:
+ await self.create_structure(dict(row))
+ except IntegrityError:
+ logger.debug("report-structure default '%s' already inserted by a concurrent request", row["id"])
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/model.py
index bba96994..8aeed00f 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/model.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/model.py
@@ -63,6 +63,12 @@ class RoundtableChainRow(Base):
is_public: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="0", index=True
)
+ # 启停开关:True = 启用(可在选链弹窗 / 公共入口里选用);False = 停用(管理列表仍可见,
+ # 但不可选入研讨)。默认启用,存量链条补列后也为 True。``server_default`` 必须显式声明,
+ # 否则既有表上启动期 ``_ensure_orm_columns_sync`` 会跳过「NOT NULL 且无 server_default」的列。
+ enabled: Mapped[bool] = mapped_column(
+ Boolean, nullable=False, default=True, server_default="1"
+ )
created_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/sql.py
index 756eca86..fac6c8cf 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/sql.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/sql.py
@@ -62,6 +62,8 @@ class RoundtableChainRepository:
"gather_first": bool(getattr(row, "gather_first", False)),
# 发布状态 + 是否本人所有(前端据此显示「公共」徽标、决定能否编辑 / 发布)。
"is_public": bool(getattr(row, "is_public", False)),
+ # 启停状态:缺省 True(存量行 / 补列前视为启用)。
+ "enabled": bool(getattr(row, "enabled", True)),
"is_mine": (current_user_id is None) or (row.user_id == current_user_id),
# 配置者(owner)的 user_id;路由层据此解析成可读用户名(owner_name)。
"owner_id": row.user_id,
@@ -122,6 +124,7 @@ class RoundtableChainRepository:
seat_mode=(data.get("seat_mode") or None),
gather_first=bool(data.get("gather_first") or False),
is_public=bool(data.get("is_public") or False),
+ enabled=True if data.get("enabled") is None else bool(data.get("enabled")),
created_at=now,
updated_at=now,
)
@@ -145,6 +148,7 @@ class RoundtableChainRepository:
seat_mode=_UNSET,
gather_first=_UNSET,
is_public=_UNSET,
+ enabled=_UNSET,
is_admin: bool = False,
) -> dict[str, Any] | None:
async with self._sf() as session:
@@ -170,6 +174,8 @@ class RoundtableChainRepository:
row.gather_first = bool(gather_first)
if is_public is not _UNSET and is_public is not None:
row.is_public = bool(is_public)
+ if enabled is not _UNSET and enabled is not None:
+ row.enabled = bool(enabled)
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
diff --git a/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql b/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql
index 34926d2e..af410e69 100644
--- a/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql
+++ b/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql
@@ -94,6 +94,11 @@ CALL _add_column_if_missing('roundtable_chains', 'seat_mode', 'VARCHAR(16) NULL
-- ORM: RoundtableChainRow.gather_first = Boolean, NOT NULL, server_default '0'。
CALL _add_column_if_missing('roundtable_chains', 'gather_first', 'TINYINT(1) NOT NULL DEFAULT 0');
+-- enabled: 业务链条启停开关,对应 migration 20260819_02。True=启用(可选入研讨);
+-- False=停用(管理列表仍可见)。默认启用,存量行补列后也为 True。
+-- ORM: RoundtableChainRow.enabled = Boolean, NOT NULL, server_default '1'。
+CALL _add_column_if_missing('roundtable_chains', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1');
+
-- ---- 岗位 (positions) 订阅系统,对应 migration 20260616_01 -------------------
-- positions / position_tag_bindings 是【新表】,由 create_all 自动建,无需补列。
-- 以下是给【已存在的表】补的列:
@@ -212,5 +217,6 @@ SELECT table_name, column_name
OR (table_name = 'roundtable_jobs' AND column_name = 'task_id')
OR (table_name = 'position_roundtable_sessions' AND column_name = 'task_scoped')
OR (table_name = 'task_buttons' AND column_name IN ('append_auth', 'auth_token_param', 'auth_name_param'))
- OR (table_name = 'compact_menu_overrides' AND column_name = 'menu_type'))
+ OR (table_name = 'compact_menu_overrides' AND column_name = 'menu_type')
+ OR (table_name = 'roundtable_chains' AND column_name = 'enabled'))
ORDER BY table_name, column_name;
diff --git a/offline-backend-20260512/backend/tests/test_agents_backend.py b/offline-backend-20260512/backend/tests/test_agents_backend.py
index f87df609..b9a033fc 100644
--- a/offline-backend-20260512/backend/tests/test_agents_backend.py
+++ b/offline-backend-20260512/backend/tests/test_agents_backend.py
@@ -26,7 +26,7 @@ from deerflow.persistence.tags.memory import MemoryTagStore
from deerflow.services.position_sync import PositionSyncService
-@pytest.fixture
+@pytest.fixture
def isolated_paths(tmp_path, monkeypatch):
"""Redirect get_paths() at a fresh temp directory for the duration of one test."""
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
@@ -119,20 +119,20 @@ async def _default_positions() -> MemoryPositionStore:
}
)
return positions
-
-
-@pytest.mark.asyncio
+
+
+@pytest.mark.asyncio
async def test_agent_store_allows_duplicate_chinese_names_and_filters_visibility() -> None:
store = MemoryAgentStore()
chinese_name = "\u4e2d\u6587\u52a9\u624b"
- await store.ensure_builtin({"id": "builtin-agent", "name": chinese_name, "description": "built in"})
- await store.create({"id": "private-a", "name": chinese_name, "description": "private", "user_id": "user-a"})
- await store.create({"id": "published-b", "name": chinese_name, "description": "published", "user_id": "user-b", "published": True})
-
- user_a_ids = {agent["id"] for agent in await store.list_visible("user-a")}
- assert user_a_ids == {"builtin-agent", "private-a", "published-b"}
-
+ await store.ensure_builtin({"id": "builtin-agent", "name": chinese_name, "description": "built in"})
+ await store.create({"id": "private-a", "name": chinese_name, "description": "private", "user_id": "user-a"})
+ await store.create({"id": "published-b", "name": chinese_name, "description": "published", "user_id": "user-b", "published": True})
+
+ user_a_ids = {agent["id"] for agent in await store.list_visible("user-a")}
+ assert user_a_ids == {"builtin-agent", "private-a", "published-b"}
+
user_c_ids = {agent["id"] for agent in await store.list_visible("user-c")}
assert user_c_ids == {"builtin-agent", "published-b"}
@@ -271,254 +271,297 @@ async def test_manual_favorite_upgrades_existing_auto_builtin_favorite(sql_sessi
async def test_agent_store_only_owner_can_update_or_delete() -> None:
store = MemoryAgentStore()
- await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
- await store.create({"id": "private-a", "name": "Owned", "user_id": "user-a"})
-
- assert await store.update("builtin-agent", "user-a", {"name": "Nope"}) is None
- assert await store.update("private-a", "user-b", {"name": "Nope"}) is None
-
- updated = await store.update("private-a", "user-a", {"name": "Renamed"})
- assert updated is not None
- assert updated["name"] == "Renamed"
-
- assert await store.delete("private-a", "user-b") is False
- assert await store.delete("private-a", "user-a") is True
-
-
-@pytest.mark.asyncio
-async def test_delete_builtin_only_targets_builtins() -> None:
- store = MemoryAgentStore()
-
- await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
- await store.create({"id": "owned-agent", "name": "Owned", "user_id": "user-a"})
-
- # delete_builtin refuses to touch a user-owned agent
- assert await store.delete_builtin("owned-agent") is False
- assert await store.get_any("owned-agent") is not None
-
- # delete_builtin succeeds on a built-in
- assert await store.delete_builtin("builtin-agent") is True
- assert await store.get_any("builtin-agent") is None
-
- # And no-ops on a missing id
- assert await store.delete_builtin("does-not-exist") is False
-
-
-@pytest.mark.asyncio
-async def test_update_builtin_only_targets_builtins() -> None:
- store = MemoryAgentStore()
-
- await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
- await store.create({"id": "owned-agent", "name": "Owned", "user_id": "user-a"})
-
- # update_builtin succeeds on a built-in agent
- updated = await store.update_builtin("builtin-agent", {"name": "Renamed by admin"})
- assert updated is not None
- assert updated["name"] == "Renamed by admin"
-
- # update_builtin refuses to touch a user-owned agent (returns None)
- assert await store.update_builtin("owned-agent", {"name": "Hijacked"}) is None
-
- # And on a missing agent
- assert await store.update_builtin("does-not-exist", {"name": "Ghost"}) is None
-
-
-@pytest.mark.asyncio
-async def test_get_editable_or_raise_admin_can_edit_builtin() -> None:
- """Route helper: admin gets edit access to any agent; non-admin still 403."""
- from fastapi import HTTPException
-
- from app.gateway.routers.agents import _get_editable_or_raise
-
- store = MemoryAgentStore()
- await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
- await store.create({"id": "owned-agent", "name": "Owned", "user_id": "user-a"})
-
- # Owner edits their own agent → returns ('owner', record)
- record, mode = await _get_editable_or_raise(
- store, "owned-agent", "user-a", is_admin=False
- )
- assert mode == "owner" and record["id"] == "owned-agent"
-
- # Admin edits a built-in agent → returns ('admin', record)
- record, mode = await _get_editable_or_raise(
- store, "builtin-agent", "user-a", is_admin=True
- )
- assert mode == "admin" and record["id"] == "builtin-agent"
-
- # Non-admin tries to edit a built-in → 403
- with pytest.raises(HTTPException) as exc:
- await _get_editable_or_raise(store, "builtin-agent", "user-a", is_admin=False)
- assert exc.value.status_code == 403
-
- # Admin edits someone else's custom agent → returns ('admin', record).
- # Behavior changed: admins manage the full agent catalog (built-in + any
- # user's agents), not just built-ins. Non-admins are still owner-only.
- await store.create(
- {"id": "published-other", "name": "Pub", "user_id": "user-x", "published": True}
- )
- record, mode = await _get_editable_or_raise(
- store, "published-other", "user-a", is_admin=True
- )
- assert mode == "admin" and record["id"] == "published-other"
-
-
-def test_build_run_config_injects_custom_assistant_as_agent_id() -> None:
- config = build_run_config("thread-1", None, None, assistant_id="agent-123")
-
- assert config["configurable"]["thread_id"] == "thread-1"
- assert config["configurable"]["agent_id"] == "agent-123"
- assert "agent_name" not in config["configurable"]
-
-
-def test_write_agent_config_persists_skills_into_config_yaml(isolated_paths) -> None:
- agent_id = "agent-001"
- _write_agent_config(
- agent_id,
- name="中文助手",
- description="desc",
- skills=["s1", "s2"],
- model="gpt-x",
- tool_groups=["tg-a"],
- )
-
- config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
- raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
-
- assert raw["id"] == agent_id
- assert raw["name"] == "中文助手"
- assert raw["description"] == "desc"
- assert raw["skills"] == ["s1", "s2"]
- assert raw["model"] == "gpt-x"
- assert raw["tool_groups"] == ["tg-a"]
-
-
-def test_load_config_extras_returns_values_written_to_yaml(isolated_paths) -> None:
- agent_id = "agent-002"
- _write_agent_config(
- agent_id,
- name="agent-002",
- description="",
- skills=["s1"],
- model=None,
- tool_groups=None,
- )
-
- extras = _load_config_extras(agent_id)
- assert extras == {"skills": ["s1"], "model": None, "tool_groups": None, "common_questions": None, "template_name": None, "seat_skill_directive": None}
-
-
-def test_load_config_extras_missing_directory_returns_none_triple(isolated_paths) -> None:
- extras = _load_config_extras("does-not-exist")
- assert extras == {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None, "seat_skill_directive": None}
-
-
-def test_write_agent_config_persists_common_questions(isolated_paths) -> None:
- agent_id = "agent-cq-1"
- # Structured {title, prompt} questions round-trip as clean dict mappings.
- questions = [
- {"title": "能做什么", "prompt": "这个智能体能做什么?"},
- {"title": "上传文件", "prompt": "如何上传文件?"},
- ]
- _write_agent_config(agent_id, name="助手", description="", common_questions=questions)
-
- config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
- raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
- assert raw["common_questions"] == questions
-
- extras = _load_config_extras(agent_id)
- assert [q.model_dump() for q in extras["common_questions"]] == questions
-
-
-def test_write_agent_config_coerces_legacy_string_questions(isolated_paths) -> None:
- """Legacy ``list[str]`` configs coerce to {title, prompt} with title=content."""
- agent_id = "agent-cq-legacy"
- _write_agent_config(
- agent_id, name="助手", description="", common_questions=["如何上传文件?"]
- )
-
- config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
- raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
- assert raw["common_questions"] == [
- {"title": "如何上传文件?", "prompt": "如何上传文件?"}
- ]
-
-
-def test_write_agent_config_omits_common_questions_when_none(isolated_paths) -> None:
- agent_id = "agent-cq-2"
- _write_agent_config(agent_id, name="x", description="")
-
- config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
- raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
- assert "common_questions" not in raw
- assert _load_config_extras(agent_id)["common_questions"] is None
-
-
-def test_write_agent_config_explicit_empty_common_questions_is_persisted(isolated_paths) -> None:
- agent_id = "agent-cq-3"
- _write_agent_config(agent_id, name="x", description="", common_questions=[])
-
- assert _load_config_extras(agent_id)["common_questions"] == []
-
-
-def test_write_agent_config_explicit_empty_skills_is_persisted(isolated_paths) -> None:
- agent_id = "agent-003"
- _write_agent_config(agent_id, name="x", description="", skills=[])
-
- extras = _load_config_extras(agent_id)
- assert extras["skills"] == []
-
-
-def test_write_agent_config_omits_skills_when_none(isolated_paths) -> None:
- agent_id = "agent-004"
- _write_agent_config(agent_id, name="x", description="")
-
- config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
- raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
-
- assert "skills" not in raw
- assert "model" not in raw
- assert "tool_groups" not in raw
-
-
-@pytest.mark.asyncio
-async def test_backfill_listing_cache_mirrors_config_values() -> None:
- store = MemoryAgentStore()
- cfg = AgentConfig(id="a1", name="Agent", skills=["s1", "s2"], tool_groups=["g1"], model="m1")
- await _backfill_listing_cache(store, [("a1", cfg)])
-
- extras = await store.get_extras_for(["a1"])
- assert extras["a1"]["skills"] == ["s1", "s2"]
- assert extras["a1"]["tool_groups"] == ["g1"]
- assert extras["a1"]["model"] == "m1"
-
-
-@pytest.mark.asyncio
-async def test_backfill_listing_cache_is_idempotent_and_skips_unchanged(monkeypatch) -> None:
- store = MemoryAgentStore()
- cfg = AgentConfig(id="a1", name="Agent", skills=["s1"], tool_groups=None, model=None)
- await _backfill_listing_cache(store, [("a1", cfg)])
-
- # On a second run with identical config, no re-write should occur.
- calls = {"replace": 0, "set": 0}
- orig_replace = store.replace_skills
- orig_set = store.set_extras
-
- async def _replace(agent_id, skills):
- calls["replace"] += 1
- await orig_replace(agent_id, skills)
-
- async def _set(agent_id, *, tool_groups, model):
- calls["set"] += 1
- await orig_set(agent_id, tool_groups=tool_groups, model=model)
-
- monkeypatch.setattr(store, "replace_skills", _replace)
- monkeypatch.setattr(store, "set_extras", _set)
-
- await _backfill_listing_cache(store, [("a1", cfg)])
- assert calls == {"replace": 0, "set": 0}
-
- # Changing the config triggers a re-write.
- cfg2 = AgentConfig(id="a1", name="Agent", skills=["s1", "s2"], tool_groups=None, model=None)
- await _backfill_listing_cache(store, [("a1", cfg2)])
- assert calls["replace"] == 1
+ await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
+ await store.create({"id": "private-a", "name": "Owned", "user_id": "user-a"})
+
+ assert await store.update("builtin-agent", "user-a", {"name": "Nope"}) is None
+ assert await store.update("private-a", "user-b", {"name": "Nope"}) is None
+
+ updated = await store.update("private-a", "user-a", {"name": "Renamed"})
+ assert updated is not None
+ assert updated["name"] == "Renamed"
+
+ assert await store.delete("private-a", "user-b") is False
+ assert await store.delete("private-a", "user-a") is True
+
+
+@pytest.mark.asyncio
+async def test_delete_builtin_only_targets_builtins() -> None:
+ store = MemoryAgentStore()
+
+ await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
+ await store.create({"id": "owned-agent", "name": "Owned", "user_id": "user-a"})
+
+ # delete_builtin refuses to touch a user-owned agent
+ assert await store.delete_builtin("owned-agent") is False
+ assert await store.get_any("owned-agent") is not None
+
+ # delete_builtin succeeds on a built-in
+ assert await store.delete_builtin("builtin-agent") is True
+ assert await store.get_any("builtin-agent") is None
+
+ # And no-ops on a missing id
+ assert await store.delete_builtin("does-not-exist") is False
+
+
+@pytest.mark.asyncio
+async def test_update_builtin_only_targets_builtins() -> None:
+ store = MemoryAgentStore()
+
+ await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
+ await store.create({"id": "owned-agent", "name": "Owned", "user_id": "user-a"})
+
+ # update_builtin succeeds on a built-in agent
+ updated = await store.update_builtin("builtin-agent", {"name": "Renamed by admin"})
+ assert updated is not None
+ assert updated["name"] == "Renamed by admin"
+
+ # update_builtin refuses to touch a user-owned agent (returns None)
+ assert await store.update_builtin("owned-agent", {"name": "Hijacked"}) is None
+
+ # And on a missing agent
+ assert await store.update_builtin("does-not-exist", {"name": "Ghost"}) is None
+
+
+@pytest.mark.asyncio
+async def test_get_editable_or_raise_admin_can_edit_builtin() -> None:
+ """Route helper: admin gets edit access to any agent; non-admin still 403."""
+ from fastapi import HTTPException
+
+ from app.gateway.routers.agents import _get_editable_or_raise
+
+ store = MemoryAgentStore()
+ await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
+ await store.create({"id": "owned-agent", "name": "Owned", "user_id": "user-a"})
+
+ # Owner edits their own agent → returns ('owner', record)
+ record, mode = await _get_editable_or_raise(
+ store, "owned-agent", "user-a", is_admin=False
+ )
+ assert mode == "owner" and record["id"] == "owned-agent"
+
+ # Admin edits a built-in agent → returns ('admin', record)
+ record, mode = await _get_editable_or_raise(
+ store, "builtin-agent", "user-a", is_admin=True
+ )
+ assert mode == "admin" and record["id"] == "builtin-agent"
+
+ # Non-admin tries to edit a built-in → 403
+ with pytest.raises(HTTPException) as exc:
+ await _get_editable_or_raise(store, "builtin-agent", "user-a", is_admin=False)
+ assert exc.value.status_code == 403
+
+ # Admin edits someone else's custom agent → returns ('admin', record).
+ # Behavior changed: admins manage the full agent catalog (built-in + any
+ # user's agents), not just built-ins. Non-admins are still owner-only.
+ await store.create(
+ {"id": "published-other", "name": "Pub", "user_id": "user-x", "published": True}
+ )
+ record, mode = await _get_editable_or_raise(
+ store, "published-other", "user-a", is_admin=True
+ )
+ assert mode == "admin" and record["id"] == "published-other"
+
+
+def test_build_run_config_injects_custom_assistant_as_agent_id() -> None:
+ config = build_run_config("thread-1", None, None, assistant_id="agent-123")
+
+ assert config["configurable"]["thread_id"] == "thread-1"
+ assert config["configurable"]["agent_id"] == "agent-123"
+ assert "agent_name" not in config["configurable"]
+
+
+def test_write_agent_config_persists_skills_into_config_yaml(isolated_paths) -> None:
+ agent_id = "agent-001"
+ _write_agent_config(
+ agent_id,
+ name="中文助手",
+ description="desc",
+ skills=["s1", "s2"],
+ model="gpt-x",
+ tool_groups=["tg-a"],
+ )
+
+ config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
+ raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
+
+ assert raw["id"] == agent_id
+ assert raw["name"] == "中文助手"
+ assert raw["description"] == "desc"
+ assert raw["skills"] == ["s1", "s2"]
+ assert raw["model"] == "gpt-x"
+ assert raw["tool_groups"] == ["tg-a"]
+
+
+def test_load_config_extras_returns_values_written_to_yaml(isolated_paths) -> None:
+ agent_id = "agent-002"
+ _write_agent_config(
+ agent_id,
+ name="agent-002",
+ description="",
+ skills=["s1"],
+ model=None,
+ tool_groups=None,
+ )
+
+ extras = _load_config_extras(agent_id)
+ assert extras == {
+ "skills": ["s1"],
+ "model": None,
+ "tool_groups": None,
+ "common_questions": None,
+ "template_name": None,
+ "seat_skill_directive": None,
+ "job_responsibilities": None,
+ "llmwiki_knowledge_base_ids": None,
+ }
+
+
+def test_load_config_extras_missing_directory_returns_none_triple(isolated_paths) -> None:
+ extras = _load_config_extras("does-not-exist")
+ assert extras == {
+ "skills": None,
+ "model": None,
+ "tool_groups": None,
+ "common_questions": None,
+ "template_name": None,
+ "seat_skill_directive": None,
+ "job_responsibilities": None,
+ "llmwiki_knowledge_base_ids": None,
+ }
+
+
+def test_write_agent_config_persists_common_questions(isolated_paths) -> None:
+ agent_id = "agent-cq-1"
+ # Structured {title, prompt} questions round-trip as clean dict mappings.
+ questions = [
+ {"title": "能做什么", "prompt": "这个智能体能做什么?"},
+ {"title": "上传文件", "prompt": "如何上传文件?"},
+ ]
+ _write_agent_config(agent_id, name="助手", description="", common_questions=questions)
+
+ config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
+ raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
+ assert raw["common_questions"] == questions
+
+ extras = _load_config_extras(agent_id)
+ assert [q.model_dump() for q in extras["common_questions"]] == questions
+
+
+def test_write_agent_config_coerces_legacy_string_questions(isolated_paths) -> None:
+ """Legacy ``list[str]`` configs coerce to {title, prompt} with title=content."""
+ agent_id = "agent-cq-legacy"
+ _write_agent_config(
+ agent_id, name="助手", description="", common_questions=["如何上传文件?"]
+ )
+
+ config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
+ raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
+ assert raw["common_questions"] == [
+ {"title": "如何上传文件?", "prompt": "如何上传文件?"}
+ ]
+
+
+def test_write_agent_config_omits_common_questions_when_none(isolated_paths) -> None:
+ agent_id = "agent-cq-2"
+ _write_agent_config(agent_id, name="x", description="")
+
+ config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
+ raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
+ assert "common_questions" not in raw
+ assert _load_config_extras(agent_id)["common_questions"] is None
+
+
+def test_write_agent_config_explicit_empty_common_questions_is_persisted(isolated_paths) -> None:
+ agent_id = "agent-cq-3"
+ _write_agent_config(agent_id, name="x", description="", common_questions=[])
+
+ assert _load_config_extras(agent_id)["common_questions"] == []
+
+
+def test_write_agent_config_explicit_empty_skills_is_persisted(isolated_paths) -> None:
+ agent_id = "agent-003"
+ _write_agent_config(agent_id, name="x", description="", skills=[])
+
+ extras = _load_config_extras(agent_id)
+ assert extras["skills"] == []
+
+
+def test_write_agent_config_omits_skills_when_none(isolated_paths) -> None:
+ agent_id = "agent-004"
+ _write_agent_config(agent_id, name="x", description="")
+
+ config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
+ raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
+
+ assert "skills" not in raw
+ assert "model" not in raw
+ assert "tool_groups" not in raw
+
+
+def test_write_agent_config_persists_job_responsibilities(isolated_paths) -> None:
+ agent_id = "agent-job-1"
+ _write_agent_config(
+ agent_id,
+ name="助手",
+ description="",
+ job_responsibilities=" 负责意图识别与业务链确认 ",
+ )
+
+ config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
+ raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
+ assert raw["job_responsibilities"] == "负责意图识别与业务链确认"
+ assert _load_config_extras(agent_id)["job_responsibilities"] == "负责意图识别与业务链确认"
+
+
+def test_write_agent_config_omits_blank_job_responsibilities(isolated_paths) -> None:
+ agent_id = "agent-job-2"
+ _write_agent_config(agent_id, name="助手", description="", job_responsibilities=" ")
+
+ config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
+ raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
+ assert "job_responsibilities" not in raw
+ assert _load_config_extras(agent_id)["job_responsibilities"] is None
+
+
+@pytest.mark.asyncio
+async def test_backfill_listing_cache_mirrors_config_values() -> None:
+ store = MemoryAgentStore()
+ cfg = AgentConfig(id="a1", name="Agent", skills=["s1", "s2"], tool_groups=["g1"], model="m1")
+ await _backfill_listing_cache(store, [("a1", cfg)])
+
+ extras = await store.get_extras_for(["a1"])
+ assert extras["a1"]["skills"] == ["s1", "s2"]
+ assert extras["a1"]["tool_groups"] == ["g1"]
+ assert extras["a1"]["model"] == "m1"
+
+
+@pytest.mark.asyncio
+async def test_backfill_listing_cache_is_idempotent_and_skips_unchanged(monkeypatch) -> None:
+ store = MemoryAgentStore()
+ cfg = AgentConfig(id="a1", name="Agent", skills=["s1"], tool_groups=None, model=None)
+ await _backfill_listing_cache(store, [("a1", cfg)])
+
+ # On a second run with identical config, no re-write should occur.
+ calls = {"replace": 0, "set": 0}
+ orig_replace = store.replace_skills
+ orig_set = store.set_extras
+
+ async def _replace(agent_id, skills):
+ calls["replace"] += 1
+ await orig_replace(agent_id, skills)
+
+ async def _set(agent_id, *, tool_groups, model):
+ calls["set"] += 1
+ await orig_set(agent_id, tool_groups=tool_groups, model=model)
+
+ monkeypatch.setattr(store, "replace_skills", _replace)
+ monkeypatch.setattr(store, "set_extras", _set)
+
+ await _backfill_listing_cache(store, [("a1", cfg)])
+ assert calls == {"replace": 0, "set": 0}
+
+ # Changing the config triggers a re-write.
+ cfg2 = AgentConfig(id="a1", name="Agent", skills=["s1", "s2"], tool_groups=None, model=None)
+ await _backfill_listing_cache(store, [("a1", cfg2)])
+ assert calls["replace"] == 1