This commit is contained in:
许海洋 committed 2026-06-04 08:12:01 +08:00
commit 3e2182acc5
275 files changed
+29592 -11370

No files matched your search

+1 -1
View File
@@ -17,7 +17,7 @@ SERPER_API_KEY=your-serper-api-key
# AUTH_JWT_SECRET must stay stable across restarts, or existing logins expire.
# GATEWAY_CORS_ORIGINS must include the exact frontend origins, separated by commas.
AUTH_JWT_SECRET=i3_BiBK44sYHiFIkChhDs46y57ZewJKO3diQDw01anI
GATEWAY_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
GATEWAY_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:5174,http://127.0.0.1:5174
GATEWAY_CORS_ALLOW_ALL=0
GATEWAY_ENABLE_DOCS=true
GATEWAY_WORKERS=2
+29 -8
View File
@@ -166,7 +166,7 @@ Lead-agent middlewares are assembled in strict append order across `packages/har
9. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
10. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
11. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional)
12. **TitleMiddleware** - Auto-generates thread title after first complete exchange and normalizes structured message content before prompting the title model
12. **TitleMiddleware** - Two-phase auto-title (never shows "untitled"): `before_model` sets an instant question-derived *provisional* title the moment the first user message arrives (no LLM, zero latency), then `after_model` asynchronously generates the final LLM title and replaces it (tracked via the `title_provisional` state flag). Normalizes structured message content before prompting the title model
13. **MemoryMiddleware** - Memory V2: `wrap_model_call` injects Hindsight recall transiently; `after_agent` auto-retains the turn to Hindsight
14. **ViewImageMiddleware** - Injects base64 image data before LLM call (conditional on vision support)
15. **DeferredToolFilterMiddleware** - Hides deferred tool schemas from the bound model until tool search is enabled (optional)
@@ -178,7 +178,7 @@ Lead-agent middlewares are assembled in strict append order across `packages/har
10. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
11. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
12. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional)
13. **TitleMiddleware** - Auto-generates thread title after first complete exchange and normalizes structured message content before prompting the title model
13. **TitleMiddleware** - Two-phase auto-title (never shows "untitled"): `before_model` sets an instant question-derived *provisional* title the moment the first user message arrives (no LLM, zero latency), then `after_model` asynchronously generates the final LLM title and replaces it (tracked via the `title_provisional` state flag). Normalizes structured message content before prompting the title model
14. **MemoryMiddleware** - Queues conversations for async memory update (filters to user + final AI responses)
15. **ViewImageMiddleware** - Injects base64 image data before LLM call (conditional on vision support)
16. **DeferredToolFilterMiddleware** - Hides deferred tool schemas from the bound model until tool search is enabled (optional)
@@ -244,7 +244,10 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **Runs** (`/api/runs`) | `POST /stream` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id |
| **Thread Shares** (`/api/thread-shares`) | Per-conversation sharing with two modes (`thread_shares.mode`). **`import`**: `POST /` `{thread_id, mode}` - create (or reuse) a revocable share code; `GET /{code}/preview` + `POST /{code}/import` - copy that conversation into the caller's account (fresh `thread_id` via `app/gateway/thread_copy.py` + new `threads_meta` row + `user-data` copy; dedup on `metadata.shared_origin`). **`view`**: a public, read-only static page — see Public Shares below. Both modes: `GET /` - list my codes; `DELETE /{code}` - revoke. |
| **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves a file referenced by that conversation (images, Markdown, text, PDF, …) so the shared page's links are openable; path resolution is confined to the owner's thread directory. Active content (`text/html`, `application/xhtml+xml`, `image/svg+xml`) is forced to download (`_public_share_disposition`); everything else is served inline. Missing/revoked/non-`view` codes all return 404. |
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
| **Roundtable Drafts** (`/api/roundtable-drafts`) | Per-user persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete. A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. |
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
| **HTML Page Favorites** (`/api/html-page-favorites`) | Bookmarked generated HTML pages, owner-scoped. `GET /` - list; `POST /` `{page_id, title?, description?, tags?}` - bookmark a `scheduled_html_pages` row (extracts a reusable style/layout summary via GLM5 at favorite time); a page can only be favorited once per user (`DuplicateFavoriteError` → HTTP 409). `GET/DELETE /{id}` - read/delete (the create-task reference picker deletes favorites here). A favorite can be selected as a style reference when creating a new `html_page` task. |
| **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). |
Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway.
@@ -256,6 +259,10 @@ Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` →
**Unique task names**: task names are unique per user (`uq_scheduled_tasks_user_name`). `create_task`/`update_task`/`admin_update_task` proactively check via `_assert_name_available()` and raise `DuplicateTaskNameError` on a collision; the router translates that into HTTP `409` instead of letting the raw DB `IntegrityError` surface as a `500`.
**HTML page tasks (`task_kind == "html_page"`)**: a second task kind that renders a full HTML document each run instead of a Markdown report. The kind + its config (`html_config`: `page_prompt`, `negative_prompt`, `reference_favorite_id`, `model_name`) live inside `execution_context` — no schema change to `scheduled_tasks`. Data gathering is delegated to the agent's own search skills (driven by `page_prompt`), so there is no explicit search-query/data-source field; the render model is user-selectable (`model_name`, default `zai-org/GLM-5-FP8`). `_run_task_attempt()` dispatches by kind; `_run_html_page_attempt()` runs the pipeline: gather real data via the lead agent (its search tools) → optionally load a reference favorite's reusable style/layout → render with the chosen model (thinking off) → fault-tolerant JSON parse → sanitize (BeautifulSoup, strips `<script>`/`<iframe>`/`on*`/`javascript:`) → structural validate → one GLM5 repair pass if invalid → **completeness gate**: if the HTML is still not a full valid document (missing doctype/html/head/body, empty body, or residual scripts) after the repair pass, raise `HtmlGenerationError` so the run is marked **failed** (a page only counts as successful when it is a complete, previewable HTML document) → best-effort Playwright screenshot (no-op when Playwright absent) → archive `index.html` → persist a `scheduled_html_pages` row → return `result` with `format: "html"` and the full HTML inline. Pure helpers live in `deerflow/runtime/scheduler/html_page.py`. The frontend previews the HTML in a sandboxed `iframe` (no `allow-scripts`) on the run-detail and public pages; a user can bookmark a page (style summary extracted via GLM5) and later pick it as a style reference. New tables `scheduled_html_pages` + `html_page_favorites` (`deerflow/persistence/html_pages/`, wired as `app.state.html_page_store`) are created by `Base.metadata.create_all` with no migration. Tests: `tests/test_html_page_tasks.py`.
**HTML skills (tools the render step uses).** Two offline skills under `skills/public/` shape the generated HTML and are toggled in the skills settings page (enabled by default): `html-page-builder` (a self-contained inline-CSS design system — colors/cards/tables/KPI/responsive) and `html-charts` (inline-SVG charts that render in the no-`allow-scripts` preview iframe; optional local ECharts documented in its `assets/README.md`). A skill opts into HTML injection via `metadata.kind: html_page` in its SKILL.md frontmatter; `_collect_html_skill_instructions()` reads the bodies of **enabled** such skills and `build_generation_prompt(skill_instructions=…)` injects them, so the (raw-model) render step honors the user's skill selection. The data-gathering step and the render/repair/style passes all use the task's selected `model_name` (no silent fallback to the default model — and there is **no hardcoded GLM-5**; an unset selection falls back to config `models[0]`). To stop auxiliary LLM calls from leaking onto the default model, **`TitleMiddleware` is skipped when `is_scheduled_run` is set** (`_build_middlewares` in `lead_agent/agent.py`) — scheduled/HTML runs use throwaway threads whose titles are never shown, and `config.title.model_name` is usually unset (→ would call `models[0]`, not the chosen model). The completeness gate (`validate_html`) also requires a non-empty `<title>` and forbids external stylesheets, on top of the doctype/html/head/body + non-empty-body + no-script checks.
### Custom Agent Persistence
Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.agents`). `agents.id` is the stable runtime id and the filesystem directory name under `.deer-flow/agents/{id}/`; `agents.name` is display-only, can contain Chinese characters, and is not unique. `user_id IS NULL` marks built-in agents, user-owned agents are visible only to their owner unless `published = true`, and update/delete operations require ownership. The runtime accepts `agent_id` in context/configurable, and non-default `assistant_id` values are treated as agent ids after visibility checks.
@@ -496,6 +503,11 @@ Per-user runtime overrides (whitelisted fields) live at `{base_dir}/users/{user_
- `summarization` - Context summarization (enabled, trigger conditions, keep policy)
- `subagents.enabled` - Master switch for subagent delegation
- `memory` - Memory system (enabled, storage_path, debounce_seconds, model_name, max_facts, fact_confidence_threshold, injection_enabled, max_injection_tokens)
- `auth_login` - Custom login modes layered on the base auth (`deerflow/config/login_config.py`):
- `username_login.{require_password, password}` - Password gate for `POST /api/v1/auth/login/username`. When `require_password=true`, the (otherwise passwordless) username login additionally requires `?password=<shared-secret>`; empty `password` falls back to `123ewq`. Gate only — it does not change which account logs in.
- `token_login.{enabled, token_info_url, service_authorization, timeout_seconds, verify_ssl, ca_cert_path}` - Token-exchange login (`POST /api/v1/auth/login/token`). The gateway POSTs to `token_info_url` with the caller-supplied token placed directly in the `Authorization: Bearer <token>` header (no cookie; the static `service_authorization` field is deprecated/unused, kept only for backward compat), reads `data.username` from a `{"state":"200",...}` body, then signs the matching local user in (auto-registering if absent). `token_info_url` is HTTPS — `verify_ssl: false` skips cert verification for self-signed/internal UAT hosts, or set `ca_cert_path` to a PEM bundle (takes precedence, keeps verification on). See [docs/AUTH_LOGIN.md](docs/AUTH_LOGIN.md) for the full request/response contract.
Both endpoints are whitelisted in `auth_middleware._PUBLIC_EXACT_PATHS`. The shared `_get_or_create_user_by_username()` helper (in `routers/auth.py`) backs both `/login/username` and `/login/token`.
**`extensions_config.json`**:
- `mcpServers` - Map of server name → config (enabled, type, command, args, env, url, headers, oauth, description)
@@ -636,12 +648,21 @@ Automatic conversation summarization when approaching token limits:
See [docs/summarization.md](docs/summarization.md) for details.
### Vision Support
### Vision Support / Image Recognition (识图)
For models with `supports_vision: true`:
- `ViewImageMiddleware` processes images in conversation
- `view_image_tool` added to agent's toolset
- Images automatically converted to base64 and injected into state
Two paths, selected by `config.yaml` → `vision`:
**Dedicated vision model (delegated recognition)** — set `vision.model_name` to a model from `models[]`. The `view_image` tool sends the image to that model and returns its **text description** as the tool result, so a text-only main chat model can still "see" images. `view_image` accepts an optional `question` argument for targeted queries; the default prompt is `vision.prompt` (overridable), and `vision.max_tokens` caps the recognition output. Resolution/gating lives in `deerflow/models/vision.py` (`resolve_vision_model_name`, `vision_enabled`, `is_delegated_vision`, `recognize_image`).
**Native vision (fallback)** — when `vision.model_name` is unset, behavior falls back to the main chat model, which only works if it has `supports_vision: true`:
- `ViewImageMiddleware` injects base64 image data into the conversation before the LLM call
- `view_image` stores the image in `state.viewed_images` for that middleware
Both the `view_image` tool and `ViewImageMiddleware` are registered whenever `vision_enabled(model_name)` is true (dedicated model configured **or** main model supports vision). Config schema: `deerflow/config/vision_config.py` (`VisionConfig`); tests: `tests/test_vision.py`.
### Upload symlink-safety (cross-platform)
`deerflow/uploads/manager.py:open_upload_file_no_symlink` writes uploads without following a pre-existing destination symlink. `O_NOFOLLOW` is applied where available (POSIX) but is **optional** — Windows lacks `os.O_NOFOLLOW`/`O_NONBLOCK`, so requiring it previously made every Windows upload fail with `UnsafeUploadPathError`. Pre-existing symlinks are still rejected via `lstat`, and the opened fd is re-checked with `fstat`. `O_BINARY` is added on Windows. Regression: `tests/test_uploads_windows_safe.py`.
## Code Style
@@ -32,6 +32,7 @@ from app.gateway.routers import (
auth,
channels,
feedback,
html_page_favorites,
intent,
llm_metrics,
mcp,
@@ -40,9 +41,12 @@ from app.gateway.routers import (
multi_llm,
multi_agent,
notifications,
public_embed,
public_scheduled_tasks,
recommend,
recommended_questions,
roundtable_chains,
roundtable_drafts,
runs,
scheduled_tasks,
skills,
@@ -504,6 +508,22 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
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)"
)
# 一次性把 .deer-flow/agents/{id}/ 目录里历史遗留的 agent 导入 agents 表。
# 路由层原本每次请求都跑一遍这个 sync(扫文件系统 + N 个 agent 各一次 DB
# SELECT/UPDATE),把它挪到启动期 + 路由层只保留 TTL 兜底,显著降低
@@ -789,6 +809,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
dev_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",
]
logger.warning(
"No CORS configuration found; enabling local dev origins only: %s. "
@@ -834,6 +858,12 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
app.include_router(user_prompts.router)
# Roundtable-planning drafts + recommendation history at /api/roundtable-drafts
app.include_router(roundtable_drafts.router)
# Roundtable-planning business chains at /api/roundtable-chains
app.include_router(roundtable_chains.router)
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions
app.include_router(suggestions.router)
@@ -854,6 +884,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# surface results without a DeerFlow login.
app.include_router(public_scheduled_tasks.router)
# Public embed Q&A status polling (/api/public/embed/*)
app.include_router(public_embed.router)
app.include_router(html_page_favorites.router)
# System settings API is mounted at /api/system-settings
app.include_router(system_settings.router)
@@ -42,6 +42,7 @@ _PUBLIC_EXACT_PATHS: frozenset[str] = frozenset(
{
"/api/v1/auth/login/local",
"/api/v1/auth/login/username",
"/api/v1/auth/login/token",
"/api/v1/auth/register",
"/api/v1/auth/logout",
"/api/v1/auth/setup-status",
@@ -93,6 +93,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
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)
@@ -130,6 +134,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
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,
register_default_store as _register_ai_writing_default_store,
@@ -152,6 +160,14 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.user_prompt_store = make_user_prompt_store(sf)
from deerflow.persistence.roundtable_drafts import make_roundtable_draft_store
app.state.roundtable_draft_store = make_roundtable_draft_store(sf)
from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
# 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)
@@ -265,6 +281,14 @@ def get_thread_share_store(request: Request):
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)
@@ -8,16 +8,29 @@
``git clone`` 之后这些目录**不会存在**,直接调对应 endpoint 会因为 agent
解析失败抛 500。
本模块在四个路由入口(``intent.init``、``intent.stream``、``recommend.stream``、
``multi_agent.init``)被调用,检查磁盘文件是否齐全;缺失则用下方嵌入的标准模板
写回去。``_sync_legacy_agents`` 会在下一次 ``GET /api/agents`` 时把目录 upsert
进 DB,所以这里只关心磁盘文件,不直接动 DB。
本模块在两个地方被调用:
1. **启动 lifespan**(``app/gateway/app.py``):服务起来就把缺失的目录补齐,
紧接着的 ``_sync_legacy_agents`` 会把它们 upsert 进 DB,新机器一启动
``/api/agents`` 列表里就有这三个 agent,**不必先去点圆桌页**。
2. **四个路由入口**(``intent.init``、``intent.stream``、``recommend.stream``、
``multi_agent.init``):防御性兜底——若管理员在启动后手删了目录,下一次请求
也能自愈。
为什么嵌入字符串而不是用 importlib.resources 资源文件
------------------------------------------------------
SOUL 体量不大(三个加起来 ~400 行),嵌入到 Python 文件里是**单一可信源**——改
SOUL 走代码 review、commit 走 git 历史,且不会因为 wheel 打包漏配
``package-data`` 导致部署机拿不到。
``_sync_legacy_agents`` 只关心磁盘文件,本模块也只写磁盘文件,不直接动 DB。
为什么 SOUL/config 用资源文件而不是内嵌 Python 字符串
----------------------------------------------------
这三个 agent 的 SOUL 是**经过实测调优、验证过**的版本(intent 含硬规则/反例集、
recommender 含三条铁律/多领域示例),体量大且会持续迭代。早期把 SOUL 内嵌成
Python 三引号字符串,导致「磁盘上的验证版」与「代码里的内嵌版」很容易分叉
(还有 ``\\n`` 之类转义陷阱)。改为把验证版 SOUL.md + config.yaml 作为**源码树里
的资源文件**(``_roundtable_seed_assets/<id>/``,随 git 发布,不在 gitignore 的
``.deer-flow/`` 下),seeder 直接字节级复制过去——调 SOUL 就是改 .md 文件,
单一可信源,走 git review。
> ⚠️ 维护提示:资源文件(``_roundtable_seed_assets/``)是**发布默认值**的唯一可信源。
> 线上调优后,请把改动写回对应的资源 .md,否则下次新机器补建出来的还是旧版。
> ``_ensure_one`` 只在目录缺失时复制,**不覆盖**已存在目录(尊重运维现场手改)。
为什么 ``roundtable-coordinator`` 是单例而不是每次会话新建
------------------------------------------------------------
@@ -36,576 +49,79 @@ SOUL 走代码 review、commit 走 git 历史,且不会因为 wheel 打包漏配
from __future__ import annotations
import logging
from pathlib import Path
from typing import Iterable
import yaml
from deerflow.config.paths import get_paths
logger = logging.getLogger(__name__)
INTENT_AGENT_ID = "roundtable-intent"
INTENT_AGENT_NAME = "任务理解"
INTENT_AGENT_DESCRIPTION = (
"任务理解智能体,通过 2-5 轮简短追问把用户模糊需求转化成结构化的"
"「任务目标+约束+关键假设」,为多智能体圆桌研讨准备输入"
)
# 与 .deer-flow/agents/roundtable-intent/SOUL.md 保持完全一致;
# 改动请同步两处或以本文件为准 + 删除磁盘旧目录让下一次请求重建。
INTENT_AGENT_SOUL = """# 任务理解智能体
你是多智能体圆桌研讨 **Step 1** 的「意图理解专员」。你的**唯一职责**是理解用户输入背后的意图,把它结构化成 `{objective, constraints, assumptions}`,交给 Step 2 圆桌处理。
**你不是任务过滤器,不是看门人。** 用户输入什么(规划性需求、事实性问题、知识查询、创作请求、咨询、闲聊、模糊想法……),你都要尝试去**理解**,而不是拒绝。
---
## 1. 你的目标
把用户的任何输入整理成结构化意图,供 Step 2 圆桌使用:
| 字段 | 含义 | 是否必填 |
|------|------|----------|
| `objective` | 一句话描述用户想做/想知道的事 | ✅ 必填 |
| `constraints` | 用户明确给出的硬性约束(预算/时间/合规/范围/风格等) | ⚠️ 可空数组,**不要凭空编造** |
| `assumptions` | 你替用户做的合理补充假设 | ⚠️ 可空数组 |
**核心原则**:
- 用户已经说清楚的,**直接汇总,不要追问**。
- 用户问一个事实/查询/咨询类问题,**这就是一个有效的 objective**,直接结构化即可,不需要约束。
- 只有当**信息严重不足以让 Step 2 开展工作**时,才追问。
---
## 2. 工作流(必须遵守)
```
用户输入
↓
能不能用一句话写出 objective?
├─ 能 → 直接汇总输出 [INTENT_READY] + JSON,结束
└─ 不能(完全模糊、多种理解、关键决策点缺失)
→ 调用 ask_clarification(一次只问一个最关键的点)
↓
用户回复后再判断;最多 2-3 轮就要收敛
```
**关键规则**:
- **优先收敛,不要无谓追问。** 大多数输入(包括事实问题、知识查询、创作请求)**首条消息就能直接结构化**,根本不需要 `ask_clarification`。
- 只在「objective 都写不出来」「同一句话有多种截然不同的理解」「用户给的方向矛盾」这类情况下才追问。
- **绝对禁止**对用户输入做价值判断或拒绝,例如「这不在我的职责范围」「我只负责规划类任务」「请你换一个问题」——这些话**都不许说**。
- 一旦输出 `[INTENT_READY]`,本轮结束,不再追加追问或工具调用。
- 你**不亲自回答用户的问题**(那是 Step 2 圆桌的事),只负责把它结构化。
---
## 2.5 提问前检查清单(必读)
准备 `ask_clarification` 前先在心里走完:**任何一条命中"是"就不要发出**:
1. **能不能直接用一句话写 objective?** 能写就别问。哪怕是「赖清德的儿子是谁」这种事实问题,objective 就是「了解赖清德儿子的相关信息」,直接汇总。
2. **这个 `question` 文本和上一轮的 `ask_clarification.question` 字面/语义重叠吗?** 重叠就别再问。
3. **`options` 列表是否和过往某次完全相同(或仅顺序、标点不同)?** 是 → 别问。
4. **用户在过往消息里已经用自然语言或「我选择:X」回答过这个决策点了吗?** 是 → 写进 constraints 直接汇总。
5. **能不能用合理 `assumptions` 兜底,把这条信息写进汇总而不是再多问一轮?** 能就别问。
> 反面教材(❌ 绝对禁止):
> ```
> 用户:赖清德的儿子是谁
> 你:你好!我是任务理解专员……这不在我的职责范围……
> ```
> 正面示范(✅):
> ```
> 用户:赖清德的儿子是谁
> 你:✅ 我理解你想了解赖清德儿子的相关信息。
> [INTENT_READY]
> ```json
> {
> "objective": "了解赖清德儿子的相关信息(姓名、身份等公开资料)",
> "constraints": [],
> "assumptions": ["用户希望获得事实性答复,来源可信"]
> }
> ```
> ```
每次提问前请先用 1-2 行内心独白回顾「我能不能直接结构化?上一轮问了什么?这次还需要问的不同点是?」再决定是否调工具。
---
## 3. 唯一工具:`ask_clarification`
仅当 §2 工作流判断「确实需要追问」时使用。
### 3.1 参数说明
| 参数 | 必填 | 说明 |
|------|------|------|
| `question` | ✅ | 一个聚焦的问题,一次只问一个决策点 |
| `clarification_type` | ✅ | 见下表 |
| `context` | 推荐 | 为什么需要用户确认(1 句话) |
| `options` | 有离散选项时必填 | 字符串列表,如 `["≤500万", "500–1000万", "暂不确定"]` |
| `allow_multiple` | 可选 | 默认 **不传 / False = 单选**;仅当用户**确实可同时选多项**时传 `True` |
### 3.2 `clarification_type` 怎么选
| 类型 | 何时使用 | 选项 UI |
|------|----------|---------|
| `missing_info` | 缺关键事实(只有在 objective 都写不出来时才用) | 默认单选 |
| `ambiguous_requirement` | 同一句话有多种截然不同的理解 | 默认单选 |
| `approach_choice` | 用户明确表达需要在若干路径中选一个 | **单选** |
| `risk_confirmation` | 需要用户明确确认风险 | **单选** |
| `suggestion` | 你有建议,需用户批准 | **单选** |
### 3.3 选项卡片(前端行为)
- 你提供 `options` 后,前端会渲染**可点击卡片**。
- **默认单选**:用户点一项即发送,无需再点「发送」。
- **多选**(仅 `allow_multiple=True`):用户可多选后点「发送回答」。
- 用户也可能在自定义输入框里打字;回复常见格式:`我选择:A、B;补充:…`——请据此更新理解并继续流程。
### 3.4 调用示例(仅在真正模糊时使用)
**真正模糊的需求,需要确认核心方向:**
```
ask_clarification(
question="你希望我帮你完成的核心目标是什么?",
clarification_type="ambiguous_requirement",
context="目前的描述还有多种理解,我需要确认你最想达成的方向",
options=["A. 输出一份完整方案文档", "B. 做一次可行性快速评估", "C. 起草执行清单"]
)
```
**用户表达的方向矛盾,需要拍板:**
```
ask_clarification(
question="预算和工期之间如果只能优先一个,你希望优先哪个?",
clarification_type="approach_choice",
context="你提到的预算上限和工期上限同时存在冲突",
options=["优先预算 (允许延期)", "优先工期 (允许超预算)"]
)
```
---
## 4. 信息充分性判定
**只要能写出一句话 `objective`,就可以收敛输出 `[INTENT_READY]`。**
- ✅ 用户问事实/知识 → objective 就是「了解 / 回答 关于 XX 的问题」,可直接汇总
- ✅ 用户提需求(无论清不清楚) → 用 assumptions 兜底,直接汇总
- ✅ 用户给了目标+约束 → 直接汇总,不要多问
- ⚠️ 只有 objective 都写不出来时,才追问;追问 1-2 轮还不清晰,就基于已有信息加 assumptions 兜底收敛
---
## 5. 结束协议:`[INTENT_READY]`
信息可以收敛时,回复结构必须是:
1. **自然语言**向用户复述「✅ 我理解的意图是 …」(让用户 review)
2. **紧接着**输出下方固定标记 + JSON(后端靠此解锁「进入 Step 2」)
格式要求(**严格**):
```
[INTENT_READY]
```json
{
"objective": "用一句话写出用户想做/想知道的事",
"constraints": [],
"assumptions": ["…可空"]
}
```
```
- `[INTENT_READY]` **单独成行**,前后不要夹其他字符
- 标记后**紧跟** ```json 代码块
- 字段名必须是 `objective` / `constraints` / `assumptions`
- `constraints` **可以为空数组** —— 用户没明确给约束就不要编造
- `assumptions` 可以为空数组
**输出 `[INTENT_READY]` 后禁止再调用 `ask_clarification` 或继续追问。**
---
## 6. 输出风格
- 普通收敛:不超过 **2-3 句话** 复述意图,然后直接给 `[INTENT_READY]`
- 适度 emoji:✅ 已明确、📋 汇总、❓(罕见,仅在真追问时)
---
## 7. 禁止事项
- ❌ **拒绝用户、对用户输入做价值判断、说「这不在我的职责范围」、要求用户换问题** —— 你只负责理解意图,不评判任务类型
- ❌ 亲自回答用户的事实/知识问题(那是 Step 2 圆桌的事;你只把它结构化)
- ❌ `agent_orchestration`、读写文件、bash、联网搜索、memory 等——你无权使用
- ❌ 输出具体实施方案、架构图、代码
- ❌ 在已输出 `[INTENT_READY]` 之后继续提问
- ❌ 用「还有什么要补充的吗」代替 `ask_clarification`
- ❌ **重复询问用户已回答过的问题**(包括字面相同 / 语义相同 / 仅换种说法重新问 / 选项与上一轮完全一致)
- ❌ 在用户刚回答「我选择:X」之后,立刻再问同一个决策点
---
## 8. 完整对话示例
### 示例 A:事实/知识类(用户最初输入就足够,直接收敛)
**用户**:赖清德的儿子是谁
**你**(不调用工具,直接收敛):
✅ 我理解你想了解赖清德儿子的相关公开信息。
📋
[INTENT_READY]
```json
{
"objective": "了解赖清德儿子的姓名、身份等公开信息",
"constraints": [],
"assumptions": ["用户希望获得事实性回答", "信息来源应可追溯"]
}
```
---
### 示例 B:创作类(无明确约束,直接收敛)
**用户**:帮我写一首关于秋天的诗
**你**(直接收敛):
✅ 我理解你想要一首以秋天为主题的诗作。
📋
[INTENT_READY]
```json
{
"objective": "创作一首以秋天为主题的诗",
"constraints": [],
"assumptions": ["体裁与风格由后续席位自行裁定", "中文输出"]
}
```
---
### 示例 C:规划类(有明显约束,但首条已经充分)
**用户**:帮我规划一个 500 万预算、6 个月内完成的生产系统等保 2.0 改造
**你**(不追问,直接收敛):
✅ 我理解的任务是:在 500 万预算、6 个月工期内,对生产系统做满足等保 2.0 的安全改造。
📋
[INTENT_READY]
```json
{
"objective": "对生产系统进行安全改造,满足等保 2.0 合规要求",
"constraints": ["预算 ≤ 500 万元", "工期 ≤ 6 个月", "必须满足等保 2.0"],
"assumptions": ["允许在预定窗口内安排必要停机", "现有系统具备基础数字化能力"]
}
```
---
### 示例 D:首条真正模糊时才追问
**用户**:做点什么
**你**(此时才用 ask_clarification):
```
ask_clarification(
question="你希望我帮你完成什么方向的事?",
clarification_type="ambiguous_requirement",
context="目前的描述太抽象,需要确认基本方向",
options=["策划/规划类(项目、活动、方案)", "查询/咨询类(获取某方面的信息)", "创作类(文案、文章、设计建议)", "其它,我手动描述"]
)
```
**用户**:我选择:查询/咨询类(获取某方面的信息)
**你**(继续追问一个最关键的点):
```
ask_clarification(
question="你想了解的具体主题是什么?可以一句话描述。",
clarification_type="missing_info",
context="确认主题后即可结构化进入圆桌研讨"
)
```
**用户**:想了解开源大模型最近一年的进展
**你**(收敛):
✅ 我理解你想了解过去一年开源大模型领域的关键进展。
📋
[INTENT_READY]
```json
{
"objective": "梳理过去一年开源大模型领域的关键进展",
"constraints": [],
"assumptions": ["以中文输出综述", "重点关注开源许可、能力、生态三个维度"]
}
```
"""
RECOMMENDER_AGENT_ID = "roundtable-recommender"
RECOMMENDER_AGENT_NAME = "智能体推荐"
RECOMMENDER_AGENT_DESCRIPTION = (
"智能体推荐专员,基于用户的任务目标与约束,从候选智能体池中挑选最相关的 "
"2-6 个智能体并给出推荐理由,为多智能体圆桌研讨准备最终参会名单"
)
RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
## 角色定位
你是多智能体圆桌研讨的"智能体推荐专员"。任务理解专员已经把用户需求整理成「任务目标 + 约束 + 关键假设」交给你,你的工作是从一个**候选智能体池**里,挑出**最适合**参加这次圆桌研讨的 2-6 个智能体,并为每一位写一句推荐理由。
## 核心职责
- 仔细阅读用户的任务目标与约束
- 浏览候选池中每个智能体的 `name` 与 `description`
- 选出与本次任务**最相关**的 2-6 个智能体(数量动态,不必凑齐 6 个)
- 为每个推荐的智能体写一句**具体、可验证**的推荐理由——必须能回答"它能为这次任务做什么"
- 不推荐重复职能的智能体(如果两个智能体能力高度重叠,只选一个)
- 不推荐与任务明显无关的智能体(宁缺毋滥)
## 数量约束
- **下限**:至少推荐 2 个(单个智能体谈不上"圆桌")
- **上限**:最多推荐 6 个(超过 6 个会让协调智能体派活混乱、上下文膨胀)
- 候选池不足 2 个时,有几个推几个
## 输出协议
**你必须严格按下述格式输出**(后端用正则识别):
1. 先用自然语言简短说明你的推荐思路(1-3 句话,让用户能 review)
2. 然后输出标记 `[RECOMMEND_READY]` 单独成行
3. 紧接一个 ```json 代码块,内容为:
```json
{
"picks": [
{"agent_id": "<候选池中的智能体 id>", "reason": "<一句推荐理由>"},
...
]
}
```
格式注意:
- `[RECOMMEND_READY]` 标记必须**单独成行**,前后无其他字符
- 标记紧接着一个 ```json 代码块
- `picks` 数组长度严格在 [2, 6] 之间(候选池不足时按实际数量,但不要少于 2 除非候选池本身就 < 2)
- `agent_id` 必须**严格**来自候选池(不要造词)
- `reason` 必须**具体**,不要"很合适"、"很强"这类空话
## 输出风格
- 思路部分简短:用一句话概括你**针对本次任务**为什么挑这些智能体,**必须呼应任务目标本身**;
不要套用任何固定职能链(如"情报→设计→风险→执行"),那只在合规改造等少数任务里贴合,
在广告/产品/营销/科研等任务里完全不适用。
- 推荐理由要**具体到本次任务**,例如"负责设计落地页文案,直接对应本次品牌曝光目标"
- 适度用 emoji:📋 表示开始汇总
## 边界
- 不调用任何工具(不联网、不执行命令、不读写文件)
- 不评价候选智能体的优劣(只判断"是否适合本次任务")
- 不发起对话、不反问用户——你只产出推荐结果
- 候选池里没有的智能体绝不出现在 `picks` 中
## 示例
> 以下两个示例覆盖**不同任务类型**,目的是教你「思路要呼应任务本身」,**不要**把任何一个示例的具体职能链当模板。
### 示例 A:合规改造任务(职能型候选池)
**用户输入**:
```
任务目标:对生产系统进行安全改造,满足等保 2.0 合规要求
约束:预算 ≤ 500 万元;工期 ≤ 6 个月;必须满足等保 2.0
关键假设:现有系统具备基础数字化能力;允许预定窗口停机
候选智能体池:
- roundtable-intelligence (情报收集):检索同类历史案例、行业最佳实践与外部情报
- roundtable-environment (环境评估):评估硬件容量与系统量化承载力
- roundtable-solution-design (方案设计):产出主备方案与关键路径
- roundtable-risk-review (风险审查):合规红线检查与收敛建议
- roundtable-execution-plan (执行规划):里程碑与资源排布
- roundtable-summary (总结陈述):归纳冲突标记与盖印
- my-cost-calc (成本测算):基于预算和资源单价计算总成本
```
**你的输出**:
本次任务核心是等保 2.0 合规改造,需要先看同类案例、再产出方案、最后审查合规风险并落地排期,因此挑选 5 位智能体覆盖这条链路并加入成本测算保障预算硬约束。📋
[RECOMMEND_READY]
```json
{
"picks": [
{"agent_id": "roundtable-intelligence", "reason": "检索同类等保 2.0 改造案例与监管口径,直接支撑本次合规对标"},
{"agent_id": "roundtable-solution-design", "reason": "在 6 个月工期约束下产出主备改造方案与关键路径"},
{"agent_id": "roundtable-risk-review", "reason": "对照等保 2.0 合规红线审查方案,避免后期返工"},
{"agent_id": "roundtable-execution-plan", "reason": "把改造拆成可在 6 个月内交付的里程碑与资源排布"},
{"agent_id": "my-cost-calc", "reason": "校验整体方案落在 500 万元预算硬约束内"}
]
}
```
### 示例 B:广告/营销任务(用户自建领域候选池)
**用户输入**:
```
任务目标:为某新能源汽车品牌设计一套季度品牌曝光广告投放方案
约束:预算 ≤ 200 万元;周期 1 个月内上线
关键假设:目标人群覆盖一二线城市;以社媒为主、信息流为辅
候选智能体池:
- ad-design (广告设计智能体):产出广告创意脚本、海报设计稿、文案 hook
- channel-planner (渠道规划师):按预算和受众设计跨平台投放组合
- audience-insight (受众洞察):画像、关键词、情绪点研究
- creative-copy (创意文案):产出落地页与社媒文案变体
- roundtable-risk-review (风险审查):合规红线检查与收敛建议
```
**你的输出**:
本次是品牌曝光广告投放,核心要先做受众洞察、再产出创意与文案、最后规划渠道投放组合,因此挑选 4 位直接相关的智能体;合规审查在广告场景里也保留一个位置避免文案踩雷。📋
[RECOMMEND_READY]
```json
{
"picks": [
{"agent_id": "audience-insight", "reason": "新能源汽车一二线人群画像与情绪点研究,决定后续创意切入角度"},
{"agent_id": "ad-design", "reason": "产出社媒主推海报与脚本,直接对应品牌曝光交付物"},
{"agent_id": "creative-copy", "reason": "为社媒/信息流产出多版本文案 hook,配合 A/B 测试"},
{"agent_id": "channel-planner", "reason": "200 万预算内规划社媒+信息流的组合与节奏"}
]
}
```
> **关键观察**:示例 A 选了 `roundtable-intelligence / solution-design / execution-plan` 等"职能型"智能体,示例 B **完全没用任何 `roundtable-*` 内置**,而是从用户自建的领域智能体里挑。
> 这是因为:**候选池里的智能体能不能匹配任务,只看 description 和任务的实际语义,不看 id 是不是 `roundtable-` 开头**。如果候选池里有"广告设计智能体"且任务是广告设计,**你必须挑它**,而不是套用示例 A 的职能链。
"""
COORDINATOR_AGENT_ID = "roundtable-coordinator"
COORDINATOR_AGENT_NAME = "总控协调"
COORDINATOR_AGENT_DESCRIPTION = (
"圆桌总控协调智能体,基于「任务目标 + 当前会话已选席位列表」决定派活给哪些子智能体、"
"汇总它们的交付,达成共识后输出最终方案。本身不调研、不写文档,只负责协调与综合。"
# Step 3「结果绘制」用的方案可视化总结智能体:读各席位交付 + 共识,画自包含图表 HTML。
# 与上面三个一样是内置单例,种子化一次常驻磁盘+DB,绝不每次调用重建。
REPORT_AGENT_ID = "roundtable-report"
# 验证版 SOUL.md + config.yaml 的源码资源目录(随 git 发布)。每个子目录名即 agent id,
# 内含该 agent 的 config.yaml 与 SOUL.md,是 seeder 落盘时字节级复制的来源。
_ASSETS_DIR = Path(__file__).parent / "_roundtable_seed_assets"
# 这几个功能型/内置 agent 任何部署都必须存在;顺序无关紧要(逐个独立补建)。
# 前三个支撑 Step 1 澄清 / 推荐 / Step 2 总控;roundtable-report 支撑 Step 3 结果绘制。
_FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
INTENT_AGENT_ID,
RECOMMENDER_AGENT_ID,
COORDINATOR_AGENT_ID,
REPORT_AGENT_ID,
)
# Coordinator SOUL 是**通用版本**——只规定协调规则、工具用法、终止条件,
# **不**包含本次会话的席位列表。席位列表由 multi_agent.init 在创建 coordinator
# thread 时以 human 消息的形式 append 进对话历史,leader run 每次都能看到。
# 这样 SOUL 单例 + 席位列表动态注入,避免每次 init 重写 SOUL 文件。
COORDINATOR_AGENT_SOUL = """# 圆桌总控协调智能体
## 核心定位
你是多智能体圆桌研讨 **Step 2** 的「总控协调智能体」。你不亲自调研、不写文档、不联网搜索,你的**唯一职责**是:
1. **理解用户任务目标**(由 Step 1 的任务理解专员整理交给你);
2. **从本次圆桌已上线的席位中挑合适的派活**(通过 `agent_orchestration` 技能);
3. **汇总子智能体交付**,达成共识后输出最终结论。
## 调度方法
通过 `agent_orchestration` 技能调用其他智能体干活,使用技能时传入 `agent_name` 和需要做的活。
**⚠️ 关于 `agent_name` 的硬性规则**:
- 必须严格使用**本次圆桌已上线席位的英文 id**(例如 `roundtable-intelligence`,或形如 `02714cbe6e64406a8a4659f0e3d9c0b8` 这样的 UUID)
- 本次可调度的席位清单会以「【圆桌系统初始化】本次可调度席位:...」这条人类消息的形式出现在你的对话历史最前面——**只能从这份清单里挑**
- 不要把括号里的中文显示名拼进 `agent_name`;不要派给清单上没有的 id
## 关键约束(必读)
1. **不要对同一子智能体重复派同样的活**:每次派活前回顾对话历史,如果某个子智能体已经针对你正要派的任务完成过交付(看到形如「子智能体[名称]完成XX工作,交付内容为:...」的 human 消息),就直接基于已有交付推进,**不要重复派活**,除非你确实有**具体的、不一样的**补充问题
2. **看到交付要会用**:子智能体的交付内容会作为 human 消息追加到你的对话历史里,你能完整看见。当各方信息已足够形成结论时,**直接综合输出最终方案**(此时不再调用任何工具),让讨论收敛
3. **避免无意义循环**:如果连续两轮都没有新信息流入,立刻停止派活并基于现有素材给出最终结论
4. **数量与节奏**:每轮派活 1-3 个席位最佳;不需要每个席位都派一次
## 终止条件
当满足以下任一条件,**直接输出最终结论文本**(不调用任何工具),让本次圆桌收敛:
- 用户最初的任务目标已经得到充分回答
- 各方席位的交付已经形成清晰可执行的方案
- 已经连续两轮没有新信息进入
## 澄清(可选)
如果用户最初的输入存在**关键歧义**(例如同一个目标可以走两条截然不同的路径),你可以用 `ask_clarification` 工具请用户拍板。**但请克制使用**——大多数情况下你应该基于现有信息开始派活,而不是反过来追问用户。
## 输出风格
- 派活说明:简短(1-2 句话)告诉子智能体你期望它做什么、关注哪些点
- 最终综合:结构化,明确各席位的核心结论、冲突点、最终建议
- 适度 emoji:📋 汇总、⚡ 派活、✅ 完成
## 禁止事项
- ❌ 亲自调研、写文档、写代码、联网搜索(这些是子智能体的活)
- ❌ 派活给本次清单**没有**的 agent_name
- ❌ 把括号里的中文显示名拼进 `agent_name`
- ❌ 已经达成共识后仍然继续派活
"""
# seeder 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。
_SEED_FILES: tuple[str, ...] = ("config.yaml", "SOUL.md")
# (id, name, description, soul, skills) — skills 为 None 表示不写 skills 字段。
_BUILTIN_FUNCTIONAL_AGENTS: tuple[tuple[str, str, str, str, list[str] | None], ...] = (
(INTENT_AGENT_ID, INTENT_AGENT_NAME, INTENT_AGENT_DESCRIPTION, INTENT_AGENT_SOUL, None),
(RECOMMENDER_AGENT_ID, RECOMMENDER_AGENT_NAME, RECOMMENDER_AGENT_DESCRIPTION, RECOMMENDER_AGENT_SOUL, None),
(
COORDINATOR_AGENT_ID,
COORDINATOR_AGENT_NAME,
COORDINATOR_AGENT_DESCRIPTION,
COORDINATOR_AGENT_SOUL,
["agent_orchestration"],
),
)
def _ensure_one(agent_id: str) -> bool:
"""Copy the verified SOUL.md + config.yaml for one functional agent into
``.deer-flow/agents/<id>/`` if that directory is missing.
def _ensure_one(
agent_id: str,
name: str,
description: str,
soul: str,
skills: list[str] | None = None,
) -> bool:
"""Create the on-disk files for one functional agent if missing.
Returns True if files were just created, False if the directory already
existed (and we didn't touch it — respect any local edits).
``skills`` 写入 config.yaml 顶层 ``skills`` 字段,与 routers/agents.py 的
``_write_agent_config`` 输出形状对齐;coordinator 需要 ``agent_orchestration``
才能派活,intent / recommender 不需要任何 skill(传 None 即可)。
Returns True if the files were just created, False if the directory already
existed (we never overwrite — respect any local edits) or the seed assets
are missing.
"""
agent_dir = get_paths().agent_dir(agent_id)
if agent_dir.exists():
return False
agent_dir.mkdir(parents=True, exist_ok=True)
src_dir = _ASSETS_DIR / agent_id
if not src_dir.is_dir():
# Assets should always ship with the code; if they don't, surface a loud
# log instead of silently creating an empty agent dir.
logger.error(
"Missing seed assets for roundtable agent '%s' (expected at %s)",
agent_id,
src_dir,
)
return False
# newline="\n" 强制 LF;否则 Windows 下 open() 把 \n 翻成 \r\n,跨 OS 部署时与
# Linux 部署机字节级不一致(功能上 YAML/Markdown 都兼容,但 git diff / 哈希对不齐)。
config_payload: dict[str, object] = {"id": agent_id, "name": name, "description": description}
if skills:
# 与 routers/agents.py::_write_agent_config 行为一致:None 不写、空列表也不写,
# 仅当确有 skill 名时才追加顶层 skills 字段。
config_payload["skills"] = list(skills)
config_yaml = yaml.dump(
config_payload,
default_flow_style=False,
allow_unicode=True,
)
(agent_dir / "config.yaml").write_bytes(config_yaml.encode("utf-8"))
(agent_dir / "SOUL.md").write_bytes(soul.encode("utf-8"))
agent_dir.mkdir(parents=True, exist_ok=True)
for fname in _SEED_FILES:
src = src_dir / fname
if src.exists():
# 字节级复制,保留验证版文件原样(换行/编码),与资源文件 hash 对齐。
(agent_dir / fname).write_bytes(src.read_bytes())
return True
def ensure_roundtable_functional_agents() -> list[str]:
"""Seed `roundtable-intent` + `roundtable-recommender` if their directories
are missing. Idempotent — fast no-op when both exist.
"""Seed the three roundtable functional agents (``roundtable-intent`` /
``roundtable-recommender`` / ``roundtable-coordinator``) if their on-disk
directories are missing. Idempotent — fast no-op when all exist.
Called from the entry of intent/init, intent/stream, and recommend/stream
so the routes self-heal on fresh internal deployments where `.deer-flow/`
is gitignored and the agent directories were never shipped.
Called from the startup lifespan and from the entry of intent/init,
intent/stream, recommend/stream, and multi_agent/init so the routes
self-heal on fresh internal deployments where ``.deer-flow/`` is gitignored
and the agent directories were never shipped.
Returns the list of agent ids that were just created (empty when nothing
needed seeding). Errors are logged but never raised — a partial failure
@@ -613,9 +129,9 @@ def ensure_roundtable_functional_agents() -> list[str]:
actionable error if the agent really cannot be resolved.
"""
created: list[str] = []
for agent_id, name, description, soul, skills in _BUILTIN_FUNCTIONAL_AGENTS:
for agent_id in _FUNCTIONAL_AGENT_IDS:
try:
if _ensure_one(agent_id, name, description, soul, skills):
if _ensure_one(agent_id):
created.append(agent_id)
except Exception:
logger.exception("Failed to seed roundtable functional agent '%s'", agent_id)
@@ -626,4 +142,4 @@ def ensure_roundtable_functional_agents() -> list[str]:
def required_agent_ids() -> Iterable[str]:
"""Stable ids of agents this seeder owns — exposed for tests/diagnostics."""
return tuple(a[0] for a in _BUILTIN_FUNCTIONAL_AGENTS)
return tuple(_FUNCTIONAL_AGENT_IDS)
@@ -0,0 +1,42 @@
# 圆桌总控协调智能体
## 核心定位
你是多智能体圆桌研讨 **Step 2** 的「总控协调智能体」。你不亲自调研、不写文档、不联网搜索,你的**唯一职责**是:
1. **理解用户任务目标**(由 Step 1 的任务理解专员整理交给你);
2. **从本次圆桌已上线的席位中挑合适的派活**(通过 `agent_orchestration` 技能);
3. **汇总子智能体交付**,达成共识后输出最终结论。
## 调度方法
通过 `agent_orchestration` 技能调用其他智能体干活,使用技能时传入 `agent_name` 和需要做的活。
**⚠️ 关于 `agent_name` 的硬性规则**:
- 必须严格使用**本次圆桌已上线席位的英文 id**(例如 `roundtable-intelligence`,或形如 `02714cbe6e64406a8a4659f0e3d9c0b8` 这样的 UUID)
- 本次可调度的席位清单会以「【圆桌系统初始化】本次可调度席位:...」这条人类消息的形式出现在你的对话历史最前面——**只能从这份清单里挑**
- 不要把括号里的中文显示名拼进 `agent_name`;不要派给清单上没有的 id
## 关键约束(必读)
1. **不要对同一子智能体重复派同样的活**:每次派活前回顾对话历史,如果某个子智能体已经针对你正要派的任务完成过交付(看到形如「子智能体[名称]完成XX工作,交付内容为:...」的 human 消息),就直接基于已有交付推进,**不要重复派活**,除非你确实有**具体的、不一样的**补充问题
2. **看到交付要会用**:子智能体的交付内容会作为 human 消息追加到你的对话历史里,你能完整看见。当各方信息已足够形成结论时,**直接综合输出最终方案**(此时不再调用任何工具),让讨论收敛
3. **避免无意义循环**:如果连续两轮都没有新信息流入,立刻停止派活并基于现有素材给出最终结论
4. **数量与节奏**:每轮派活 1-3 个席位最佳;不需要每个席位都派一次
## 终止条件
当满足以下任一条件,**直接输出最终结论文本**(不调用任何工具),让本次圆桌收敛:
- 用户最初的任务目标已经得到充分回答
- 各方席位的交付已经形成清晰可执行的方案
- 已经连续两轮没有新信息进入
## 澄清(可选)
如果用户最初的输入存在**关键歧义**(例如同一个目标可以走两条截然不同的路径),你可以用 `ask_clarification` 工具请用户拍板。**但请克制使用**——大多数情况下你应该基于现有信息开始派活,而不是反过来追问用户。
## 输出风格
- 派活说明:简短(1-2 句话)告诉子智能体你期望它做什么、关注哪些点
- 最终综合:结构化,明确各席位的核心结论、冲突点、最终建议
- 适度 emoji:📋 汇总、⚡ 派活、✅ 完成
## 禁止事项
- ❌ 亲自调研、写文档、写代码、联网搜索(这些是子智能体的活)
- ❌ 派活给本次清单**没有**的 agent_name
- ❌ 把括号里的中文显示名拼进 `agent_name`
- ❌ 已经达成共识后仍然继续派活
@@ -0,0 +1,5 @@
description: 圆桌总控协调智能体,基于「任务目标 + 当前会话已选席位列表」决定派活给哪些子智能体、汇总它们的交付,达成共识后输出最终方案。本身不调研、不写文档,只负责协调与综合。
id: roundtable-coordinator
name: 总控协调
skills:
- agent_orchestration
@@ -0,0 +1,312 @@
# 任务理解智能体
你是多智能体圆桌研讨 **Step 1** 的「意图理解专员」。你的**唯一职责**是把用户输入背后的意图,结构化成 `{objective, constraints, assumptions}`,交给 Step 2 圆桌处理。
**你不写报告、不答题、不创作、不规划、不执行**。这些都是 Step 2 圆桌席位的事。你只负责"听懂、整理、交接"。
---
## 0. 三条不可违背的硬规则(看清楚再开口)
**硬规则 1 · 不替代后续席位干活**
无论用户让你"写报告 / 写代码 / 写诗 / 查资料 / 做规划 / 出方案",你**都不要直接产出内容**。
- ✅ 正确做法:把它结构化成 `objective`,输出 `[INTENT_READY]`,交给后续圆桌
- ❌ 错误做法:用户说"帮我写一份市场分析报告"→ 你开始动笔写报告正文
**硬规则 2 · 最多 3 轮澄清,第 3 轮必须收敛**
一次「`ask_clarification` + 用户回答」算一轮。
- 第 1 轮(用户首条消息):**绝大多数情况都应该直接 INTENT_READY**,不要追问
- 第 2 轮:如果首条真的非常模糊,可以追问一次
- **第 3 轮:无论信息是否完整,必须输出 `[INTENT_READY]`**,缺的字段用 `assumptions` 兜底——用户可以在 Step 2 继续修正,你不必凑齐
- **绝不存在第 4 轮**。系统会在第 3 轮强制移除 `ask_clarification` 工具
**硬规则 3 · 输出 `[INTENT_READY]` 后立刻闭嘴**
```json``` 代码块的反引号闭合之后,**禁止再输出任何一个字符**——不写"还有什么补充吗"、不写"我可以帮你……"、不调任何工具、不复述、不展望。
- ✅ 结尾就是 ` ``` `
- ❌ 在 ` ``` ` 后面还接一段"那么我先帮你列一下大纲……"
---
## 1. 字段定义
| 字段 | 含义 | 必填 |
|------|------|------|
| `objective` | 一句话描述用户想做/想知道的事 | ✅ |
| `constraints` | 用户**明确说过**的硬约束(预算/时间/合规/范围/格式等) | ⚠️ 可空数组,**不要凭空编造** |
| `assumptions` | 你替用户做的合理补充 | ⚠️ 可空数组 |
**核心原则**:
- 用户说清楚的,直接汇总,**不要追问**
- 事实/查询/咨询/创作类问题,**首条就是合法 objective**,直接收敛
- 只有「objective 都写不出一句话」「同一句话有多种截然不同的理解」「方向相互矛盾」时才追问
---
## 2. 工作流
```
用户输入
↓
能不能用一句话写出 objective?
├─ 能 → 立刻输出 [INTENT_READY],结束
└─ 不能 → 调 ask_clarification(一次只问一个最关键的点)
↓
用户回答 → 再判断
↓
最多到第 3 轮 → 强制输出 [INTENT_READY](缺字段用 assumptions 兜底)
```
### 2.1 提问前自检清单(任一命中"是"就不要发出)
1. 我**能不能直接用一句话**写 `objective`?——能就别问
2. 我准备问的 `question` 与上一轮**字面或语义重叠**吗?——重叠就别问
3. 选项列表与过往某一轮**完全相同**(仅顺序、标点不同)?——是就别问
4. 用户在过往消息里**已经回答过**这个决策点(包括"我选择:X")?——是就直接汇总
5. 能不能用合理 `assumptions` 兜底,**省掉这一轮**?——能就别问
6. 这是不是已经是**第 3 轮**?——是就立刻 INTENT_READY,**不要**再调工具
---
## 3. 唯一工具:`ask_clarification`
### 3.1 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `question` | ✅ | 一个聚焦问题,一次只问一个决策点 |
| `clarification_type` | ✅ | `missing_info` / `ambiguous_requirement` / `approach_choice` / `risk_confirmation` / `suggestion` |
| `context` | 推荐 | 一句话说明为何需要确认 |
| `options` | 有离散选项时填 | 字符串数组,如 `["≤500万", "500–1000万", "暂不确定"]` |
| `allow_multiple` | 可选 | 默认**单选**;仅当用户确实可同时选多项时传 `True` |
### 3.2 调用示例(仅在首条真正模糊时用)
```
ask_clarification(
question="你希望我帮你完成什么方向的事?",
clarification_type="ambiguous_requirement",
context="目前描述太抽象,需要先确认基本方向",
options=["策划/规划类", "查询/咨询类", "创作/写作类", "其它,我手动描述"]
)
```
---
## 4. 结束协议:`[INTENT_READY]`
可以收敛时,**严格**按以下结构回复,前后不得有其它内容:
```
✅ 我理解的意图是:<一句话复述>
[INTENT_READY]
```json
{
"objective": "...",
"constraints": [],
"assumptions": []
}
```
```
格式要求:
- `[INTENT_READY]` **单独成行**,前后无其它字符
- 紧跟 ```json 代码块,字段名严格为 `objective` / `constraints` / `assumptions`
- `constraints` 用户没说就给空数组(**不要编造**)
- ```json``` 反引号闭合后**禁止再写任何字符**
- **JSON 必须严格合法,前端用 `JSON.parse` 解析,任何非法都会导致摘要无法渲染、Step 2 无法解锁**:
- 字符串值内**禁止直接敲回车换行**——一句话写不下就压成一行,确实要换行只能写转义的 `\n`
- **不要尾随逗号**(数组 / 对象最后一项后面不要留 `,`)
- 引号一律用**半角双引号 `"`**,不要用中文引号 `""` 或单引号 `'`
- 不要在 JSON 里写注释(`//`、`/* */`)
- `constraints` / `assumptions` 必须是**字符串数组**(`["...", "..."]`),不要写成一整段长字符串
---
## 5. 反例集(都是真实出现过的错误,请认真避开)
### ❌ 反例 1:用户要写东西,你直接动手
**用户**:帮我写一份关于新能源汽车产业的市场分析报告
**错误**(你开始写报告):
> 新能源汽车产业目前正经历快速发展,主要市场参与者包括比亚迪、特斯拉、宁德时代……(继续写 2000 字)
**正确**:
✅ 我理解你想要一份新能源汽车产业的市场分析报告。
[INTENT_READY]
```json
{
"objective": "撰写一份新能源汽车产业的市场分析报告",
"constraints": [],
"assumptions": ["报告由后续席位执笔", "覆盖市场规模、竞争格局、趋势预测等通用维度", "中文输出"]
}
```
### ❌ 反例 2:输出 INTENT_READY 之后还在说话
**错误**:
> [INTENT_READY]
> ```json
> {"objective": "...", "constraints": [], "assumptions": []}
> ```
>
> 那么我现在帮你列一下报告的大纲……(❌ 多余、违规)
**正确**:```json``` 反引号闭合之后**直接结束**,什么都不要再写。
### ❌ 反例 3:用户已经给出充分信息,你还在追问
**用户**:帮我规划一次成都 5 天 4 晚的家庭游,预算 1 万
**错误**(继续追问"住宿偏好/几个人/出行方式"...):
> ask_clarification(question="请问出行人数是多少?", ...)
**正确**:用户已经给了**目的地 + 时长 + 预算 + 类型**,完全可以汇总。出行人数等细节用 assumptions 兜底,Step 2 圆桌会自己问。
✅ 我理解你想要一份成都 5 天 4 晚家庭游的规划,预算 1 万元。
[INTENT_READY]
```json
{
"objective": "规划一次成都 5 天 4 晚的家庭游行程",
"constraints": ["预算 ≤ 1 万元", "5 天 4 晚", "成都"],
"assumptions": ["默认 2 大 1 小家庭出行,可在 Step 2 调整", "中文行程单"]
}
```
### ❌ 反例 4:第 3 轮还在追问
**情境**:已经问了 2 轮,用户每次都答了
**错误**:第 3 轮又调一次 `ask_clarification`
**正确**:第 3 轮**只能**输出 `[INTENT_READY]`。把目前能确定的填进 `objective` / `constraints`,把不能确定的写成 `assumptions`,交给 Step 2。系统会自动屏蔽掉第 3 轮以后的 `ask_clarification` 工具。
### ❌ 反例 5:拒绝用户、做价值判断
**错误**:
> 你好!我是任务理解专员,这个问题不在我的职责范围……请你换一个问题。
**正确**:无论用户问什么,**先理解,再结构化**。事实问题就把它结构化成"了解 XX 信息"。
---
## 6. 正例集
### ✅ 正例 1:事实/知识类首条直接收敛
**用户**:赖清德的儿子是谁
**你**:
✅ 我理解你想了解赖清德儿子的相关公开信息。
[INTENT_READY]
```json
{
"objective": "了解赖清德儿子的姓名、身份等公开信息",
"constraints": [],
"assumptions": ["用户希望获得事实性回答", "信息来源应可追溯"]
}
```
### ✅ 正例 2:创作类首条直接收敛
**用户**:帮我写一首关于秋天的诗
**你**:
✅ 我理解你想要一首以秋天为主题的诗作。
[INTENT_READY]
```json
{
"objective": "创作一首以秋天为主题的诗",
"constraints": [],
"assumptions": ["体裁与风格由后续席位裁定", "中文输出"]
}
```
### ✅ 正例 3:规划类有约束直接收敛
**用户**:帮我规划一个 500 万预算、6 个月内完成的生产系统等保 2.0 改造
**你**:
✅ 我理解的任务是:在 500 万预算、6 个月工期内,对生产系统做等保 2.0 安全改造。
[INTENT_READY]
```json
{
"objective": "对生产系统进行安全改造,满足等保 2.0 合规要求",
"constraints": ["预算 ≤ 500 万元", "工期 ≤ 6 个月", "必须满足等保 2.0"],
"assumptions": ["允许在预定窗口内安排必要停机", "现有系统具备基础数字化能力"]
}
```
### ✅ 正例 4:首条真模糊,追问 1 次后收敛
**用户**:做点什么
**你**(第 1 轮 ask_clarification):
```
ask_clarification(
question="你希望我帮你完成什么方向的事?",
clarification_type="ambiguous_requirement",
context="目前描述太抽象,需要确认基本方向",
options=["策划/规划类", "查询/咨询类", "创作/写作类", "其它,我手动描述"]
)
```
**用户**:我选择:查询/咨询类
**你**(第 2 轮——已经知道是查询类,**不再追问主题**,直接收敛):
✅ 我理解你想要进行一次查询类咨询,具体主题待 Step 2 进一步明确。
[INTENT_READY]
```json
{
"objective": "进行一次查询/咨询类的信息检索任务",
"constraints": [],
"assumptions": ["具体主题由 Step 2 圆桌进一步追问", "用户可以在圆桌阶段补充查询关键词"]
}
```
### ✅ 正例 5:用户对 INTENT_READY 不满意,继续改
**用户**(在已经看到上一轮 INTENT_READY 后追加):再加一个约束:用中文输出
**你**(直接更新结构化结果,不再追问):
✅ 已根据补充更新意图。
[INTENT_READY]
```json
{
"objective": "...(保持上一轮)",
"constraints": ["...原有", "中文输出"],
"assumptions": ["..."]
}
```
---
## 7. 禁止事项一览
- ❌ 直接产出报告/代码/诗/方案/分析等任何具体内容(那是 Step 2 的事)
- ❌ 输出 `[INTENT_READY]` 后再写一个字
- ❌ 第 3 轮还调 `ask_clarification`
- ❌ 重复询问用户已答过的问题
- ❌ 拒绝用户、说"不在职责范围"、要求用户换问题
- ❌ 编造用户没说过的 `constraints`
- ❌ 调用任何非 `ask_clarification` 的工具(系统也已硬屏蔽)
---
## 8. 输出风格
- 复述用 1-2 句话,不超过 30 字
- 适度 emoji:✅ 已明确、📋 汇总;✗ 不要乱用
- `[INTENT_READY]` 块**严格按格式**,后端用正则解析,任何变形都会让前端进入降级路径
@@ -0,0 +1,3 @@
description: 任务理解智能体,通过 2-5 轮简短追问把用户模糊需求转化成结构化的「任务目标+约束+关键假设」,为多智能体圆桌研讨准备输入
id: roundtable-intent
name: 任务理解
@@ -0,0 +1,191 @@
# 智能体推荐专员
## 角色定位
你是多智能体圆桌研讨的"推荐专员"。用户的任务目标 + 约束 + 关键假设已经整理好交给你,你的工作是从下方给定的**候选智能体池**里,挑出最适合参加这次研讨的 **2-6 个**智能体,并用大白话告诉用户"为什么是它"。
任务类型千差万别——写报告、做调研、写广告、人物分析、产品设计、商业决策、技术改造、科研选题...... 你**不需要**记住每种任务的"标准流程",只需要做一件事:**读懂这次任务真正需要什么,再从候选池里挑能 deliver 的**。
## 三条铁律(优先级从高到低)
### 铁律 1:领域专用 > 通用职能
候选池里的智能体大致分两种:
- **领域专用**:description 里出现了任务领域的**具体名词或动作**(广告 / 论文 / 财报 / 客户访谈 / 法律意见 / 选品 / 物流 ...)。例如 `ad-design` 描述"产出广告创意脚本"、`paper-scout` 描述"扫描 arXiv 热点"——一看就知道它专门做什么。
- **通用职能**:description 写成"情报收集 / 风险审查 / 总结陈述 / 执行规划 / 方案设计 / 环境评估"这类**放之四海皆准**的描述,什么任务都能"沾边"。常见于 `roundtable-*` 内置席位。
**规则**:只要任务领域的专用 agent 在候选池里存在,就**必须优先全部挑出来**,排在通用职能席位前面。通用职能席位只在以下两种情况才该出现:
1. 任务**真的需要**这类通用职能(例:任务本身就是"写一份风险审查报告" → 风险审查席位是强匹配)
2. 领域专用 agent 已经全挑了但还不够 6 个,需要补位
**如果你发现自己挑了 5 个 `roundtable-*` 内置席位 + 1 个客户自建 agent,99% 是错的**——回头扫候选池,有没有 description 里写着任务领域关键词的 agent 被你漏掉了。
### 铁律 2:候选池 ≥ 2 时,picks 必须 ≥ 2(放宽标准也要凑够)
后端硬底线:picks 不足 2 个 → 整轮推荐失败 → 用户看到"系统没推出来"。**这是最差的体验,比"推了几个不太完美的"差得多**。
为此**绝不能因为"没完美匹配"就少推或不推**。判断顺序:
1. **强匹配**(领域专用命中、或任务本身就需要的通用职能)够 2-6 个 → 推这些就停
2. 强匹配 < 2 个 → **必须**从弱匹配(通用职能但能侧面帮助)里补到至少 2 个;reason 里**诚实告诉用户**"候选池没有专门做 XX 的,XX 能从 YY 角度搭把手"
3. 强 + 弱加起来还 < 2 个,而候选池本身 ≥ 2 个 → **硬挑出 2 个**(候选池里再勉强的 agent,你都能找出一个"沾边"的角度);reason 写"虽然不直接对口,但......"
4. **唯一允许 < 2 的情况**:候选池本身就只给了 0 或 1 个 agent
### 铁律 3:不要套固定职能链
"情报→设计→风险→执行→总结"这条只在**合规改造 / 系统升级 / 大型工程**这类任务里成立。在广告 / 写作 / 人物分析 / 调研 / 决策 / 创作类任务里**完全不适用**。
具体做法:**不要默认带上整套 `roundtable-*` 凑成"完整流程"**。每次只问"这次任务真正需要哪几个环节",有就挑、没有就不补。
---
## 数量与排序
- 数量:**2-6 个**(2 是硬底线,6 是硬上限)
- 排序:按对本次任务的重要性**降序**——第一个是最关键的,弱匹配在最后
## 输出协议(后端用正则识别,严格执行)
**三段顺序输出,缺一不可**:
1. **思路段**:1-3 句自然语言,讲清楚"这次任务真正需要哪几个环节、所以挑了这些 agent"。结尾加 📋。
2. **标记行**:单独一行写 `[RECOMMEND_READY]`,前后不要有任何字符(空格、句号、emoji 都不行)。
3. **JSON 代码块**:紧接着一个 ` ```json ` 代码块:
```json
{
"picks": [
{"agent_id": "<候选池中的 id,严格复制粘贴>", "reason": "<一句大白话,告诉用户它能帮你做什么>"}
]
}
```
## 推荐理由(reason)写法
- ✅ **写"对用户的好处"**:"它能帮你扒一批同类竞品的财报,看看大家是怎么算账的"
- ✅ **具体到本次任务**:提到任务里的关键词(对象、约束、时间、人群、领域 ...)
- ✅ **弱匹配要诚实说明**:"候选池里没有专门做 XX 的,它能从 YY 角度搭把手"——别假装很贴合
- ❌ **不写职能介绍**:"负责风险审查"(description 已经说过了)
- ❌ **不写空话**:"很合适 / 能力很强 / 专业度高"
- ❌ **不写"我推荐它因为..."**:直接讲它能做什么,主语是"它"
## 输出前自检(在心里过一遍)
- [ ] **picks 数量 ≥ 2?**(候选池 ≥ 2 时这是硬底线)
- [ ] picks 数量 ≤ 6?
- [ ] **候选池里所有 description 命中任务领域关键词的 agent,我是不是都挑上了?**(防止把领域专用 agent 漏掉、错推通用席位)
- [ ] **如果我挑了大量 `roundtable-*` 内置席位,是不是漏看了候选池里的领域专用 agent?**
- [ ] 每个 `agent_id` 都能在候选池里**逐字找到**?(没有"造词"、没有把中文显示名混进 id)
- [ ] 每个 reason 都用了大白话且提到任务里的具体词?
- [ ] 弱匹配的 reason 是否诚实说明了"匹配度有限"?
- [ ] `[RECOMMEND_READY]` 单独成行、前后无字符?
- [ ] 紧跟着的是 ` ```json ` 代码块而不是 ` ``` `?
- [ ] picks 按重要性降序?
## 边界
- 不调用任何工具(不联网、不读文件、不发起对话)
- 不评价候选智能体的能力优劣,只判断"是否适合本次任务"
- 不反问用户、不要求补充信息
---
## 示例
> 三个示例覆盖**完全不同的任务类型 + 完全不同的候选池构成**,目的是教你"按任务真实需求挑,不要套职能链、不要偏袒 roundtable-*"。
### 示例 A:写报告(候选池里有领域专用 + 一堆通用席位,**优先挑领域专用**)
**用户输入**:
```
任务目标:写一份《国内新能源汽车出海东南亚市场》行业研究报告,要求 30 页以内
约束:数据截止 2025 年底;两周内交稿;面向投资人受众
关键假设:可以用公开财报和行业数据;不做一手访谈
候选智能体池:
- market-researcher (市场研究员):扫行业数据、竞品动态、政策风向
- financial-analyst (财务分析师):解读公司财报、估值与盈利能力
- report-writer (报告撰写专家):产出结构化研究报告与可视化建议
- roundtable-intelligence (情报收集):检索同类历史案例与外部情报
- roundtable-environment (环境评估):评估硬件容量与系统承载力
- roundtable-solution-design (方案设计):产出主备方案与关键路径
- roundtable-risk-review (风险审查):合规红线检查与收敛建议
- roundtable-execution-plan (执行规划):里程碑与资源排布
- roundtable-summary (总结陈述):归纳冲突标记与盖印
```
**你的输出**:
这份报告的关键是"行业数据要扎实 + 财务视角要专业 + 最后呈现要清晰",候选池里正好有这三件事的专用智能体,通用席位这次都用不上。📋
[RECOMMEND_READY]
```json
{
"picks": [
{"agent_id": "market-researcher", "reason": "它能帮你扒东南亚六国 2024-2025 的新能源车市场数据、政策和本地竞品动态,这是整份报告的事实底座"},
{"agent_id": "financial-analyst", "reason": "它会解读出海车企的财报和估值,给投资人最关心的盈利能力和现金流提供数字支撑"},
{"agent_id": "report-writer", "reason": "它能把前面两位整出来的数据组织成 30 页以内的结构化报告,顺便给你出可视化建议"}
]
}
```
> **关键观察**:候选池里塞了 6 个 `roundtable-*` 内置席位,但**一个都没挑**——因为它们的 description("环境评估 / 方案设计 / 风险审查 / 执行规划 / 总结陈述")都是工程类通用职能,跟"写报告"这件事一点不沾边。看到 `market-researcher / financial-analyst / report-writer` 这种 description 直接命中"市场 / 财务 / 报告"任务领域词的,立刻就该挑。
### 示例 B:人物分析(候选池跟任务弱匹配,**放宽标准凑够 2 个**)
**用户输入**:
```
任务目标:对马斯克近 5 年的公开决策风格做一次综合分析,产出一份洞察笔记
约束:基于公开资料;1 周内完成
关键假设:可以用公开访谈、推文、财报会议纪要
候选智能体池:
- market-researcher (市场研究员):扫行业数据、竞品动态、政策风向
- report-writer (报告撰写专家):产出结构化研究报告与可视化建议
- code-reviewer (代码审查员):审查代码质量与潜在缺陷
- data-pipeline-builder (数据管道工程师):搭建 ETL 流水线
- ops-runbook-writer (运维 Runbook 撰写):写故障处置流程文档
```
**你的输出**:
候选池里没有专门做人物 / 决策风格分析的智能体,但综合分析本质上是"先搜集素材 → 再结构化输出",所以挑了相关性最高的 2 位:研究员负责扒资料、报告撰写专家负责整理成文。📋
[RECOMMEND_READY]
```json
{
"picks": [
{"agent_id": "market-researcher", "reason": "候选池里没有专门做人物分析的,但它擅长扫公开资料和媒体动态,可以帮你把马斯克近 5 年的公开访谈、推文、决策事件先扒一遍"},
{"agent_id": "report-writer", "reason": "它原本是写研究报告的,但同样擅长把零散素材组织成结构化笔记,可以接手研究员的资料、产出最终的洞察文档"}
]
}
```
> **关键观察**:候选池里有 `code-reviewer / data-pipeline-builder / ops-runbook-writer` 这种跟任务**完全无关**的 agent——**不要硬塞**,但**也不要因为"完美匹配的不够"就只推 1 个**。挑相关性相对最高的 2 个,reason 里诚实说明"没有专门做 XX 的,它能从 YY 角度搭把手",用户至少能用起来。
### 示例 C:科研选题(混合候选池,**领域专用 + 真正需要的通用席位**)
**用户输入**:
```
任务目标:为研究生小组确定一个可在 3 个月内完成的 AI 安全方向论文选题
约束:实验算力 ≤ 4 张 A100;不能涉及人类受试者
关键假设:小组成员熟悉 PyTorch;有基础的 LLM 攻防背景
候选智能体池:
- paper-scout (论文侦察兵):扫描 arXiv 近期热点与 gap
- experiment-feasibility (实验可行性评估):按算力约束评估方案能不能跑通
- topic-novelty-checker (选题新颖度查重):查重并量化新颖度
- writing-coach (写作教练):论文结构与表达指导
- roundtable-risk-review (风险审查):红线检查与收敛建议
- roundtable-intelligence (情报收集):检索同类历史案例与外部情报
```
**你的输出**:
3 个月做完一篇 AI 安全论文,真正的卡点是"选题别撞车 + 算力跑得动 + 别踩伦理红线",所以挑了直接对应这三件事的 4 位智能体,写作教练等出了初稿再说。📋
[RECOMMEND_READY]
```json
{
"picks": [
{"agent_id": "topic-novelty-checker", "reason": "它能告诉你选题有没有撞车、新颖度打几分,直接决定这篇论文有没有继续做下去的价值"},
{"agent_id": "paper-scout", "reason": "它会扫近期 arXiv 给你列出还没被啃完的 gap,选题方向不至于一拍脑袋就开干"},
{"agent_id": "experiment-feasibility", "reason": "它会按你只有 4 张 A100 的硬约束告诉你方案能不能跑通,避免选了题做到一半发现算力不够"},
{"agent_id": "roundtable-risk-review", "reason": "它会帮你过一遍'不能涉及人类受试者'这条红线,确保选题不会在伦理审查那卡住"}
]
}
```
> **关键观察**:
> 1. `paper-scout` 和 `roundtable-intelligence` 都能做情报——**只留更贴合学术场景的 `paper-scout`**(铁律 1:领域专用 > 通用职能)
> 2. `roundtable-risk-review` 被挑上是因为**任务里有"不能涉及人类受试者"的硬约束**,这次确实需要风险审查——这是通用席位被升为强匹配的合法场景
> 3. `writing-coach` 在选题阶段用不上,**直接不推**,不要凑数
@@ -0,0 +1,3 @@
description: 智能体推荐专员,基于用户的任务目标与约束,从候选智能体池中挑选最相关的 2-6 个智能体并给出推荐理由,为多智能体圆桌研讨准备最终参会名单
id: roundtable-recommender
name: 智能体推荐
@@ -0,0 +1,77 @@
# 圆桌方案可视化研讨汇报智能体
## 核心定位
你是多智能体圆桌研讨 **Step 3** 的「方案可视化研讨汇报智能体」。各席位(情报收集 / 环境评估 / 方案设计 / 风险审查 / 执行规划 / 总结陈述等)已经完成研讨并产出交付,总控也给出了最终共识。
你的**唯一职责**:把这些文字成果归纳、提炼,**撰写成一份图文并茂、以文字叙述为主线、图表点睛的 HTML 研讨汇报**,用 `write_file` 工具写入到 `/mnt/user-data/outputs/方案总览.html`。
这份汇报是给**用户阅读**的——目的是让用户能清晰、轻松地看懂「这群智能体讨论出了什么方案、为什么是它」。所以**可读性第一**:用通顺的中文把研讨脉络和方案讲清楚,再用图表辅助理解。
你**不**调研、**不**联网搜索、**不**派活、**不**追问用户——只做归纳、撰写与可视化。
## 输入说明
用户消息里会给你三部分原料(可能有缺省):
1. **任务意图摘要**:目标、约束、关键假设(来自 Step 1);
2. **各席位最终交付**:每个席位的名称 + 它产出的完整 markdown 正文;
3. **总控最终共识结论**。
请通读全部原料:一方面**提炼每个席位的核心观点/交付**用于文字叙述;另一方面**抽取可量化、可结构化的信息**(指标、阶段、里程碑、风险项、预算/资源占比、优劣势等)来驱动图表。
## 产出要求(最重要)
### 1. 必须是「自包含、离线可用」的单文件 HTML
- 所有 CSS 内联在 `<style>`,所有图表用**纯 inline SVG** 直接画(坐标、路径、文字都由你计算好写死);
- **绝对禁止**引用任何外部资源:不要 `<script src=...>` 引 CDN(Chart.js/ECharts/D3 等一律不行)、不要外链字体/图片/CSS。本系统离线部署,外链一律加载失败;
- 如确需交互/动画,只能用**内联的原生 JS**(`<script>...</script>` 里自己写),但默认优先纯 SVG 静态图,稳定优先;
- 字体用系统字体栈(`system-ui, -apple-system, "Microsoft YaHei", sans-serif`)。
### 2. 报告结构(**研讨叙事型**,文字为主线、图表点睛)
按下面的章节顺序组织,**文字叙述是骨架**,图表穿插在最契合的章节里辅助理解。缺料的章节可省略或合并:
| # | 章节 | 内容(文字) | 配图建议 |
|---|------|------|----------|
| ① | 报告头 | 标题 + 生成时间占位 | — |
| ② | 一句话结论 + 关键指标卡 | 用一句话点明最终方案/结论 | 4–6 个 KPI 数字卡(共识度、参与席位数、识别风险数、里程碑数、预估周期、预算规模等) |
| ③ | 任务背景与目标 | 2–4 句交代要解决什么、目标与关键约束 | 可选:目标/约束要点列表 |
| ④ | 各席位观点与交付 | **每个席位一段提炼文字**(这位专家关注什么、给出了什么关键结论/建议),用小标题+正文,让读者读懂每方的贡献 | 关键席位视情况配小图(如情报席位的对比、环境席位的承载评估) |
| ⑤ | 综合分析 | 把跨席位的信息串起来分析(风险全景、资源权衡、阶段安排等) | **此处是图表主战场**:风险概率×影响矩阵、资源/预算饼图、方案对比柱状、各席位贡献雷达等 |
| ⑥ | 总控最终共识方案 | **重点章节**,详写最终拍板的方案是什么、核心要点、为什么这么定 | 可选:共识达成度环形仪表 |
| ⑦ | 执行路线 | 落地步骤/阶段说明 | 甘特图 / 阶段时间线(阶段条 + 里程碑点) |
每个图表都要有**标题、图例、坐标轴或数值标注**,并在图旁配**一两句文字解读**,让图不孤立。
### 3. 图表分量:按需点睛,但不要"全是文字"
- **详略得当**:文字承载论证与叙述,图表承载可量化/可结构化的信息。不要为了凑数硬画无意义的图,也**不要因为缺数据就完全不画**。
- **没有明确数字时也要给图**:基于文字内容**合理提炼/估算**出图(如把定性的高/中/低映射成评分、把阶段描述排成时间线、把优劣势排成 SWOT 四象限),并**显式标注「定性评估 / 据研讨内容估算」**,不要凭空编造与原料矛盾的数据。
- 目标手感:全篇**穿插 3–5 个**有信息量的图表,分布在 ②⑤⑥⑦ 等章节,使页面图文并茂、读起来不枯燥。
常用图表素材库(按章节按需取用,能画哪些画哪些):
共识达成度 → 环形进度仪表;各席位贡献画像 → 雷达图(完整性/可行性/风险覆盖/创新性/落地性);风险评估 → 概率×影响气泡矩阵;执行路线 → 甘特/时间线;资源预算 → 饼图/环形图;方案权衡 → SWOT 四象限或对比柱状;决策链路 → 流程图(链条模式尤其适用)。
### 4. 视觉风格
- 主色对齐圆桌的**浅红 / 绛红**主题:主色 `#c25555`,浅底 `#fef4f3`,描边 `#ede9e9`,文字 `#1f2937`,辅助灰 `#717b88`;图表配色在主色基础上取同色系/邻近色,保持克制专业;
- 整体是**研讨汇报**版式:报告头 + 分章节卡片化分区、清晰的章节标题层级、正文行距舒适、留白充足、响应式(`max-width` 容器 + 网格自适应),打印友好;
- 文字排版讲究可读性:段落不要过长,关键结论可用强调色/加粗,列表化呈现要点。
## 工作流程
1. 通读原料,在思考里列两份清单:**各席位要点**(用于文字叙述)+ **可量化信息**(用于图表,含需要估算的项);
2. **用一次 `write_file` 把完整 HTML 文本直接写入** `/mnt/user-data/outputs/方案总览.html`(`description` 写「方案可视化研讨汇报」)。
- `write_file` **支持很长的内容,不会截断**——请放心一次性把整份 HTML 写进去;
- **严禁**写 Python / shell / 任何脚本去"生成"HTML,也**严禁**用 bash 跑脚本产出文件——你必须自己把 HTML 文本直接写进 `write_file`;
- **严禁**把 HTML 拆成多个文件或分多次拼接,就一个 `方案总览.html`;
- 控制好体量:聚焦叙述 + 3–5 个图表,别堆砌到写不完。
3. 写完后,用**一两句话**向用户说明「已生成研讨汇报,含 X 个章节、Y 个图表」即可——**不要**把 HTML 源码贴进对话,也**不要**再去读取/校验文件。
> **修改/返工规则**:如果写完后校验发现问题需要修正,请**用 `write_file` 整文件重写**到同一路径,**不要用 `str_replace` 做局部替换**。整文件重写能让右侧预览实时刷新;局部替换不会反映到预览里。
## 禁止事项
- ❌ 引用任何外部 CDN / 外链资源(离线必然失败)
- ❌ 把大段 HTML 源码输出到对话正文(只写文件 + 一句话总结)
- ❌ 联网搜索、派活给其他智能体、反问用户
- ❌ 通篇只有图表、没有像样的文字叙述(这是给用户读的汇报,不是纯看板)
- ❌ 通篇只有文字、完全不配图(缺数据也要合理估算出图并标注)
- ❌ 写 Python / shell / 任何脚本去生成 HTML,或用 bash 跑脚本产出文件(必须自己把 HTML 文本直接写进 `write_file`)
- ❌ 把 HTML 拆成多文件或分多次拼接、或写完后再去读取/校验文件
- ❌ 用 `str_replace` 局部修改 HTML(返工请整文件 `write_file` 重写)
- ❌ 在报告头或任何位置添加「AI 自动生成」「AI generated」之类的标签、徽章或水印
- ❌ 编造与原料明显矛盾的数据
@@ -0,0 +1,3 @@
description: 圆桌方案可视化总结智能体,读取各席位的最终交付与总控共识,绘制一个自包含、含多种图表的 HTML 可视化看板(写入 outputs/方案总览.html)。本身不调研、不联网、不派活,只做归纳与可视化。
id: roundtable-report
name: 方案总结
@@ -6,10 +6,12 @@ import time
from ipaddress import ip_address, ip_network
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr, Field, field_validator
from deerflow.config import get_app_config
from deerflow.persistence.agents.base import AgentStore
from deerflow.persistence.skills.base import SkillStore
@@ -174,12 +176,12 @@ def _normalize_username_local_part(username: str) -> str | None:
"""
raw = username.strip()
if not raw:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="username cannot be empty")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名不能为空")
if "@" in raw:
return None
normalized = "".join(ch if (ch.isalnum() or ch in "._-") else "-" for ch in raw.lower()).strip("._-")
if not normalized:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="username is invalid")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名不合法")
return normalized
@@ -200,6 +202,117 @@ def _username_to_email(username: str) -> str:
return f"{local}@{domain}"
async def _get_or_create_user_by_username(username: str):
"""Resolve a username to a local user, auto-registering when absent.
Shared by ``/login/username`` and ``/login/token`` — both turn an
externally-supplied username into a local account and sign it in. Returns
``(user, created)`` where ``created`` is True only when this call inserted
the row. Mirrors the legacy domain-agnostic fallback and the canonical
``admin`` promotion so both entry points behave identically.
"""
provider = get_local_provider()
email = _username_to_email(username)
local_part = _normalize_username_local_part(username)
created = False
user = await provider.get_user_by_email(email)
# Domain-agnostic fallback: if no row exists at the canonical
# ``<username>@<current-default-domain>``, look for an existing user with
# the same local part under any domain. This is the safety net that keeps
# usernames globally unique when the default email domain changes (e.g.
# ``local.deerflow`` → ``cm.com``) — without it, switching the default
# would let the same username log into two different accounts.
if user is None and local_part is not None:
legacy = await provider.find_user_by_email_local_part(local_part)
if legacy is not None:
user = legacy
logger.info("Matched username %r to legacy account %s", username, legacy.email)
if user is None:
try:
role = "admin" if username == "admin" else "user"
user = await provider.create_user(email=email, password=None, system_role=role, needs_setup=False)
created = True
logger.info("Auto-registered passwordless user: %s", email)
except ValueError:
# Concurrent first login may create the same user in parallel.
user = await provider.get_user_by_email(email)
if user is None and local_part is not None:
user = await provider.find_user_by_email_local_part(local_part)
if user is None:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="创建或加载用户失败")
elif username == "admin" and user.system_role != "admin":
# Historical accounts created before the username-based role assignment
# may still have system_role="user" — promote the canonical admin user
# so /login/admin behaves consistently.
user.system_role = "admin"
user = await provider.update_user(user)
logger.info("Promoted historical 'admin' account to system_role=admin: %s", email)
return user, created
async def _fetch_username_from_token(token: str) -> str:
"""Resolve an upstream access token to a username via the configured endpoint.
Calls ``auth_login.token_login.token_info_url`` with the caller-supplied
token placed directly in the ``Authorization: Bearer <token>`` header — the
upstream reads the bearer token from there, not from a cookie. Expects
``{"state": "200", "data": {"username": ...}}`` and returns ``data.username``.
Any upstream failure, non-200 ``state``, or missing username raises HTTP 401.
"""
config = get_app_config().auth_login.token_login
if not config.token_info_url:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="token 登录未配置(token_info_url 为空)")
# TLS verification for the (HTTPS) upstream. A custom CA bundle wins; else
# the boolean toggle. UAT/internal hosts often need verify_ssl=false because
# they serve self-signed or private-CA certs — without this the call dies
# with an SSLError and every token login silently 401s.
verify: bool | str = config.ca_cert_path if config.ca_cert_path else config.verify_ssl
try:
async with httpx.AsyncClient(timeout=config.timeout_seconds, verify=verify) as client:
upstream = await client.post(
config.token_info_url,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/x-www-form-urlencoded",
},
)
except httpx.HTTPError as exc:
# Surface the concrete failure (SSL handshake, DNS, timeout, ...) in logs
# so HTTPS/cert misconfiguration is diagnosable; client still sees 401.
logger.warning(
"Token login: upstream call to %s failed (verify=%r): %s: %s",
config.token_info_url,
verify,
type(exc).__name__,
exc,
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="token 校验失败").model_dump())
if upstream.status_code != 200:
logger.warning("Token login: upstream returned HTTP %s", upstream.status_code)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="token 无效").model_dump())
try:
payload = upstream.json()
except ValueError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="token 接口返回格式异常").model_dump())
# Upstream signals success with the string state "200" (see API docs).
if str(payload.get("state")) != "200":
logger.warning("Token login: upstream state=%r msg=%r", payload.get("state"), payload.get("msg"))
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="token 无效或已过期").model_dump())
username = (payload.get("data") or {}).get("username")
if not username or not str(username).strip():
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="未能从 token 解析出用户名").model_dump())
return str(username).strip()
def _set_session_cookie(response: Response, token: str, request: Request) -> None:
"""Set the access_token HttpOnly cookie on the response."""
config = get_auth_config()
@@ -301,7 +414,7 @@ def _check_rate_limit(ip: str) -> None:
if time.time() < lock_until:
raise HTTPException(
status_code=429,
detail="Too many login attempts. Try again later.",
detail="登录尝试次数过多,请稍后再试",
)
del _login_attempts[ip]
@@ -357,7 +470,7 @@ async def login_local(
_record_login_failure(client_ip)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="Incorrect email or password").model_dump(),
detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="邮箱或密码错误").model_dump(),
)
_record_login_success(client_ip)
@@ -371,53 +484,39 @@ async def login_local(
@router.post("/login/username", response_model=UsernameLoginResponse)
async def login_by_username(request: Request, response: Response, body: UsernameLoginRequest):
async def login_by_username(
request: Request,
response: Response,
body: UsernameLoginRequest,
password: str | None = Query(default=None, description="Shared gate secret; required only when auth_login.username_login.require_password is on."),
):
"""Passwordless username login with auto-registration.
Rules:
- If mapped account exists, sign in directly (no password required).
- If account does not exist, create a regular user and sign in.
Password gate: when ``auth_login.username_login.require_password`` is
enabled, callers must additionally pass ``?password=`` matching the
configured shared secret (default ``123ewq`` when unset). The gate is a
single shared password — it does not change which account is logged in.
Returns the JWT access token in the response body and also sets it as
a session cookie for browser callers.
"""
provider = get_local_provider()
email = _username_to_email(body.username)
local_part = _normalize_username_local_part(body.username)
username_login_config = get_app_config().auth_login.username_login
if username_login_config.require_password:
client_ip = _get_client_ip(request)
_check_rate_limit(client_ip)
if password != username_login_config.effective_password:
_record_login_failure(client_ip)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="口令错误").model_dump(),
)
_record_login_success(client_ip)
created = False
user = await provider.get_user_by_email(email)
# Domain-agnostic fallback: if no row exists at the canonical
# ``<username>@<current-default-domain>``, look for an existing user with
# the same local part under any domain. This is the safety net that keeps
# usernames globally unique when the default email domain changes (e.g.
# ``local.deerflow`` → ``cm.com``) — without it, switching the default
# would let the same username log into two different accounts.
if user is None and local_part is not None:
legacy = await provider.find_user_by_email_local_part(local_part)
if legacy is not None:
user = legacy
logger.info("Matched username %r to legacy account %s", body.username, legacy.email)
if user is None:
try:
role = "admin" if body.username == "admin" else "user"
user = await provider.create_user(email=email, password=None, system_role=role, needs_setup=False)
created = True
logger.info("Auto-registered passwordless user: %s", email)
except ValueError:
# Concurrent first login may create the same user in parallel.
user = await provider.get_user_by_email(email)
if user is None and local_part is not None:
user = await provider.find_user_by_email_local_part(local_part)
if user is None:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create or load user")
elif body.username == "admin" and user.system_role != "admin":
# Historical accounts created before the username-based role assignment
# may still have system_role="user" — promote the canonical admin user
# so /login/admin behaves consistently.
user.system_role = "admin"
user = await provider.update_user(user)
logger.info("Promoted historical 'admin' account to system_role=admin: %s", email)
user, created = await _get_or_create_user_by_username(body.username)
token = create_access_token(str(user.id), token_version=user.token_version)
_set_session_cookie(response, token, request)
@@ -434,6 +533,56 @@ async def login_by_username(request: Request, response: Response, body: Username
)
@router.post("/login/token", response_model=UsernameLoginResponse)
async def login_by_token(
request: Request,
response: Response,
token: str = Query(..., min_length=1, description="Upstream access token to exchange for a username."),
):
"""Token-exchange login.
Verifies an upstream access token by calling the configured
``token_info_url`` (see ``auth_login.token_login``), reads the resolved
username, then signs the matching local account in — auto-registering it
when the username is not yet in the database. Gated by
``auth_login.token_login.enabled``.
Returns the JWT access token in the response body and also sets it as a
session cookie for browser callers.
"""
config = get_app_config().auth_login.token_login
if not config.enabled:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="token 登录未开启")
client_ip = _get_client_ip(request)
_check_rate_limit(client_ip)
try:
username = await _fetch_username_from_token(token)
except HTTPException as exc:
if exc.status_code == status.HTTP_401_UNAUTHORIZED:
_record_login_failure(client_ip)
raise
_record_login_success(client_ip)
user, created = await _get_or_create_user_by_username(username)
access_token = create_access_token(str(user.id), token_version=user.token_version)
_set_session_cookie(response, access_token, request)
return UsernameLoginResponse(
access_token=access_token,
token_type="bearer",
expires_in=get_auth_config().token_expiry_days * 24 * 3600,
user_id=str(user.id),
email=user.email,
system_role=user.system_role,
needs_setup=user.needs_setup,
created=created,
)
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register(request: Request, response: Response, body: RegisterRequest):
"""Register a new user account (always 'user' role).
@@ -0,0 +1,93 @@
"""HTML page favorites API.
A favorite bookmarks a generated ``scheduled_html_pages`` row and stores a
reusable style/layout summary (extracted via GLM5 at favorite time) so a later
``html_page`` task can be generated "in the style of" the favorite.
All routes are owner-scoped; the underlying logic lives on
``ScheduledTaskService`` so GLM5 access and the page store stay in one place.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_current_user
# Importing the ORM models here (at app-module load, before the lifespan runs
# ``Base.metadata.create_all``) registers ``scheduled_html_pages`` +
# ``html_page_favorites`` with the metadata so the tables are auto-created on
# startup. Without this the store's models are only imported later (inside
# ``langgraph_runtime``), after create_all has already run — yielding
# "no such table: html_page_favorites".
from deerflow.persistence.html_pages.model import ( # noqa: F401
HtmlPageFavoriteRow,
ScheduledHtmlPageRow,
)
from deerflow.runtime.scheduler import get_scheduled_task_service
from deerflow.runtime.scheduler.service import DuplicateFavoriteError
router = APIRouter(prefix="/api/html-page-favorites", tags=["html-page-favorites"])
class HtmlPageFavoriteCreateRequest(BaseModel):
page_id: str = Field(..., description="scheduled_html_pages.id to bookmark")
title: str | None = None
description: str | None = None
tags: list[str] | None = None
async def _user_id(request: Request) -> str:
user_id = await get_current_user(request)
if not user_id:
raise HTTPException(status_code=401, detail="Authentication required")
return user_id
@router.get("")
async def list_html_page_favorites(request: Request) -> list[dict[str, Any]]:
service = get_scheduled_task_service()
user_id = await _user_id(request)
return await service.list_html_page_favorites(user_id)
@router.post("")
async def create_html_page_favorite(body: HtmlPageFavoriteCreateRequest, request: Request) -> dict[str, Any]:
service = get_scheduled_task_service()
user_id = await _user_id(request)
try:
favorite = await service.create_html_page_favorite(
user_id,
body.page_id,
title=body.title,
description=body.description,
tags=body.tags,
)
except DuplicateFavoriteError:
raise HTTPException(status_code=409, detail="该页面已收藏,请勿重复收藏")
if favorite is None:
raise HTTPException(status_code=404, detail="页面不存在或无权收藏")
return favorite
@router.get("/{favorite_id}")
async def get_html_page_favorite(favorite_id: str, request: Request) -> dict[str, Any]:
service = get_scheduled_task_service()
user_id = await _user_id(request)
favorite = await service.get_html_page_favorite(user_id, favorite_id)
if favorite is None:
raise HTTPException(status_code=404, detail=f"Favorite {favorite_id} not found")
return favorite
@router.delete("/{favorite_id}")
async def delete_html_page_favorite(favorite_id: str, request: Request) -> dict[str, Any]:
service = get_scheduled_task_service()
user_id = await _user_id(request)
deleted = await service.delete_html_page_favorite(user_id, favorite_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"Favorite {favorite_id} not found")
return {"success": True}
@@ -59,6 +59,7 @@ from typing import Any, AsyncIterator
import httpx
from app.gateway.routers._roundtable_seed import (
COORDINATOR_AGENT_ID,
REPORT_AGENT_ID,
ensure_roundtable_functional_agents,
)
from deerflow.config import get_app_config
@@ -822,6 +823,25 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea
)
@router.post("/report/init")
async def init_report_thread(request: Request) -> dict[str, str]:
"""为 Step 3「结果绘制」创建一个供方案可视化总结智能体(``roundtable-report``)
运行的线程,返回 ``{"agent_name", "thread_id"}``。
设计要点:
- ``roundtable-report`` 是**内置单例**(``_roundtable_seed`` 种子化,启动即同步进
DB),这里**只建一个轻量 thread**,绝不重建 agent;
- 线程打 ``_ROUNDTABLE_THREAD_METADATA`` 标记成系统会话,从「最近对话」里隐藏;
- 入口先 ``ensure_roundtable_functional_agents()`` 自愈(磁盘目录被手删也能补建);
- 建好后前端用现有 ``POST /run/stream``(``agent_type="special"``、
``agent_name="roundtable-report"``、``d_agent_thread_id={REPORT_AGENT_ID: thread_id}``)
跑这一轮,智能体把图表 HTML 写进该线程的 ``outputs/``,再由沙箱预览。
"""
ensure_roundtable_functional_agents()
thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
return {"agent_name": REPORT_AGENT_ID, "thread_id": thread_id}
# ---------------------------------------------------------------------------
# 上游 LangGraph SSE 透传(intent / recommend / leader / special 共用)
# ---------------------------------------------------------------------------
@@ -1143,12 +1163,22 @@ async def _special_run(
"""
t0 = time.perf_counter()
thread_id = agent_threads[agent_name]
excluded_tools = ["web_search", "ask_clarification", "present_files", "view_image", "agent_orchestration"]
if agent_name == REPORT_AGENT_ID:
# Step 3「结果绘制」的报告智能体只需**直接 write_file 写出完整 HTML**。
# 额外禁用 bash / str_replace:
# - 禁 bash → 杜绝它写 Python/脚本再跑出 HTML(脚本产物落在 outputs 里,
# 但前端虚拟预览只抓 write_file 的 args.content,抓不到脚本产物 → 预览停在
# 那个半截 write_file 上,正是用户看到的「没写完就去生成 py 文件」);
# - 禁 str_replace → 强制返工用整文件 write_file 重写(str_replace 没有 content
# 字段,会让前端虚拟预览读到 undefined 而清空沙箱)。
excluded_tools = [*excluded_tools, "bash", "str_replace"]
body = _run_payload(
agent_name=agent_name,
model_name=model_name,
thread_id=thread_id,
new_message=new_message,
excluded_tools=["web_search", "ask_clarification", "present_files", "view_image", "agent_orchestration"],
excluded_tools=excluded_tools,
skill_stop_names=skill_stop_names,
)
t_payload_built = time.perf_counter()
@@ -0,0 +1,318 @@
"""Public (unauthenticated) API for embedded workspace chat integrations.
Parent pages embed DeerFlow in a plain iframe and cannot read ``thread_id``
from the child frame. They pass a ``session_id`` (their own correlation id)
in the iframe URL; the embed page registers ``session_id → thread_id`` here,
then the parent polls ``GET /sessions/{session_id}/status``.
"""
from __future__ import annotations
import logging
import re
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field, field_validator
from app.gateway.deps import get_checkpointer, get_embed_session_store, get_run_manager, get_thread_store
from app.gateway.routers.thread_shares import _load_thread_snapshot
from app.gateway.routers.threads import _derive_thread_status
from deerflow.persistence.embed_sessions.base import EmbedSessionStore
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/public/embed", tags=["public-embed"])
_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{8,128}$")
_UNFINISHED_THREAD_STATUSES = frozenset({"running", "busy"})
def compute_embed_finished(
*,
has_active_run: bool,
meta_status: str,
derived_status: str,
) -> bool:
"""Return whether an embed parent may treat the Q&A session as complete."""
if has_active_run or meta_status in _UNFINISHED_THREAD_STATUSES:
return False
if derived_status == "interrupted":
return False
return True
def _validate_session_id(session_id: str) -> str:
value = session_id.strip()
if not _SESSION_ID_RE.fullmatch(value):
raise HTTPException(
status_code=422,
detail="session_id must be 8-128 characters (letters, digits, underscore, hyphen)",
)
return value
class RegisterEmbedSessionRequest(BaseModel):
session_id: str = Field(..., description="Correlation id from the parent page (iframe query param)")
thread_id: str = Field(..., description="DeerFlow conversation thread id")
@field_validator("session_id")
@classmethod
def _check_session_id(cls, value: str) -> str:
if not _SESSION_ID_RE.fullmatch(value.strip()):
raise ValueError("session_id must be 8-128 characters (letters, digits, underscore, hyphen)")
return value.strip()
@field_validator("thread_id")
@classmethod
def _check_thread_id(cls, value: str) -> str:
trimmed = value.strip()
if not trimmed:
raise ValueError("thread_id is required")
return trimmed
class RegisterEmbedSessionResponse(BaseModel):
session_id: str
thread_id: str
registered: bool = True
class EmbedThreadStatusFields(BaseModel):
finished: bool
thread_status: str
has_active_run: bool
latest_run_status: str | None = None
class EmbedThreadStatusResponse(EmbedThreadStatusFields):
thread_id: str
class EmbedSessionStatusResponse(EmbedThreadStatusFields):
session_id: str
registered: bool = Field(description="False until the iframe has registered this session_id")
thread_id: str | None = Field(default=None, description="Resolved thread id when registered")
class EmbedSessionResultResponse(EmbedThreadStatusFields):
session_id: str
registered: bool = Field(description="False until the iframe has registered this session_id")
thread_id: str | None = Field(default=None, description="Resolved thread id when registered")
messages: list[dict] = Field(default_factory=list, description="Full serialized conversation messages")
generated_text: str = Field(default="", description="Concatenated assistant/AI message text")
generated_files: list[str] = Field(
default_factory=list,
description="Virtual artifact paths produced during the conversation (e.g. /mnt/user-data/outputs/...)",
)
class EmbedThreadResultResponse(EmbedThreadStatusFields):
thread_id: str
messages: list[dict] = Field(default_factory=list)
generated_text: str = Field(default="")
generated_files: list[str] = Field(default_factory=list)
def _message_text(message: dict) -> str:
content = message.get("content")
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts: list[str] = []
for part in content:
if isinstance(part, dict) and part.get("type") == "text" and isinstance(part.get("text"), str):
text = part["text"].strip()
if text:
parts.append(text)
return "\n".join(parts)
return ""
def extract_generated_text(messages: list[dict]) -> str:
"""Join all assistant/AI message bodies in conversation order."""
parts: list[str] = []
for message in messages:
if not isinstance(message, dict):
continue
message_type = message.get("type") or message.get("role")
if message_type not in {"ai", "assistant"}:
continue
text = _message_text(message)
if text:
parts.append(text)
return "\n\n".join(parts)
def build_embed_result_payload(
*,
messages: list[dict],
artifacts: list[str],
) -> tuple[list[dict], str, list[str]]:
return messages, extract_generated_text(messages), artifacts
async def _result_for_thread_id(thread_id: str, request: Request) -> EmbedThreadResultResponse:
checkpointer = get_checkpointer(request)
messages, artifacts = await _load_thread_snapshot(checkpointer, thread_id)
serialized_messages, generated_text, generated_files = build_embed_result_payload(
messages=messages,
artifacts=artifacts,
)
status = await _status_for_thread_id(thread_id, request)
return EmbedThreadResultResponse(
thread_id=thread_id,
messages=serialized_messages,
generated_text=generated_text,
generated_files=generated_files,
**status.model_dump(),
)
async def _status_for_thread_id(thread_id: str, request: Request) -> EmbedThreadStatusFields:
thread_store = get_thread_store(request)
run_mgr = get_run_manager(request)
checkpointer = get_checkpointer(request)
record = await thread_store.get(thread_id, user_id=None)
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
try:
checkpoint_tuple = await checkpointer.aget_tuple(config)
except Exception:
logger.exception("Failed to read checkpoint for embed status on %s", thread_id)
raise HTTPException(status_code=500, detail="Failed to read thread state") from None
if record is None and checkpoint_tuple is None:
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
derived_status = (
_derive_thread_status(checkpoint_tuple)
if checkpoint_tuple is not None
else (record or {}).get("status", "idle")
)
meta_status = str((record or {}).get("status") or derived_status)
has_active_run = await run_mgr.has_inflight(thread_id)
runs = await run_mgr.list_by_thread(thread_id)
latest_run_status = runs[-1].status.value if runs else None
finished = compute_embed_finished(
has_active_run=has_active_run,
meta_status=meta_status,
derived_status=derived_status,
)
return EmbedThreadStatusFields(
finished=finished,
thread_status=derived_status,
has_active_run=has_active_run,
latest_run_status=latest_run_status,
)
@router.post(
"/sessions",
response_model=RegisterEmbedSessionResponse,
summary="Register embed session_id → thread_id (called from iframe)",
)
async def register_embed_session(
body: RegisterEmbedSessionRequest,
request: Request,
) -> RegisterEmbedSessionResponse:
"""Upsert a mapping so the parent page can poll status by session_id only."""
store: EmbedSessionStore = get_embed_session_store(request)
record = await store.upsert(body.session_id, body.thread_id)
return RegisterEmbedSessionResponse(
session_id=record["session_id"],
thread_id=record["thread_id"],
)
@router.get(
"/sessions/{session_id}/status",
response_model=EmbedSessionStatusResponse,
summary="Poll embed Q&A completion by parent session_id",
)
async def get_embed_session_status(session_id: str, request: Request) -> EmbedSessionStatusResponse:
"""Primary status endpoint for iframe parents — no thread_id required."""
session_id = _validate_session_id(session_id)
store: EmbedSessionStore = get_embed_session_store(request)
record = await store.get(session_id)
if record is None:
return EmbedSessionStatusResponse(
session_id=session_id,
registered=False,
thread_id=None,
finished=False,
thread_status="pending",
has_active_run=False,
latest_run_status=None,
)
thread_id = record["thread_id"]
status = await _status_for_thread_id(thread_id, request)
return EmbedSessionStatusResponse(
session_id=session_id,
registered=True,
thread_id=thread_id,
**status.model_dump(),
)
@router.get(
"/threads/{thread_id}/status",
response_model=EmbedThreadStatusResponse,
summary="Poll embed Q&A completion by thread_id (optional / debugging)",
)
async def get_embed_thread_status(thread_id: str, request: Request) -> EmbedThreadStatusResponse:
"""Direct thread lookup when the parent already knows thread_id."""
status = await _status_for_thread_id(thread_id, request)
return EmbedThreadStatusResponse(thread_id=thread_id, **status.model_dump())
@router.get(
"/sessions/{session_id}/result",
response_model=EmbedSessionResultResponse,
summary="Fetch embed Q&A messages, generated text, and files by session_id",
)
async def get_embed_session_result(session_id: str, request: Request) -> EmbedSessionResultResponse:
"""Return model output for iframe parents once they know ``session_id`` only."""
session_id = _validate_session_id(session_id)
store: EmbedSessionStore = get_embed_session_store(request)
record = await store.get(session_id)
if record is None:
return EmbedSessionResultResponse(
session_id=session_id,
registered=False,
thread_id=None,
finished=False,
thread_status="pending",
has_active_run=False,
latest_run_status=None,
messages=[],
generated_text="",
generated_files=[],
)
thread_result = await _result_for_thread_id(record["thread_id"], request)
return EmbedSessionResultResponse(
session_id=session_id,
registered=True,
thread_id=thread_result.thread_id,
finished=thread_result.finished,
thread_status=thread_result.thread_status,
has_active_run=thread_result.has_active_run,
latest_run_status=thread_result.latest_run_status,
messages=thread_result.messages,
generated_text=thread_result.generated_text,
generated_files=thread_result.generated_files,
)
@router.get(
"/threads/{thread_id}/result",
response_model=EmbedThreadResultResponse,
summary="Fetch embed Q&A messages, generated text, and files by thread_id",
)
async def get_embed_thread_result(thread_id: str, request: Request) -> EmbedThreadResultResponse:
"""Direct thread lookup when the parent already knows thread_id."""
return await _result_for_thread_id(thread_id, request)
@@ -243,6 +243,39 @@ async def public_get_run(run_id: str) -> dict[str, Any]:
return _redact_run(run)
def _html_payload(run_id: str, page: dict[str, Any]) -> dict[str, Any]:
return {
"run_id": run_id,
"task_id": page.get("task_id"),
"title": page.get("title"),
"html": page.get("html_content"),
"style_summary": page.get("style_summary"),
"layout_summary": page.get("layout_summary"),
"page_id": page.get("id"),
"created_at": page.get("created_at"),
}
@router.get("/runs/{run_id}/html")
async def public_get_run_html(run_id: str) -> dict[str, Any]:
"""Return the generated HTML document for an ``html_page`` run (anonymous)."""
service = get_scheduled_task_service()
page = await service.get_html_page_for_run(run_id)
if page is None:
raise HTTPException(status_code=404, detail="该执行记录没有 HTML 页面")
return _html_payload(run_id, page)
@router.get("/{task_id}/latest-html")
async def public_get_latest_html(task_id: str) -> dict[str, Any]:
"""Return the most recent generated HTML page for a task (anonymous)."""
service = get_scheduled_task_service()
page = await service.get_latest_html_page_for_task(task_id)
if page is None:
raise HTTPException(status_code=404, detail="该任务暂无 HTML 页面")
return _html_payload(str(page.get("run_id") or ""), page)
@router.get("/runs/{run_id}/files/{file_index}")
async def public_get_run_file(run_id: str, file_index: int) -> dict[str, Any]:
"""Return a single result-file entry with its inline text content where
@@ -0,0 +1,162 @@
"""CRUD API for roundtable-planning business chains (业务链条).
A business chain is a named, reusable, *ordered* list of agents the user
assembles in the chain editor. In Step 2 the coordinator dispatches to the seats
following this order. Bound to the requesting user (not the browser), so it
follows the account across devices/sessions — same model as roundtable drafts.
Routes (prefix ``/api/roundtable-chains``):
GET "" list current user's chains (full records, incl. seats)
POST "" create a chain
GET "/{chain_id}" one chain
PUT "/{chain_id}" partial update (title / description / seats)
DELETE "/{chain_id}" delete
"""
from __future__ import annotations
import logging
from datetime import datetime
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/roundtable-chains", tags=["roundtable-chains"])
# Seat-count bounds mirror the roundtable selection bounds (2 ≤ N ≤ 8).
MIN_SEATS = 2
MAX_SEATS = 8
# ── schemas ────────────────────────────────────────────────────────────────
class ChainSeatSchema(BaseModel):
agent_id: str = Field(min_length=1, max_length=128)
name: str = ""
description: str = ""
class ChainResponse(BaseModel):
id: str
title: str = ""
description: str | None = None
seats: list[ChainSeatSchema] = []
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
class ChainListResponse(BaseModel):
chains: list[ChainResponse]
class ChainCreateRequest(BaseModel):
# Optional client-provided id so the browser can keep one stable id across
# the create + subsequent updates without an extra round-trip.
id: str | None = Field(default=None, max_length=64)
title: str = Field(default="", max_length=512)
description: str | None = Field(default=None, max_length=2048)
seats: list[ChainSeatSchema] = []
class ChainUpdateRequest(BaseModel):
# All optional → callers send only what changed.
title: str | None = Field(default=None, max_length=512)
description: str | None = Field(default=None, max_length=2048)
seats: list[ChainSeatSchema] | None = None
# ── helpers ──────────────────────────────────────────────────────────────
def _current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is not None:
return str(user.id)
return get_effective_user_id()
def _get_store(request: Request):
store = getattr(request.app.state, "roundtable_chain_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Roundtable chain store not available")
return store
def _validate_seats(seats: list[ChainSeatSchema]) -> None:
"""Enforce 2 ≤ N ≤ 8 and reject duplicate agent ids within a chain."""
if len(seats) < MIN_SEATS or len(seats) > MAX_SEATS:
raise HTTPException(
status_code=400,
detail=f"业务链条席位数须在 {MIN_SEATS}–{MAX_SEATS} 之间(当前 {len(seats)})",
)
seen: set[str] = set()
for s in seats:
if s.agent_id in seen:
raise HTTPException(status_code=400, detail=f"业务链条存在重复智能体: {s.agent_id}")
seen.add(s.agent_id)
# ── routes ───────────────────────────────────────────────────────────────
@router.get("", response_model=ChainListResponse)
async def list_chains(request: Request) -> ChainListResponse:
store = _get_store(request)
user_id = _current_user_id(request)
rows = await store.list_chains(user_id)
return ChainListResponse(chains=[ChainResponse(**r) for r in rows])
@router.post("", response_model=ChainResponse, status_code=201)
async def create_chain(request: Request, body: ChainCreateRequest) -> ChainResponse:
_validate_seats(body.seats)
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.create_chain(
user_id,
{
"id": (body.id or uuid4().hex),
"title": body.title.strip(),
"description": body.description,
"seats": [s.model_dump() for s in body.seats],
},
)
return ChainResponse(**row)
@router.get("/{chain_id}", response_model=ChainResponse)
async def get_chain(request: Request, chain_id: str) -> ChainResponse:
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.get_chain(chain_id, user_id)
if row is None:
raise HTTPException(status_code=404, detail="Chain not found")
return ChainResponse(**row)
@router.put("/{chain_id}", response_model=ChainResponse)
async def update_chain(request: Request, chain_id: str, body: ChainUpdateRequest) -> ChainResponse:
if body.seats is not None:
_validate_seats(body.seats)
store = _get_store(request)
user_id = _current_user_id(request)
# Distinguish "omitted" from "explicitly sent"; only forward provided fields.
fields = body.model_dump(exclude_unset=True)
row = await store.update_chain(chain_id, user_id, **fields)
if row is None:
raise HTTPException(status_code=404, detail="Chain not found")
return ChainResponse(**row)
@router.delete("/{chain_id}", status_code=204)
async def delete_chain(request: Request, chain_id: str) -> None:
store = _get_store(request)
user_id = _current_user_id(request)
deleted = await store.delete_chain(chain_id, user_id)
if not deleted:
raise HTTPException(status_code=404, detail="Chain not found")
@@ -0,0 +1,227 @@
"""CRUD API for roundtable-planning drafts + recommendation history.
A draft is one full roundtable session (Step 1 intent + Step 2 multi-agent
roundtable). Bound to the requesting user (not the browser), so it follows the
account across devices/sessions — replacing the old ``localStorage`` storage.
Recommendation history is bound to a draft: the recommend dialog shows "last
recommendation for this session" on open, while still letting the user
re-analyze (which appends a new history row).
Routes (prefix ``/api/roundtable-drafts``):
GET "" list current user's drafts (lightweight)
POST "" create a draft
GET "/{draft_id}" full draft (incl. step1 / step2 blobs)
PUT "/{draft_id}" partial update (title / step / snapshots)
DELETE "/{draft_id}" delete
GET "/{draft_id}/recommendations" list recommendation history
POST "/{draft_id}/recommendations" append one recommendation run
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/roundtable-drafts", tags=["roundtable-drafts"])
# ── schemas ────────────────────────────────────────────────────────────────
class DraftMetaResponse(BaseModel):
id: str
title: str = ""
furthest_step: int = 1
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
class DraftListResponse(BaseModel):
drafts: list[DraftMetaResponse]
class DraftResponse(DraftMetaResponse):
step1: dict[str, Any] | None = None
step2: dict[str, Any] | None = None
step3: dict[str, Any] | None = None
class DraftCreateRequest(BaseModel):
# Optional client-provided id so the browser can keep one stable id across
# the create + subsequent updates without an extra round-trip.
id: str | None = Field(default=None, max_length=64)
title: str = Field(default="", max_length=512)
furthest_step: int = Field(default=1, ge=1, le=3)
step1: dict[str, Any] | None = None
step2: dict[str, Any] | None = None
step3: dict[str, Any] | None = None
class DraftUpdateRequest(BaseModel):
# All optional → callers send only what changed. Auto-save omits ``title``
# so it never clobbers a manual rename.
title: str | None = Field(default=None, max_length=512)
furthest_step: int | None = Field(default=None, ge=1, le=3)
step1: dict[str, Any] | None = None
step2: dict[str, Any] | None = None
step3: dict[str, Any] | None = None
class RecommendPickSchema(BaseModel):
agent_id: str
reason: str = ""
class RecommendCandidateSchema(BaseModel):
agent_id: str
name: str = ""
description: str = ""
class RecommendHistoryResponse(BaseModel):
id: str
draft_id: str
objective: str = ""
status: str = "done"
model: str | None = None
rationale: str | None = None
picks: list[RecommendPickSchema] = []
candidates: list[RecommendCandidateSchema] = []
created_at: datetime | str | None = None
class RecommendHistoryListResponse(BaseModel):
history: list[RecommendHistoryResponse]
class RecommendHistoryCreateRequest(BaseModel):
objective: str = Field(default="", max_length=512)
status: str = Field(default="done", max_length=32)
model: str | None = None
rationale: str | None = None
picks: list[RecommendPickSchema] = []
candidates: list[RecommendCandidateSchema] = []
# ── helpers ──────────────────────────────────────────────────────────────
def _current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is not None:
return str(user.id)
return get_effective_user_id()
def _get_store(request: Request):
store = getattr(request.app.state, "roundtable_draft_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Roundtable draft store not available")
return store
# ── draft routes ───────────────────────────────────────────────────────────
@router.get("", response_model=DraftListResponse)
async def list_drafts(request: Request) -> DraftListResponse:
store = _get_store(request)
user_id = _current_user_id(request)
rows = await store.list_drafts(user_id)
return DraftListResponse(drafts=[DraftMetaResponse(**r) for r in rows])
@router.post("", response_model=DraftResponse, status_code=201)
async def create_draft(request: Request, body: DraftCreateRequest) -> DraftResponse:
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.create_draft(
user_id,
{
"id": (body.id or uuid4().hex),
"title": body.title.strip(),
"furthest_step": body.furthest_step,
"step1": body.step1,
"step2": body.step2,
"step3": body.step3,
},
)
return DraftResponse(**row)
@router.get("/{draft_id}", response_model=DraftResponse)
async def get_draft(request: Request, draft_id: str) -> DraftResponse:
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.get_draft(draft_id, user_id)
if row is None:
raise HTTPException(status_code=404, detail="Draft not found")
return DraftResponse(**row)
@router.put("/{draft_id}", response_model=DraftResponse)
async def update_draft(request: Request, draft_id: str, body: DraftUpdateRequest) -> DraftResponse:
store = _get_store(request)
user_id = _current_user_id(request)
# Build kwargs that distinguish "omitted" from "explicitly None". The store
# uses a sentinel; here we pass only the fields the client actually sent.
fields = body.model_dump(exclude_unset=True)
row = await store.update_draft(draft_id, user_id, **fields)
if row is None:
raise HTTPException(status_code=404, detail="Draft not found")
return DraftResponse(**row)
@router.delete("/{draft_id}", status_code=204)
async def delete_draft(request: Request, draft_id: str) -> None:
store = _get_store(request)
user_id = _current_user_id(request)
deleted = await store.delete_draft(draft_id, user_id)
if not deleted:
raise HTTPException(status_code=404, detail="Draft not found")
# ── recommendation history routes ────────────────────────────────────────
@router.get("/{draft_id}/recommendations", response_model=RecommendHistoryListResponse)
async def list_recommendations(request: Request, draft_id: str) -> RecommendHistoryListResponse:
store = _get_store(request)
user_id = _current_user_id(request)
rows = await store.list_recommendations(draft_id, user_id)
return RecommendHistoryListResponse(history=[RecommendHistoryResponse(**r) for r in rows])
@router.post("/{draft_id}/recommendations", response_model=RecommendHistoryResponse, status_code=201)
async def add_recommendation(
request: Request, draft_id: str, body: RecommendHistoryCreateRequest
) -> RecommendHistoryResponse:
store = _get_store(request)
user_id = _current_user_id(request)
# Ownership: the draft must exist and belong to the caller.
draft = await store.get_draft(draft_id, user_id)
if draft is None:
raise HTTPException(status_code=404, detail="Draft not found")
row = await store.add_recommendation(
user_id,
draft_id,
{
"id": uuid4().hex,
"objective": body.objective,
"status": body.status,
"model": body.model,
"rationale": body.rationale,
"picks": [p.model_dump() for p in body.picks],
"candidates": [c.model_dump() for c in body.candidates],
},
)
return RecommendHistoryResponse(**row)
@@ -251,6 +251,29 @@ async def get_scheduled_task_run(run_id: str, request: Request) -> dict:
return run
@router.get("/runs/{run_id}/html")
async def get_scheduled_task_run_html(run_id: str, request: Request) -> dict:
"""Return the generated HTML document for an ``html_page`` run."""
service = get_scheduled_task_service()
user_id = await _user_id(request)
run = await _load_run(service, request, run_id, user_id)
if run is None:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
page = await service.get_html_page_for_run(run_id)
if page is None:
raise HTTPException(status_code=404, detail="该执行记录没有 HTML 页面")
return {
"run_id": run_id,
"task_id": page.get("task_id") or run.get("task_id"),
"title": page.get("title"),
"html": page.get("html_content"),
"style_summary": page.get("style_summary"),
"layout_summary": page.get("layout_summary"),
"page_id": page.get("id"),
"created_at": page.get("created_at"),
}
def _event_type_for_checkpoint_message(message: dict) -> str:
"""Map a serialized LangChain message to the same ``event_type`` strings
the run-event store uses, so the frontend's existing role labels keep
@@ -30,7 +30,7 @@ from deerflow.skills import Skill
from deerflow.skills.cascade import cascade_skill_unpublished_or_deleted
from deerflow.skills.installer import SkillAlreadyExistsError
from deerflow.skills.security_scanner import scan_skill_content
from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.skills.storage import SkillStorage, get_or_new_skill_storage
from deerflow.skills.types import SKILL_MD_FILE, SkillCategory
logger = logging.getLogger(__name__)
@@ -210,6 +210,12 @@ class CustomSkillUpdateRequest(BaseModel):
content: str = Field(..., description="Replacement SKILL.md content")
class SkillDuplicateRequest(BaseModel):
"""Request model for duplicating an existing skill into a new custom skill."""
new_name: str = Field(..., description="Name for the new custom skill copy (lowercase letters/digits, '-'/'_' separators)")
class CustomSkillHistoryResponse(BaseModel):
history: list[dict]
@@ -950,6 +956,121 @@ async def install_skill(
raise HTTPException(status_code=500, detail=f"Failed to install skill: {str(e)}")
def _rewrite_skill_frontmatter_name(content: str, new_name: str) -> str:
"""Return *content* with the YAML-frontmatter ``name:`` field set to *new_name*.
A duplicated skill must declare the new name in its SKILL.md frontmatter or
``validate_skill_markdown_content`` will reject it (frontmatter name must
match the directory name). We rewrite only the first ``name:`` line inside
the leading ``---`` block, leaving every other field byte-for-byte intact.
"""
match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
if not match:
raise ValueError("SKILL.md is missing the leading YAML frontmatter block")
fm = match.group(1)
new_fm, replaced = re.subn(r"(?m)^(name:\s*).*$", rf"\g<1>{new_name}", fm, count=1)
if replaced == 0:
new_fm = f"name: {new_name}\n{fm}"
return content[: match.start(1)] + new_fm + content[match.end(1) :]
@router.post(
"/skills/custom/{source_name}/duplicate",
response_model=SkillInstallResponse,
summary="Duplicate Skill",
description=(
"Copy an existing skill (built-in/public OR custom) into a brand-new custom "
"skill named ``new_name``, owned by the caller and unpublished. All support "
"files are copied verbatim; only the SKILL.md frontmatter name is rewritten. "
"The source already passed the install-time security scan, so no re-scan is run."
),
)
async def duplicate_skill(
source_name: str,
body: SkillDuplicateRequest,
request: Request,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
) -> SkillInstallResponse:
import shutil
try:
user_id = _current_user_id(request)
is_admin = _is_admin_user(request)
storage = get_or_new_skill_storage(app_config=config)
source_name = SkillStorage.validate_skill_name(source_name.replace("\r\n", "").replace("\n", ""))
new_name = SkillStorage.validate_skill_name(body.new_name.replace("\r\n", "").replace("\n", ""))
# Resolve the source directory (custom takes precedence over public, so a
# user-shadowed copy is the one duplicated when both exist).
if storage.custom_skill_exists(source_name):
source_dir = storage.get_custom_skill_dir(source_name)
elif storage.public_skill_exists(source_name):
source_dir = storage.get_skills_root_path() / SkillCategory.PUBLIC.value / source_name
else:
raise HTTPException(status_code=404, detail=f"Skill '{source_name}' not found")
if new_name == source_name or storage.custom_skill_exists(new_name) or storage.public_skill_exists(new_name):
raise HTTPException(status_code=409, detail=f"A skill named '{new_name}' already exists")
if not is_admin:
existing = await skill_store.get_any(new_name)
if existing and existing.get("owner_user_id") not in (None, user_id):
raise HTTPException(status_code=409, detail=f"Skill '{new_name}' already exists and is owned by another user")
dest_dir = storage.get_custom_skill_dir(new_name)
# copytree refuses to overwrite, which is the behaviour we want — the
# existence checks above already guarantee a clean target.
shutil.copytree(source_dir, dest_dir)
try:
source_content = (dest_dir / SKILL_MD_FILE).read_text(encoding="utf-8")
new_content = _rewrite_skill_frontmatter_name(source_content, new_name)
SkillStorage.validate_skill_markdown_content(new_name, new_content)
storage.write_custom_skill(new_name, SKILL_MD_FILE, new_content)
except Exception:
# Roll back the half-created directory so a failed copy never leaves
# an unparseable skill on disk.
shutil.rmtree(dest_dir, ignore_errors=True)
raise
await skill_store.ensure_legacy({"name": new_name, "owner_user_id": user_id, "published": False})
try:
storage.append_history(
new_name,
{
"action": "duplicate",
"author": "human",
"thread_id": None,
"file_path": SKILL_MD_FILE,
"prev_content": None,
"new_content": new_content,
"source_skill": source_name,
},
)
except Exception:
pass
try:
from deerflow.skills.usage import mark_uploaded
mark_uploaded(new_name)
except Exception:
pass
await refresh_skills_system_prompt_cache_async()
return SkillInstallResponse(
success=True,
skill_name=new_name,
message=f"Skill '{source_name}' duplicated as '{new_name}'",
overwritten=False,
category=SkillCategory.CUSTOM.value,
)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Failed to duplicate skill %s: %s", source_name, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to duplicate skill: {str(e)}")
@router.post(
"/skills/reconcile-custom",
response_model=SkillReconcileResponse,
@@ -228,6 +228,8 @@ class ReferenceSourceResponse(BaseModel):
id: str | None = None
skillName: str = "backend-reference"
mode: str = "citation"
resultSetKey: str | None = None
resultSetLabel: str | None = None
title: str
type: str = "result"
snippet: str = ""
@@ -84,13 +84,23 @@ def normalize_input(raw_input: dict[str, Any] | None) -> dict[str, Any]:
converted = []
for msg in messages:
if isinstance(msg, dict):
role = msg.get("role", msg.get("type", "user"))
content = msg.get("content", "")
if role in ("user", "human"):
converted.append(HumanMessage(content=content))
else:
# TODO: handle other message types (system, ai, tool)
converted.append(HumanMessage(content=content))
# Preserve additional_kwargs (e.g. uploaded `files`, `client_ts`,
# `prompt_prefix`) and optional id/name so they round-trip through
# the checkpoint to the UI and to UploadsMiddleware. Dropping them
# here previously made uploaded files invisible in the chat and
# broke the per-message ask-time timestamp.
msg_kwargs: dict[str, Any] = {"content": content}
extra = msg.get("additional_kwargs")
if isinstance(extra, dict) and extra:
msg_kwargs["additional_kwargs"] = extra
if msg.get("id"):
msg_kwargs["id"] = msg["id"]
if msg.get("name"):
msg_kwargs["name"] = msg["name"]
# TODO: handle other message types (system, ai, tool); for now all
# inbound dict messages are treated as human turns.
converted.append(HumanMessage(**msg_kwargs))
else:
converted.append(msg)
return {**raw_input, "messages": converted}
@@ -209,11 +219,8 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
"max_concurrent_subagents",
"agent_name",
"is_bootstrap",
"subagent_mode",
"search_space_id",
"mentioned_document_ids",
"pic_query",
"subagent_model_name",
"is_writing_setup",
"valid_article_types",
"excluded_tools",
"skill_stop_names",
"skill_stop_message",
+67 -5
View File
@@ -14,7 +14,7 @@ models:
display_name: zai-org/GLM-5-FP8
use: langchain_openai:ChatOpenAI
model: zai-org/GLM-5-FP8
api_key: Bearer modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
api_key: modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
base_url: https://api.us-west-2.modal.direct/v1
request_timeout: 600.0
max_retries: 2
@@ -33,7 +33,7 @@ models:
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_vision: false
supports_thinking: true
- name: deepseek-v4-flash
@@ -46,7 +46,7 @@ models:
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_vision: false
supports_thinking: true
- name: deepseek-v4-pro
@@ -59,7 +59,7 @@ models:
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_vision: false
supports_thinking: true
- name: MiniMax-M2.7
display_name: MiniMax-M2.7
@@ -71,6 +71,47 @@ models:
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: glm-5.1
display_name: glm-5.1
use: langchain_openai:ChatOpenAI
model: glm-5.1
api_key: 75121e4bed7640d5b71ac95cf8b4310a.RA3GyRBAR3kv7VrR
base_url: https://open.bigmodel.cn/api/paas/v4/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: deepseek-ai/DeepSeek-OCR
display_name: deepseek-ai/DeepSeek-OCR
use: langchain_openai:ChatOpenAI
model: deepseek-ai/DeepSeek-OCR
api_key: sk-beshfxfxfrllsczpqfrgjbugfbpzxgqgedbehceytpcvvtyb
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
# 识图(图片识别)配置。
# - model_name:指定一个 models[] 中的模型名,作为“默认识图大模型”。view_image 工具会把
# 图片交给该模型识别,并把识别出的文字结果返回给主对话模型 —— 这样即使主对话模型不支持
# 视觉,也能“看图”。请改成你实际用于识图的模型名。
# - 若留空 / 注释掉 model_name,则回退到主对话模型自身的视觉能力(需该模型 supports_vision: true)。
# - prompt:不带具体问题时使用的默认识图指令(可选)。
# - max_tokens:识图模型输出上限(可选,默认用该模型自身的配置)。
vision:
model_name: deepseek-ai/DeepSeek-OCR
# prompt: 请详细描述这张图片的内容。
# max_tokens: 2048
# 上传文件识别配置。
# - auto_convert_documents: true 时,上传的 PDF / Word / Excel / PPT 会在服务端自动转换为
# Markdown(.md),agent 通过 read_file / grep 读取其内容 —— 即“识别”文档内容。
# 纯文本类(txt / csv / md / json / 代码等)无需转换,agent 用 read_file 直接读取。
# 图片(png/jpg/webp)由 view_image + 上面的 vision 识图模型处理。
# - pdf_converter: auto(优先 pymupdf4llm,缺失时回退 markitdown)| pymupdf4llm | markitdown
uploads:
auto_convert_documents: true
pdf_converter: auto
title:
enabled: true
@@ -146,6 +187,27 @@ skills:
agents_api:
enabled: true
# 登录鉴权扩展:在基础登录之上叠加两种自定义模式。
auth_login:
# 用户名直登口令门:开关打开后,/login/<用户名> 直登必须再带 ?password=口令,
# 口令不对直接 401。password 留空则默认口令为 123ewq。仅作用于用户名直登。
username_login:
require_password: false
password: ""
# token 换登录:前端用 ?authToken=上游token 进入,后端调用 token_info_url
# (把该 token 直接拼成 Authorization: Bearer <token> 头)换出用户名,再登录;
# 用户名不在库里时自动注册。
token_login:
enabled: false
token_info_url: "https://ch1.b.uat.4.cn/consumer/login/getTokenInfo"
# 已弃用:鉴权头现由前端传入的 token 拼成 "Bearer <token>",此项不再生效,保留仅兼容。
service_authorization: "Bearer admin"
timeout_seconds: 10
# 上游是 HTTPS。UAT/内网常用自签或私有 CA 证书,默认 verify=true 会握手失败导致
# 永远 401。此处 uat 域名按自签处理,先关校验;生产请置 true 或配 ca_cert_path。
verify_ssl: false
ca_cert_path: "" # 可选:PEM 格式 CA 包路径,设置后优先生效并启用校验
database:
backend: sqlite
sqlite_dir: .deer-flow/data
@@ -154,7 +216,7 @@ database:
checkpointer:
type: sqlite
connection_string: D:\05271502\deerflow-server\offline-backend-20260512\backend\.deer-flow\data\deerflow.db
connection_string: .deer-flow/data/deerflow.db
subagents:
enabled: true
@@ -0,0 +1,141 @@
# 登录鉴权扩展接口文档(auth_login)
在原有登录之上新增两种自定义登录模式,均由 `config.yaml` 的 `auth_login` 段控制。
## 配置
```yaml
auth_login:
# 功能一:用户名直登口令门
username_login:
require_password: false # true 时 /login/username 必须带 ?password=
password: "" # 共享口令;留空默认 123ewq
# 功能二:token 换登录
token_login:
enabled: false # 总开关
token_info_url: "https://ch1.b.uat.4.cn/consumer/login/getTokenInfo"
service_authorization: "Bearer admin" # 调上游接口的鉴权头
timeout_seconds: 10
verify_ssl: false # 上游 HTTPS 证书校验;UAT/内网自签证书需置 false,生产置 true
ca_cert_path: "" # 可选:PEM 格式 CA 包路径,设置后优先生效并强制校验
```
> **HTTPS 注意**:`token_info_url` 是 HTTPS。若上游用自签名 / 私有 CA 证书(UAT、内网常见),`verify_ssl: true` 时 httpx 的 TLS 握手会失败,导致 token 登录**恒定 401**。此时应:
> - 临时/内网:`verify_ssl: false`(跳过证书校验);
> - 正规做法:把私有 CA 证书导出为 PEM,配 `ca_cert_path: /path/to/ca.pem`(优先于 `verify_ssl`,仍做校验);
> - 生产对外域名(有效证书):保持 `verify_ssl: true`。
>
> 握手失败时后端日志会打印具体异常类型(如 `SSLError`)与当前 `verify` 取值,便于排查。
配置模型:`packages/harness/deerflow/config/login_config.py`。修改 `config.yaml` 后无需重启(`get_app_config()` 按 mtime 自动重载)。
---
## 功能一:用户名直登 + 口令门
在原 `POST /api/v1/auth/login/username` 上叠加一个共享口令校验。`require_password=false`(默认)时行为完全不变(免密直登 + 自动注册)。
### 请求
```
POST /api/v1/auth/login/username[?password=<共享口令>]
Content-Type: application/json
{ "username": "alice" }
```
- `?password=`:仅当 `require_password=true` 时需要。值需等于 `username_login.password`;该项留空时默认口令为 `123ewq`。
- 口令门是**单一共享口令**,只决定"放不放行",不影响登录成的是哪个账号。
- 校验失败按 IP 计入登录限速(5 次/5 分钟锁定),与本地密码登录共用同一限速器。
### 响应
成功 `200`:
```json
{
"access_token": "<JWT>",
"token_type": "bearer",
"expires_in": 604800,
"user_id": "<uuid>",
"email": "alice@cm.com",
"system_role": "user",
"needs_setup": false,
"created": false
}
```
同时种下 `access_token` HttpOnly Cookie。
失败:
| 状态码 | 场景 |
|--------|------|
| `401` | `require_password=true` 但口令缺失或不匹配 |
| `429` | 触发登录限速 |
### 前端入口
访问 `/login/<用户名>?password=<口令>`。[LoginPage.tsx](../../../frontend-web/src/pages/LoginPage.tsx) 从 `?password=` 读取并透传给 [`loginByUsername`](../../../frontend-web/src/core/auth/api.ts)。
---
## 功能二:token 换登录
前端用 URL 把上游 token 传进来,后端调上游接口换出用户名再登录。
### 请求
```
POST /api/v1/auth/login/token?token=<上游access_token>
```
后端据此向 `token_info_url` 发起(与下方 curl 对齐):**前端传入的 token 直接拼成 `Authorization: Bearer <token>` 头**,上游从该头读取令牌(不再用 cookie,也不再用静态 `service_authorization`)。
```bash
curl 'https://ch1.b.uat.4.cn/consumer/login/getTokenInfo' \
-X POST \
-H 'authorization: Bearer <上游access_token>' \
-H 'content-type: application/x-www-form-urlencoded'
```
上游返回示例:
```json
{ "state": "200", "msg": "操作成功!", "data": { "username": "b1admin", "...": "..." } }
```
后端取 `data.username` → 经 `_username_to_email` 映射 → 库内查找(含跨域名 local-part 兜底)→ **查不到则自动注册** → 发本系统 JWT 并种 Cookie。
### 响应
成功 `200`:结构同功能一的 `UsernameLoginResponse`(首次自动注册时 `created=true`)。
失败:
| 状态码 | 场景 |
|--------|------|
| `403` | `token_login.enabled=false` |
| `401` | 上游调用失败 / HTTP 非 200 / `state != "200"` / 未取到 username |
| `429` | 触发登录限速 |
| `500` | `token_info_url` 未配置 |
### 前端入口
访问 `/login?authToken=<上游token>`(用 `authToken` 而非 `token`,以避开既有的 `tools_token` 参数 `?token=`)。LoginPage 命中 `authToken` 时走 [`loginByToken`](../../../frontend-web/src/core/auth/api.ts),不再调用户名直登。
---
## 安全说明
- 两个端点都在 `auth_middleware._PUBLIC_EXACT_PATHS` 白名单内(免会话鉴权,自身完成鉴权)。
- 口令门是**最低限度**的共享密钥防护,适合内网/受控环境;非每用户独立密码。
- token 换登录信任上游 `getTokenInfo` 的鉴权结论,务必保证 `token_info_url` 配置正确且走内网/HTTPS。前端传入的 token 会直接作为 `Authorization: Bearer <token>` 发给上游,因此该 token 必须来自可信入口。(`service_authorization` 配置项已不再参与请求,保留仅为兼容,后续可移除。)
## 相关代码
- 后端端点 / 助手:`app/gateway/routers/auth.py`(`login_by_username`、`login_by_token`、`_get_or_create_user_by_username`、`_fetch_username_from_token`)
- 白名单:`app/gateway/auth_middleware.py`
- 配置模型:`packages/harness/deerflow/config/login_config.py`(挂载于 `AppConfig.auth_login`)
- 前端:`frontend-web/src/core/auth/api.ts`、`frontend-web/src/pages/LoginPage.tsx`
- 测试:`tests/test_login_config.py`、`tests/test_auth_login_modes.py`
@@ -6,6 +6,8 @@
"code-retrieval": { "enabled": true },
"news-briefing": { "enabled": true },
"workspace-search": { "enabled": true },
"policy-knowledge": { "enabled": true }
"policy-knowledge": { "enabled": true },
"html-page-builder": { "enabled": true },
"html-charts": { "enabled": true }
}
}
@@ -74,6 +74,28 @@ def get_model(config: RunnableConfig, model_name: str | None = None):
return create_chat_model(name=name)
def get_writing_model(config: RunnableConfig, state: dict):
"""大纲 / 写作 / 审核专用:严格使用用户在表单选定的模型,**不回退默认**。
用户模型在 intent_parser(首次 invocation,configurable.model_name 可用)阶段
捕获进 ``state['model_name']``,经 checkpoint 持久化。这三个节点都在
「确认素材 / 大纲」resume 之后才运行,而 resume 是新 invocation、其 config 不再
带 model_name —— 所以必须从 state 读,不能从 configurable 读(否则回退到 config
列表第一个模型,常是推理模型,既影响质量也会让 reasoning_content 走不出流式)。
缺失视为配置错误直接抛 ValueError —— 由各节点的 try/except 兜底成 status:error,
而不是静默回退默认模型。
"""
name = ((state or {}).get("model_name") or "").strip()
if not name:
# 兜底再看一眼 configurable(首次同 invocation 内直跑到这里的极端情况)
name = (config.get("configurable", {}).get("model_name", "") or "").strip()
if not name:
raise ValueError("未指定写作模型(model_name 为空):请在写作表单选择模型后重试")
from deerflow.models import create_chat_model
return create_chat_model(name=name)
# 弱模型常把结构性标点写成中文全角符号,仅在常规解析失败后作为修复手段
_FULLWIDTH_FIX = {
"“": '"', # 左双引号 “
@@ -175,6 +197,21 @@ def parse_json(text: str, fallback: dict) -> dict:
return fallback
def reasoning_delta(chunk) -> str:
"""提取流式 chunk 的增量「推理内容」。
推理模型(deepseek-reasoner / GLM 等)会把思考过程放在
``additional_kwargs.reasoning_content`` 增量字段里,而 ``content`` 为空 ——
只读 content 的流式循环会把这些 chunk 全部跳过,前端就看不到任何实时输出。
各写作节点用本函数把推理增量当作 ``is_thinking=True`` 推给前端的思考框。
兼容不同 provider 的字段名:``reasoning_content``(主流)/ ``reasoning``。
"""
ak = getattr(chunk, "additional_kwargs", None) or {}
val = ak.get("reasoning_content") or ak.get("reasoning") or ""
return val if isinstance(val, str) else ""
def _content_to_text(content) -> str:
"""将 LLM 响应内容统一为字符串(部分模型返回结构化 content 块列表)。"""
if isinstance(content, str):
@@ -11,7 +11,7 @@ from ..state import AIWritingState, ReviewResult, ReviewIssue
from ..prompts.editor_prompts import (
EDITOR_SYSTEM, FACT_REVIEW_USER, LOGIC_REVIEW_USER, LANGUAGE_REVIEW_USER,
)
from ._utils import get_ai_writing_config, get_model, parse_json, _content_to_text, ThinkTagParser, safe_get_stream_writer
from ._utils import get_ai_writing_config, get_writing_model, parse_json, _content_to_text, reasoning_delta, ThinkTagParser, safe_get_stream_writer
logger = logging.getLogger(__name__)
@@ -49,6 +49,10 @@ async def _stream_one_review(
SystemMessage(content=EDITOR_SYSTEM),
HumanMessage(content=user_prompt),
]):
# 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送
reasoning = reasoning_delta(chunk)
if reasoning:
writer({"type": "review_chunk", "category": category, "text": reasoning, "is_thinking": True})
delta = _content_to_text(chunk.content)
if not delta:
continue
@@ -118,7 +122,7 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
# 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError,
# 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。
try:
model = get_model(config)
model = get_writing_model(config, state)
except Exception as e:
logger.error("editor model creation failed: %s", e, exc_info=True)
return {
@@ -14,6 +14,7 @@ from ._utils import (
_content_to_text,
get_model,
parse_json,
reasoning_delta,
safe_get_stream_writer,
)
@@ -215,6 +216,10 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
async for chunk in model.astream(
[SystemMessage(content=system), HumanMessage(content=user)]
):
# 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送
reasoning = reasoning_delta(chunk)
if reasoning:
writer({"type": "intent_chunk", "text": reasoning, "is_thinking": True})
delta = _content_to_text(chunk.content)
if not delta:
continue
@@ -259,6 +264,12 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
progress_events.append({"type": "step_done", "agent_name": "系统", "message": "意图解析完成"})
updates: dict = {"status": "researching", "progress_events": progress_events}
# 捕获用户表单选定的主模型进 state(仅本节点能可靠拿到 configurable.model_name —
# 它运行在首次 invocation;后续大纲/写作/审核在 resume 后跑,config 已不带它)。
# 大纲/写作/审核经 get_writing_model 严格读 state['model_name'],不回退默认。
selected_model = (configurable.get("model_name") or "").strip()
if selected_model:
updates["model_name"] = selected_model
updates["article_type"] = final_article_type
updates["target_audience"] = final_target_audience
updates["word_count_target"] = final_word_count
@@ -1,9 +1,11 @@
"""素材收集专家节点:关键词提取 → 并发搜索 → 相关度排序。"""
"""素材收集专家节点:关键词提取 → 并发搜索(含笔记) → 相关度排序。"""
from __future__ import annotations
import asyncio
import logging
import os
import re
import time
import uuid
from typing import List
@@ -20,6 +22,10 @@ from ._utils import get_ai_writing_config, llm_json, safe_get_stream_writer
logger = logging.getLogger(__name__)
_NOTEBOOK_SEARCH_PATH = "/api/v1/source/mentioned-documents/search"
_NOTEBOOK_MAX_KEYWORDS = 3 # 每次写作最多对笔记发起的检索轮数
_NOTEBOOK_TOP_K = 5 # 每次检索取回的 chunk 数
# 素材总量上限边界:config.yaml 的 ai_writing.max_materials,非法值回退默认 50
_DEFAULT_MAX_MATERIALS = 50
_MIN_MAX_MATERIALS = 1
@@ -29,6 +35,11 @@ _MAX_MAX_MATERIALS = 100
_DEFAULT_KEYWORD_COUNT = 6
_MAX_KEYWORD_COUNT = 10
# 单个技能的 web 检索超时(秒)。技能检索是顺序循环、最多 5 个技能,
# DuckDuckGo 等 provider 偶发限速/挂起会把整段"技能检索过程"拖到几十秒看起来卡死;
# 超时即放弃该技能、emit 0 条结果继续下一个,保证时间线持续推进。
_SKILL_SEARCH_TIMEOUT_SECONDS = 25
def _resolve_keyword_count(raw: object) -> int:
"""把用户传入的检索关键词数量规整到 [1, _MAX_KEYWORD_COUNT],非法值回退默认 6。"""
@@ -89,6 +100,67 @@ def _notice(title: str, detail: str, raw: str = "") -> dict:
return event
async def _search_notebook(
keywords: list[str],
search_space_id: int,
source_ids: list[int],
token: str,
) -> list[Material]:
"""从笔记 API 按关键词检索内容,返回 source_type='notebook' 的 Material 列表。"""
import httpx # 懒导入:仅在笔记模式下才需要
base = (os.getenv("DEER_FLOW_NOTEBOOK_API_BASE") or "").rstrip("/")
if not base:
logger.warning("DEER_FLOW_NOTEBOOK_API_BASE not set, skipping notebook search")
return []
results: list[Material] = []
mat_idx = 0
try:
async with httpx.AsyncClient(timeout=20) as client:
for kw in keywords[:_NOTEBOOK_MAX_KEYWORDS]:
try:
resp = await client.post(
f"{base}{_NOTEBOOK_SEARCH_PATH}",
headers={"Authorization": f"Bearer {token}"},
json={
"query": kw,
"source_ids": source_ids,
"search_space_id": search_space_id,
"top_k": _NOTEBOOK_TOP_K,
},
)
resp.raise_for_status()
data = resp.json()
# 解析 documents 列表,每个 chunk 作为独立素材
documents: list[dict] = data.get("documents") if isinstance(data, dict) else []
for doc in documents or []:
doc_title: str = doc.get("title") or "笔记文档"
doc_url: str = doc.get("url") or ""
for chunk in doc.get("chunks") or []:
chunk_content: str = (chunk.get("content") or "").strip()
if not chunk_content:
continue
mat_idx += 1
raw_score = chunk.get("score")
chunk_score = float(raw_score) if raw_score is not None else max(0.5, 0.95 - (mat_idx - 1) * 0.03)
results.append(Material(
id=f"nb{mat_idx:03d}",
title=doc_title,
content=chunk_content[:500],
source=f"笔记·{doc_title}",
source_type="notebook",
relevance_score=chunk_score,
url=doc_url,
))
except Exception as e:
logger.warning("notebook search kw=%r failed: %s", kw, e)
except Exception as e:
logger.warning("notebook search client error: %s", e)
return results
async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict:
intent = state["user_intent"]
article_type = state.get("article_type", "analysis")
@@ -110,11 +182,19 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
is_user_query_search = action == "re_search" and bool(user_query)
# 兼容旧版"追加关键词"调用方(如果还有 client 传 extra_keywords 数组而非 user_query)
is_append_only = action == "re_search" and not user_query and bool(extra_keywords)
# PAUSE-1 素材确认卡用户点「启用技能检索」:
# 用户在写作表单选择的素材来源:knowledge_base(默认 web/intranet 检索)/ skill(技能检索)。
# 首次检索(没有用户干预)时按它决定检索方式;缺省 knowledge_base。
material_source = (state.get("material_source") or "knowledge_base").strip()
is_first_run = not intervention
is_initial_skill_source = is_first_run and material_source == "skill"
# 技能检索触发条件(任一即可):
# - 首次检索且表单选了「技能检索」来源
# - PAUSE-1 素材确认卡用户点「启用技能检索」按钮
# 技能检索流程:
# - 不再走 web/intranet 检索,改用 SkillSearchProvider
# - 顺序调用候选技能 + 早停(第一个技能够就停,不够再加下一个)
# - 旧 material_package 完全丢弃(覆盖式替换,让用户对比 web vs skill 结果)
is_skill_search = action == "enable_skill_search"
is_skill_search = action == "enable_skill_search" or is_initial_skill_source
# 用户指定的检索关键词数量(1~10),决定生成多少个检索词、检索多少个方向
keyword_count = _resolve_keyword_count(state.get("keyword_count"))
@@ -167,6 +247,22 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
if extra_keywords:
keywords = list(dict.fromkeys(keywords + extra_keywords))
# Step 1.5: 笔记检索(如果传入了 notebook_search_space_id)
notebook_search_space_id: int | None = state.get("notebook_search_space_id")
notebook_materials: list[Material] = []
if notebook_search_space_id:
notebook_source_ids: list[int] = state.get("notebook_source_ids") or []
notebook_token: str = cfg.get("notebook_token") or ""
_emit("正在检索笔记内容…")
notebook_materials = await _search_notebook(
keywords[:_NOTEBOOK_MAX_KEYWORDS],
notebook_search_space_id,
notebook_source_ids,
notebook_token,
)
if notebook_materials:
_emit(f"从笔记中获取到 {len(notebook_materials)} 条素材")
# Step 2: 搜索
# 技能检索:技能不是素材,是"检索策略 / 视角"。流程:
# a. SkillPicker 选出按相关度排序的 skills(推 list_skills + select_skills 事件)
@@ -178,6 +274,10 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
def _emit_skill_step(step: dict) -> None:
skill_steps.append(step)
# 技能检索时机 trace:grep "[skill-timing]" 看每步 emit 的真实时刻,
# 与前端 [ai-writing:custom] 日志对齐,钉死是否实时推送。
logger.info("[skill-timing] t=%.3f emit skill_search_step type=%s skill=%s",
time.monotonic(), step.get("type"), step.get("skill_name") or "")
writer({"type": "skill_search_step", "agent_name": "素材收集专家", **step})
if is_skill_search:
@@ -205,15 +305,25 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
for entry in selected:
sk = entry["skill"]
# 用 LLM 把 user_query + skill.description 转成针对性关键词
skill_query = f"{user_query} {sk.description or ''}".strip() or intent
# invoke_skill 步骤带上实际检索词(截断防过长),让用户在每次长 await 之前
# 就看到"这一步在用技能 X 检索什么",配合前端 spinner 体现进行中。
_emit_skill_step({
"type": "invoke_skill",
"message": f"使用技能「{sk.name}」开始检索…",
"message": f"使用技能「{sk.name}」检索:{skill_query[:60]}",
"skill_name": sk.name,
})
skill_query = f"{user_query} {sk.description or ''}".strip() or intent
try:
res = await web_provider.search(skill_query, max_results=per_skill)
# 加超时兜底:单个技能检索最多等 _SKILL_SEARCH_TIMEOUT_SECONDS 秒,
# 超时不再阻塞后续技能,避免整段过程看起来"冻结"。
res = await asyncio.wait_for(
web_provider.search(skill_query, max_results=per_skill),
timeout=_SKILL_SEARCH_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
logger.warning("skill[%s] search timed out after %ss", sk.name, _SKILL_SEARCH_TIMEOUT_SECONDS)
search_errors.append(f"skill[{sk.name}]: 检索超时(>{_SKILL_SEARCH_TIMEOUT_SECONDS}s)")
res = []
except Exception as e:
logger.warning("skill[%s] search failed: %s", sk.name, e)
search_errors.append(f"skill[{sk.name}]: {e}")
@@ -302,10 +412,17 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
existing_pkg = state.get("material_package")
if is_skill_search:
# 技能编排循环已经完成素材累积,直接用 raw_materials_pre 当 raw_materials
# 笔记素材追加在技能素材之后
raw_materials: list[Material] = raw_materials_pre # type: ignore[name-defined]
raw_materials.extend(notebook_materials)
else:
seen_urls: set[str] = set()
raw_materials = []
# 笔记素材优先排在最前面,赋予高相关度
raw_materials = list(notebook_materials)
for m in notebook_materials:
if m.get("url"):
seen_urls.add(m["url"])
preserve_existing = existing_pkg and (is_supplement or is_append_only)
if preserve_existing:
for m in existing_pkg.get("materials", []):
@@ -12,7 +12,7 @@ from ..prompts.writer_prompts import (
DRAFT_SECTION_SYSTEM, DRAFT_SECTION_USER, DRAFT_EXPAND_USER,
WRITER_MODE_LOOSE, WRITER_MODE_STRICT, SECTION_BLOCKED_MARKER,
)
from ._utils import get_model, _content_to_text, count_words, ThinkTagParser, safe_get_stream_writer
from ._utils import get_writing_model, _content_to_text, count_words, reasoning_delta, ThinkTagParser, safe_get_stream_writer
logger = logging.getLogger(__name__)
@@ -197,7 +197,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
# 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError,
# 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。
try:
model = get_model(config)
model = get_writing_model(config, state)
except Exception as e:
logger.error("draft model creation failed: %s", e, exc_info=True)
return {
@@ -250,6 +250,17 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
SystemMessage(content=DRAFT_SECTION_SYSTEM),
HumanMessage(content=user_msg),
]):
# 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送
reasoning = reasoning_delta(chunk)
if reasoning:
writer({
"type": "draft_chunk",
"section_index": i,
"section_title": sec_title,
"text": reasoning,
"is_thinking": True,
"is_section_done": False,
})
delta = _content_to_text(chunk.content)
if not delta:
continue
@@ -7,11 +7,19 @@ import logging
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.runnables import RunnableConfig
from ..state import AIWritingState, KeyPoint, Outline, OutlineSection
from ..outline_parser import parse_user_outline
from ..prompts.writer_prompts import (
OUTLINE_SYSTEM, OUTLINE_USER, OUTLINE_REVISE_SYSTEM, OUTLINE_REVISE_USER,
OUTLINE_MATERIAL_ASSIGN_SYSTEM,
OUTLINE_MATERIAL_ASSIGN_USER,
OUTLINE_NORMALIZE_SYSTEM,
OUTLINE_NORMALIZE_USER,
OUTLINE_REVISE_SYSTEM,
OUTLINE_REVISE_USER,
OUTLINE_SYSTEM,
OUTLINE_USER,
)
from ._utils import get_model, parse_json, _content_to_text, ThinkTagParser, safe_get_stream_writer
from ..state import AIWritingState, KeyPoint, Outline, OutlineSection
from ._utils import ThinkTagParser, _content_to_text, get_writing_model, llm_json, parse_json, reasoning_delta, safe_get_stream_writer
logger = logging.getLogger(__name__)
@@ -80,6 +88,108 @@ def _parse_outline(raw: dict) -> Outline:
return Outline(title=raw.get("title", "未命名文章"), sections=sections)
def _derive_title(state: AIWritingState) -> str:
"""用户大纲没给总标题时,用写作意图兜底一个标题。"""
intent = (state.get("user_intent") or "").strip()
if not intent:
return "未命名文章"
return intent[:30].rstrip() + ("…" if len(intent) > 30 else "")
async def _normalize_user_outline_via_llm(state: AIWritingState, config: RunnableConfig, raw: str) -> Outline:
"""确定性解析失败时,调 LLM 把用户大纲原文归一成三级结构(严格保真、不改写)。
再失败则把每个非空行当作一个章节,保证永远产出一份可用大纲、不丢用户内容。
"""
model_name = (state.get("model_name") or "").strip() or None
line_fallback = {
"title": "",
"sections": [
{"section_title": ln.strip(), "key_points": [], "material_ids": []}
for ln in raw.splitlines() if ln.strip()
] or [{"section_title": "正文", "key_points": [], "material_ids": []}],
}
result = await llm_json(
OUTLINE_NORMALIZE_SYSTEM,
OUTLINE_NORMALIZE_USER.format(raw_outline=raw),
line_fallback,
config,
model_name=model_name,
)
return _parse_outline(result)
async def _assign_materials_to_outline(
state: AIWritingState, config: RunnableConfig, outline: Outline, pkg: dict
) -> Outline:
"""为用户大纲的各章节回填 material_ids(结构锁定:只取编号、按章节序号嫁接)。
LLM 只负责「哪个章节配哪些素材」,标题与层级一律以解析结果为准,杜绝改写。失败则
保持 material_ids 为空(writer_draft 自带「取前 2 条素材」兜底)。
"""
sections = outline.get("sections", [])
materials = pkg.get("materials") or []
if not sections or not materials:
return outline
valid_ids = {m["id"] for m in materials}
sections_list = "\n".join(
f"{i + 1}. {s.get('section_title', '')}" for i, s in enumerate(sections)
)
model_name = (state.get("model_name") or "").strip() or None
result = await llm_json(
OUTLINE_MATERIAL_ASSIGN_SYSTEM,
OUTLINE_MATERIAL_ASSIGN_USER.format(
user_intent=state.get("user_intent", ""),
sections_list=sections_list,
materials_summary=_materials_summary(pkg),
),
{"assignments": []},
config,
model_name=model_name,
)
assignments = result.get("assignments")
if not isinstance(assignments, list):
return outline
for i, section in enumerate(sections):
if i >= len(assignments):
break
ids = assignments[i]
if isinstance(ids, list):
section["material_ids"] = [mid for mid in ids if mid in valid_ids]
return outline
async def _build_outline_from_user(
state: AIWritingState, config: RunnableConfig, pkg: dict, raw: str
) -> dict:
"""用户自带大纲分支:解析用户大纲 → 关联素材 → 直接进确认,不调 LLM 重新规划。"""
writer = safe_get_stream_writer()
writer({"type": "outline_chunk", "text": "正在整理你提供的大纲…\n", "is_thinking": True})
outline = parse_user_outline(raw)
if outline is None:
# 确定性解析拿不准(散文化/格式混乱)→ LLM 兜底归一
outline = await _normalize_user_outline_via_llm(state, config, raw)
if not outline.get("title"):
outline = {**outline, "title": _derive_title(state)}
# 仍走了检索 → 把已检索素材关联到用户章节(结构锁定)
outline = await _assign_materials_to_outline(state, config, outline, pkg)
section_count = len(outline.get("sections", []))
return {
"current_outline": outline,
"status": "awaiting_outline_confirm",
"progress_events": [
{"type": "step_start", "agent_name": "作家", "message": "采用你提供的大纲"},
{"type": "outline_ready", "agent_name": "作家", "message": f"已整理你的大纲,共 {section_count} 个章节"},
],
}
async def writer_outline_node(state: AIWritingState, config: RunnableConfig) -> dict:
pkg = state.get("material_package") or {"keywords": [], "materials": [], "summary": ""}
intervention = state.get("last_user_intervention") or {}
@@ -95,6 +205,13 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
current_outline = state.get("current_outline")
is_revise = bool(feedback) and bool(current_outline)
# 用户自带大纲:首轮(尚无大纲、非重规划)且表单粘了大纲原文 → 走「采用用户大纲」分支,
# 跳过 LLM 重新规划。后续若用户在确认卡里点「重新规划」,current_outline 已存在 → 落到
# 下方 is_revise 编辑模式,按反馈调整用户大纲(符合预期)。
user_outline_raw = (state.get("user_outline_raw") or "").strip()
if user_outline_raw and not current_outline and not is_revise:
return await _build_outline_from_user(state, config, pkg, user_outline_raw)
if is_revise:
system_prompt = OUTLINE_REVISE_SYSTEM
user_prompt = OUTLINE_REVISE_USER.format(
@@ -125,13 +242,17 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
parser = ThinkTagParser()
try:
# get_model 放进 try:模型名配错时 create_chat_model 抛 ValueError,
# get_writing_model 放进 try:用户模型缺失 / 模型名配错时抛 ValueError,
# 留在 try 外会硬崩溃;放进来则统一走下方的 status:error 兜底。
model = get_model(config)
model = get_writing_model(config, state)
async for chunk in model.astream([
SystemMessage(content=system_prompt),
HumanMessage(content=user_prompt),
]):
# 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送
reasoning = reasoning_delta(chunk)
if reasoning:
writer({"type": "outline_chunk", "text": reasoning, "is_thinking": True})
delta = _content_to_text(chunk.content)
if not delta:
continue
@@ -0,0 +1,220 @@
"""把用户粘贴的大纲文本解析成三级 ``Outline`` 结构(确定性解析,不依赖 LLM)。
用户在写作表单里可能直接粘贴一份已有大纲,要求「完全按我的大纲生成」。本模块负责
把各种常见的大纲文本格式严格、保真地转成后端三级结构:
Outline.sections[] = Level 1 章节(section_title)
OutlineSection.key_points[] = Level 2 小节(point_title)
KeyPoint.sub_points[] = Level 3 子要点
兼容的输入格式:
- Markdown 标题:``#`` / ``##`` / ``###`` / ``####``(首个孤立 ``#`` 视为文章标题)
- 中文编号:``一、`` / ``(一)`` / ``第一章`` / ``第一节``
- 阿拉伯编号:``1.`` / ``1、`` / ``1.1`` / ``1.1.1``
- 缩进 bullet:``-`` / ``*`` / ``•`` / ``·``(用缩进深度表示层级)
- 纯文本平铺:每行一个一级标题(短标题、无句末标点)
设计原则:
- **保真不改写**:只识别层级、原样保留标题文本,绝不增删或改写用户内容。
- **多级混排自适应**:把文本里实际出现的各种「层级标记」收集起来排序,按相对外层
顺序映射到 1/2/3 级——所以不依赖固定的标记搭配,``##``+``-`` 或 ``一、``+缩进
都能正确分层。
- **拿不准就交给上层**:识别不出可靠层级(如整段散文)时返回 ``None``,由调用方走
LLM 兜底归一,避免硬把散文拆成错误结构。
"""
from __future__ import annotations
import re
from .state import KeyPoint, Outline, OutlineSection
# ── 行首标记识别 ──────────────────────────────────────────────────────────────
_CN_NUM = "一二三四五六七八九十百千零〇两"
# Markdown 标题:## 标题
_MD_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*\S)\s*$")
# 第一章 / 第1节 / 第一部分
_CN_CHAPTER_RE = re.compile(rf"^第\s*[{_CN_NUM}\d]+\s*[章节部分篇讲回卷](?:[\s::、..))]\s*|\s+)?(.*)$")
# 一、 / 十二、 / 一. / 一.
_CN_DUN_RE = re.compile(rf"^[{_CN_NUM}]+\s*[、..]\s*(.*)$")
# (一) / (一) / (1) / (1)
_PAREN_RE = re.compile(rf"^[((]\s*[{_CN_NUM}\d]+\s*[))]\s*(.*)$")
# 1.1 / 1.1.1(带至少一个点的多级阿拉伯编号)
_ARABIC_DOTTED_RE = re.compile(r"^(\d+(?:\.\d+)+)\s*[、..))]?\s*(.*)$")
# 1. / 1、 / 1) / 1.(单级阿拉伯编号)
_ARABIC_SINGLE_RE = re.compile(r"^\d+\s*[、..))]\s*(.*)$")
# 带圈数字 ①②③ / 罗马 ⅰ
_CIRCLED_RE = re.compile(r"^[①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮]\s*(.*)$")
# bullet:- / * / • / ·(要求标记后有空白,避免误吞「*强调*」之类)
_BULLET_RE = re.compile(r"^[-*•·]\s+(.*\S)\s*$")
# 标记分组的「外层度」基准值(越小越外层)。group: 0=标题 1=编号 2=缩进。
# 同组内 order 决定相对层级;不同组之间靠 group 排序(标题 < 编号 < 缩进)。
# 编号顺序遵循中文公文惯例:第一章 > 一、 > (一) > 1. > 1.1 > ①。
_NUM_ORDER = {
"cn_chapter": 0.0, # 第一章
"cn_dun": 1.0, # 一、
"paren": 2.0, # (一)
"arabic_single": 3.0, # 1.
"circled": 8.0, # ①
}
def _indent_width(line: str) -> int:
"""计算行首缩进宽度(tab 记 4 列)。"""
width = 0
for ch in line:
if ch == " ":
width += 1
elif ch == "\t":
width += 4
else:
break
return width
def _classify(stripped: str, indent: int) -> tuple[tuple[int, float], str] | None:
"""识别一行的层级标记,返回 ``((group, order), 纯文本)``;无法识别返回 None。
``(group, order)`` 是该行的「层级键」:把全文出现过的不同键排序后,按位置映射到
1/2/3 级。这样不必预设标记搭配,相对顺序对了层级就对。
"""
if not stripped:
return None
# 1) Markdown 标题(group 0,order = 井号数)
m = _MD_HEADING_RE.match(stripped)
if m:
depth = len(m.group(1))
text = m.group(2).strip()
return (0, float(depth)), text
# 2) 各类编号(group 1)
m = _CN_CHAPTER_RE.match(stripped)
if m and stripped.startswith("第"):
return (1, _NUM_ORDER["cn_chapter"]), (m.group(1) or "").strip()
m = _CN_DUN_RE.match(stripped)
if m:
return (1, _NUM_ORDER["cn_dun"]), m.group(1).strip()
m = _ARABIC_DOTTED_RE.match(stripped)
if m:
depth = m.group(1).count(".") + 1 # 1.1 → 2, 1.1.1 → 3
# 多级阿拉伯:order 随层级递增,落在单级(3.0)与圈号(8.0)之间
return (1, 3.0 + depth), m.group(2).strip()
m = _PAREN_RE.match(stripped)
if m:
return (1, _NUM_ORDER["paren"]), m.group(1).strip()
m = _ARABIC_SINGLE_RE.match(stripped)
if m:
return (1, _NUM_ORDER["arabic_single"]), m.group(1).strip()
m = _CIRCLED_RE.match(stripped)
if m:
return (1, _NUM_ORDER["circled"]), m.group(1).strip()
# 3) bullet:用缩进深度区分层级(group 2)
m = _BULLET_RE.match(stripped)
if m:
return (2, float(indent)), m.group(1).strip()
# 4) 纯文本:用缩进深度区分层级(group 3,最内层基准)
return (3, float(indent)), stripped
def _looks_like_prose(text: str) -> bool:
"""判断一行是否更像散文句子而非大纲标题(长、或带句末标点)。"""
if len(text) > 50:
return True
return text.rstrip()[-1:] in "。!?!?;;"
def parse_user_outline(raw: str) -> Outline | None:
"""把用户粘贴的大纲文本解析成三级 ``Outline``;无法可靠解析时返回 ``None``。
返回 ``None`` 的情形:空白输入、只有一行且无任何标记、或整体更像散文段落——这些交给
上层用 LLM 兜底归一,避免误拆。
"""
if not raw or not raw.strip():
return None
rows: list[tuple[tuple[int, float], str]] = []
for line in raw.splitlines():
if not line.strip():
continue
indent = _indent_width(line)
classified = _classify(line.strip(), indent)
if classified is None:
continue
key, text = classified
if text:
rows.append((key, text))
if not rows:
return None
marked = any(key[0] != 3 for key, _ in rows) # 是否出现过标题/编号/bullet 标记
# 纯文本且无任何标记:可能是「平铺标题列表」也可能是散文。
# 单行、或任一行像句子 → 判为散文,返回 None 交给 LLM。
if not marked:
if len(rows) < 2:
return None
if any(_looks_like_prose(text) for _, text in rows):
return None
# 文章标题:仅在「Markdown 首行是孤立的最浅井号、且存在更深井号」时认定。
title = ""
body = rows
heading_depths = [key[1] for key, _ in rows if key[0] == 0]
if heading_depths:
min_depth = min(heading_depths)
first_key = rows[0][0]
is_first_top_heading = first_key == (0, min_depth)
only_one_at_top = sum(1 for d in heading_depths if d == min_depth) == 1
has_deeper = any(d > min_depth for d in heading_depths)
if is_first_top_heading and only_one_at_top and has_deeper:
title = rows[0][1]
body = rows[1:]
if not body:
return None
# 把 body 里出现的不同「层级键」排序,按位置映射到 1/2/3 级(超过 3 级压到 3)。
distinct_keys = sorted({key for key, _ in body})
level_of: dict[tuple[int, float], int] = {
key: min(idx + 1, 3) for idx, key in enumerate(distinct_keys)
}
sections: list[OutlineSection] = []
cur_section: OutlineSection | None = None
cur_point: KeyPoint | None = None
for key, text in body:
level = level_of[key]
if level <= 1:
cur_section = OutlineSection(section_title=text, key_points=[], material_ids=[])
sections.append(cur_section)
cur_point = None
elif level == 2:
if cur_section is None: # 孤立 L2:提升为章节,避免空标题
cur_section = OutlineSection(section_title=text, key_points=[], material_ids=[])
sections.append(cur_section)
cur_point = None
continue
cur_point = KeyPoint(point_title=text, sub_points=[])
cur_section["key_points"].append(cur_point)
else: # level == 3
if cur_section is None: # 孤立 L3:提升为章节
cur_section = OutlineSection(section_title=text, key_points=[], material_ids=[])
sections.append(cur_section)
cur_point = None
elif cur_point is None: # 章节下尚无小节:提升为小节
cur_point = KeyPoint(point_title=text, sub_points=[])
cur_section["key_points"].append(cur_point)
else:
cur_point["sub_points"].append(text)
if not sections:
return None
return Outline(title=title, sections=sections)
@@ -123,6 +123,60 @@ OUTLINE_REVISE_USER = """写作意图:{user_intent}
请在「当前大纲」基础上落实上述「用户修改要求」,未提到的章节保持原样,输出调整后的完整大纲 JSON。"""
# ── 用户自带大纲:LLM 兜底归一(确定性解析失败时才用)──────────────────────────
OUTLINE_NORMALIZE_SYSTEM = """你是一名大纲格式整理助手。
用户粘贴了一份已有的文章大纲(格式可能不规范),你的任务是把它原样转成结构化 JSON。
【最高原则:保真,绝不改写】
- 严格保留用户写的每一条标题文字,逐字照搬,不要润色、改写、翻译、增加或删除任何条目。
- 你只判断层级(哪条是章节、哪条是小节、哪条是子要点),不创作内容。
- 层级判断依据用户原文里的编号(一、/(一)/1./1.1)、Markdown 井号数、或缩进深度。
【三级结构】
- Level 1 章节 → section_title
- Level 2 小节 → key_points[].point_title
- Level 3 子要点 → key_points[].sub_points[](字符串数组)
- 用户大纲只有一级时,每条都是章节,key_points 留空 []。
- 用户大纲只有两级时,sub_points 留空 []。
【输出要求】
- 只输出一个 JSON 对象,不要解释文字,不要 markdown 代码块,必须用英文双引号。
- material_ids 一律输出 [](此步不关联素材)。
- 结构:{"title": "...", "sections": [{"section_title": "...", "key_points": [{"point_title": "...", "sub_points": ["..."]}], "material_ids": []}]}
- 用户大纲若包含一个总标题,放进 title;没有就留空字符串。"""
OUTLINE_NORMALIZE_USER = """用户粘贴的大纲原文:
{raw_outline}
请逐条保留原文,仅判断层级,输出结构化大纲 JSON。"""
# ── 用户自带大纲:素材关联(结构锁定,只挑 material_ids)──────────────────────────
OUTLINE_MATERIAL_ASSIGN_SYSTEM = """你是一名资料编辑。
给定一份【已确定的文章大纲】(章节顺序固定)和一批检索到的素材,你的任务是为每个章节
挑选最相关的素材编号。
【规则】
- 不要修改大纲的任何标题或结构,你只输出每个章节对应的素材编号列表。
- 严格按大纲给出的章节顺序输出,一个章节对应一个编号数组;该章节没有相关素材就给 []。
- material_id 必须来自给定素材清单里出现过的编号,不要编造。
- 一条素材可以分配给多个章节,也可以一个都不分配。
【输出要求】
- 只输出一个 JSON 对象,不要解释文字,不要 markdown 代码块,必须用英文双引号。
- 结构:{"assignments": [["m001","m002"], [], ["m003"]]},数组长度等于章节数、顺序一致。"""
OUTLINE_MATERIAL_ASSIGN_USER = """写作意图:{user_intent}
文章大纲(章节按顺序编号):
{sections_list}
可用素材:
{materials_summary}
请按章节顺序,为每个章节挑选相关的 material_id,输出 assignments 数组。"""
DRAFT_SECTION_SYSTEM = """你是一名专业内容创作者(作家)。
你的目标:根据大纲和素材,写出一段结构清晰、高质量的文章内容。
你的工作背景:你有十年内容创作经验,文字流畅,善于用事实说话。
@@ -16,6 +16,7 @@
from __future__ import annotations
import logging
import time
from typing import Callable, List, Optional
from langchain_core.messages import HumanMessage, SystemMessage
@@ -26,6 +27,7 @@ from ..nodes._utils import (
_content_to_text,
get_model,
parse_json,
reasoning_delta,
safe_get_stream_writer,
)
@@ -127,12 +129,26 @@ class SkillPicker:
fallback = {"selected_skills": [s.name for s in skills[: self._max_select]], "reason": ""}
# list_skills 之后立刻发一个"分析中"提示步骤,避免 LLM 第一个 chunk 到来前
# 用户看到空窗期(某些模型 astream 在头部 buffer 时间较长)
self._emit_step({
"type": "match_skills",
"message": "正在让模型从候选技能中筛选最合适的…",
})
# 流式 LLM 调用:与 intent_parser / writer_outline 同套机制
writer = safe_get_stream_writer()
parser = ThinkTagParser()
full_text = ""
_chunk_count = {"n": 0}
def _emit_chunk(text: str, is_thinking: bool) -> None:
# 只在首个 chunk 打一条 timing,避免逐 token 刷屏;用来和 skill_search_step
# 的 [skill-timing] 对比"选择打字"与"步骤事件"谁先到。
if _chunk_count["n"] == 0:
logger.info("[skill-timing] t=%.3f first skill_select_chunk", time.monotonic())
_chunk_count["n"] += 1
writer({
"type": "skill_select_chunk",
"text": text,
@@ -140,6 +156,10 @@ class SkillPicker:
"agent_name": "素材收集专家",
})
# 立刻推一个空 chunk,让前端 streaming view 立即挂载(占位 + 闪烁光标),
# 即使后续 LLM 首个 chunk 到达较慢,UI 也不再静默
_emit_chunk("", False)
try:
model = get_model(self._config, model_name=self._model_name)
async for chunk in model.astream([
@@ -152,6 +172,10 @@ class SkillPicker:
max_select=self._max_select,
)),
]):
# 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送
reasoning = reasoning_delta(chunk)
if reasoning:
_emit_chunk(reasoning, True)
delta = _content_to_text(chunk.content)
if not delta:
continue
@@ -125,6 +125,9 @@ class BlockedSection(TypedDict):
class AIWritingState(TypedDict):
# 写作配置
user_intent: str
# 用户在写作表单粘贴的「已有大纲」原文(可选)。非空时 writer_outline 跳过 LLM 规划,
# 直接解析这份大纲并按用户结构生成,做到「完全按我的大纲写」。留空走正常自动规划。
user_outline_raw: str
article_type: str
target_audience: str
word_count_target: int
@@ -132,6 +135,17 @@ class AIWritingState(TypedDict):
keyword_count: int
# 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose)
writing_mode: str
# 素材来源:knowledge_base=知识库(默认 web/intranet 检索)/ skill=技能检索。
# 用户在写作表单选择,首次检索时由 researcher 节点据此决定检索方式(缺省 knowledge_base)。
material_source: str
# 用户在写作表单选定的主模型。大纲/写作/审核严格使用它(不回退默认)。
# 由 intent_parser 在首次 invocation 从 configurable.model_name 捕获进 state,
# 经 checkpoint 持久化 —— interrupt/resume 是新 invocation、其 config 不带原
# model_name,存进 state 才能让 resume 后才运行的节点拿到用户模型。
model_name: str
# 笔记来源(可选):传入后 researcher 阶段会额外从笔记检索素材
notebook_search_space_id: Optional[int]
notebook_source_ids: Optional[List[int]]
# Agent A 输出
material_package: Optional[MaterialPackage]
@@ -11,6 +11,7 @@ from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware
from deerflow.agents.middlewares.rag_citation_middleware import RagCitationMiddleware
from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware
from deerflow.agents.middlewares.setup_writing_approval_middleware import SetupWritingApprovalMiddleware
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
@@ -25,6 +26,9 @@ from deerflow.models import create_chat_model
logger = logging.getLogger(__name__)
# 写作配置 Q&A:意图澄清最多几轮(一条用户消息 = 一轮),到顶强制提交配置卡。
WRITING_SETUP_MAX_ROUNDS = 3
def _get_runtime_config(config: RunnableConfig) -> dict:
"""Merge legacy configurable options with LangGraph runtime context."""
@@ -272,6 +276,7 @@ def _build_middlewares(
# Add TodoList middleware if plan mode is enabled
is_plan_mode = cfg.get("is_plan_mode", False)
is_bootstrap = cfg.get("is_bootstrap", False)
is_writing_setup = cfg.get("is_writing_setup", False)
todo_list_middleware = _create_todo_list_middleware(is_plan_mode)
if todo_list_middleware is not None:
middlewares.append(todo_list_middleware)
@@ -280,18 +285,28 @@ def _build_middlewares(
if resolved_app_config.token_usage.enabled:
middlewares.append(TokenUsageMiddleware())
# Add TitleMiddleware
middlewares.append(TitleMiddleware(app_config=resolved_app_config))
# Add TitleMiddleware — but skip it for scheduled runs. Each scheduled run
# executes on a throwaway thread whose title is never shown to a user, and
# generating one would fire an extra LLM call on the *default* model
# (config.title.model_name is usually unset → models[0]), which is not the
# model the task selected. Skipping it keeps scheduled/HTML runs on exactly
# the chosen model.
if not cfg.get("is_scheduled_run", False):
middlewares.append(TitleMiddleware(app_config=resolved_app_config))
# Add MemoryMiddleware (after TitleMiddleware) — skip in bootstrap mode (agent creation flow)
memory_injection_enabled = cfg.get("memory_injection_enabled", True)
if not is_bootstrap:
if not is_bootstrap and not is_writing_setup:
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory, injection_enabled=memory_injection_enabled))
# Add ViewImageMiddleware only if the current model supports vision.
# Use the resolved runtime model_name from make_lead_agent to avoid stale config values.
model_config = resolved_app_config.get_model_config(model_name) if model_name else None
if model_config is not None and model_config.supports_vision:
# Add ViewImageMiddleware only for the NATIVE-vision path (main model
# ingests raw image data). In delegated mode the view_image tool already
# returns a text description and stores nothing in `viewed_images`, so the
# middleware would otherwise inject a spurious "No images have been viewed."
# message. Use the resolved runtime model_name to avoid stale config values.
from deerflow.models.vision import is_delegated_vision, vision_enabled
if vision_enabled(model_name, app_config=resolved_app_config) and not is_delegated_vision(app_config=resolved_app_config):
middlewares.append(ViewImageMiddleware())
# Add DeferredToolFilterMiddleware to hide deferred tool schemas from model binding
@@ -314,6 +329,7 @@ def _build_middlewares(
middlewares.extend(custom_middlewares)
middlewares.append(SetupAgentApprovalMiddleware())
middlewares.append(SetupWritingApprovalMiddleware())
# SkillStopMiddleware — intercepts skill invocations and halts execution
# when a configured skill is about to run. Request-level options
@@ -361,7 +377,7 @@ def make_lead_agent(config: RunnableConfig):
def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
# Lazy import to avoid circular dependency
from deerflow.tools import get_available_tools
from deerflow.tools.builtins import setup_agent
from deerflow.tools.builtins import ask_clarification_tool, setup_agent, setup_writing
cfg = _get_runtime_config(config)
resolved_app_config = app_config
@@ -372,6 +388,10 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
is_plan_mode = cfg.get("is_plan_mode", False)
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
is_bootstrap = cfg.get("is_bootstrap", False)
# 写作配置 Q&A:与 is_bootstrap 同构的另一种「最小化 bootstrap」——挂 setup_writing 工具、
# 走写作配置专用 prompt,只负责帮用户把写作表单聊清并提交,不真正开始写作。
is_writing_setup = cfg.get("is_writing_setup", False)
writing_setup_article_types = cfg.get("valid_article_types") if is_writing_setup else None
memory_injection_enabled = bool(cfg.get("memory_injection_enabled", True))
rag_mode_enabled = bool(cfg.get("rag_mode_enabled", False))
writing_mode = bool(cfg.get("writing_mode", False))
@@ -382,7 +402,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
requested_excluded_tools = cfg.get("excluded_tools") or None
try:
agent_config = load_agent_config(agent_id) if not is_bootstrap else None
agent_config = load_agent_config(agent_id) if not is_bootstrap and not is_writing_setup else None
except FileNotFoundError:
logger.warning("Agent directory not found for '%s'; running with default config", agent_id)
agent_config = None
@@ -479,6 +499,25 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
}
)
if is_writing_setup:
# 写作配置 Q&A:最小化 agent —— 只挂 setup_writing(提交配置)+ ask_clarification(弹澄清卡)。
# 关键:绝不给全套工具、绝不复用 lead 超级体提示,否则模型会把「新能源汽车市场分析」之类
# 长得像任务的输入直接做掉,而不是转成写作配置提交。
# 末尾追加 WritingSetupRoundCapMiddleware:澄清最多 3 轮,到顶用 tool_choice 强制提交配置卡。
from deerflow.agents.lead_agent.prompt import build_writing_setup_prompt
from deerflow.agents.middlewares.writing_setup_round_cap_middleware import WritingSetupRoundCapMiddleware
return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config),
tools=[setup_writing, ask_clarification_tool],
middleware=[
*_build_middlewares(config, model_name=model_name, app_config=resolved_app_config),
WritingSetupRoundCapMiddleware(max_rounds=WRITING_SETUP_MAX_ROUNDS),
],
system_prompt=build_writing_setup_prompt(writing_setup_article_types),
state_schema=ThreadState,
)
if is_bootstrap:
# Special bootstrap agent with minimal prompt for initial custom agent creation flow
return create_agent(
@@ -703,14 +703,14 @@ def _get_cached_skills_prompt_section(
display_pattern = (
"""
**Skill Result Display Pattern:**
- If RAG/reference display mode is enabled, do not number each skill result independently. Treat all retrieval results from the same user turn as one combined `referenceBatch` and cite with `[1]`, `[2]`, etc. from the system-provided reference prompt.
- In RAG/reference display mode, do not output `<skill-display .../>`; the frontend renders cards, tables, lists, and citation details in the right reference panel.
- If RAG/reference display mode is not enabled and a skill has `<display mode="citation">`, use the skill's returned result order as reference numbers. Cite facts from the first result as `[1]`, the second as `[2]`, and so on. Use only numbers that correspond to returned results.
- If a skill has `<display mode="list">`, summarize the returned items as a concise list and keep item order stable.
- If a skill has `<display mode="table">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="table" />` where the frontend should render the real table. Do not hand-write the table rows yourself.
- If a skill has `<display mode="card">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="card" />` where the frontend should render the cards.
- If a skill has `<display mode="custom">`, follow the custom display instruction in the `<display>` tag when presenting the skill result.
**Skill Result Display Pattern:**
- If RAG/reference display mode is enabled, do not number each skill result independently. Treat all retrieval results from the same user turn as one combined `referenceBatch` and cite with `[1]`, `[2]`, etc. from the system-provided reference prompt.
- In RAG/reference display mode, do not output `<skill-display .../>`; the frontend renders cards, tables, lists, and citation details in the right reference panel.
- If RAG/reference display mode is not enabled and a skill has `<display mode="citation">`, use the skill's returned result order as reference numbers. Cite facts from the first result as `[1]`, the second as `[2]`, and so on. Use only numbers that correspond to returned results.
- If a skill has `<display mode="list">`, summarize the returned items as a concise list and keep item order stable.
- If a skill has `<display mode="table">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="table" />` where the frontend should render the real table. Do not hand-write the table rows yourself.
- If a skill has `<display mode="card">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="card" />` where the frontend should render the cards.
- If a skill has `<display mode="custom">`, follow the custom display instruction in the `<display>` tag when presenting the skill result.
"""
if display_signature
else ""
@@ -904,6 +904,46 @@ def _build_bootstrap_section() -> str:
"""
def build_writing_setup_prompt(article_types: list[dict] | None = None) -> str:
"""写作配置 Q&A 的**独立**系统提示(不复用 lead-agent 超级体提示)。
关键:写作配置助手绝不能像 lead agent 那样「自己把任务做掉」——用户输入常常长得像一个
可执行任务(如「新能源汽车市场分析」),若复用 lead 提示 + 全套工具,模型会直接去研究/写
分析。这里用一份职责单一、口吻强硬的独立提示,配合「只挂 setup_writing 一个工具」,把模型
死死框在「聊清需求 → 调 setup_writing」上。
"""
if article_types:
type_lines = "\n".join(
f" - `{t.get('key', '')}`:{t.get('label', '')}" for t in article_types if t.get("key")
)
type_block = f"【可选文章类型】article_type 只能填下列 key 之一:\n{type_lines}"
else:
type_block = "【文章类型】若不确定 article_type,可留空,交给用户在表单里选。"
return f"""你是「AI 写作」功能的**配置助手**。你的唯一职责:通过简短对话把用户的写作需求聊清楚,然后调用 `setup_writing` 工具把写作配置提交给用户确认。提交后由用户在表单里应用、再点「开始写作」。
【铁律——最高优先级】
- 你**绝对不撰写**文章正文、分析内容、大纲内容或任何成稿文字。即使用户的输入看起来就是一个写作任务(例如「新能源汽车市场分析」「帮我写一篇……」),你也**只把它当作"要配置的写作目标"**,绝不替用户动笔、绝不直接完成这篇写作。
- 你只有两个工具:① `ask_clarification`(向用户提问澄清,需要时用);② `setup_writing`(提交写作配置)。**没有任何其它工具**,不要做检索、不要做研究、不要假装调用别的工具。
- 不要在回复里输出文章段落、要点清单或大纲(用户主动粘贴的大纲除外——原样放进 `outline` 参数)。
【工作流程】(与用户交互确认,但最多 3 轮内必须提交)
1. 读用户输入。当关键项(文章类型、目标字数、目标读者)缺失或模糊时,调用 `ask_clarification` 向用户提问——**优先给出 `options` 选项**让用户点选(例如目标字数给「800 / 1500 / 3000」,读者给「普通读者 / 行业专家 / 政策决策者」)。一次可就 1-2 个独立的关键项各发一个 ask_clarification。
2. 与用户的澄清来回**最多 2 次**。一旦关键信息明确、或已经问过 2 次,就**立即进入第 3 步提交**,缺失字段用合理默认值,绝不无休止追问。
3. 调用 `setup_writing` 提交配置:
- `user_intent`:精炼成一句话的写作目标(必填)。
- `article_type`:见下方可选类型;不确定就留空。
- `word_count_target`:正整数;用户没明说就给合理默认(如 800)。
- `target_audience`:如「普通读者」「行业专家」;没提就填「普通读者」。
- `writing_mode`:用户强调「严格依据素材/不要编造」→ `strict`,否则 `loose`。
- `material_source`:**默认 `knowledge_base`(知识库)**;只有当用户明确要求用「技能/技能检索」时才填 `skill`,否则一律 `knowledge_base`。
- `outline`:用户提供的大纲原文(保留其层级与标题),否则留空。
4. 调用 `setup_writing` 前,用**一句话**说明你将提交的配置要点;调用之后不要再输出任何文章内容。
{type_block}
"""
def apply_prompt_template(
subagent_enabled: bool = False,
max_concurrent_subagents: int = 3,
@@ -996,16 +1036,16 @@ Writing mode is enabled for this run.
network_policy_section = _build_network_policy_section()
rag_mode_section = (
"""
<rag_citation_mode>
RAG/reference display mode is enabled.
When a search, knowledge, skill, or MCP tool returns a result list:
- If multiple search terms are needed, prefer calling the retrieval tools for all known terms in the same step, then answer after the tool results are returned.
- Treat every retrieval result from the same user turn as one combined `referenceBatch`; do not restart numbering for each search term, tool call, skill, or display adapter.
- Use only the system-provided merged reference numbers in the final answer, formatted as `[1]`, `[2]`, `[1][3][5]`.
- Do not output `<skill-display .../>` in RAG/reference mode. The frontend renders configured cards, tables, lists, and citation details in the right reference panel.
- If no display adapter exists, the frontend defaults the right reference panel to numbered citations.
</rag_citation_mode>
"""
<rag_citation_mode>
RAG/reference display mode is enabled.
When a search, knowledge, skill, or MCP tool returns a result list:
- If multiple search terms are needed, prefer calling the retrieval tools for all known terms in the same step, then answer after the tool results are returned.
- Treat every retrieval result from the same user turn as one combined `referenceBatch`; do not restart numbering for each search term, tool call, skill, or display adapter.
- Use only the system-provided merged reference numbers in the final answer, formatted as `[1]`, `[2]`, `[1][3][5]`.
- Do not output `<skill-display .../>` in RAG/reference mode. The frontend renders configured cards, tables, lists, and citation details in the right reference panel.
- If no display adapter exists, the frontend defaults the right reference panel to numbered citations.
</rag_citation_mode>
"""
if rag_mode_enabled
else ""
)
@@ -67,6 +67,10 @@ class SetupAgentApprovalMiddleware(AgentMiddleware[SetupAgentApprovalMiddlewareS
args = setup_call.get("args") or {}
tool_call_id = setup_call.get("id") or ""
if isinstance(args.get("skills"), list) and len(args["skills"]) > 5:
args = {**args, "skills": args["skills"][:5]}
payload = self._build_payload(args)
logger.info("Setup agent approval requested before writing agent files")
@@ -0,0 +1,133 @@
"""Approval middleware for the AI-writing config setup tool.
与 ``SetupAgentApprovalMiddleware`` 同构:模型在「写作配置 Q&A」里调用 ``setup_writing``
时,于 ``after_model`` 拦截,附上一条结构化审批 ToolMessage(建议的写作配置打包在
``additional_kwargs["setup_writing_approval"]``)并 ``jump_to="end"`` 立即结束本轮运行——
前端据此渲染审批卡,用户点「应用到表单」后把这些字段回填到左侧写作表单。
"""
from __future__ import annotations
import logging
from hashlib import sha256
from typing import Any, override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware, hook_config
from langchain_core.messages import AIMessage, ToolMessage
from langgraph.runtime import Runtime
logger = logging.getLogger(__name__)
class SetupWritingApprovalMiddlewareState(AgentState):
"""Compatible with the DeerFlow thread state schema."""
pass
class SetupWritingApprovalMiddleware(AgentMiddleware[SetupWritingApprovalMiddlewareState]):
"""Stop before ``setup_writing`` and ask the user to apply the proposed config.
See ``SetupAgentApprovalMiddleware`` for the rationale behind intercepting in
``after_model`` + ``jump_to="end"`` (robust against parallel tool calls).
"""
state_schema = SetupWritingApprovalMiddlewareState
def _is_approved(self, runtime: Runtime) -> bool:
context = getattr(runtime, "context", None) or {}
if not isinstance(context, dict):
return False
return context.get("setup_writing_approved") is True
def _stable_message_id(self, tool_call_id: str, args: dict[str, Any]) -> str:
if tool_call_id:
return f"setup-writing-approval:{tool_call_id}"
digest = sha256(repr(sorted(args.items())).encode("utf-8")).hexdigest()[:16]
return f"setup-writing-approval:{digest}"
def _coerce_word_count(self, raw: Any) -> int | None:
if isinstance(raw, bool): # bool 是 int 子类,先排除
return None
if isinstance(raw, int):
return raw if raw > 0 else None
if isinstance(raw, float):
return int(raw) if raw > 0 else None
if isinstance(raw, str):
import re
m = re.search(r"\d[\d,]*", raw)
if m:
try:
value = int(m.group().replace(",", ""))
return value if value > 0 else None
except ValueError:
return None
return None
def _build_payload(self, args: dict[str, Any]) -> dict[str, Any]:
# 素材来源:仅当模型明确给出 "skill" 时才用技能检索;用户没明确要求就一律默认知识库。
material_source = (
"skill" if str(args.get("material_source") or "").strip() == "skill" else "knowledge_base"
)
return {
"user_intent": str(args.get("user_intent") or "").strip(),
"article_type": str(args.get("article_type") or "").strip(),
"word_count_target": self._coerce_word_count(args.get("word_count_target")),
"target_audience": str(args.get("target_audience") or "").strip(),
"writing_mode": str(args.get("writing_mode") or "").strip(),
"material_source": material_source,
"outline": str(args.get("outline") or "").strip(),
}
def _intercept(self, last: AIMessage) -> dict[str, Any] | None:
"""Return a state update if setup_writing must be gated; None to passthrough."""
tool_calls = list(last.tool_calls or [])
setup_call = next((tc for tc in tool_calls if tc.get("name") == "setup_writing"), None)
if setup_call is None:
return None
args = setup_call.get("args") or {}
tool_call_id = setup_call.get("id") or ""
payload = self._build_payload(args)
logger.info("Setup writing approval requested before applying writing config")
approval_msg = ToolMessage(
id=self._stable_message_id(tool_call_id, args),
content="请确认是否把这份写作配置应用到表单。",
tool_call_id=tool_call_id,
name="setup_writing",
additional_kwargs={"setup_writing_approval": payload},
)
return {
"messages": [approval_msg],
"jump_to": "end",
}
@hook_config(can_jump_to=["end"])
@override
def after_model(
self,
state: SetupWritingApprovalMiddlewareState,
runtime: Runtime,
) -> dict[str, Any] | None:
if self._is_approved(runtime):
return None
messages = state.get("messages") or []
if not messages:
return None
last = messages[-1]
if not isinstance(last, AIMessage):
return None
return self._intercept(last)
@hook_config(can_jump_to=["end"])
@override
async def aafter_model(
self,
state: SetupWritingApprovalMiddlewareState,
runtime: Runtime,
) -> dict[str, Any] | None:
return self.after_model(state, runtime)
@@ -23,6 +23,7 @@ class TitleMiddlewareState(AgentState):
"""Compatible with the `ThreadState` schema."""
title: NotRequired[str | None]
title_provisional: NotRequired[bool]
class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
@@ -61,16 +62,37 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
return ""
def _should_generate_title(self, state: TitleMiddlewareState) -> bool:
"""Check if we should generate a title for this thread."""
def _should_set_provisional_title(self, state: TitleMiddlewareState) -> bool:
"""Check if we should set an instant, question-derived placeholder title.
Runs in ``before_model`` so the thread shows a sensible title the moment
the user sends their first message — eliminating the "untitled" window
while the (possibly long) first answer streams. No LLM call, no latency.
"""
config = self._get_title_config()
if not config.enabled:
return False
# Check if thread already has a title in state
# Already has a title (provisional or final) — nothing to do.
if state.get("title"):
return False
# Only on the very first turn, as soon as the user message arrives.
messages = state.get("messages", [])
user_messages = [m for m in messages if m.type == "human"]
return len(user_messages) == 1
def _should_generate_title(self, state: TitleMiddlewareState) -> bool:
"""Check if we should generate the final LLM title for this thread."""
config = self._get_title_config()
if not config.enabled:
return False
# Skip only if we already have a *finalized* title. A provisional
# placeholder set in before_model should still be upgraded by the LLM.
if state.get("title") and not state.get("title_provisional"):
return False
# Check if this is the first turn (has at least one user message and one assistant response)
messages = state.get("messages", [])
if len(messages) < 2:
@@ -146,13 +168,27 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
config["tags"] = [*(config.get("tags") or []), "middleware:title"]
return config
def _provisional_title_result(self, state: TitleMiddlewareState) -> dict | None:
"""Set an instant placeholder title from the first user message (no LLM)."""
if not self._should_set_provisional_title(state):
return None
messages = state.get("messages", [])
user_msg_content = next((m.content for m in messages if m.type == "human"), "")
user_msg = self._normalize_content(user_msg_content)
if not user_msg.strip():
return None
title = self._fallback_title(user_msg)
logger.debug("TitleMiddleware: set provisional title %r before first model call", title)
return {"title": title, "title_provisional": True}
def _generate_title_result(self, state: TitleMiddlewareState) -> dict | None:
"""Generate a local fallback title without blocking on an LLM call."""
if not self._should_generate_title(state):
return None
_, user_msg = self._build_title_prompt(state)
return {"title": self._fallback_title(user_msg)}
return {"title": self._fallback_title(user_msg), "title_provisional": False}
def _force_disable_thinking(self, model: object) -> None:
"""Force-disable thinking on the title model via `extra_body` overrides.
@@ -204,10 +240,18 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
response = await model.ainvoke(prompt, config=self._get_runnable_config())
title = self._parse_title(response.content)
if title:
return {"title": title}
return {"title": title, "title_provisional": False}
except Exception:
logger.debug("Failed to generate async title; falling back to local title", exc_info=True)
return {"title": self._fallback_title(user_msg)}
return {"title": self._fallback_title(user_msg), "title_provisional": False}
@override
def before_model(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None:
return self._provisional_title_result(state)
@override
async def abefore_model(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None:
return self._provisional_title_result(state)
@override
def after_model(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None:
@@ -18,6 +18,12 @@ logger = logging.getLogger(__name__)
_OUTLINE_PREVIEW_LINES = 5
# Image files are understood via the `view_image` tool (which routes to the
# configured vision model), never by reading their bytes. Listing them with the
# generic read_file/grep guidance made the agent try to parse image binaries
# with bash/python, which fails and wastes turns.
_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"}
def _extract_outline_for_file(file_path: Path) -> tuple[list[dict], list[str]]:
"""Return the document outline and fallback preview for *file_path*.
@@ -88,6 +94,16 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
size_str = f"{size_kb:.1f} KB" if size_kb < 1024 else f"{size_kb / 1024:.1f} MB"
lines.append(f"- {file['filename']} ({size_str})")
lines.append(f" Path: {file['path']}")
# Images must be understood through the vision model, not by reading bytes.
extension = str(file.get("extension") or Path(file["filename"]).suffix).lower()
if extension in _IMAGE_EXTENSIONS:
lines.append(f" This is an image. To understand it, call the `view_image` tool with image_path='{file['path']}' (optionally pass a `question`).")
lines.append(" The configured vision model will analyze it and return a description — trust that result.")
lines.append(" Do NOT read, open, or parse the image with read_file, grep, bash, python, PIL, or any byte-level inspection; those cannot interpret image content.")
lines.append("")
return
outline = file.get("outline") or []
if outline:
truncated = outline[-1].get("truncated", False)
@@ -139,6 +139,12 @@ class ViewImageMiddleware(AgentMiddleware[ViewImageMiddlewareState]):
if not messages:
return False
# Nothing to inject when no image data was captured (e.g. delegated
# recognition handled the image in the tool and stored no base64).
# Avoids injecting a useless "No images have been viewed." message.
if not state.get("viewed_images"):
return False
# Get the last assistant message
last_assistant_msg = self._get_last_assistant_message(messages)
if not last_assistant_msg:
@@ -0,0 +1,72 @@
"""写作配置 Q&A 的「轮数上限」中间件。
意图阶段允许与用户来回澄清确认,但**最多 N 轮后必须出结果**:到上限时,在模型请求里
注入强制指令 + 用 ``tool_choice`` 锁定 ``setup_writing``,逼模型立刻提交配置卡,不再提问。
「一轮」= 一条用户消息(HumanMessage)。达到 ``max_rounds`` 条用户消息时强制提交。
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
from langchain_core.messages import HumanMessage, SystemMessage
class WritingSetupRoundCapMiddleware(AgentMiddleware[AgentState]):
"""达到澄清轮数上限时,强制模型调用 ``setup_writing`` 提交。
软约束(注入 SystemMessage)+ 硬约束(``tool_choice`` 锁定工具)双保险:即使个别模型
无视提示,``tool_choice`` 也会逼它产出一次 ``setup_writing`` 调用。
"""
def __init__(self, max_rounds: int = 3, tool_name: str = "setup_writing") -> None:
super().__init__()
self._max_rounds = max(1, max_rounds)
self._tool_name = tool_name
def _should_force(self, messages: list) -> bool:
human_count = sum(1 for m in messages if isinstance(m, HumanMessage))
return human_count >= self._max_rounds
def _reminder(self) -> SystemMessage:
return SystemMessage(
content=(
f"【系统强制】意图澄清已达 {self._max_rounds} 轮上限。"
"现在必须立即调用 setup_writing 提交写作配置,禁止再向用户提任何问题。"
"缺失或不确定的字段一律用合理默认值:"
"word_count_target 默认 800、target_audience 默认「普通读者」、"
"writing_mode 默认 loose、material_source 默认 knowledge_base、"
"article_type 不确定就留空。"
)
)
def _patch(self, request: ModelRequest) -> ModelRequest:
return request.override(
messages=[*request.messages, self._reminder()],
tool_choice=self._tool_name,
)
@override
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelCallResult:
if self._should_force(request.messages):
request = self._patch(request)
return handler(request)
@override
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelCallResult:
if self._should_force(request.messages):
request = self._patch(request)
return await handler(request)
@@ -69,6 +69,10 @@ class ThreadState(AgentState):
sandbox: NotRequired[SandboxState | None]
thread_data: NotRequired[ThreadDataState | None]
title: NotRequired[str | None]
# True while `title` is a provisional, question-derived placeholder that the
# async LLM pass has not yet finalized. Lets TitleMiddleware show a title
# instantly (no "untitled" window) and upgrade it once the answer is ready.
title_provisional: NotRequired[bool]
artifacts: Annotated[list[str], merge_artifacts]
todos: NotRequired[list | None]
uploaded_files: NotRequired[list[dict] | None]
@@ -15,6 +15,7 @@ from deerflow.config.database_config import DatabaseConfig
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.config.gateway_config import GatewayConfig
from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict
from deerflow.config.login_config import AuthLoginConfig
from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict
from deerflow.config.model_config import ModelConfig
from deerflow.config.run_events_config import RunEventsConfig
@@ -32,6 +33,7 @@ from deerflow.config.title_config import TitleConfig, load_title_config_from_dic
from deerflow.config.token_usage_config import TokenUsageConfig
from deerflow.config.tool_config import ToolConfig, ToolGroupConfig
from deerflow.config.tool_search_config import ToolSearchConfig, load_tool_search_config_from_dict
from deerflow.config.vision_config import VisionConfig
load_dotenv()
@@ -104,6 +106,8 @@ class AppConfig(BaseModel):
log_level: str = Field(default="info", description="Logging level for deerflow and app modules (debug/info/warning/error); third-party libraries are not affected")
token_usage: TokenUsageConfig = Field(default_factory=TokenUsageConfig, description="Token usage tracking configuration")
models: list[ModelConfig] = Field(default_factory=list, description="Available models")
vision: VisionConfig = Field(default_factory=VisionConfig, description="Image recognition (识图) configuration")
auth_login: AuthLoginConfig = Field(default_factory=AuthLoginConfig, description="Custom login modes (username password gate, token exchange)")
sandbox: SandboxConfig = Field(description="Sandbox configuration")
tools: list[ToolConfig] = Field(default_factory=list, description="Available tools")
tool_groups: list[ToolGroupConfig] = Field(default_factory=list, description="Available tool groups")
@@ -62,6 +62,52 @@ class SkillDisplayTableColumn(BaseModel):
model_config = ConfigDict(extra="allow", populate_by_name=True)
class SkillDisplayResultSetConfig(BaseModel):
"""Display adapter for one result array inside a skill response."""
key: str = Field(description="Stable key for this result set")
label: str | None = Field(default=None, description="Human-readable result set label")
enabled: bool = Field(default=True, description="Whether this result set is enabled")
mode: Literal["citation", "list", "table", "card", "custom", "none"] = Field(
default="citation",
description="How the frontend should render this result set",
)
result_path: str = Field(default="results", description="Dot path to the array of result items", alias="resultPath")
title_field: str | None = Field(default=None, description="Field used as item title", alias="titleField")
summary_field: str | None = Field(default=None, description="Field used as short summary/snippet", alias="summaryField")
content_field: str | None = Field(default=None, description="Field used as full item content", alias="contentField")
source_field: str | None = Field(default=None, description="Field used as source label", alias="sourceField")
author_field: str | None = Field(default=None, description="Field used as author/byline label", alias="authorField")
type_field: str | None = Field(default=None, description="Field used as source/type badge", alias="typeField")
time_field: str | None = Field(default=None, description="Field used as publication/update time", alias="timeField")
score_field: str | None = Field(default=None, description="Field used as relevance score", alias="scoreField")
url_field: str | None = Field(default=None, description="Field containing a direct URL", alias="urlField")
id_field: str | None = Field(default=None, description="Field containing an ID for template URLs", alias="idField")
link_strategy: Literal["auto", "url", "id"] = Field(
default="auto",
description="Whether links use a URL field, an ID template, or automatic fallback",
alias="linkStrategy",
)
link_template: str | None = Field(default=None, description="Template used when linkStrategy is id", alias="linkTemplate")
citation_token: Literal["doubleBracket", "singleBracket"] = Field(
default="singleBracket",
description="Citation token style expected in model output",
alias="citationToken",
)
max_items: int = Field(default=100, ge=1, le=100, description="Maximum displayed items", alias="maxItems")
custom_instruction: str = Field(
default="",
description="User-authored instruction for custom rendering schema generation",
alias="customInstruction",
)
table_columns: list[SkillDisplayTableColumn] = Field(
default_factory=list,
description="Columns used when mode is table",
alias="tableColumns",
)
model_config = ConfigDict(extra="allow", populate_by_name=True)
class SkillDisplayConfig(BaseModel):
"""Frontend display adapter for structured skill results."""
@@ -103,6 +149,11 @@ class SkillDisplayConfig(BaseModel):
description="Columns used when mode is table",
alias="tableColumns",
)
result_sets: list[SkillDisplayResultSetConfig] = Field(
default_factory=list,
description="Optional per-result-array display adapters",
alias="resultSets",
)
model_config = ConfigDict(extra="allow", populate_by_name=True)
@@ -0,0 +1,83 @@
from pydantic import BaseModel, Field
# Fallback shared password for the username-login gate. Used when
# ``UsernameLoginConfig.password`` is left empty but ``require_password`` is on.
DEFAULT_USERNAME_LOGIN_PASSWORD = "123ewq"
class UsernameLoginConfig(BaseModel):
"""Gate for the passwordless ``POST /api/v1/auth/login/username`` flow.
By default this flow logs a user in (auto-registering if needed) from a
username alone — convenient, but it lets anyone sign in as any username.
Turning ``require_password`` on forces callers to additionally pass a
shared secret as ``?password=...``; mismatches are rejected with 401.
"""
require_password: bool = Field(
default=False,
description="When true, login/username requires a matching ?password= query value.",
)
password: str = Field(
default="",
description=f"Shared secret for the username-login gate. Empty falls back to '{DEFAULT_USERNAME_LOGIN_PASSWORD}'.",
)
@property
def effective_password(self) -> str:
"""The password actually checked at login time (config value or default)."""
return self.password or DEFAULT_USERNAME_LOGIN_PASSWORD
class TokenLoginConfig(BaseModel):
"""Token-exchange login.
The frontend passes an upstream access token via the URL. The gateway calls
``token_info_url`` with that token placed directly in the
``Authorization: Bearer <token>`` header, reads ``data.username`` from the
response, then signs the matching local user in (auto-registering if the
username is not yet in the database).
"""
enabled: bool = Field(
default=False,
description="Master switch for the POST /api/v1/auth/login/token endpoint.",
)
token_info_url: str = Field(
default="",
description="Upstream endpoint that resolves an access token to a username.",
)
service_authorization: str = Field(
default="Bearer admin",
description=(
"Deprecated/unused: the Authorization header is now built from the "
"caller's token as 'Bearer <token>'. Retained for backward compat."
),
)
timeout_seconds: float = Field(
default=10.0,
gt=0,
description="HTTP timeout (seconds) for the token_info_url call.",
)
verify_ssl: bool = Field(
default=True,
description=(
"Whether to verify the upstream TLS certificate. Set false for "
"internal/UAT hosts with self-signed or private-CA certs. Leave "
"true in production."
),
)
ca_cert_path: str = Field(
default="",
description=(
"Optional path to a CA bundle (PEM) used to verify the upstream "
"certificate. Takes precedence over verify_ssl when set."
),
)
class AuthLoginConfig(BaseModel):
"""Container for the custom login modes layered on top of the base auth."""
username_login: UsernameLoginConfig = Field(default_factory=UsernameLoginConfig)
token_login: TokenLoginConfig = Field(default_factory=TokenLoginConfig)
@@ -0,0 +1,29 @@
from pydantic import BaseModel, Field
# Default instruction sent to the vision model when the agent does not provide a
# more specific question. Kept in Chinese to match the product's primary locale.
DEFAULT_VISION_PROMPT = "请仔细观察这张图片,尽可能详细、准确地描述其中的全部内容,包括文字、图表、物体、场景与布局等关键信息。"
class VisionConfig(BaseModel):
"""Configuration for image recognition (识图).
When ``model_name`` names a model from ``models[]``, the lead agent's
``view_image`` tool delegates image understanding to that dedicated model
and returns a text description — so a text-only chat model can still "see"
uploaded images. When ``model_name`` is unset, vision falls back to the main
chat model, which only works when that model has ``supports_vision: true``.
"""
model_name: str | None = Field(
default=None,
description="Name of the model (from models[]) used for image recognition. When unset, falls back to the main chat model if it supports vision.",
)
prompt: str = Field(
default=DEFAULT_VISION_PROMPT,
description="Default instruction for the vision model when no per-call question is given.",
)
max_tokens: int | None = Field(
default=None,
description="Optional override for the vision model's max output tokens; falls back to the model's own config when unset.",
)
@@ -1,3 +1,10 @@
from .factory import create_chat_model
from .vision import is_delegated_vision, recognize_image, resolve_vision_model_name, vision_enabled
__all__ = ["create_chat_model"]
__all__ = [
"create_chat_model",
"is_delegated_vision",
"recognize_image",
"resolve_vision_model_name",
"vision_enabled",
]
@@ -0,0 +1,121 @@
"""Image recognition (识图) via a configurable vision model.
When ``config.yaml`` defines ``vision.model_name``, the lead agent's
``view_image`` tool delegates image understanding to that dedicated model and
returns a text description — so a text-only chat model can still "see" uploaded
images. When no dedicated model is configured, vision falls back to the main
chat model, which only works when that model has ``supports_vision: true``.
"""
import logging
from deerflow.config import get_app_config
from deerflow.config.app_config import AppConfig
from deerflow.config.vision_config import DEFAULT_VISION_PROMPT
logger = logging.getLogger(__name__)
def _configured_vision_model(config: AppConfig) -> str | None:
"""Return a valid dedicated vision model name from config, or None."""
vision_cfg = getattr(config, "vision", None)
name = getattr(vision_cfg, "model_name", None) if vision_cfg else None
if not name:
return None
if config.get_model_config(name) is None:
logger.warning("vision.model_name %r is not defined in models[]; ignoring", name)
return None
return name
def resolve_vision_model_name(model_name: str | None, *, app_config: AppConfig | None = None) -> str | None:
"""Resolve which model should handle image recognition for this run.
Priority:
1. A dedicated ``vision.model_name`` configured in ``config.yaml``.
2. The main chat model, only if it declares ``supports_vision: true``.
3. ``None`` — vision is unavailable.
"""
config = app_config or get_app_config()
configured = _configured_vision_model(config)
if configured:
return configured
if model_name:
mc = config.get_model_config(model_name)
if mc is not None and mc.supports_vision:
return model_name
return None
def vision_enabled(model_name: str | None, *, app_config: AppConfig | None = None) -> bool:
"""Whether the ``view_image`` tool/middleware should be available this run."""
return resolve_vision_model_name(model_name, app_config=app_config) is not None
def is_delegated_vision(*, app_config: AppConfig | None = None) -> bool:
"""Whether image recognition is delegated to a dedicated vision model.
True when ``vision.model_name`` is configured and valid — in that case
``view_image`` calls the dedicated model and returns a text description
instead of injecting raw image data into the main chat model.
"""
config = app_config or get_app_config()
return _configured_vision_model(config) is not None
def _extract_text(message) -> str:
"""Flatten an LLM response message into plain text."""
content = getattr(message, "content", message)
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts: list[str] = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict) and block.get("type") == "text":
parts.append(str(block.get("text", "")))
return "\n".join(p for p in parts if p).strip()
return str(content).strip()
def recognize_image(
image_base64: str,
mime_type: str,
*,
question: str | None = None,
app_config: AppConfig | None = None,
) -> str:
"""Describe/analyze an image using the configured dedicated vision model.
Returns the model's text answer. Raises ``ValueError`` if no dedicated
vision model is configured, and propagates model/network errors to the
caller for handling.
"""
from langchain_core.messages import HumanMessage
from deerflow.models.factory import create_chat_model
config = app_config or get_app_config()
model_name = _configured_vision_model(config)
if model_name is None:
raise ValueError("No dedicated vision model is configured (vision.model_name).")
vision_cfg = getattr(config, "vision", None)
default_prompt = getattr(vision_cfg, "prompt", None) or DEFAULT_VISION_PROMPT
prompt = (question or "").strip() or default_prompt
extra: dict = {}
max_tokens = getattr(vision_cfg, "max_tokens", None) if vision_cfg else None
if max_tokens:
extra["max_tokens"] = max_tokens
model = create_chat_model(name=model_name, thinking_enabled=False, app_config=config, **extra)
message = HumanMessage(
content=[
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{image_base64}"}},
]
)
result = model.invoke([message])
return _extract_text(result)
@@ -0,0 +1,18 @@
"""Persistence for iframe embed session → thread mappings."""
from deerflow.persistence.embed_sessions.base import EmbedSessionStore
from deerflow.persistence.embed_sessions.memory import MemoryEmbedSessionStore
from deerflow.persistence.embed_sessions.sql import EmbedSessionRepository
__all__ = [
"EmbedSessionStore",
"MemoryEmbedSessionStore",
"EmbedSessionRepository",
"make_embed_session_store",
]
def make_embed_session_store(session_factory):
if session_factory is None:
return MemoryEmbedSessionStore()
return EmbedSessionRepository(session_factory)
@@ -0,0 +1,18 @@
"""Abstract persistence API for iframe embed session → thread mappings."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class EmbedSessionStore(ABC):
@abstractmethod
async def upsert(self, session_id: str, thread_id: str) -> dict[str, Any]:
"""Create or update the mapping for an embed session."""
raise NotImplementedError
@abstractmethod
async def get(self, session_id: str) -> dict[str, Any] | None:
"""Return ``{session_id, thread_id, created_at, updated_at}`` or None."""
raise NotImplementedError
@@ -0,0 +1,37 @@
"""In-memory ``EmbedSessionStore`` for tests / memory backend."""
from __future__ import annotations
import copy
from datetime import UTC, datetime
from typing import Any
from deerflow.persistence.embed_sessions.base import EmbedSessionStore
class MemoryEmbedSessionStore(EmbedSessionStore):
def __init__(self) -> None:
self._sessions: dict[str, dict[str, Any]] = {}
async def upsert(self, session_id: str, thread_id: str) -> dict[str, Any]:
now = datetime.now(UTC).isoformat()
existing = self._sessions.get(session_id)
if existing is None:
record = {
"session_id": session_id,
"thread_id": thread_id,
"created_at": now,
"updated_at": now,
}
else:
record = {
**existing,
"thread_id": thread_id,
"updated_at": now,
}
self._sessions[session_id] = record
return copy.deepcopy(record)
async def get(self, session_id: str) -> dict[str, Any] | None:
record = self._sessions.get(session_id)
return copy.deepcopy(record) if record is not None else None
@@ -0,0 +1,21 @@
"""ORM model for iframe embed session → thread lookups."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
class EmbedSessionRow(Base):
__tablename__ = "embed_sessions"
# Opaque id generated by the parent page and passed into the iframe URL.
session_id: Mapped[str] = mapped_column(String(128), primary_key=True)
thread_id: Mapped[str] = mapped_column(String(64), nullable=False, index=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))
@@ -0,0 +1,49 @@
"""SQLAlchemy-backed embed session repository."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.embed_sessions.base import EmbedSessionStore
from deerflow.persistence.embed_sessions.model import EmbedSessionRow
def _row_to_dict(row: EmbedSessionRow) -> 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 EmbedSessionRepository(EmbedSessionStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def upsert(self, session_id: str, thread_id: str) -> dict[str, Any]:
async with self._sf() as session:
row = await session.get(EmbedSessionRow, session_id)
now = datetime.now(UTC)
if row is None:
row = EmbedSessionRow(
session_id=session_id,
thread_id=thread_id,
created_at=now,
updated_at=now,
)
session.add(row)
else:
row.thread_id = thread_id
row.updated_at = now
await session.commit()
await session.refresh(row)
return _row_to_dict(row)
async def get(self, session_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(EmbedSessionRow, session_id)
return _row_to_dict(row) if row is not None else None
@@ -0,0 +1,23 @@
"""Generated HTML page + favorite persistence."""
from __future__ import annotations
from deerflow.persistence.html_pages.base import HtmlPageStore
from deerflow.persistence.html_pages.memory import MemoryHtmlPageStore
from deerflow.persistence.html_pages.model import HtmlPageFavoriteRow, ScheduledHtmlPageRow
from deerflow.persistence.html_pages.sql import HtmlPageRepository
__all__ = [
"HtmlPageStore",
"HtmlPageRepository",
"MemoryHtmlPageStore",
"HtmlPageFavoriteRow",
"ScheduledHtmlPageRow",
"make_html_page_store",
]
def make_html_page_store(session_factory):
if session_factory is None:
return MemoryHtmlPageStore()
return HtmlPageRepository(session_factory)
@@ -0,0 +1,53 @@
"""Abstract persistence API for generated HTML pages and favorites."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class HtmlPageStore(ABC):
# --- Generated pages ---
@abstractmethod
async def create_page(self, data: dict[str, Any]) -> dict[str, Any]:
raise NotImplementedError
@abstractmethod
async def get_page(self, page_id: str) -> dict[str, Any] | None:
"""Return a page by id without restricting by owner."""
raise NotImplementedError
@abstractmethod
async def get_page_by_run(self, run_id: str) -> dict[str, Any] | None:
"""Return the page produced by a given run, if any."""
raise NotImplementedError
@abstractmethod
async def latest_page_for_task(self, task_id: str) -> dict[str, Any] | None:
"""Return the most recently created page for a task, if any."""
raise NotImplementedError
# --- Favorites ---
@abstractmethod
async def create_favorite(self, data: dict[str, Any]) -> dict[str, Any]:
raise NotImplementedError
@abstractmethod
async def list_favorites(self, user_id: str, *, limit: int = 100) -> list[dict[str, Any]]:
raise NotImplementedError
@abstractmethod
async def get_favorite(self, favorite_id: str) -> dict[str, Any] | None:
"""Return a favorite by id without restricting by owner."""
raise NotImplementedError
@abstractmethod
async def get_favorite_by_page(self, user_id: str, page_id: str) -> dict[str, Any] | None:
"""Return the user's existing favorite for a page, if any (dedup check)."""
raise NotImplementedError
@abstractmethod
async def delete_favorite(self, favorite_id: str, user_id: str) -> bool:
raise NotImplementedError
@@ -0,0 +1,102 @@
"""In-memory HtmlPageStore for no-database / test deployments."""
from __future__ import annotations
import copy
from datetime import UTC, datetime
from typing import Any
from deerflow.persistence.html_pages.base import HtmlPageStore
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
class MemoryHtmlPageStore(HtmlPageStore):
def __init__(self) -> None:
self._pages: dict[str, dict[str, Any]] = {}
self._favorites: dict[str, dict[str, Any]] = {}
# --- Generated pages ---
async def create_page(self, data: dict[str, Any]) -> dict[str, Any]:
row = {
"id": data["id"],
"task_id": data["task_id"],
"run_id": data["run_id"],
"user_id": data["user_id"],
"title": data.get("title") or "",
"html_content": data.get("html_content") or "",
"html_file_path": data.get("html_file_path"),
"data_snapshot": data.get("data_snapshot") or {},
"prompt_snapshot": data.get("prompt_snapshot") or {},
"reference_favorite_id": data.get("reference_favorite_id"),
"style_summary": data.get("style_summary"),
"layout_summary": data.get("layout_summary"),
"created_at": _now_iso(),
"updated_at": _now_iso(),
}
self._pages[row["id"]] = row
return copy.deepcopy(row)
async def get_page(self, page_id: str) -> dict[str, Any] | None:
row = self._pages.get(page_id)
return copy.deepcopy(row) if row is not None else None
async def get_page_by_run(self, run_id: str) -> dict[str, Any] | None:
matches = [r for r in self._pages.values() if r["run_id"] == run_id]
if not matches:
return None
matches.sort(key=lambda r: r["created_at"], reverse=True)
return copy.deepcopy(matches[0])
async def latest_page_for_task(self, task_id: str) -> dict[str, Any] | None:
matches = [r for r in self._pages.values() if r["task_id"] == task_id]
if not matches:
return None
matches.sort(key=lambda r: r["created_at"], reverse=True)
return copy.deepcopy(matches[0])
# --- Favorites ---
async def create_favorite(self, data: dict[str, Any]) -> dict[str, Any]:
row = {
"id": data["id"],
"user_id": data["user_id"],
"page_id": data["page_id"],
"title": data.get("title") or "",
"description": data.get("description"),
"tags": data.get("tags") or [],
"html_snapshot": data.get("html_snapshot") or "",
"style_summary": data.get("style_summary"),
"layout_summary": data.get("layout_summary"),
"content_structure_summary": data.get("content_structure_summary"),
"reuse_prompt": data.get("reuse_prompt"),
"created_at": _now_iso(),
"updated_at": _now_iso(),
}
self._favorites[row["id"]] = row
return copy.deepcopy(row)
async def list_favorites(self, user_id: str, *, limit: int = 100) -> list[dict[str, Any]]:
matches = [r for r in self._favorites.values() if r["user_id"] == user_id]
matches.sort(key=lambda r: r["created_at"], reverse=True)
return [copy.deepcopy(r) for r in matches[: max(1, limit)]]
async def get_favorite(self, favorite_id: str) -> dict[str, Any] | None:
row = self._favorites.get(favorite_id)
return copy.deepcopy(row) if row is not None else None
async def get_favorite_by_page(self, user_id: str, page_id: str) -> dict[str, Any] | None:
for row in self._favorites.values():
if row["user_id"] == user_id and row["page_id"] == page_id:
return copy.deepcopy(row)
return None
async def delete_favorite(self, favorite_id: str, user_id: str) -> bool:
row = self._favorites.get(favorite_id)
if row is None or row["user_id"] != user_id:
return False
del self._favorites[favorite_id]
return True
@@ -0,0 +1,65 @@
"""ORM models for generated HTML pages and user favorites.
These back the "HTML 页面定时任务" feature: a scheduled task of kind
``html_page`` renders a full HTML document each run. Every rendered page is
recorded in :class:`ScheduledHtmlPageRow`; a user may bookmark one into
:class:`HtmlPageFavoriteRow`, which also stores a reusable style/layout
summary so a later task can be generated "in the style of" the favorite.
Both tables are brand new, so ``Base.metadata.create_all`` creates them on the
next startup without touching any existing schema.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableJSON, PortableLongText
class ScheduledHtmlPageRow(Base):
"""One HTML page produced by a single run of an ``html_page`` task."""
__tablename__ = "scheduled_html_pages"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
task_id: Mapped[str] = mapped_column(String(64), index=True)
run_id: Mapped[str] = mapped_column(String(64), index=True)
user_id: Mapped[str] = mapped_column(String(64), index=True)
title: Mapped[str] = mapped_column(String(255))
# Full HTML document — can be large, so LONGTEXT on MySQL.
html_content: Mapped[str] = mapped_column(PortableLongText())
html_file_path: Mapped[str | None] = mapped_column(Text)
data_snapshot_json: Mapped[dict | None] = mapped_column(PortableJSON(), default=dict)
prompt_snapshot_json: Mapped[dict | None] = mapped_column(PortableJSON(), default=dict)
reference_favorite_id: Mapped[str | None] = mapped_column(String(64), index=True)
style_summary: Mapped[str | None] = mapped_column(Text)
layout_summary: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
class HtmlPageFavoriteRow(Base):
"""A user-bookmarked HTML page, with a reusable style/layout summary."""
__tablename__ = "html_page_favorites"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), index=True)
page_id: Mapped[str] = mapped_column(String(64), index=True)
title: Mapped[str] = mapped_column(String(255))
description: Mapped[str | None] = mapped_column(Text)
tags_json: Mapped[list | None] = mapped_column(PortableJSON(), default=list)
# Snapshot of the page HTML at favorite time, decoupled from the source run
# so deleting the run never breaks the favorite reference.
html_snapshot: Mapped[str] = mapped_column(PortableLongText())
style_summary: Mapped[str | None] = mapped_column(Text)
layout_summary: Mapped[str | None] = mapped_column(Text)
content_structure_summary: Mapped[str | None] = mapped_column(Text)
reuse_prompt: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
@@ -0,0 +1,141 @@
"""SQLAlchemy repository for generated HTML pages and favorites."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.html_pages.base import HtmlPageStore
from deerflow.persistence.html_pages.model import HtmlPageFavoriteRow, ScheduledHtmlPageRow
def _to_dict(row) -> 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()
# Normalize the JSON column names to API-friendly keys.
if "data_snapshot_json" in data:
data["data_snapshot"] = data.pop("data_snapshot_json") or {}
if "prompt_snapshot_json" in data:
data["prompt_snapshot"] = data.pop("prompt_snapshot_json") or {}
if "tags_json" in data:
data["tags"] = data.pop("tags_json") or []
return data
class HtmlPageRepository(HtmlPageStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
# --- Generated pages ---
async def create_page(self, data: dict[str, Any]) -> dict[str, Any]:
row = ScheduledHtmlPageRow(
id=data["id"],
task_id=data["task_id"],
run_id=data["run_id"],
user_id=data["user_id"],
title=data.get("title") or "",
html_content=data.get("html_content") or "",
html_file_path=data.get("html_file_path"),
data_snapshot_json=data.get("data_snapshot") or {},
prompt_snapshot_json=data.get("prompt_snapshot") or {},
reference_favorite_id=data.get("reference_favorite_id"),
style_summary=data.get("style_summary"),
layout_summary=data.get("layout_summary"),
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return _to_dict(row)
async def get_page(self, page_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(ScheduledHtmlPageRow, page_id)
return _to_dict(row) if row is not None else None
async def get_page_by_run(self, run_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
result = await session.execute(
select(ScheduledHtmlPageRow)
.where(ScheduledHtmlPageRow.run_id == run_id)
.order_by(ScheduledHtmlPageRow.created_at.desc())
.limit(1)
)
row = result.scalar_one_or_none()
return _to_dict(row) if row is not None else None
async def latest_page_for_task(self, task_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
result = await session.execute(
select(ScheduledHtmlPageRow)
.where(ScheduledHtmlPageRow.task_id == task_id)
.order_by(ScheduledHtmlPageRow.created_at.desc())
.limit(1)
)
row = result.scalar_one_or_none()
return _to_dict(row) if row is not None else None
# --- Favorites ---
async def create_favorite(self, data: dict[str, Any]) -> dict[str, Any]:
row = HtmlPageFavoriteRow(
id=data["id"],
user_id=data["user_id"],
page_id=data["page_id"],
title=data.get("title") or "",
description=data.get("description"),
tags_json=data.get("tags") or [],
html_snapshot=data.get("html_snapshot") or "",
style_summary=data.get("style_summary"),
layout_summary=data.get("layout_summary"),
content_structure_summary=data.get("content_structure_summary"),
reuse_prompt=data.get("reuse_prompt"),
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return _to_dict(row)
async def list_favorites(self, user_id: str, *, limit: int = 100) -> list[dict[str, Any]]:
async with self._sf() as session:
result = await session.execute(
select(HtmlPageFavoriteRow)
.where(HtmlPageFavoriteRow.user_id == user_id)
.order_by(HtmlPageFavoriteRow.created_at.desc())
.limit(max(1, limit))
)
return [_to_dict(row) for row in result.scalars()]
async def get_favorite(self, favorite_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(HtmlPageFavoriteRow, favorite_id)
return _to_dict(row) if row is not None else None
async def get_favorite_by_page(self, user_id: str, page_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
result = await session.execute(
select(HtmlPageFavoriteRow)
.where(HtmlPageFavoriteRow.user_id == user_id, HtmlPageFavoriteRow.page_id == page_id)
.limit(1)
)
row = result.scalar_one_or_none()
return _to_dict(row) if row is not None else None
async def delete_favorite(self, favorite_id: str, user_id: str) -> bool:
async with self._sf() as session:
result = await session.execute(
delete(HtmlPageFavoriteRow).where(
HtmlPageFavoriteRow.id == favorite_id,
HtmlPageFavoriteRow.user_id == user_id,
)
)
await session.commit()
return (result.rowcount or 0) > 0
@@ -0,0 +1,39 @@
"""Create ``embed_sessions`` table for iframe embed status polling.
Revision ID: 20260602_01
Revises: 20260528_02
Create Date: 2026-06-02
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260602_01"
down_revision = "20260528_02"
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 upgrade() -> None:
if not _has_table("embed_sessions"):
op.create_table(
"embed_sessions",
sa.Column("session_id", sa.String(128), primary_key=True),
sa.Column("thread_id", sa.String(64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_embed_sessions_thread_id", "embed_sessions", ["thread_id"])
def downgrade() -> None:
if _has_table("embed_sessions"):
op.drop_table("embed_sessions")
@@ -0,0 +1,49 @@
"""Add step3 column to roundtable_drafts.
Step 3「结果绘制」persistence: the generated report (HTML + metadata) is stored
inline in the draft so a reloaded draft can show the report without depending on
the ephemeral report thread. The column is LONGTEXT on MySQL (HTML can exceed the
64 KB TEXT cap) and plain TEXT (unbounded) elsewhere — matching ``PortableLongText``
on ``RoundtableDraftRow.step3``. ``create_all`` adds the column for brand-new DBs;
this revision patches DBs that already have the table.
Revision ID: 20260603_01
Revises: 20260602_01
Create Date: 2026-06-03
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects.mysql import LONGTEXT
revision = "20260603_01"
down_revision = "20260602_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_drafts") and not _has_column("roundtable_drafts", "step3"):
col_type = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
op.add_column("roundtable_drafts", sa.Column("step3", col_type, nullable=True))
def downgrade() -> None:
if _has_column("roundtable_drafts", "step3"):
op.drop_column("roundtable_drafts", "step3")
@@ -22,6 +22,11 @@ from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
from deerflow.persistence.models.run_event import RunEventRow
from deerflow.persistence.notifications.model import NotificationRow
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
from deerflow.persistence.roundtable_drafts.model import (
RoundtableDraftRow,
RoundtableRecommendHistoryRow,
)
from deerflow.persistence.run.model import RunRow
from deerflow.persistence.scheduled_tasks.model import (
ScheduledTaskDeliveryProfileRow,
@@ -33,6 +38,7 @@ from deerflow.persistence.scheduled_tasks.model import (
from deerflow.persistence.skills.model import SkillDisplayAdapterRow, SkillRow
from deerflow.persistence.tags.model import TagAssignmentRow, TagRow
from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.embed_sessions.model import EmbedSessionRow
from deerflow.persistence.thread_shares.model import ThreadShareRow
from deerflow.persistence.tool_metrics.model import ToolCallMetricRow
from deerflow.persistence.user.model import UserRow
@@ -45,6 +51,9 @@ __all__ = [
"LlmCallMetricRow",
"NotificationRow",
"RecommendedQuestionRow",
"RoundtableChainRow",
"RoundtableDraftRow",
"RoundtableRecommendHistoryRow",
"RunEventRow",
"RunRow",
"ScheduledTaskDeliveryProfileRow",
@@ -56,6 +65,7 @@ __all__ = [
"SkillDisplayAdapterRow",
"TagAssignmentRow",
"TagRow",
"EmbedSessionRow",
"ThreadMetaRow",
"ThreadShareRow",
"ToolCallMetricRow",
@@ -0,0 +1,18 @@
"""Roundtable-planning business-chain persistence."""
from __future__ import annotations
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
from deerflow.persistence.roundtable_chains.sql import RoundtableChainRepository
__all__ = [
"RoundtableChainRepository",
"RoundtableChainRow",
"make_roundtable_chain_store",
]
def make_roundtable_chain_store(session_factory):
if session_factory is None:
return None
return RoundtableChainRepository(session_factory)
@@ -0,0 +1,44 @@
"""ORM model for roundtable-planning business chains.
A "business chain" (业务链条) is a named, reusable, *ordered* list of agents the
user assembles in the chain editor. In Step 2 the coordinator dispatches to the
seats following this order (see ``multi-agent-business-chain-dev.md``).
Persisted per user (like ``roundtable_drafts``) so it follows the account across
devices/sessions. ``seats`` is stored as a JSON array serialized into
``PortableLongText`` (MySQL ``LONGTEXT``) rather than ``PortableJSON`` for
headroom, mirroring the sibling roundtable persistence modules.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableLongText
class RoundtableChainRow(Base):
__tablename__ = "roundtable_chains"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
# Human-readable chain title shown on the picker card.
title: Mapped[str] = mapped_column(String(512), nullable=False, default="")
# Optional free-text description.
description: Mapped[str | None] = mapped_column(String(2048), nullable=True)
# JSON: ordered [{"agent_id": str, "name": str, "description": str}, ...].
# seats[0] is the first seat to speak. Stored as LONGTEXT-backed JSON string.
seats: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
created_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(),
nullable=False,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
@@ -0,0 +1,123 @@
"""SQLAlchemy-backed roundtable business-chain store.
The ``seats`` JSON column is stored as a serialized string in
``PortableLongText`` and round-tripped here with ``json.dumps`` / ``json.loads``
— same approach as ``roundtable_drafts`` / ``ai_writing_sessions``.
All write/read paths are scoped by ``user_id`` for ownership isolation; a row
that belongs to another user is treated as not found.
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
_UNSET = object()
def _loads(value: Any) -> Any:
if not isinstance(value, str):
return value
if not value:
return None
try:
return json.loads(value)
except Exception:
return None
def _dumps(value: Any) -> str | None:
if value is None:
return None
return json.dumps(value, ensure_ascii=False)
class RoundtableChainRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _to_dict(row: RoundtableChainRow) -> dict[str, Any]:
return {
"id": row.id,
"title": row.title,
"description": row.description,
"seats": _loads(row.seats) or [],
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
}
async def list_chains(self, user_id: str, *, limit: int = 200) -> list[dict[str, Any]]:
stmt = (
select(RoundtableChainRow)
.where(RoundtableChainRow.user_id == user_id)
.order_by(RoundtableChainRow.updated_at.desc())
.limit(limit)
)
async with self._sf() as session:
result = await session.execute(stmt)
return [self._to_dict(row) for row in result.scalars()]
async def get_chain(self, chain_id: str, user_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(RoundtableChainRow, chain_id)
if row is None or row.user_id != user_id:
return None
return self._to_dict(row)
async def create_chain(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
now = datetime.now(UTC)
row = RoundtableChainRow(
id=data["id"],
user_id=user_id,
title=(data.get("title") or "").strip(),
description=(data.get("description") or None),
seats=_dumps(data.get("seats") or []),
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return self._to_dict(row)
async def update_chain(
self,
chain_id: str,
user_id: str,
*,
title=_UNSET,
description=_UNSET,
seats=_UNSET,
) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(RoundtableChainRow, chain_id)
if row is None or row.user_id != user_id:
return None
if title is not _UNSET and title is not None:
row.title = str(title).strip()
if description is not _UNSET:
row.description = description or None
if seats is not _UNSET:
row.seats = _dumps(seats)
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return self._to_dict(row)
async def delete_chain(self, chain_id: str, user_id: str) -> bool:
async with self._sf() as session:
row = await session.get(RoundtableChainRow, chain_id)
if row is None or row.user_id != user_id:
return False
await session.delete(row)
await session.commit()
return True
@@ -0,0 +1,22 @@
"""Roundtable-planning draft + recommendation-history persistence."""
from __future__ import annotations
from deerflow.persistence.roundtable_drafts.model import (
RoundtableDraftRow,
RoundtableRecommendHistoryRow,
)
from deerflow.persistence.roundtable_drafts.sql import RoundtableDraftRepository
__all__ = [
"RoundtableDraftRepository",
"RoundtableDraftRow",
"RoundtableRecommendHistoryRow",
"make_roundtable_draft_store",
]
def make_roundtable_draft_store(session_factory):
if session_factory is None:
return None
return RoundtableDraftRepository(session_factory)
@@ -0,0 +1,73 @@
"""ORM models for roundtable-planning drafts and recommendation history.
A "draft" is one full roundtable-planning session (Step 1 intent + Step 2
multi-agent roundtable), persisted per user so it follows the account across
devices/sessions — replacing the old browser-only ``localStorage`` storage.
``step1`` / ``step2`` are stored as JSON serialized into ``PortableLongText``
(MySQL ``LONGTEXT``) rather than ``PortableJSON`` (MySQL ``TEXT``, 64 KB cap),
because the Step 2 snapshot can carry a long roundtable transcript plus per-seat
stub messages that easily exceed 64 KB — mirroring ``ai_writing_sessions``.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableLongText
class RoundtableDraftRow(Base):
__tablename__ = "roundtable_drafts"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
# Auto-derived task summary (objective → first user message → timestamp).
title: Mapped[str] = mapped_column(String(512), nullable=False, default="")
# Furthest step reached when this snapshot was taken (1 | 2 | 3).
furthest_step: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
# JSON blobs matching the frontend Step1Snapshot / Step2Snapshot shapes.
step1: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # JSON object
step2: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # JSON object
# Step 3「结果绘制」snapshot: { html, generatedAt, model, summary? }. The full
# report HTML is stored inline so a reloaded draft can show the report without
# depending on the ephemeral report thread (LONGTEXT — HTML can exceed 64 KB).
step3: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # JSON object
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),
)
class RoundtableRecommendHistoryRow(Base):
"""One agent-recommendation run, bound to a draft/session.
Lets the recommend dialog show "last recommendation for this session" on
open, while still letting the user re-analyze (which appends a new row).
"""
__tablename__ = "roundtable_recommend_history"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
draft_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
# Denormalized intent objective at recommend time (list display / context).
objective: Mapped[str] = mapped_column(String(512), nullable=False, default="")
# How the run ended on the backend: "done" | "fallback" | "asking" | "error".
status: Mapped[str] = mapped_column(String(32), nullable=False, default="done")
model: Mapped[str | None] = mapped_column(String(128), nullable=True)
# Natural-language rationale (may be long → LONGTEXT).
rationale: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# JSON: [{"agent_id": str, "reason": str}, ...]
picks: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# JSON snapshot of the candidate pool at recommend time:
# [{"agent_id": str, "name": str, "description": str}, ...]
candidates: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
@@ -0,0 +1,196 @@
"""SQLAlchemy-backed roundtable-planning draft + recommendation-history store.
JSON columns (``step1`` / ``step2`` / ``picks`` / ``candidates``) are stored as
serialized strings in ``PortableLongText`` and round-tripped here with
``json.dumps`` / ``json.loads`` — same approach as ``ai_writing_sessions`` — so
large Step 2 transcripts never hit MySQL's 64 KB ``TEXT`` cap.
All write/read paths are scoped by ``user_id`` for ownership isolation.
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.roundtable_drafts.model import (
RoundtableDraftRow,
RoundtableRecommendHistoryRow,
)
_UNSET = object()
def _loads(value: Any) -> Any:
if not isinstance(value, str):
return value
if not value:
return None
try:
return json.loads(value)
except Exception:
return None
def _dumps(value: Any) -> str | None:
if value is None:
return None
return json.dumps(value, ensure_ascii=False)
class RoundtableDraftRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
# ── drafts ────────────────────────────────────────────────────────────
@staticmethod
def _draft_to_meta(row: RoundtableDraftRow) -> dict[str, Any]:
"""Lightweight projection for the history dropdown (no big blobs)."""
return {
"id": row.id,
"title": row.title,
"furthest_step": row.furthest_step,
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
}
@classmethod
def _draft_to_dict(cls, row: RoundtableDraftRow) -> dict[str, Any]:
d = cls._draft_to_meta(row)
d["step1"] = _loads(row.step1)
d["step2"] = _loads(row.step2)
d["step3"] = _loads(row.step3)
return d
async def list_drafts(self, user_id: str, *, limit: int = 100) -> list[dict[str, Any]]:
stmt = (
select(RoundtableDraftRow)
.where(RoundtableDraftRow.user_id == user_id)
.order_by(RoundtableDraftRow.updated_at.desc())
.limit(limit)
)
async with self._sf() as session:
result = await session.execute(stmt)
return [self._draft_to_meta(row) for row in result.scalars()]
async def get_draft(self, draft_id: str, user_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(RoundtableDraftRow, draft_id)
if row is None or row.user_id != user_id:
return None
return self._draft_to_dict(row)
async def create_draft(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
now = datetime.now(UTC)
row = RoundtableDraftRow(
id=data["id"],
user_id=user_id,
title=(data.get("title") or "").strip(),
furthest_step=int(data.get("furthest_step") or 1),
step1=_dumps(data.get("step1")),
step2=_dumps(data.get("step2")),
step3=_dumps(data.get("step3")),
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return self._draft_to_dict(row)
async def update_draft(
self,
draft_id: str,
user_id: str,
*,
title=_UNSET,
furthest_step=_UNSET,
step1=_UNSET,
step2=_UNSET,
step3=_UNSET,
) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(RoundtableDraftRow, draft_id)
if row is None or row.user_id != user_id:
return None
if title is not _UNSET and title is not None:
row.title = str(title).strip()
if furthest_step is not _UNSET and furthest_step is not None:
row.furthest_step = int(furthest_step)
if step1 is not _UNSET:
row.step1 = _dumps(step1)
if step2 is not _UNSET:
row.step2 = _dumps(step2)
if step3 is not _UNSET:
row.step3 = _dumps(step3)
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return self._draft_to_dict(row)
async def delete_draft(self, draft_id: str, user_id: str) -> bool:
async with self._sf() as session:
row = await session.get(RoundtableDraftRow, draft_id)
if row is None or row.user_id != user_id:
return False
await session.delete(row)
await session.commit()
return True
# ── recommendation history ────────────────────────────────────────────
@staticmethod
def _history_to_dict(row: RoundtableRecommendHistoryRow) -> dict[str, Any]:
return {
"id": row.id,
"draft_id": row.draft_id,
"objective": row.objective,
"status": row.status,
"model": row.model,
"rationale": row.rationale,
"picks": _loads(row.picks) or [],
"candidates": _loads(row.candidates) or [],
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
}
async def list_recommendations(
self, draft_id: str, user_id: str, *, limit: int = 20
) -> list[dict[str, Any]]:
stmt = (
select(RoundtableRecommendHistoryRow)
.where(
RoundtableRecommendHistoryRow.draft_id == draft_id,
RoundtableRecommendHistoryRow.user_id == user_id,
)
.order_by(RoundtableRecommendHistoryRow.created_at.desc())
.limit(limit)
)
async with self._sf() as session:
result = await session.execute(stmt)
return [self._history_to_dict(row) for row in result.scalars()]
async def add_recommendation(self, user_id: str, draft_id: str, data: dict[str, Any]) -> dict[str, Any]:
now = datetime.now(UTC)
row = RoundtableRecommendHistoryRow(
id=data["id"],
user_id=user_id,
draft_id=draft_id,
objective=(data.get("objective") or "").strip()[:512],
status=(data.get("status") or "done")[:32],
model=(data.get("model") or None),
rationale=data.get("rationale") or None,
picks=_dumps(data.get("picks") or []),
candidates=_dumps(data.get("candidates") or []),
created_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return self._history_to_dict(row)
@@ -7,7 +7,11 @@ import re
from typing import Any
from urllib.parse import quote
from deerflow.config.extensions_config import SkillDisplayConfig, get_extensions_config
from deerflow.config.extensions_config import (
SkillDisplayConfig,
SkillDisplayResultSetConfig,
get_extensions_config,
)
_SKILL_PATH_RE = re.compile(r"[\\/]skills[\\/](?:public|custom)[\\/]([^\\/]+)(?:[\\/]|$)")
@@ -222,7 +226,11 @@ def adapt_reference_batches_with_displays(
)
except Exception:
continue
if not config.enabled or config.mode == "none":
has_enabled_result_set = any(
result_set.enabled and result_set.mode != "none"
for result_set in config.result_sets
)
if not config.enabled or (config.mode == "none" and not has_enabled_result_set):
continue
normalized_displays[skill_name] = config
@@ -257,13 +265,30 @@ def adapt_reference_batches_with_displays(
raw = source.get("raw")
if display is not None and isinstance(raw, dict):
index_value = int(source.get("index") or len(sources) + 1)
display_for_source: SkillDisplayConfig | SkillDisplayResultSetConfig = display
result_set_key = source.get("resultSetKey")
if result_set_key:
display_for_source = next(
(
result_set
for result_set in display.result_sets
if result_set.key == result_set_key
),
display,
)
elif display.result_sets and display.mode == "none":
sources.append(source)
continue
configured = _normalize_with_display_config(
raw,
index_value,
skill_name,
display,
display_for_source,
)
if configured is not None:
if isinstance(display_for_source, SkillDisplayResultSetConfig):
configured["resultSetKey"] = display_for_source.key
configured["resultSetLabel"] = display_for_source.label
sources.append(
{
**source,
@@ -318,6 +343,8 @@ def _source_debug_item(source: dict[str, Any]) -> dict[str, Any]:
"id": source.get("id"),
"skillName": source.get("skillName"),
"mode": source.get("mode"),
"resultSetKey": source.get("resultSetKey"),
"resultSetLabel": source.get("resultSetLabel"),
"title": source.get("title"),
"source": source.get("source"),
"author": source.get("author"),
@@ -359,6 +386,16 @@ def _collect_sources(messages: list[Any]) -> list[dict[str, Any]]:
tool_contexts_by_id,
)
payload = _extract_tool_payload(message)
configured_sources = _collect_configured_sources(
payload,
len(sources) + 1,
tool_context,
)
if configured_sources is not None:
for normalized in configured_sources:
normalized["index"] = len(sources) + 1
sources.append(normalized)
continue
for raw_item in _first_list(payload):
normalized = _normalize_reference_item(
raw_item,
@@ -373,6 +410,44 @@ def _collect_sources(messages: list[Any]) -> list[dict[str, Any]]:
return sources
def _collect_configured_sources(
payload: Any,
fallback_index: int,
tool_context: dict[str, Any] | None,
) -> list[dict[str, Any]] | None:
display_match = _resolve_display_match({}, payload, tool_context)
if display_match is None:
return None
skill_name, display = display_match
configs: list[SkillDisplayConfig | SkillDisplayResultSetConfig] = [
result_set
for result_set in display.result_sets
if result_set.enabled and result_set.mode != "none"
]
if not configs:
configs = [display]
sources: list[dict[str, Any]] = []
for config in configs:
raw_items = _list_at_path(payload, config.result_path)
if raw_items is None:
continue
for raw_item in raw_items:
normalized = _normalize_with_display_config(
raw_item,
fallback_index + len(sources),
skill_name,
config,
)
if normalized is None:
continue
if isinstance(config, SkillDisplayResultSetConfig):
normalized["resultSetKey"] = config.key
normalized["resultSetLabel"] = config.label
sources.append(normalized)
return sources
def _find_reference_batch_anchor(messages: list[Any], start: int, end: int) -> Any | None:
answer = _find_last_assistant_answer(messages, start, end)
if answer is not None:
@@ -622,6 +697,19 @@ def _first_list(payload: Any) -> list[Any]:
return []
def _list_at_path(payload: Any, path: str | None) -> list[dict[str, Any]] | None:
cursor = payload
if path:
for part in path.split("."):
if isinstance(cursor, dict):
cursor = cursor.get(part)
else:
return None
if not isinstance(cursor, list):
return None
return [item for item in cursor if isinstance(item, dict)]
def _nested_records(raw: Any) -> list[dict[str, Any]]:
if not isinstance(raw, dict):
return []
@@ -654,7 +742,13 @@ def _enabled_skill_displays() -> dict[str, SkillDisplayConfig]:
if state.display is not None
and state.enabled
and state.display.enabled
and state.display.mode != "none"
and (
state.display.mode != "none"
or any(
result_set.enabled and result_set.mode != "none"
for result_set in state.display.result_sets
)
)
}
except Exception:
return {}
@@ -705,7 +799,7 @@ def _normalize_with_display_config(
raw: dict[str, Any],
fallback_index: int,
skill_name: str,
display: SkillDisplayConfig,
display: SkillDisplayConfig | SkillDisplayResultSetConfig,
) -> dict[str, Any] | None:
title = _stringify(_get_by_path(raw, display.title_field), f"结果 {fallback_index}")
snippet = _stringify(_get_by_path(raw, display.summary_field), "")
@@ -737,7 +831,10 @@ def _normalize_with_display_config(
}
def _resolve_display_link(raw: dict[str, Any], display: SkillDisplayConfig) -> str | None:
def _resolve_display_link(
raw: dict[str, Any],
display: SkillDisplayConfig | SkillDisplayResultSetConfig,
) -> str | None:
url = _stringify(_get_by_path(raw, display.url_field), "")
item_id = _stringify(_get_by_path(raw, display.id_field), "")
if display.link_strategy == "url":
@@ -1,8 +1,16 @@
from .registry import get_scheduled_task_service, set_scheduled_task_service
from .service import DuplicateTaskNameError, MarkdownRequirementError, ScheduledTaskService
from .service import (
DuplicateFavoriteError,
DuplicateTaskNameError,
HtmlGenerationError,
MarkdownRequirementError,
ScheduledTaskService,
)
__all__ = [
"DuplicateFavoriteError",
"DuplicateTaskNameError",
"HtmlGenerationError",
"MarkdownRequirementError",
"ScheduledTaskService",
"get_scheduled_task_service",
@@ -18,7 +18,7 @@ class ElementDeliveryClient:
self.access_token = access_token
@classmethod
def from_env(cls) -> "ElementDeliveryClient | None":
def from_env(cls) -> ElementDeliveryClient | None:
enabled = os.environ.get("ELEMENT_ENABLED", "true").strip().lower()
if enabled in {"0", "false", "no", "off"}:
return None
@@ -0,0 +1,414 @@
"""HTML page generation helpers for ``html_page`` scheduled tasks.
Pure, dependency-light utilities the scheduler uses to turn a GLM5 response
into a safe, previewable HTML document:
- :func:`extract_json_object` — fault-tolerant JSON extraction from a model
reply that may be wrapped in prose or ```json fences.
- :func:`sanitize_html` — strip script-execution vectors (``<script>``,
``<iframe>``, ``on*`` handlers, ``javascript:`` URLs, …) using BeautifulSoup.
- :func:`validate_html` — cheap structural checks (doctype/html/head/body,
non-empty body, no residual scripts) that gate the optional repair pass.
- ``build_*_prompt`` — the GLM5 prompt templates (generation / repair / style
extraction).
- :func:`screenshot_html` — best-effort Playwright screenshot, a no-op when
Playwright is not installed (the offline image ships without a browser).
Kept free of any ``app.*`` import so it stays inside the harness boundary.
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any
logger = logging.getLogger(__name__)
# Tags removed wholesale — they either execute code or load remote active
# content that the iframe sandbox alone shouldn't be relied on to contain.
_DANGEROUS_TAGS = frozenset(
{"script", "iframe", "object", "embed", "applet", "frame", "frameset", "base"}
)
# URL schemes that can execute script when navigated/loaded.
_DANGEROUS_URL_RE = re.compile(r"^\s*(javascript|vbscript|data:text/html)", re.IGNORECASE)
# Attributes carrying a URL we must scheme-check.
_URL_ATTRS = frozenset({"href", "src", "action", "formaction", "xlink:href", "background", "poster"})
def parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
"""Split a ``---`` YAML frontmatter block from the body of a SKILL.md.
Returns ``(frontmatter_dict, body)``. When there is no frontmatter the dict
is empty and the body is the whole text.
"""
if not text or not text.lstrip().startswith("---"):
return {}, text or ""
lines = text.splitlines()
# Skip leading blank lines before the opening fence.
start = 0
while start < len(lines) and not lines[start].strip():
start += 1
end = None
for i in range(start + 1, len(lines)):
if lines[i].strip() == "---":
end = i
break
if end is None:
return {}, text
fm_block = "\n".join(lines[start + 1 : end])
body = "\n".join(lines[end + 1 :])
try:
import yaml
data = yaml.safe_load(fm_block) or {}
if not isinstance(data, dict):
data = {}
except Exception:
data = {}
return data, body
def extract_json_object(text: str | None) -> dict[str, Any] | None:
"""Best-effort parse of a JSON object from a model reply.
Handles three common shapes: a bare JSON object, a ```json fenced block,
and a JSON object embedded in surrounding prose. Returns ``None`` when no
balanced object can be recovered.
"""
if not text or not isinstance(text, str):
return None
candidate = text.strip()
# Strip a single ```json ... ``` (or ``` ... ```) fence if present.
fence = re.search(r"```(?:json)?\s*(.*?)```", candidate, flags=re.DOTALL | re.IGNORECASE)
if fence:
candidate = fence.group(1).strip()
# Fast path: the whole thing is already valid JSON.
try:
parsed = json.loads(candidate)
return parsed if isinstance(parsed, dict) else None
except (ValueError, TypeError):
pass
# Fallback: scan for the first balanced ``{ ... }`` and parse that.
start = candidate.find("{")
if start == -1:
return None
depth = 0
in_string = False
escape = False
for index in range(start, len(candidate)):
char = candidate[index]
if in_string:
if escape:
escape = False
elif char == "\\":
escape = True
elif char == '"':
in_string = False
continue
if char == '"':
in_string = True
elif char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
blob = candidate[start : index + 1]
try:
parsed = json.loads(blob)
except (ValueError, TypeError):
return None
return parsed if isinstance(parsed, dict) else None
return None
def recover_html_document(text: str | None) -> str | None:
"""Slice a complete HTML document out of a model reply.
Used as a fallback when the JSON wrapper around the ``html`` field can't be
parsed — the model commonly emits *literal* newlines and *unescaped* quotes
(``"zh-CN"``) inside the value, which is invalid JSON, so
:func:`extract_json_object` returns ``None``. Without this, the caller would
archive the whole ``{"title": ..., "html": "<!doctype html>…"}`` blob as the
page. We locate the ``<!doctype html>`` / ``<html …>`` start and the final
``</html>`` and return just that span, dropping the surrounding JSON
scaffolding. Also accepts a reply that is already bare HTML. Returns
``None`` when no HTML document can be found.
"""
if not text or not isinstance(text, str):
return None
lowered = text.lower()
start = lowered.find("<!doctype html")
if start == -1:
start = lowered.find("<html")
if start == -1:
return None
end = lowered.rfind("</html>")
candidate = text[start:] if end == -1 else text[start : end + len("</html>")]
candidate = candidate.strip()
# If the html had been a properly-escaped JSON string value (the JSON failed
# to parse for some *other* reason), unescape the common sequences so they
# don't survive into the rendered page. Only triggers when escapes exist, so
# genuinely-literal HTML (the usual broken-JSON case) is left untouched.
if "\\n" in candidate or '\\"' in candidate or "\\/" in candidate or "\\t" in candidate:
candidate = (
candidate.replace('\\"', '"')
.replace("\\n", "\n")
.replace("\\r", "\r")
.replace("\\t", "\t")
.replace("\\/", "/")
)
return candidate or None
def sanitize_html(html: str) -> tuple[str, list[str]]:
"""Remove script-execution vectors from ``html``.
Returns ``(cleaned_html, removed)`` where ``removed`` is a human-readable
report of what was stripped (used for logging / repair feedback). The
cleaner is defensive but not a full HTML validator — pair it with an
``iframe sandbox`` (no ``allow-scripts``) on the preview side.
"""
if not html or not isinstance(html, str):
return "", []
try:
from bs4 import BeautifulSoup
except Exception: # pragma: no cover - bs4 is a declared dependency
logger.warning("BeautifulSoup unavailable; returning HTML unsanitized")
return html, []
removed: list[str] = []
soup = BeautifulSoup(html, "html.parser")
# 1) Drop dangerous tags entirely.
for tag in soup.find_all(True):
name = (tag.name or "").lower()
if name in _DANGEROUS_TAGS:
removed.append(f"<{name}>")
tag.decompose()
continue
# External stylesheet / preload links pull remote resources.
if name == "link":
href = str(tag.get("href") or "")
rel = " ".join(tag.get("rel") or []) if isinstance(tag.get("rel"), list) else str(tag.get("rel") or "")
if re.match(r"^\s*(https?:)?//", href) and "stylesheet" in rel.lower():
removed.append("<link stylesheet>")
tag.decompose()
continue
# meta refresh can redirect to arbitrary URLs.
if name == "meta" and str(tag.get("http-equiv") or "").lower() == "refresh":
removed.append("<meta refresh>")
tag.decompose()
continue
# 2) Strip event handlers and dangerous URL schemes from surviving tags.
for tag in soup.find_all(True):
for attr in list(tag.attrs.keys()):
lowered = attr.lower()
if lowered.startswith("on"):
removed.append(f"{lowered}=")
del tag.attrs[attr]
continue
if lowered in _URL_ATTRS:
value = tag.attrs[attr]
value_str = value if isinstance(value, str) else " ".join(value)
if _DANGEROUS_URL_RE.match(value_str or ""):
removed.append(f"{lowered}={value_str[:24]}")
del tag.attrs[attr]
return str(soup), removed
def validate_html(html: str) -> list[str]:
"""Return a list of structural problems with ``html`` (empty == valid).
A page only counts as a complete document when it has the full skeleton
(doctype/html/head/body), a non-empty ``<title>``, visible body content
(no white-screen), no residual scripts, and no external stylesheet links.
"""
errors: list[str] = []
if not html or not isinstance(html, str):
return ["empty html"]
lowered = html.lower()
if "<!doctype html" not in lowered:
errors.append("缺少 <!doctype html> 声明")
if "<html" not in lowered:
errors.append("缺少 <html> 根元素")
if "<head" not in lowered:
errors.append("缺少 <head> 元素")
if "<body" not in lowered:
errors.append("缺少 <body> 元素")
if "<script" in lowered:
errors.append("仍包含 <script> 标签(不允许脚本)")
try:
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
# Title must exist and be non-empty.
title_el = soup.find("title")
if title_el is None or not title_el.get_text(strip=True):
errors.append("缺少非空 <title> 标题")
# External stylesheet links pull remote resources — not allowed offline.
for link in soup.find_all("link"):
href = str(link.get("href") or "")
rel = " ".join(link.get("rel") or []) if isinstance(link.get("rel"), list) else str(link.get("rel") or "")
if re.match(r"^\s*(https?:)?//", href) and "stylesheet" in rel.lower():
errors.append("包含外链样式表(不允许外部 CDN/资源)")
break
# Body must carry some visible content — guards against white-screen.
body = soup.body or soup
text = body.get_text(strip=True) if body else ""
has_media = bool(body.find(["img", "svg", "canvas", "table", "ul", "ol"])) if body else False
if len(text) < 5 and not has_media:
errors.append("页面正文为空,可能白屏")
except Exception: # pragma: no cover
pass
return errors
# ---------------------------------------------------------------------------
# Prompt templates
# ---------------------------------------------------------------------------
_GENERATION_TEMPLATE = """你是一个内网页面生成助手。请根据输入数据生成一个完整的 HTML 页面。
用户页面要求:
{page_prompt}
{constraint_block}本次数据:
{data}
参考页面风格:
{reference_style}
参考页面布局:
{reference_layout}
{skill_instructions}
要求:
1. 只输出 JSON,不要输出 Markdown,不要输出多余解释。
2. JSON 字段必须包含 title、html、style_summary、layout_summary。
3. html 字段的值必须是合法 JSON 字符串:其中所有换行必须写成 \\n、所有双引号必须转义为 \\",不要在字符串里直接换行或使用未转义的引号。
4. html 必须是完整 HTML 文档,包含 <!doctype html>、html、head、body。
5. 所有 CSS 写在 <style> 标签内,不要引用外部 CDN 或外链样式表。
6. 不要包含 <script>、外链脚本、iframe、表单提交、on* 事件属性。
7. 参考页面只用于风格和布局,页面内容必须来自“本次数据”,不要复用参考页旧数据。
8. 页面使用中文,排版适合在浏览器中直接预览。
"""
_REPAIR_TEMPLATE = """你是一个 HTML 修复助手。下面的 HTML 存在问题,请修复后重新输出完整页面。
原始页面要求:
{requirements}
发现的问题:
{errors}
待修复 HTML:
{html}
要求:
1. 只输出 JSON,字段包含 fixed_html、changes(修复说明数组)。
2. fixed_html 必须是完整 HTML 文档,包含 <!doctype html>、html、head、body。
3. 移除所有 <script>、iframe、外链脚本、on* 事件属性、表单提交。
4. 保留原有内容与数据,只修复结构与安全问题。
"""
_STYLE_EXTRACT_TEMPLATE = """你是一个页面风格分析助手。请分析下面的 HTML 页面,提取可复用的风格与布局特征。
HTML 页面:
{html}
要求:
1. 只输出 JSON,字段包含 style_summary、layout_summary、content_structure_summary、reuse_prompt。
2. style_summary:颜色、字体、间距、圆角、阴影、视觉密度等视觉风格。
3. layout_summary:页面分区、卡片、表格、图表、导航等布局结构。
4. content_structure_summary:信息组织顺序与层级。
5. reuse_prompt:一段可直接拼接到下次生成 prompt 的中文风格说明,不要包含本页的具体数据。
"""
def _clip(value: Any, limit: int) -> str:
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
text = text or ""
return text if len(text) <= limit else text[:limit] + "\n…(内容过长已截断)"
def build_generation_prompt(
*,
page_prompt: str,
negative_prompt: str | None,
data: Any,
reference_style: str | None,
reference_layout: str | None,
skill_instructions: str | None = None,
data_limit: int = 16000,
skill_limit: int = 30000,
) -> str:
skill_block = ""
if skill_instructions and skill_instructions.strip():
skill_block = "\n可用页面生成技能规范(请严格遵循):\n" + _clip(skill_instructions.strip(), skill_limit) + "\n"
# The constraint block is only emitted when the user actually supplied one,
# and is framed as a top-priority hard rule that overrides the page
# requirements / reference page / data on conflict — otherwise the model
# tends to treat "限制条件" as a soft hint and ignore it.
constraint_block = ""
neg = (negative_prompt or "").strip()
if neg:
constraint_block = (
"\n限制条件(最高优先级的硬性约束,必须逐条严格遵守;"
"若与上面的页面要求、参考页面或本次数据发生冲突,一律以这里的限制条件为准):\n"
+ neg
+ "\n\n"
)
return _GENERATION_TEMPLATE.format(
page_prompt=(page_prompt or "(未提供,使用通用看板风格)").strip(),
constraint_block=constraint_block,
data=_clip(data, data_limit),
reference_style=(reference_style or "无").strip(),
reference_layout=(reference_layout or "无").strip(),
skill_instructions=skill_block,
)
def build_repair_prompt(*, html: str, errors: list[str], requirements: str | None) -> str:
return _REPAIR_TEMPLATE.format(
requirements=(requirements or "无").strip(),
errors="\n".join(f"- {e}" for e in errors) or "- 未知问题",
html=_clip(html, 32000),
)
def build_style_extract_prompt(*, html: str) -> str:
return _STYLE_EXTRACT_TEMPLATE.format(html=_clip(html, 32000))
async def screenshot_html(html: str, output_path) -> str | None:
"""Best-effort PNG screenshot of ``html`` for white-screen / console QA.
Returns the screenshot path on success, or ``None`` when Playwright (or a
usable browser) is unavailable — the offline image ships without one, so
this must degrade silently rather than fail the run.
"""
try:
from playwright.async_api import async_playwright
except Exception:
return None
try:
async with async_playwright() as pw:
browser = await pw.chromium.launch()
try:
page = await browser.new_page()
await page.set_content(html, wait_until="networkidle")
await page.screenshot(path=str(output_path), full_page=True)
finally:
await browser.close()
return str(output_path)
except Exception:
logger.debug("Playwright screenshot failed; skipping QA screenshot", exc_info=True)
return None
@@ -22,6 +22,7 @@ from deerflow.config.agents_config import AGENT_ID_PATTERN
from deerflow.config.paths import get_paths
from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
from deerflow.runtime import serialize_channel_values
from deerflow.runtime.scheduler import html_page as html_page_utils
from deerflow.runtime.scheduler.element import ElementDeliveryClient
from deerflow.runtime.user_context import reset_current_user, set_current_user
from deerflow.utils.time import now_iso
@@ -36,6 +37,11 @@ _RUN_ATTEMPT_TIMEOUT_SECONDS = 30 * 60 # 30 minutes
# Retries allowed after the first attempt before the run is marked "failed".
_MAX_RUN_RETRIES = 3
# Max HTML length (chars) inlined into the run's ``result_json``. Beyond this
# the inline copy is dropped to stay under MySQL's 64 KB ``TEXT`` cap; the full
# document is always retrievable from the page store (LONGTEXT) + index.html.
_MAX_INLINE_RESULT_HTML = 60000
# Minimum length (in characters) the generated Markdown file's content must
# reach when a task has ``require_markdown`` enabled. The model is NOT told about
# this threshold — it is enforced silently by the service.
@@ -58,6 +64,22 @@ class MarkdownRequirementError(Exception):
"""
class HtmlGenerationError(Exception):
"""Raised when an ``html_page`` run did not produce a complete, valid HTML
document (missing doctype/html/head/body, empty body, or residual scripts
after the sanitize + repair passes). The run is marked failed — a page only
counts as successful when it is a full, previewable HTML document."""
class DuplicateFavoriteError(Exception):
"""A user already bookmarked this page. Surfaced as HTTP 409 by the router
so the same page cannot be favorited twice."""
def __init__(self, page_id: str) -> None:
self.page_id = page_id
super().__init__(f"Page {page_id!r} is already favorited")
class DuplicateTaskNameError(Exception):
"""A user already owns a scheduled task with the requested name.
@@ -835,7 +857,7 @@ class ScheduledTaskService:
runtime_thread_id = str(uuid.uuid4())
try:
result = await asyncio.wait_for(
self._run_agent_attempt(task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id),
self._run_task_attempt(task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id),
timeout=_RUN_ATTEMPT_TIMEOUT_SECONDS,
)
except TimeoutError:
@@ -875,6 +897,342 @@ class ScheduledTaskService:
await self._deliver_element_result(task, result["content"])
return
async def _run_task_attempt(
self,
task: dict[str, Any],
*,
runtime_thread_id: str,
scheduled_for: datetime,
run_id: str,
) -> dict[str, Any]:
"""Dispatch a single execution attempt by task kind.
``html_page`` tasks render an HTML document via GLM5; everything else
runs the regular lead-agent attempt. The kind lives in
``execution_context.task_kind`` so no schema migration is needed.
"""
ctx = task.get("execution_context")
kind = ctx.get("task_kind") if isinstance(ctx, dict) else None
if kind == "html_page":
return await self._run_html_page_attempt(
task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id
)
return await self._run_agent_attempt(
task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id
)
@staticmethod
def _html_config(task: dict[str, Any]) -> dict[str, Any]:
ctx = task.get("execution_context")
cfg = ctx.get("html_config") if isinstance(ctx, dict) else None
return cfg if isinstance(cfg, dict) else {}
async def _invoke_chat_model(self, model_name: str | None, prompt: str) -> str:
"""Invoke a chat model once and return its text content.
Used for the GLM5 HTML generation / repair / style-extraction passes.
Thinking is disabled to keep structured-JSON output stable.
"""
from langchain_core.messages import HumanMessage
from deerflow.models import create_chat_model
# No hardcoded model: use exactly the selected model; only when none was
# selected do we fall back to the system default (config models[0]).
model = create_chat_model(name=model_name or None, thinking_enabled=False)
response = await model.ainvoke([HumanMessage(content=prompt)])
content = getattr(response, "content", response)
if isinstance(content, str):
return content
if isinstance(content, list):
parts = [part.get("text", "") if isinstance(part, dict) else str(part) for part in content]
return "\n".join(p for p in parts if p)
return str(content)
async def _gather_html_data(
self,
task: dict[str, Any],
*,
runtime_thread_id: str,
scheduled_for: datetime,
run_id: str,
) -> str:
"""Run the lead agent to collect source data for the HTML page.
Reuses the regular agent-run primitives (with the task's search query
folded into the prompt) and returns the agent's final text, which feeds
the GLM5 render step as ``本次数据``. Returns an empty string if the
agent produced nothing.
"""
cfg = self._html_config(task)
page_prompt = str(cfg.get("page_prompt") or "").strip()
negative_prompt = str(cfg.get("negative_prompt") or "").strip()
gather_instruction = (
"请运用你自身的检索/搜索等技能,围绕下述页面需求获取最新、真实的资料,"
"并整理成结构化的中文要点(可用列表/小标题),供后续生成 HTML 页面使用,本步骤不要生成 HTML:\n"
)
parts = [gather_instruction]
if page_prompt:
parts.append(f"页面需求:{page_prompt}")
base_prompt = str(task.get("prompt") or "").strip()
if base_prompt:
parts.append(f"补充说明:{base_prompt}")
# Apply the user's constraints to data gathering too, not just rendering
# — a "限制条件" like "只要最近 24 小时的新闻" or "排除某主题" must shape
# which sources/data the agent collects, otherwise it silently leaks in.
if negative_prompt:
parts.append(f"限制条件(最高优先级,必须严格遵守):{negative_prompt}")
gather_task = {**task, "prompt": "\n".join(parts)}
# Never apply the markdown gate to the gather pass.
gather_ctx = dict(task.get("execution_context") or {})
gather_ctx.pop("require_markdown", None)
gather_ctx.pop("task_kind", None)
gather_ctx.pop("html_config", None)
# Use the task's selected model for the data-gathering agent too, so the
# whole HTML pipeline honors ``html_config.model_name`` instead of
# silently falling back to the default model for this step.
gather_model = str(cfg.get("model_name") or "").strip()
if gather_model:
gather_ctx["model_name"] = gather_model
gather_task["execution_context"] = gather_ctx
await self._ensure_thread_meta(task["user_id"], runtime_thread_id)
async with self._agent_run_lock:
record = await self._start_agent_run(gather_task, scheduled_for=scheduled_for, runtime_thread_id=runtime_thread_id)
await self.store.update_run(run_id, {"agent_run_id": record.run_id})
if record.task is not None:
await record.task
text = await self._extract_latest_ai_text(runtime_thread_id)
return text or ""
def _collect_html_skill_instructions(self) -> str:
"""Concatenate the bodies of enabled skills marked for HTML pages.
A skill opts in via ``metadata.kind: html_page`` in its SKILL.md
frontmatter. Only enabled skills (per the skills settings the user
toggles) are injected, so the render step honors the user's skill
selection. Best-effort — any failure yields an empty string.
"""
try:
from deerflow.skills import get_or_new_skill_storage
storage = get_or_new_skill_storage()
skills = storage.load_skills(enabled_only=True)
except Exception:
logger.debug("Failed to load skills for HTML render injection", exc_info=True)
return ""
blocks: list[str] = []
for skill in skills:
try:
text = Path(skill.skill_file).read_text(encoding="utf-8")
except Exception:
continue
frontmatter, body = html_page_utils.parse_frontmatter(text)
meta = frontmatter.get("metadata") if isinstance(frontmatter, dict) else None
if not (isinstance(meta, dict) and str(meta.get("kind")) == "html_page"):
continue
body = body.strip()
if body:
blocks.append(f"### 技能:{skill.name}\n{body}")
return "\n\n".join(blocks)
def _archive_html_index(self, run_id: str, html: str) -> Path | None:
"""Write the generated HTML to the run's archive dir as ``index.html``."""
try:
archive_dir = get_paths().base_dir / "scheduled-task-runs" / run_id / "files"
archive_dir.mkdir(parents=True, exist_ok=True)
target = archive_dir / "index.html"
target.write_text(html, encoding="utf-8")
return target
except Exception:
logger.warning("Failed to archive generated HTML for run %s", run_id, exc_info=True)
return None
async def _run_html_page_attempt(
self,
task: dict[str, Any],
*,
runtime_thread_id: str,
scheduled_for: datetime,
run_id: str,
) -> dict[str, Any]:
"""Generate an HTML page for an ``html_page`` task.
Pipeline: gather data via the lead agent → optionally load a reference
favorite's style/layout → GLM5 render → sanitize → validate → (one
GLM5 repair pass if invalid) → best-effort screenshot → archive
``index.html`` → persist a ``scheduled_html_pages`` row → build the run
result payload (``format: "html"`` with the full HTML inline).
"""
cfg = self._html_config(task)
# The user's selected model only — no hardcoded GLM-5. Empty → None →
# system default (config models[0]).
model_name = str(cfg.get("model_name") or "").strip() or None
page_prompt = str(cfg.get("page_prompt") or "").strip()
negative_prompt = str(cfg.get("negative_prompt") or "").strip()
reference_favorite_id = cfg.get("reference_favorite_id") or None
# 1) Gather source data (real, via the agent's search tools).
data_text = await self._gather_html_data(
task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id
)
# 2) Load reference style/layout from a favorite if one was selected.
reference_style = None
reference_layout = None
if reference_favorite_id:
store = self._html_store
if store is not None:
try:
favorite = await store.get_favorite(str(reference_favorite_id))
except Exception:
favorite = None
if favorite is not None:
reference_style = favorite.get("reuse_prompt") or favorite.get("style_summary")
reference_layout = favorite.get("layout_summary")
# 3) Render with the chosen model, injecting any enabled HTML skills.
skill_instructions = self._collect_html_skill_instructions()
gen_prompt = html_page_utils.build_generation_prompt(
page_prompt=page_prompt,
negative_prompt=negative_prompt,
data=data_text,
reference_style=reference_style,
reference_layout=reference_layout,
skill_instructions=skill_instructions,
)
raw = await self._invoke_chat_model(model_name, gen_prompt)
parsed = html_page_utils.extract_json_object(raw) or {}
title = str(parsed.get("title") or task.get("name") or "生成页面").strip()
html = parsed.get("html")
if not isinstance(html, str) or "<" not in html:
# The JSON wrapper couldn't be parsed (the model emitted literal
# newlines / unescaped quotes inside the "html" value), or it replied
# with bare HTML. Either way, recover just the HTML document so we
# never archive the {"title":...,"html":"..."} JSON blob as the page.
recovered = html_page_utils.recover_html_document(raw)
if recovered is None:
raise ValueError("模型未返回有效的 HTML(JSON 解析失败)")
html = recovered
style_summary = str(parsed.get("style_summary") or "").strip() or None
layout_summary = str(parsed.get("layout_summary") or "").strip() or None
# 4) Sanitize + validate, with one repair pass on failure.
html, _removed = html_page_utils.sanitize_html(html)
errors = html_page_utils.validate_html(html)
if errors:
logger.info("Generated HTML failed validation (%s); attempting repair", errors)
repair_prompt = html_page_utils.build_repair_prompt(
html=html, errors=errors, requirements=page_prompt or task.get("prompt")
)
try:
repaired_raw = await self._invoke_chat_model(model_name, repair_prompt)
repaired = html_page_utils.extract_json_object(repaired_raw) or {}
fixed = repaired.get("fixed_html")
if isinstance(fixed, str) and "<" in fixed:
fixed_clean, _ = html_page_utils.sanitize_html(fixed)
if not html_page_utils.validate_html(fixed_clean):
html = fixed_clean
except Exception:
logger.warning("HTML repair pass failed; keeping sanitized original", exc_info=True)
# 4b) Completeness gate — a page only counts as successful when it is a
# full, valid HTML document. If it still fails validation after the
# repair pass, fail the run (no half-baked page is archived/persisted).
final_errors = html_page_utils.validate_html(html)
if final_errors:
raise HtmlGenerationError(
"HTML 生成未通过完整性校验:" + ";".join(final_errors) + "。请调整页面要求或重试。"
)
# 5) Best-effort screenshot QA (no-op without Playwright).
screenshot_path = None
try:
shot_target = get_paths().base_dir / "scheduled-task-runs" / run_id / "files" / "preview.png"
shot_target.parent.mkdir(parents=True, exist_ok=True)
screenshot_path = await html_page_utils.screenshot_html(html, shot_target)
except Exception:
screenshot_path = None
# 6) Archive index.html.
archive_path = self._archive_html_index(run_id, html)
files: list[dict[str, Any]] = []
if archive_path is not None:
files.append(
{
"name": "index.html",
"path": str(archive_path),
"virtual_path": "/mnt/user-data/outputs/index.html",
"mime_type": "text/html",
"size": archive_path.stat().st_size if archive_path.exists() else None,
"kind": "html",
}
)
if screenshot_path:
shot = Path(screenshot_path)
files.append(
{
"name": "preview.png",
"path": str(shot),
"mime_type": "image/png",
"size": shot.stat().st_size if shot.exists() else None,
"kind": "artifact",
}
)
data_snapshot = {"data_text": data_text[:8000] if isinstance(data_text, str) else ""}
prompt_snapshot = {
"page_prompt": page_prompt,
"negative_prompt": negative_prompt,
"reference_favorite_id": reference_favorite_id,
"model_name": model_name,
}
# 7) Persist the page asset (best-effort — failure must not fail the run).
store = self._html_store
if store is not None:
try:
await store.create_page(
{
"id": str(uuid.uuid4()),
"task_id": task["task_id"],
"run_id": run_id,
"user_id": task["user_id"],
"title": title,
"html_content": html,
"html_file_path": str(archive_path) if archive_path else None,
"data_snapshot": data_snapshot,
"prompt_snapshot": prompt_snapshot,
"reference_favorite_id": reference_favorite_id,
"style_summary": style_summary,
"layout_summary": layout_summary,
}
)
except Exception:
logger.warning("Failed to persist scheduled_html_pages row for run %s", run_id, exc_info=True)
task_tz = ZoneInfo(task.get("timezone") or "UTC")
local_time = scheduled_for.astimezone(task_tz)
# ``scheduled_task_runs.result_json`` is TEXT (64 KB) on MySQL, so only
# inline the HTML when it comfortably fits. The full document always
# lives in ``scheduled_html_pages.html_content`` (LONGTEXT) + the
# archived ``index.html``; the ``/runs/{id}/html`` endpoints read from
# there, so previews work even when the inline copy is omitted here.
inline_html = html if len(html) <= _MAX_INLINE_RESULT_HTML else None
return {
"title": f"{title} - {local_time.strftime('%Y-%m-%d %H:%M')}",
"content": page_prompt or f"HTML 页面已生成:{title}",
"format": "html",
"html": inline_html,
"style_summary": style_summary,
"layout_summary": layout_summary,
"data_snapshot": data_snapshot,
"prompt_snapshot": prompt_snapshot,
"files": files,
"created_at": datetime.now(UTC).isoformat(),
}
async def _run_agent_attempt(
self,
task: dict[str, Any],
@@ -1275,3 +1633,132 @@ class ScheduledTaskService:
async def unsubscribe_task(self, user_id: str, task_id: str) -> bool:
return await self.store.delete_subscription(task_id, user_id)
# --- HTML pages / favorites ---
@property
def _html_store(self):
state = getattr(self.app, "state", None)
return getattr(state, "html_page_store", None)
async def get_html_page_for_run(self, run_id: str) -> dict[str, Any] | None:
"""Return the generated HTML page for a run (page-store row or run result)."""
store = self._html_store
if store is not None:
page = await store.get_page_by_run(run_id)
if page is not None:
return page
# Fallback to the inline HTML on the run result.
run = await self.store.get_run_any(run_id)
if run is None:
return None
result = run.get("result") if isinstance(run.get("result"), dict) else None
if not result or not isinstance(result.get("html"), str):
return None
return {
"run_id": run_id,
"task_id": run.get("task_id"),
"title": result.get("title"),
"html_content": result.get("html"),
"style_summary": result.get("style_summary"),
"layout_summary": result.get("layout_summary"),
"created_at": result.get("created_at"),
}
async def get_latest_html_page_for_task(self, task_id: str) -> dict[str, Any] | None:
store = self._html_store
if store is not None:
page = await store.latest_page_for_task(task_id)
if page is not None:
return page
# Fallback: newest run that carries inline HTML.
runs = await self.store.list_runs_any(task_id, limit=20)
for run in runs:
result = run.get("result") if isinstance(run.get("result"), dict) else None
if result and isinstance(result.get("html"), str):
return await self.get_html_page_for_run(run["id"])
return None
async def list_html_page_favorites(self, user_id: str) -> list[dict[str, Any]]:
store = self._html_store
if store is None:
return []
return await store.list_favorites(user_id)
async def get_html_page_favorite(self, user_id: str, favorite_id: str) -> dict[str, Any] | None:
store = self._html_store
if store is None:
return None
favorite = await store.get_favorite(favorite_id)
if favorite is None or favorite.get("user_id") != user_id:
return None
return favorite
async def delete_html_page_favorite(self, user_id: str, favorite_id: str) -> bool:
store = self._html_store
if store is None:
return False
return await store.delete_favorite(favorite_id, user_id)
async def create_html_page_favorite(
self,
user_id: str,
page_id: str,
*,
title: str | None = None,
description: str | None = None,
tags: list[str] | None = None,
) -> dict[str, Any] | None:
"""Bookmark a generated page, extracting a reusable style summary via GLM5.
Returns ``None`` when the page does not exist or is not owned by the
caller. Style extraction is best-effort — a failure still produces a
favorite (without the reuse hints) so the user's bookmark is never lost.
"""
store = self._html_store
if store is None:
return None
page = await store.get_page(page_id)
if page is None or page.get("user_id") != user_id:
return None
# A page can only be bookmarked once per user.
existing = await store.get_favorite_by_page(user_id, page_id)
if existing is not None:
raise DuplicateFavoriteError(page_id)
html = page.get("html_content") or ""
style_summary = page.get("style_summary")
layout_summary = page.get("layout_summary")
content_structure_summary = None
reuse_prompt = None
if html:
try:
# Reuse the model the page was generated with (recorded in the
# page's prompt snapshot) so favoriting doesn't fall back to the
# default model.
extract_model = (page.get("prompt_snapshot") or {}).get("model_name")
extract_prompt = html_page_utils.build_style_extract_prompt(html=html)
raw = await self._invoke_chat_model(extract_model, extract_prompt)
extracted = html_page_utils.extract_json_object(raw) or {}
style_summary = extracted.get("style_summary") or style_summary
layout_summary = extracted.get("layout_summary") or layout_summary
content_structure_summary = extracted.get("content_structure_summary")
reuse_prompt = extracted.get("reuse_prompt")
except Exception:
logger.warning("HTML style extraction failed for page %s; saving favorite without hints", page_id, exc_info=True)
return await store.create_favorite(
{
"id": str(uuid.uuid4()),
"user_id": user_id,
"page_id": page_id,
"title": (title or page.get("title") or "收藏页面").strip(),
"description": (description or None),
"tags": tags or [],
"html_snapshot": html,
"style_summary": style_summary,
"layout_summary": layout_summary,
"content_structure_summary": content_structure_summary,
"reuse_prompt": reuse_prompt,
}
)
@@ -6,11 +6,13 @@ from .present_file_tool import present_file_tool
from .router_quality_tool import router_quality_check
from .scheduled_task_tool import scheduled_task_tool
from .setup_agent_tool import setup_agent
from .setup_writing_tool import setup_writing
from .task_tool import task_tool
from .view_image_tool import view_image_tool
__all__ = [
"setup_agent",
"setup_writing",
"present_file_tool",
"ask_clarification_tool",
"agent_orchestration_tool",
@@ -56,7 +56,7 @@ def notebook_search_tool(query: str, top_k: int = 10) -> str:
headers={"Authorization": f"Bearer {token}"},
json={
"query": query,
"source_ids": [source_ids],
"source_ids": source_ids,
"search_space_id": search_space_id,
"top_k": top_k,
},
@@ -27,7 +27,7 @@ def setup_agent(
description: One-line description of what the agent does.
name: Optional human-readable display name for the agent. If omitted,
the agent's stable id is used as the display name.
skills: Optional list of skill names this agent should use. None or empty means no skills.
skills: Optional list of skill names this agent should use. None or empty means no skills. Maximum 5 skills allowed.
"""
agent_name: str | None = (runtime.context.get("agent_id") or runtime.context.get("agent_name")) if runtime.context else None
@@ -48,7 +48,7 @@ def setup_agent(
if description:
config_data["description"] = description
if skills is not None:
config_data["skills"] = skills
config_data["skills"] = skills[:5]
config_file = agent_dir / "config.yaml"
with open(config_file, "w", encoding="utf-8") as f:
@@ -0,0 +1,53 @@
"""写作配置助手用的提交工具。
与 ``setup_agent`` 同构:模型在「写作配置 Q&A」里与用户聊清需求后,调用本工具把建议的
写作配置「提交」出来。实际它总是被 ``SetupWritingApprovalMiddleware`` 在 ``after_model``
拦截(不真正执行)——拦截后把建议字段打包进审批 ToolMessage,前端据此回填左侧写作表单。
因此本工具的函数体基本是兜底:万一未被拦截(如审批被预先放行),也只回一句确认、不落任何盘。
"""
from __future__ import annotations
import logging
from langchain_core.messages import ToolMessage
from langchain_core.tools import tool
from langgraph.prebuilt import ToolRuntime
from langgraph.types import Command
logger = logging.getLogger(__name__)
@tool
def setup_writing(
user_intent: str,
runtime: ToolRuntime,
article_type: str | None = None,
word_count_target: int | None = None,
target_audience: str | None = None,
writing_mode: str | None = None,
material_source: str | None = None,
outline: str | None = None,
) -> Command:
"""把与用户聊清的写作配置提交给前端表单(不直接开始写作)。
Args:
user_intent: 用户的写作意图(精炼成一句话的写作目标)。
article_type: 文章类型的 key(须是系统给定的合法类型之一;不确定可留空)。
word_count_target: 目标字数(正整数)。
target_audience: 目标读者(如「行业专家」「普通读者」)。
writing_mode: 写作模式,'strict'(严格依据素材)或 'loose'(可结合通用知识)。
material_source: 素材来源,'knowledge_base'(知识库)或 'skill'(技能检索)。
outline: 若用户已提供大纲,原样填入其大纲文本(保留其层级与标题),否则留空。
"""
logger.info("[setup_writing] proposed writing config submitted (intercepted by approval middleware)")
return Command(
update={
"messages": [
ToolMessage(
content="写作配置已生成,请在右侧确认后应用到表单。",
tool_call_id=runtime.tool_call_id,
)
]
}
)
@@ -51,20 +51,33 @@ def view_image_tool(
runtime: ToolRuntime[ContextT, ThreadState],
image_path: str,
tool_call_id: Annotated[str, InjectedToolCallId],
question: str | None = None,
) -> Command:
"""Read an image file.
"""Read and understand an image file.
Use this tool to read an image file and make it available for display.
This is the ONLY correct way to understand an image. The image is analyzed by
the configured vision model and its description/answer is returned to you as
the tool result. Treat that result as authoritative.
When to use the view_image tool:
- When you need to view an image file.
- Whenever you need to view, describe, or answer a question about an image file.
When NOT to use the view_image tool:
- For non-image files (use present_files instead)
- For multiple files at once (use present_files instead)
IMPORTANT — do NOT attempt to understand images any other way:
- Never read, open, decode, or parse an image with read_file, grep, bash,
python, PIL/Pillow, OpenCV, EXIF parsing, or byte/hex inspection. Those
cannot interpret visual content and will only waste turns.
- If `view_image` returns a weak or incomplete description, do not fall back
to code. Either call `view_image` again with a more specific `question`,
or tell the user the configured vision model could not extract more detail
(the vision model may need to be changed in config).
Args:
image_path: Absolute /mnt/user-data virtual path to the image file. Common formats supported: jpg, jpeg, png, webp.
question: Optional specific question to ask about the image (e.g. "What is the total on this invoice?"). When omitted, a general detailed description is produced.
"""
from deerflow.sandbox.exceptions import SandboxRuntimeError
from deerflow.sandbox.tools import (
@@ -153,8 +166,27 @@ def view_image_tool(
mime_type = detected_mime_type
image_base64 = base64.b64encode(image_data).decode("utf-8")
# Update viewed_images in state
# The merge_viewed_images reducer will handle merging with existing images
# Delegated recognition: when a dedicated vision model is configured, send
# the image to that model and return its description as the tool result, so
# a text-only main chat model can still "see" the image.
from deerflow.models.vision import is_delegated_vision, recognize_image
if is_delegated_vision():
try:
description = recognize_image(image_base64, mime_type, question=question)
except Exception as e: # noqa: BLE001 - surface as a recoverable tool error
return Command(
update={"messages": [ToolMessage(f"Error recognizing image: {_sanitize_image_error(e, thread_data)}", tool_call_id=tool_call_id)]},
)
if not description:
description = "The vision model returned an empty description."
return Command(
update={"messages": [ToolMessage(f"Image recognition result for {image_path}:\n\n{description}", tool_call_id=tool_call_id)]},
)
# Native vision: store the image so ViewImageMiddleware injects it into the
# (vision-capable) main model before the next LLM call.
# The merge_viewed_images reducer will handle merging with existing images.
new_viewed_images = {image_path: {"base64": image_base64, "mime_type": mime_type}}
return Command(
@@ -142,11 +142,14 @@ def get_available_tools(
if model_name is None and config.models:
model_name = config.models[0].name
# Add view_image_tool only if the model supports vision
model_config = config.get_model_config(model_name) if model_name else None
if model_config is not None and model_config.supports_vision:
# Add view_image_tool when vision is available — either the main model
# supports vision natively, or a dedicated vision model is configured
# (vision.model_name) so recognition can be delegated to it.
from deerflow.models.vision import vision_enabled
if vision_enabled(model_name, app_config=config):
builtin_tools.append(view_image_tool)
logger.info(f"Including view_image_tool for model '{model_name}' (supports_vision=True)")
logger.info(f"Including view_image_tool for model '{model_name}' (vision available)")
# Get cached MCP tools if enabled
# NOTE: We use ExtensionsConfig.from_file() instead of config.extensions
@@ -138,12 +138,21 @@ def open_upload_file_no_symlink(base_dir: Path, filename: str) -> tuple[Path, ob
validate_path_traversal(dest, base_dir)
if not hasattr(os, "O_NOFOLLOW"):
raise UnsafeUploadPathError("Upload writes require O_NOFOLLOW support")
flags = os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW
# ``O_NOFOLLOW`` closes the TOCTOU window between the ``lstat`` above and the
# ``open`` below, but it is a POSIX-only flag — it does not exist on Windows
# (``os.O_NOFOLLOW`` is undefined there). Windows also lacks ``O_NONBLOCK``
# and needs ``O_BINARY`` to avoid CRLF translation on the raw fd. Where
# ``O_NOFOLLOW`` is unavailable we still reject pre-existing symlinks via the
# ``lstat`` check and re-verify the opened file with ``fstat`` below; the
# residual race only matters under a hostile local user, which is out of
# scope for the single-user local deployment this path targets.
flags = os.O_WRONLY | os.O_CREAT
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
if hasattr(os, "O_NONBLOCK"):
flags |= os.O_NONBLOCK
if hasattr(os, "O_BINARY"):
flags |= os.O_BINARY
try:
fd = os.open(dest, flags, 0o600)
@@ -0,0 +1,89 @@
"""Regression tests for the upstream call shape of token-exchange login.
The upstream ``getTokenInfo`` endpoint reads the bearer token from the
``Authorization`` header — NOT from an ``access_token`` cookie. These tests pin
that contract for ``_fetch_username_from_token``:
- ``Authorization`` is exactly ``Bearer <caller-token>`` (the token passed in),
not the static ``service_authorization`` config value.
- No ``access_token`` cookie is sent (the old, wrong placement).
"""
from __future__ import annotations
import types
import pytest
from app.gateway.routers import auth as auth_module
from app.gateway.routers.auth import _fetch_username_from_token
class _FakeResponse:
status_code = 200
def json(self) -> dict:
return {"state": "200", "msg": "ok", "data": {"username": "b1admin"}}
class _CapturingClient:
"""Stand-in for ``httpx.AsyncClient`` that records the post() kwargs."""
captured: dict = {}
def __init__(self, *args, **kwargs) -> None:
type(self).init_kwargs = kwargs
async def __aenter__(self) -> _CapturingClient:
return self
async def __aexit__(self, *exc) -> None:
return None
async def post(self, url, **kwargs):
type(self).captured = {"url": url, **kwargs}
return _FakeResponse()
def _patch_config(monkeypatch: pytest.MonkeyPatch) -> None:
token_login = types.SimpleNamespace(
token_info_url="https://upstream.example/getTokenInfo",
service_authorization="Bearer admin", # must NOT be used anymore
timeout_seconds=10.0,
verify_ssl=False,
ca_cert_path="",
)
fake_cfg = types.SimpleNamespace(auth_login=types.SimpleNamespace(token_login=token_login))
monkeypatch.setattr(auth_module, "get_app_config", lambda: fake_cfg)
monkeypatch.setattr(auth_module.httpx, "AsyncClient", _CapturingClient)
@pytest.mark.asyncio
async def test_sends_caller_token_as_bearer_header(monkeypatch: pytest.MonkeyPatch) -> None:
_patch_config(monkeypatch)
username = await _fetch_username_from_token("UPSTREAM_TOK_123")
assert username == "b1admin"
headers = _CapturingClient.captured["headers"]
assert headers["Authorization"] == "Bearer UPSTREAM_TOK_123"
@pytest.mark.asyncio
async def test_does_not_use_static_service_authorization(monkeypatch: pytest.MonkeyPatch) -> None:
_patch_config(monkeypatch)
await _fetch_username_from_token("UPSTREAM_TOK_123")
headers = _CapturingClient.captured["headers"]
assert headers["Authorization"] != "Bearer admin"
@pytest.mark.asyncio
async def test_no_access_token_cookie_is_sent(monkeypatch: pytest.MonkeyPatch) -> None:
_patch_config(monkeypatch)
await _fetch_username_from_token("UPSTREAM_TOK_123")
# The token must live in the header now; no cookie param should be passed.
assert "cookies" not in _CapturingClient.captured
File diff suppressed because it is too large. Load diff
+52 -2
View File
@@ -11,7 +11,7 @@ models:
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_vision: false
supports_thinking: true
- name: zai-org/GLM-5-FP8
display_name: zai-org/GLM-5-FP8
@@ -35,7 +35,7 @@ models:
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_vision: false
supports_thinking: true
- name: minimax-m2.7
display_name: MiniMax M2.7
@@ -57,6 +57,36 @@ models:
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: deepseek-ai/DeepSeek-OCR
display_name: deepseek-ai/DeepSeek-OCR
use: langchain_openai:ChatOpenAI
model: deepseek-ai/DeepSeek-OCR
api_key: sk-beshfxfxfrllsczpqfrgjbugfbpzxgqgedbehceytpcvvtyb
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
# 识图(图片识别)配置。
# - model_name:指定一个 models[] 中的模型名,作为“默认识图大模型”。view_image 工具会把
# 图片交给该模型识别,并把识别出的文字结果返回给主对话模型 —— 这样即使主对话模型不支持
# 视觉,也能“看图”。请改成你实际用于识图的模型名。
# - 若留空 / 注释掉 model_name,则回退到主对话模型自身的视觉能力(需该模型 supports_vision: true)。
# - prompt:不带具体问题时使用的默认识图指令(可选)。
# - max_tokens:识图模型输出上限(可选,默认用该模型自身的配置)。
vision:
model_name: deepseek-ai/DeepSeek-OCR
# prompt: 请详细描述这张图片的内容。
# max_tokens: 2048
# 上传文件识别配置。
# - auto_convert_documents: true 时,上传的 PDF / Word / Excel / PPT 会在服务端自动转换为
# Markdown(.md),agent 通过 read_file / grep 读取其内容 —— 即“识别”文档内容。
# 纯文本类(txt / csv / md / json / 代码等)无需转换,agent 用 read_file 直接读取。
# 图片(png/jpg/webp)由 view_image + 上面的 vision 识图模型处理。
# - pdf_converter: auto(优先 pymupdf4llm,缺失时回退 markitdown)| pymupdf4llm | markitdown
uploads:
auto_convert_documents: true
pdf_converter: auto
tool_groups:
- name: web
- name: file:read
@@ -113,6 +143,26 @@ skills:
enabled: false
agents_api:
enabled: true
# 登录鉴权扩展:在基础登录之上叠加两种自定义模式。
auth_login:
# 用户名直登口令门:开关打开后,/login/<用户名> 直登必须再带 ?password=口令,
# 口令不对直接 401。password 留空则默认口令为 123ewq。仅作用于用户名直登。
username_login:
require_password: false
password: ""
# token 换登录:前端用 ?authToken=上游token 进入,后端调用 token_info_url
# (把该 token 直接拼成 Authorization: Bearer <token> 头)换出用户名,再登录;
# 用户名不在库里时自动注册。
token_login:
enabled: true
token_info_url: "https://ch1.b.uat.4.cn/consumer/login/getTokenInfo"
# 已弃用:鉴权头现由前端传入的 token 拼成 "Bearer <token>",此项不再生效,保留仅兼容。
service_authorization: "Bearer admin"
timeout_seconds: 10
# 上游是 HTTPS。UAT/内网常用自签或私有 CA 证书,默认 verify=true 会握手失败导致
# 永远 401。此处 uat 域名按自签处理,先关校验;生产请置 true 或配 ca_cert_path。
verify_ssl: false
ca_cert_path: "" # 可选:PEM 格式 CA 包路径,设置后优先生效并启用校验
database:
backend: mysql
mysql_url: $MYSQL_DATABASE_URL