sync update from LAN repo (source master @ ca48c4ec)

This commit is contained in:
gozeros committed 2026-11-05 10:53:54 +08:00
1 parent 8bc8b0e082
commit ea9dcff9c0
873 files changed
+107866 -14011

No files matched your search

+1 -1
View File
@@ -18,7 +18,7 @@ SERPER_API_KEY=your-serper-api-key
# 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,http://localhost:5174,http://127.0.0.1:5174,http://localhost:5183,http://127.0.0.1:5183,http://localhost:4200,http://47.88.25.99:7010
GATEWAY_ENABLE_DOCS=false
GATEWAY_ENABLE_DOCS=true
GATEWAY_WORKERS=1
# Only effective when config.yaml runtime_performance.asyncio_thread_pool_enabled=true.
DEER_FLOW_ASYNCIO_THREAD_POOL_SIZE=100
@@ -15,3 +15,5 @@ logs
docker-data
dist
wheelhouse
.worker-pool
**/worker-control
+311 -8
View File
@@ -1,5 +1,232 @@
# CLAUDE.md
## Cold agent construction (2026-10-30)
`runtime/runs/worker.py` dispatches opted-in synchronous factories through
`build_agent_with_timing`, backed by `utils/background_init.py`'s
`run_background_init` and one shared `FileIOPool(1, 1, "agent-init")`.
`make_lead_agent._deerflow_background_build = True` opts the built-in Lead Agent
into background construction; unmarked custom factories retain their caller-loop
contract. Serial admission prevents concurrent SDK cold imports and keeps waiting
builds out of authentication's executor. The synchronous timing helper remains
available. Model execution, checkpoint/store attachment and streaming stay in the
original run task; no graph is shared or cached across runs/users.
Every async Lead Agent entry point uses this builder, including non-streaming
and streaming open-chat endpoints and Workflow agent nodes. Do not call the
synchronous factory directly on their event loop: it can block SDK import locks
or deadlock while another background build awaits MCP discovery on that loop.
Async tool consumers (AI writing skill agents and `task_tool`) use
`get_available_tools_async`; deep-research capabilities and job execution also
construct their material providers through `run_background_init`. Keep the
common helper in `utils` with a lazy MCP import to avoid runtime/agent/tools
import cycles. Synchronous client/provider APIs remain available.
Copy the caller's ContextVars into construction and restore the successfully built
context before streaming, including a deferred-tool registry reset to `None`.
The `mcp_initialization_loop` context manager forwards lazy MCP discovery from the
worker to the owner event loop; exit it before copying back context. MCP cache
invalidation and legacy synchronous callers retain their existing semantics.
Cancellation drains the actual worker before releasing capacity, never starts the
graph afterwards, and preserves terminal status/SSE cleanup. Check `abort_event`
after construction as well as honoring task cancellation.
Regression: `packages/harness/tests/test_agent_build_responsiveness.py`,
`test_agent_build_timing.py`, `test_mcp_background_build.py` and the run-finalization
tests; `tests/test_async_agent_entrypoints.py` and
`tests/test_async_tool_initialization.py` exercise overlapping HTTP/workflow/tool
initializers. The HTTP/workflow cases run in subprocesses so an event-loop
deadlock fails with a bounded timeout. Cold profiling found ~4.7 s in model SDK imports/client setup, compared with
~14 ms in skill loading; moving only disk reads would not address this stall.
Local live cold acceptance (2026-10-30, official MiniMax-M2.7, tools enabled):
the first build took 4.28 s while a second request received stream metadata in
402 ms. During the conservative interior of the build window, 43 health/auth
probes completed successfully; across all three conversations, health/auth
P95 latency was 28/73 ms, with no event-loop-stall warning. Every answer ended
normally and all synthetic threads were removed. Initialization/model latency
still contributes to first-answer time; this change prevents blocking the server
loop, rather than promising immediate model output.
A separate live read-only tool check uploaded a random marker, allowed only
`read_file`, and confirmed the tool result and final answer reproduced that marker
without placing it in the prompt; the stream ended normally and the thread was removed.
Worker compatibility: no HTTP/SSE, task payload, auth or database contract changes.
Running workers without reload retain old construction behavior; the new factory
marker is harmless to old executors. `worker_pool/revision.py` hashes only the
adapter directory and does not identify this harness patch. This change does not
alter the controller's existing snapshot/ImageID-based drain-and-replace policy
or `on_disconnect=cancel` behavior: restarting a production controller can still
rotate idle workers or disconnect proxied streams. Do not infer uninterrupted
worker operation from these process-local initialization changes. The live local
acceptance instance has no enabled worker controller and does not touch remote
workers. Existing worker routing/admission/storage/discovery/diagnostics/launcher
compatibility regressions: 68 passed.
Final combined regression (2026-10-30): all 58 newly added responsiveness,
cancellation, MCP-entrypoint and provider-lifecycle cases plus the 68 existing
worker compatibility cases passed together (126 passed, no skips).
The live recheck uploaded and converted 20 DOCX files while another conversation
produced 43 visible text increments; all 172 concurrent authenticated probes
succeeded (P95 105 ms, max 436 ms). A fresh-process cold build took 10.92 s while
617 heartbeat samples continued (P95 scheduling delay 25 ms, max 146 ms).
The existing backend and child workers were not restarted for this recheck;
later entrypoint changes were verified through isolated HTTP/workflow tests.
The full versioned backend/harness run completed with 3140 passed, 39 failed,
4 skipped and 1 deselected. Against baseline `47cfed02`, 30 failures also failed
on HEAD and the other 9 passed in isolated reruns on both versions; no current-only
stable failure remained. The deselected legacy
`test_cancelled_history_closes_iterator` hangs on both versions: its fake saver
implements `alist`, while `limit=1` takes `aget_tuple`, so its unbounded entry
event wait never completes. This is not a claim that the repository's entire
test suite is green. Changed Python files introduced no Ruff diagnostics.
## Upload responsiveness (2026-10-30)
`app/gateway/routers/uploads.py` runs each staged upload transaction in a dedicated
`FileIOPool(2, 2, "uploads")`, including 256 KiB spool copies, signature validation,
optional synchronous conversion, sandbox acquisition/sync, commit, rollback and
cleanup. Resolve the thread owner asynchronously before dispatch and propagate
contextvars into workers. Waiting batches consume no worker from authentication,
artifacts or event streaming. Per-event-loop directory locks serialize same-thread
batches and listing/deletion through rollback/cleanup; keep them held until a
cancelled worker stops. Listing/deletion use the artifact file pool off the loop.
Cancellation signals a cooperative event, checks between file chunks and around
commit/sync, and waits for real completion before multipart handles may close.
The response contract and upload-policy enforcement remain unchanged.
`sandbox/sandbox_provider.py` serializes initial singleton construction with
reset/replacement/shutdown. Concurrent cold uploads must not create competing
providers with independent sandbox reconciliation and idle-cleanup threads.
Regression: `packages/harness/tests/test_sandbox_provider_concurrency.py`.
`file_conversion.py` also isolates its asynchronous conversion/extraction callers
in a separate two-thread pool. PDF/Office parsing runs in a lazily initialized,
two-process pool using `spawn` on Windows and Linux, so Python parser CPU work
does not contend for the gateway GIL. Pass only file paths and converter choices;
bound returned text inside the child and write sidecars in the owning parent
worker. The global parser semaphore bounds sync/async callers together. A broken
process pool fails the current conversion and is rebuilt on the next call.
`FileIOPool.run_until_complete` provides non-abandoning cancellation for workers
that own temporary input files; cancellation cannot close/delete an input still
being parsed by a child process.
Regression: `tests/test_upload_responsiveness.py`,
`tests/test_conversion_responsiveness.py`, `tests/test_uploads_policy_enforcement.py`.
Local acceptance (Windows, Python 3.12, synthetic files via ASGI; no delay injected):
20 x 900 KiB TXT uploads reduced first concurrent probe delay from 664/682 ms to
3/4 ms. With real MarkItDown conversion, 20 x 800-paragraph DOCX uploads reduced
maximum probe scheduling lag from 286/285 ms to 41/14 ms; all 20 sidecars matched
the source text. The first spawned reader took 13.7 s to initialize/convert in
this environment, while concurrent probe lag stayed below 16 ms. These are local
responsiveness measurements, not production throughput guarantees. Upload,
policy, conversion, large-file and runtime-contention regressions: 87 passed.
Full-suite collection is separately blocked by the pre-existing missing
`deerflow.models.global_prompt` import in `tests/test_global_prompt.py`.
## Thread metadata query performance and ORM deadlines (2026-10-22)
`ThreadMetaRepository._query_conditions` is shared by search/count: common
SQLite scalar predicates and PG/MySQL string predicates plus system-thread
exclusion run before SQL pagination/counting. Preserve AUTO owner resolution and
the nonempty taskId owner bypass while applying agent_id/actionId together.
`json_compat.JsonPredicate` binds values, preserves local missing/null and numeric
equality on SQLite, and uses binary MySQL id equality and Unicode-safe blank
seat detection. Nested/unusual-key filters and PG/MySQL numeric filters remain exact
residual filters; openGauss retains its original path. Never drop an unsupported
predicate or replace this with upstream's unadapted SQLite/PG-only compiler.
PG's omitted pool_recycle defaults to 300s (other backends remain 1800s);
database.command_timeout is finite positive or null, default 30s. The shared
pooled-engine kwargs are used on initial creation and auto-create retry. Only
asyncpg receives command_timeout; this does not configure psycopg checkpointers.
Regression entries: packages/harness/tests/test_thread_metadata_sql_filters.py,
test_database_pool_deadlines.py and the existing test_thread_operation_timing.py.
Master integration (2026-10-23) retains the maintained task/agent/action index
columns as additional SQL narrowing before exact JSON predicates. Keep dedicated
task deep-link card synchronization on every existing metadata mutation. Hydrated
history still uses slim_display to bound oversized tool payloads without editing
checkpoint contents; frontend retrieval-progress scopes remain isolated per turn.
Thread POST defaults to metadata-only creation; only an explicit
initialize_checkpoint=true writes an empty checkpoint. Never enqueue an empty
checkpoint in the background: it could become the head after the real first run.
Authorized metadata-only threads have empty state/history. Metadata GET defaults
to the SQL row (running maps to busy); include_state=true and checkpoint-only
legacy rows retain state reads. Owner decorators run before unscoped row reads.
History explicitly uses root checkpoint_ns and closes alist before serialization;
only the latest head carries full channel values.
The frontend uses BackgroundHistory as the SDK's external history adapter. Its
post-stream mutate immediately captures streamed state with no checkpoint id;
explicit end triggers background hydration. EOF/error still requires existing
run confirmation/rejoin. Every async write is fenced by thread/run/epoch/request;
new turns, navigation and unmount invalidate prior requests even if abort is
ignored. Reject history missing the newest streamed turn, bound each request to
15 seconds and retry completion sync once. Failure keeps the streamed snapshot.
Normal explicit-end callbacks use the completed streamed snapshot before the
next send can change page refs; background hydration never repeats them. Recovery
may have missed final SSE frames, so its callback waits for guarded hydration or
the retained snapshot fallback. New submits use checkpoint:null to follow the
latest server head.
Auth's SQLiteUserRepository is a historical name: its injected session factory
is the shared business ORM. With database.backend=mysql, login/auth/users and SQL
business repositories use MySQL, independently of checkpoint configuration.
LangGraph Store still follows checkpointer config and startup awaits runtime
setup/migrations; do not claim total service/startup isolation from PG or migrate
existing stores implicitly. See docs/THREAD_LATENCY_OPTIMIZATION_ZH.md for the audit.
Additional regression entries: test_thread_lazy_checkpoint.py,
tests/test_account_authentication.py and frontend pnpm test:thread-history.
## Multi-user contention controls (2026-10-22)
- `utils/file_io.py` separates bounded artifact IO (4 workers / 8 admitted) and
event disk IO (2 / 4) from the default executor used by authentication.
Contextvars propagate; cancellation cannot release admission until disk work
actually stops. Heavy artifact listing, ZIP previews, sniffing and writes use it.
- `runtime/runs/manager.py` sweeps completed live records after 300 seconds,
200 candidates per pass. It requires the actual task to be done and verifies
status/error/metadata/kwargs in the backing RunStore, updating a final snapshot
when necessary. Failed persistence retains the record for later retry. It never
deletes RunStore rows, checkpoints, events or stream buffers. `watch_completion`
starts grace after finalization, and lifespan closes the sweeper. The live
superseded fence survives a newer record's retirement; finalization never adds
a business-database history read to its completion fence.
- Run routes authorize the thread before unscoped persisted lookups, and verify
that the loaded run belongs to that exact thread. Preserve private-owner 404s,
taskId/system-thread sharing and existing action/agent partitions. Completed
rejoin without a live bridge buffer emits end immediately; Last-Event-ID replay
during the existing replay window remains untouched.
- `run_events.backend=memory` is a compatibility alias for
`SpoolingRunEventStore`, stored under `runtime_home()/run-events/events.sqlite3`.
Per-thread sequence assignment and each batch commit are transactional; paged
queries do not retain completed events in RAM. No automatic event/message
deletion or truncation is introduced. The pure `MemoryRunEventStore` remains
available for unit tests. Explicit db/JSONL formats are unchanged; JSONL disk
operations now use the event executor and striped per-thread locks. Keep the
spool and its WAL on the persistent local runtime volume. Do not reclassify it
as a PG checkpoint or silently change explicit db configuration.
- `runtime/fair_queue.py` provides bounded round-robin admission by user for the
subagent scheduler (3 total / 2 per user) and background roundtables (4 / 2).
Queued jobs consume no worker; subagent deadlines include queue time. Per-user
pending capacity also prevents one user from exhausting the entire backlog.
Actual coroutine completion, including cancellation before its first step,
acknowledges slot release. Parent context/identity propagation stays intact.
`RuntimeContentionConfig` defines optional budgets; limits apply per process
and require restart. The older roundtable per-user environment override remains.
- Both internal roundtable creation helpers only create metadata. Init does not
append a synthetic seat message via loopback; `_leader_run` injects authoritative
IDs on each real input, deriving them from the thread mapping for older clients
without seat_meta. Background first-leader injection remains. Never schedule a
late empty checkpoint write: it could overwrite real history.
Regression entries: `test_runtime_contention.py`, `test_completed_run_retention.py`,
`test_thread_lazy_checkpoint.py`, `test_run_completion_timeout.py`,
`tests/test_position_roundtable_sessions.py`, roundtable role/model/seat tests,
artifact/auth/model concurrency tests and frontend `pnpm test:thread-history`.
See `docs/MULTI_USER_CONTENTION_AUDIT_ZH.md` for measured scope and remaining
model/SQL/checkpointer contention. Process-local run control still requires the
current single-worker deployment; these queues do not enable multi-worker routing.
## Ordinary knowledge smart retrieval dual surface (2026-10-16)
`SmartReasoningRetrievalMiddleware` treats knowledge retrieval as an ordinary
@@ -120,7 +347,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
### Ordinary Q&A map visualization
The ordinary lead-agent prompt includes `_ORDINARY_QA_VISUALIZATION_SECTION` only for normal, non-bootstrap, non-writing, non-notebook, non-scheduled runs and defines the highest-priority output-carrier gate. Only an explicit current-request ask for HTML, a webpage/page, dashboard, large-screen view, or `.html` file permits reading `page-report-generation`, calling `page_report_template`, or writing HTML. “Draw a flowchart/chart/map/mind map” and “make a visualization” never imply HTML: ordinary flowcharts must be inline Mermaid, ordinary charts keep the established message-inline path, and requested maps emit strict JSON in a `map-json` fence. A mind map is emitted only for an explicit current-request ask for a mind map/knowledge map (or `analysis-mindmap`); ordinary analyses, summaries, outlines, flowcharts, and generic JSON must never auto-upgrade into one. Its strict `mindmap-json` contract is a bounded rooted tree (`id`, `parentId`, `label`, semantic `kind`, optional detail/source refs), targeting 12–24 nodes with a hard cap of 36, at most four layers including the center topic, at most six top-level dimensions and three children beneath every later node. Models must consolidate related facts into `detail`/`sourceRefs`, not make one node per report bullet, datum, paragraph, or reference. An unfinished dedicated `mindmap-json` fence (and compatible historical `mindmap` fence) remains an ordinary streaming code block; only the completed, valid object lazy-renders as a compact left-to-right message card. Clicking a parent node, or its +/− control at the outgoing connection point, expands/collapses its child tree and the canvas uses an explicit visible edge stroke. The full dialog keeps its close and view tools in the top-right and a layout-derived all-map preview in the bottom-right; the message preview also permits pan, wheel zoom, and zoom/reset controls. The message card's icon-only download control opens PNG/SVG/JSON choices. Generic JSON never invokes this renderer. Ambiguity defaults to inline output. Once the HTML gate passes, delivery is mandatory as a sandbox-generated `/mnt/user-data/outputs/*.html` artifact opened with `present_files`; a full inline `html` fence is forbidden as a substitute. The shared frontend `MarkdownContent` keeps its exact legacy branch when no map-shaped fence exists; dedicated `map-json` fences and complete generic `json` fences that contain map fields and pass the full map schema are validated and lazy-rendered from `components/workspace/visualizations/`. Map/loading/error cards use the full message-content width so their edges align with the step panel. Non-map and incomplete generic JSON retain the ordinary code-block path.
The ordinary lead-agent prompt includes `_ORDINARY_QA_VISUALIZATION_SECTION` only for normal, non-bootstrap, non-writing, non-notebook, non-scheduled runs and defines the highest-priority output-carrier gate. Only an explicit current-request ask for HTML, a webpage/page, dashboard, large-screen view, or `.html` file permits reading `page-report-generation`, calling `page_report_template`, or writing HTML. “Draw a flowchart/chart/map/mind map” and “make a visualization” never imply HTML: ordinary flowcharts must be inline Mermaid, ordinary charts keep the established message-inline path, and requested maps emit strict JSON in a `map-json` fence (markers use `name`+`coordinate` plus optional `color`; lines are included only when the user explicitly asks to connect places, must use `coordinates` with at least two `[lng,lat]` points, not `from`/`to`/`coords`, and default to `arrow`/`flow`). The frontend recovers those common aliases, assigns distinct marker colors, draws requested lines as looping SVG arrows, and drops unusable lines so markers still render. A mind map is emitted only for an explicit current-request ask for a mind map/knowledge map (or `analysis-mindmap`); ordinary analyses, summaries, outlines, flowcharts, and generic JSON must never auto-upgrade into one. Its strict `mindmap-json` contract is a bounded rooted tree (`id`, `parentId`, `label`, semantic `kind`, optional detail/source refs), targeting 12–24 nodes with a hard cap of 36, at most four layers including the center topic, at most six top-level dimensions and three children beneath every later node. Models must consolidate related facts into `detail`/`sourceRefs`, not make one node per report bullet, datum, paragraph, or reference. An unfinished dedicated `mindmap-json` fence (and compatible historical `mindmap` fence) remains an ordinary streaming code block; only the completed, valid object lazy-renders as a compact left-to-right message card. Clicking a parent node, or its +/− control at the outgoing connection point, expands/collapses its child tree and the canvas uses an explicit visible edge stroke. The full dialog keeps its close and view tools in the top-right and a layout-derived all-map preview in the bottom-right; the message preview also permits pan, wheel zoom, and zoom/reset controls. The message card's icon-only download control opens PNG/SVG/JSON choices. Generic JSON never invokes this renderer. Ambiguity defaults to inline output. Once the HTML gate passes, delivery is mandatory as a sandbox-generated `/mnt/user-data/outputs/*.html` artifact opened with `present_files`; a full inline `html` fence is forbidden as a substitute. The shared frontend `MarkdownContent` keeps its exact legacy branch when no map-shaped fence exists; dedicated `map-json` fences and complete generic `json` fences that contain map fields and pass the full map schema are validated and lazy-rendered from `components/workspace/visualizations/`. Map/loading/error cards use the full message-content width so their edges align with the step panel. Non-map and incomplete generic JSON retain the ordinary code-block path.
HTML artifacts are single-page by default. Omitted `preferred_page_count` is normalized to `1` during direct validation. A count greater than one is allowed only when the current user directly asks for multiple pages or gives a page count; content length and report chapter count never imply multi-page intent. Explicit multi-page intent without a count defaults to a three-page lightweight portal. It is one artifact HTML, not a folder of linked pages: `<main class="pages">` contains logical `.page` sections with unique `id`/`data-page`, and `href="#page-..."` navigation works in the sandboxed Blob preview through the injected anchor handler. Its validator requires the local Bootstrap/Alpine assets, `.pages`, multiple `data-page` sections and real anchor navigation: every actual `<a href="#page-...">` and every script-set `location.hash` must match a unique logical-page id, and each page must be reachable; comments or `#overview`/`#stations` data-page values cannot spoof this check. `MultiPageHtmlMiddleware` repeats that structural inspection at the `present_files` boundary, so a model that skips validate or writes custom tabs cannot expose a nonconforming portal. A user-uploaded background goes only on the requested logical `.page` in this mode (home page when unspecified), not on every page. When a template is explicitly requested, prepare strictly excludes profiles from the wrong page-count mode, persists `requested_page_count` and `page_mode`, and validate reloads them. Validation rejects multiple detected page views (`data-page`, tabpanels, `.report-page`, or `.view`) in single-page mode, or a single view when multi-page was explicitly requested.
@@ -130,7 +357,7 @@ For an explicit multi-page HTML request without an explicit template request, `M
### Generated HTML artifact visualization runtimes
The same normal lead-agent prompt includes `_HTML_ARTIFACT_VISUALIZATION_SECTION`, but its contents are executable only after the explicit HTML-intent gate above passes; visualization content alone never implies this intent. New generated HTML explicitly loads browser distributions from Gateway `/api/public/html-runtime/`: `bootstrap.min.css`, `alpine.min.js`, `bootstrap.bundle.min.js`, `g6.min.js`, `echarts.min.js`, and then `deerflow-runtime.js`; it can also load the packaged country/region map catalog: `china-provinces`, `us-states`, `japan-prefectures`, and `taiwan-counties`. Bootstrap is the responsive layout/modal layer and Alpine is the small state/list-rendering layer; jQuery, React, Vue, Babel and public CDNs are not part of this artifact contract. The files are package resources under `packages/harness/deerflow/html_runtime/assets/` and ship with the harness wheel, so HTML execution does not depend on frontend Vite/node_modules assets or a public CDN. `system_settings.json -> html_map.engine` (`GET|PUT /api/system-settings/html-map`, admin write) chooses `echarts` (default, local GeoJSON `data-df-geo`) or `leaflet` (地图网页, `data-df-map`). `config.yaml -> html_runtime.map_tile_url` is injected into `deerflow-runtime.js` as `mapTileUrl` and is used only by the Leaflet engine. Both initializers stay in the runtime so already generated pages keep rendering after the switch. The next ordinary Q&A prompt and `page_report_template` validation follow the selected engine. `config.yaml -> html_runtime.base_url` is the browser-reachable absolute runtime base written into generated source (localhost default; deployments must replace it with the real Gateway origin). Flowcharts use paired `data-df-flow` JSON blocks and AntV G6, not Mermaid; charts use `data-df-chart`; maps use ECharts Canvas-backed `data-df-geo` with the local GeoJSON catalog. `data-df-chart` must be strict JSON: no JavaScript callbacks or function-valued colors, with literal colors/data-item styles and string tooltip formatters instead. Dark HTML must set `data-df-theme="dark"` and explicitly give ECharts global/title/legend/axis/visible-series labels readable light text, including `nameTextStyle` for named axes. Map `description` values are plain text because the runtime escapes markup; click-to-filter or page-switch behavior listens to `deerflow:geo-map-click` and uses a valid `#page-...` anchor. `deerflow-runtime.js` waits until a hidden logical page has a real layout before creating ECharts and tracks later container changes with `ResizeObserver`, so map/chart canvases never depend on a browser-window resize for their first visible render. It initializes all three contracts, opens a detail popover for described regions/markers, and exposes catalog URLs through its public dynamic response. Map large screens use a centered map stage with narrow side rails and bottom summaries, rather than cards layered across a full-page map background. Frontend runtime replacement is bypassed whenever this explicit Gateway runtime is present. This remains separate from ordinary message rendering; ordinary `map-json` keeps its existing frontend tile renderer and `VITE_MAP_TILE_URL` configuration, and ordinary `flow-json`/`chart-json` must not be reintroduced.
The same normal lead-agent prompt includes `_HTML_ARTIFACT_VISUALIZATION_SECTION`, but its contents are executable only after the explicit HTML-intent gate above passes; visualization content alone never implies this intent. New generated HTML explicitly loads browser distributions from Gateway `/api/public/html-runtime/`: `bootstrap.min.css`, `alpine.min.js`, `bootstrap.bundle.min.js`, `g6.min.js`, `echarts.min.js`, and then `deerflow-runtime.js`; it can also load the packaged country/region map catalog: `china-provinces`, `us-states`, `japan-prefectures`, and `taiwan-counties`. Bootstrap is the responsive layout/modal layer and Alpine is the small state/list-rendering layer; jQuery, React, Vue, Babel and public CDNs are not part of this artifact contract. The files are package resources under `packages/harness/deerflow/html_runtime/assets/` and ship with the harness wheel, so HTML execution does not depend on frontend Vite/node_modules assets or a public CDN. `system_settings.json -> html_map.engine` (`GET|PUT /api/system-settings/html-map`, admin write) chooses `echarts` (default, local GeoJSON `data-df-geo`) or `leaflet` (地图网页, `data-df-map`). The map tile URL saved in the settings dialog (`system_settings.json -> html_runtime.map_tile_url`, `GET|PUT /api/system-settings/html-runtime`) is injected into `deerflow-runtime.js` as `mapTileUrl` and is used only by the Leaflet engine. Both initializers stay in the runtime so already generated pages keep rendering after the switch. The next ordinary Q&A prompt and `page_report_template` validation follow the selected engine. The runtime base URL saved in the same settings block is the browser-reachable absolute address written into generated source. An empty value falls back to an older `config.yaml` entry and then `http://localhost:8001/api/public/html-runtime`. Flowcharts use paired `data-df-flow` JSON blocks and AntV G6, not Mermaid; charts use `data-df-chart`; maps use ECharts Canvas-backed `data-df-geo` with the local GeoJSON catalog. `data-df-chart` must be strict JSON: no JavaScript callbacks or function-valued colors, with literal colors/data-item styles and string tooltip formatters instead. Dark HTML must set `data-df-theme="dark"` and explicitly give ECharts global/title/legend/axis/visible-series labels readable light text, including `nameTextStyle` for named axes. Map `description` values are plain text because the runtime escapes markup; click-to-filter or page-switch behavior listens to `deerflow:geo-map-click` and uses a valid `#page-...` anchor. `deerflow-runtime.js` waits until a hidden logical page has a real layout before creating ECharts and tracks later container changes with `ResizeObserver`, so map/chart canvases never depend on a browser-window resize for their first visible render. It initializes all three contracts, opens a detail popover for described regions/markers, and exposes catalog URLs through its public dynamic response. Map large screens use a centered map stage with narrow side rails and bottom summaries, rather than cards layered across a full-page map background. Frontend runtime replacement is bypassed whenever this explicit Gateway runtime is present. This remains separate from ordinary message rendering; ordinary `map-json` keeps its existing frontend tile renderer and `VITE_MAP_TILE_URL` configuration, and ordinary `flow-json`/`chart-json` must not be reintroduced.
User-uploaded raster images can be used as generated-HTML backgrounds through signed, unauthenticated `GET /api/public/html-images/{token}` URLs. `UploadsMiddleware` mints one URL per uploaded `.jpg/.jpeg/.png/.webp/.gif/.bmp` and injects it into `<uploaded_files>` as `Public URL`; the HTML contract requires that exact absolute URL in `background-image`, never a sandbox path, artifact API path, relative path, or base64 data URI. Single-page artifacts place it on `html/body`; the multi-page portal places it only on the requested logical page. Tokens are HMAC-signed, do not expire (the URL is written into durable HTML), and only grant read access to one file under the thread `uploads/` or `outputs/` directory. Engine: `deerflow.html_runtime.public_images`; router: `app/gateway/routers/html_images.py`; tests: `tests/test_html_images.py`.
@@ -303,6 +530,28 @@ commands.py` and `tests/test_position_roundtable_concurrency.py` cover the
state transitions; `tests/test_roundtable_postgres_concurrency.py` is the real
PostgreSQL acceptance suite (set `DEERFLOW_POSTGRES_CONCURRENCY_TEST_URL`).
Step-2 roundtable rooms share one deployment-wide cap,
``system_settings.json -> roundtable_room_limit`` (default enabled, 3 rooms).
``GET/PUT /api/system-settings/roundtable-room-limit`` is the admin switch.
Occupancy is summed across **every account**: durable foreground leases keyed
by a hidden per-live-execution ``roomSessionId``, in-flight LangGraph runs on
threads stamped with the corresponding ``roundtable_room_key``, and executing
background jobs (``queued`` / ``running`` / ``cancel_requested`` with a live
worker lease or a recent update). Different pages running the same saved draft
have different session ids and therefore take different rooms; all seats and
Step 3 calls within one live execution share its key. Run-manager inspection
is reconciliation-only: when a durable lease/job already owns the same
draft+user/task scope, stale or overlapping run keys in that scope are not
counted again. Merely opening the page,
viewing saved history, paused ``awaiting_input`` jobs, stale historical rows,
and Step-1 intent threads do not occupy a slot. Foreground-to-background
handoff replaces the foreground lease with the durable job occupancy. There is
no per-user pool. Admission is serialized by a database gate row, and the cap
is enforced at acquire, init, every model-producing run, and background-job
start. Full pools return HTTP 409; an enabled gate with missing storage or a
missing session id fails closed. The switch applies on the next admission
without a restart. Position and eight-department roundtables are not counted.
Roundtable backend jobs use a database unique active-dedupe key plus a leased
dispatcher, never process-local start deduplication. Cancellation is two
phase (`cancel_requested` then `cancelled`): the lease owner’s watcher normally
@@ -449,6 +698,8 @@ from deerflow.config import get_app_config
### Agent System
**Gallery chat link.** An agent's `config.yaml` may store `chat_link` (`url`, `open_mode` of `blank`/`self`, and light-app-shaped `route_params`). The gallery 「对话」 button opens that resolved URL; an empty url keeps the in-app chat. The list endpoint reads the mirrored `agent_extras.chat_link` column (nullable JSON, added by the startup column sweep) instead of parsing every config file. Create/update and directory backfill write the mirror; a skills-only `set_extras` leaves an existing link in place.
**Lead Agent** (`packages/harness/deerflow/agents/lead_agent/agent.py`):
- Entry point: `make_lead_agent(config: RunnableConfig)` registered in `langgraph.json`
- Dynamic model selection via `create_chat_model()` with thinking/vision support
@@ -463,6 +714,8 @@ from deerflow.config import get_app_config
**Strategy-selection functional agents.** `app/gateway/routers/_strategy_selection_seed.py` seeds nine stable folder-backed agents from `_strategy_selection_seed_assets/` before `_sync_legacy_agents`: intent, governance/diplomacy/economy/security/public-opinion/law-policy angles, judge, and solution editor. Seeding is missing-directory-only and must never overwrite administrator changes. The global `strategy_agent_roles` registry classifies them independently of projects: intent/judge/solution are fixed, unique roles that may be configured but never added, disabled, reordered, or deleted; angle roles are a shared, ordered catalog that administrators may add, edit, disable (soft delete), restore, and reorder through `/api/strategy-agents`. Public `GET /api/strategy-agents` returns enabled identity/description metadata only; `include_disabled=true` or `include_config=true` is admin-only. Admin writes are `POST /api/strategy-agents/angles`, `PUT /api/strategy-agents/{id}`, and `DELETE /api/strategy-agents/{id}`; DELETE retains the folder-backed agent and role row to preserve historical references. Dynamic angles remain built-in folder-backed agents under the reserved `strategy-selection-angle-` id namespace, with `web` + `file:read`; a blank SOUL on create receives a safe role template. All strategy skill writes validate that names exist, are active, and are globally visible; generic agent CRUD cannot create or hard-delete reserved strategy IDs, and parallel execution rejects disabled roles. Angle configs reuse `policy-knowledge`; intent reuses `intent-clarification-policy`; the judge has no retrieval skill; the solution editor gets only `file:write`. Keep the SOUL contracts neutral and evidence-aware, with explicit de-escalation/civilian-protection framing and hard bans on operational combat guidance and targeted political manipulation. Tests: `tests/test_strategy_selection_seed.py`, `tests/test_strategy_agent_management.py`.
Only `strategy-selection-intent` has a clarification reply surface in the strategy workbench. `parallel_agents` must exclude `ask_clarification` for every other `strategy-selection-*` role and inject a direct-delivery guard: angle, judge, and solution stages use conservative assumptions, lower confidence/score where needed, list gaps under data limitations, and still finish in one pass. The judge brief repeats this rule at runtime because existing folder-backed agent SOUL files may be administrator-customized and are intentionally never overwritten. If a legacy backend nevertheless emits a judge clarification, the frontend treats it as a bounded repair attempt rather than asking the user to repeat the same manual run. Malformed or incomplete evaluation JSON receives the same bounded repair path; prior attempts are replaced in the live panel, never concatenated into duplicate final content.
`POST /api/parallel-agents/run/stream` must remain observable during silent skill calls. Each running seat emits an `agent_progress` heartbeat every five seconds with the latest translated tool/reasoning/body phase and monotonic elapsed seconds; an upstream iterator that produces no frame for 180 seconds is closed and that seat emits `agent_error`, while the other seats continue. Set the terminal flag before queuing `agent_done` / clarification so a late heartbeat cannot reopen a completed card. The strategy workbench renders the intent Q&A history, readable skill execution steps and live heartbeat on the affected agent card; judge and synthesis traces use the same step translation. Tests: `tests/test_roundtable_model_fallback.py` and frontend `src/strategy-selection/lib.test.ts`.
**ThreadState** (`packages/harness/deerflow/agents/thread_state.py`):
@@ -593,7 +846,7 @@ FastAPI application on port 8001 with health checks at `GET|HEAD /health`, `/api
| **Answer feedback** (`/api/threads/{id}/messages/{mid}/feedback`) | Message-scoped `PUT`/`DELETE`, `GET /feedback-context`, and batched `POST /api/threads/{id}/feedback-contexts`; persists excellent/poor ratings, six stable poor-reason codes and optional detail idempotently per user/thread/message/agent. Legacy run-scoped feedback routes remain compatible. Admin-only `GET /api/agents/{agent_id}/feedback-score` uses the project-wide `system_role == "admin"` rule (and preserves auth-disabled mode) and returns a Bayesian user-feedback answer-quality score, raw task-success rate, weighted response-quality dimensions and reason counts; it is explicitly not a complete agent-capability evaluation. |
| **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 |
| **Open Chat** (`/api/open/chat`) | **Unauthenticated** open Q&A entry for external systems (the `/api/open/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES` + CSRF-exempt in `csrf_middleware.should_check_csrf`). `POST /chat` `{message \| messages[], model_name?, agent_name?, agent_id?, thinking_enabled=false, strip_think=true, thread_id?, recursion_limit=100}` → resolves the agent by **Chinese display name** (`agent_name`, matched against each `.deer-flow/agents/*/config.yaml` `name` via `_resolve_agent_id`; explicit `agent_id` wins if given), builds the lead agent via `make_lead_agent` (so the chosen **agent's** SOUL/tools/skills all apply), `ainvoke`s it once (stateless — no checkpointer, no thread persistence; runs under the `"default"` user bucket), and returns **only the final** `{answer, model_name, agent_name, agent_id, thinking_enabled, thread_id}` (no intermediate tool-call/process). **`POST /chat/stream`** is the **SSE streaming variant** (same request body) for **long tasks** — the blocking `/chat` produces no downstream bytes for the whole run, so a front nginx `proxy_read_timeout` (default 60s) cuts it off with a **504**; the stream keeps the connection warm (a `: keepalive` SSE comment every `_HEARTBEAT_SECONDS=15`s via a background producer-task + queue, plus `event: delta` AI-text increments and `event: progress`), and ends with **`event: result`** carrying the same final `{answer, …}` JSON (or `event: error`). Clients only need to read `event: result`; intermediate events are ignorable. Both endpoints share `_build_run_context` (config/agent/thread). `GET /agents` lists `{name, id}` so callers know what `agent_name` to send. **Thinking off is reliable**: when `thinking_enabled=false` the route also sets `thinking_force_disabled=true` so the lead agent calls `create_chat_model(force_disable_thinking=True)`, which injects **three** OpenAI-compatible "thinking off" shapes into `extra_body` — top-level `enable_thinking=false` (**SiliconFlow / DashScope hybrid models** like `deepseek-ai/DeepSeek-V4-*` on `api.siliconflow.cn` — the knob the nested `chat_template_kwargs` shape does NOT reach), `chat_template_kwargs.enable_thinking=false` (vLLM/Qwen native), and `thinking.type="disabled"` (Zhipu GLM) — even for models that only declare `supports_thinking: true`. For the **web-UI path** (which sends `thinking_enabled=false` but NOT `force_disable`), SiliconFlow models need `when_thinking_disabled: {extra_body: {enable_thinking: false}}` in their `config.yaml` entry (added to `deepseek-ai/DeepSeek-V4-Flash`) for thinking-off to take effect. Sets `is_scheduled_run=true` internally to skip the auto-title LLM call. **Generated-file return (`include_files=true`, default on)**: an agent that produces its report as a file (writes to `/mnt/user-data/outputs/` + `present_files`) returns only a short summary in `answer`; to recover the real deliverable both endpoints now also return `files: [{name, virtual_path, encoding(text|base64), content}]` — `_collect_output_files` scans the run's `state['thread_data']['outputs_path']` (the open thread is stateless so artifacts can't be fetched by thread, but the endpoint runs server-side and the sandbox already wrote the files to disk), text decoded as utf-8 else base64, capped by `_MAX_OUTPUT_FILES=20` / `_MAX_OUTPUT_FILE_BYTES=5MB`. `/chat` puts `files` on the response; `/chat/stream` puts it in the `event: result` payload. A run with no answer **and** no files now 502s ("…文本答案或文件"). Router `app/gateway/routers/open_chat.py`; standalone caller `test_open_chat_api.py` (repo root, streaming by default); tests `tests/test_open_chat.py`. |
| **Open 3qfx Ask** (`/api/open/3qfx/ask`) | **Unauthenticated** open entry that, given just a **任务id**, directly kicks off the 3qfx(三情分析)「第一次问答」as a **real persisted conversation** (sibling of `/api/open/chat`; same `/api/open/` auth-whitelist + CSRF-exempt). `POST /ask` `{task_id, message?, model_name?, thinking_enabled=false}` → (1) resolves the `3Q` agent from `app.state.business_mapping_store.get_mapping("3Q")` (the **same** agent the frontend deep-link `goPath=3qfx` uses; 503 if unconfigured); (2) **fetches the task detail server-side** (consumer `cop-task-detail`) and builds the opening text **identical to the frontend** `buildOpeningText` 3qfx branch — `任务名称:…\n任务内容:…\n任务id(task_id)为{task_id}` (an explicit `message` overrides and **skips** the fetch); (3) creates a fresh thread tagged `metadata.{taskId, agent_id}` (the exact filter `useTaskThreads` uses, and `metadata.taskId` makes it task-deeplink-shared / creator-path-resolved) and **starts a background persisted run** via `start_run(..., enforce_access=False)` (writes the checkpointer, runs under the `"default"` bucket); (4) **returns immediately** `{thread_id, run_id, agent_id, status, opening_text}` — the caller just needs "started", then watches the run in the 3qfx 任务工作区 conversation list. `start_run` gained a keyword-only `enforce_access` (default `True`); the open entry passes `False` so the admin-configured agent isn't rejected by the per-(missing)-login visibility check. **Consumer fetch config** lives in `config.yaml → task_deeplink`: `cop_task_detail_url` (full URL, appends `?taskId=`), `cop_task_detail_authorization` (default `Bearer admin`), `cop_task_detail_test` + `mock.cop_task_detail` (offline fake when the upstream is unreachable — on in this deployment). Router `app/gateway/routers/open_3qfx.py`; tests `tests/test_open_3qfx.py`. |
| **Open 3qfx Ask** (`/api/open/3qfx/ask`) | **Unauthenticated** open entry that, given just a **任务id**, directly kicks off the 3qfx(三情分析)「第一次问答」as a **real persisted conversation** (sibling of `/api/open/chat`; same `/api/open/` auth-whitelist + CSRF-exempt). `POST /ask` `{task_id, message?, model_name?, thinking_enabled=false}` → (1) resolves the `3Q` agent from `app.state.business_mapping_store.get_mapping("3Q")` (the **same** agent the frontend deep-link `goPath=3qfx` uses; 503 if unconfigured); (2) **fetches the task detail server-side** (consumer `cop-task-detail`) and builds the opening text **identical to the frontend** `buildOpeningText` 3qfx branch — `任务名称:…\n任务内容:…\n任务类型:…\n任务id(task_id)为{task_id}`(`taskType` 1/2/3 → 打击/塑势/防御,无法识别则省略) (an explicit `message` overrides and **skips** the fetch); (3) creates a fresh thread tagged `metadata.{taskId, agent_id}` (the exact filter `useTaskThreads` uses, and `metadata.taskId` makes it task-deeplink-shared / creator-path-resolved) and **starts a background persisted run** via `start_run(..., enforce_access=False)` (writes the checkpointer, runs under the `"default"` bucket); (4) **returns immediately** `{thread_id, run_id, agent_id, status, opening_text}` — the caller just needs "started", then watches the run in the 3qfx 任务工作区 conversation list. `start_run` gained a keyword-only `enforce_access` (default `True`); the open entry passes `False` so the admin-configured agent isn't rejected by the per-(missing)-login visibility check. **Consumer fetch config** lives in `config.yaml → task_deeplink`: `cop_task_detail_url` (full URL, appends `?taskId=`), `cop_task_detail_authorization` (default `Bearer admin`), `cop_task_detail_test` + `mock.cop_task_detail` (offline fake when the upstream is unreachable — on in this deployment). Router `app/gateway/routers/open_3qfx.py`; tests `tests/test_open_3qfx.py`. |
| **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. |
| **Roundtable Drafts** (`/api/roundtable-drafts`) | **Strictly per-user** persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta — incl. `chain_title`, the business-chain name used this session, cheaply regex-extracted from the in-memory `step2` blob in `_draft_to_meta`/`_extract_chain_title`; null = no chain / 自由模式; surfaced on the 第一步「分析记录」history card + sidebar list instead of the old「第 X 步」); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete (all owner-scoped). 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). taskId 深链会商记录 are **not** here — they live in the separate `/api/roundtable-task-drafts` store (see below) so personal history never mixes with task records. Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. Tests: `tests/test_roundtable_task_drafts.py`. |
@@ -660,6 +913,8 @@ The intranet client uses `trust_env=False` and defaults `intranet_verify_ssl=fal
The admin ????? is served from **precomputed per-day snapshots** in `admin_leaderboard_daily_stats` (keyed by `(stat_date, settings_hash)`), not by aggregating raw rows on every request. A background scheduler inside the Gateway (`_leaderboard_snapshot_scheduler_loop` in `app/gateway/app.py`, singleton file-locked) ticks every `ADMIN_LEADERBOARD_BACKGROUND_INTERVAL_SECONDS` (default 300s): it prewarms recent days, then builds any `queued`/`error`/stale-`running` day. `GET /leaderboard` merges the per-day snapshots for the requested range. **Today** is rebuilt whenever its snapshot is older than `ADMIN_LEADERBOARD_TODAY_TTL_SECONDS` (default 43200s = half a day, so the leaderboard refreshes ~twice daily); a past day, once `ready`, is treated as fresh forever.
**Recent-only admin audit policy.** The server enforces a rolling **30-calendar-day** window for administrator leaderboard, user analytics, user Q&A/thread audit, LLM/tool metric list/summary/export, and the user-list Q&A counter; URL parameters cannot widen it. The Q&A fallback that reads checkpoints is additionally capped, while indexed run summaries remain the primary audit source. Gateway startup also launches the singleton `admin_audit_retention` sweep: it deletes audit metric rows, terminal `runs` summaries (which contain compact question/answer audit fields and were also last updated before the cutoff), and leaderboard snapshots older than **90 days**. Cleanup is deliberately low-priority: after the default 5-minute startup delay, it runs every 15 minutes using 100-row committed transactions, round-robins each store (at most four batches per store), yields between commits, and stops starting new batches after an 8-second sweep budget; operators may tune these bounds with `ADMIN_AUDIT_RETENTION_*` environment variables. It deliberately does **not** delete conversation threads, checkpoints, or message history; those are user-content records governed by their own lifecycle.
Two on-demand/automatic refresh paths layer on top:
- **Manual "????"** ? `POST /leaderboard/refresh-today` force-rebuilds *today's* snapshot **synchronously** (bounded by the same semaphore/timeout the scheduler uses), then returns the merged range. Lets an admin pull the latest real-time records into the stats without waiting for the TTL. Frontend: a "????" button on `AdminUserLeaderboardPage` ? `useRefreshTodayLeaderboard` (`core/admin/hooks.ts`), which primes + invalidates the `["admin-activity","leaderboard"]` query.
- **Daily finalize of the previous day** ? once per day, on/after `ADMIN_LEADERBOARD_FINALIZE_HOUR` (local Beijing, default `1` = ?? 1 ?), the scheduler tick force-recomputes *yesterday* a single time so runs/tool-calls that only landed near midnight are fully captured. The decision is the pure `deerflow.persistence.admin_stats.finalize.should_finalize_previous_day` (status/generated_at/now/hour) ? the rebuild stamps `generated_at` to today, which self-debounces it for the rest of the day (no extra state file). Tests: `tests/test_leaderboard_finalize.py`.
@@ -768,7 +1023,7 @@ The fixed-deep-blue wx full-chat route (`#/page/workspace/wx/chats/new`) hides t
### Business-mapping authorization
`/api/business-mapping` remains globally readable. Seeded rows include the main codes (3Q / 6BF / 7BF / 8BF / AFX) plus independent slots `3q-0…3q-6`, `6-1…6-4`, and `7-1…7-4` (empty agent/chain until configured on the 配置管理 page). Updates to 3Q, 6BF, 7BF, and those independent slots require `system_role == "admin"`; the 8BF public business-chain mapping is intentionally different and can be updated only by the trusted authenticated account whose email prefix is `lqq`. Do not rely on a username supplied in an external login URL. The frontend follows the same email-prefix rule when deciding whether to render the 8BF configuration button, while the router (`app/gateway/routers/business_mapping.py`) remains the enforcement point. Tests: `tests/test_business_mapping.py`.
`/api/business-mapping` remains globally readable. Seeded rows include the main codes (3Q / 6BF / 7BF / 8BF / AFX) plus independent slots `3q-0…3q-6`, `6-1…6-4`, and `7-1…7-4` (empty agent/chain until configured on the 配置管理 page). `3q-1…3q-6` seed as `is_custom=true` / `mapping_type=自定义类型` so `goPath=3q-1` resolves as a custom deep link; the configuration page hides those six rows and defaults the type filter to 自定义类型. Updates to 3Q, 6BF, 7BF, and those independent slots require `system_role == "admin"`; the 8BF public business-chain mapping is intentionally different and can be updated only by the trusted authenticated account whose email prefix is `lqq`. Do not rely on a username supplied in an external login URL. The frontend follows the same email-prefix rule when deciding whether to render the 8BF configuration button, while the router (`app/gateway/routers/business_mapping.py`) remains the enforcement point. Tests: `tests/test_business_mapping.py`.
### Position-collaboration foundation
@@ -1357,10 +1612,11 @@ scheduler runs once per deployment. Per run:
会商第二步(leader/seat)+ 第三步(summary/report/dashboard + 结构化抽取)单轮执行时,**某模型出问题就自动切到下一个模型继续**,并告知用户「『X』模型异常 → 已切换『Y』」。前台实时(`multi_agent.py`)与后台挂起(`engine.py` + `InProcessRoundtableGateway`)两条路径都覆盖。
- **可检测的模型失败**:`LLMErrorHandlingMiddleware` 在同一模型重试 3 次后**不抛异常**,而是返回兜底 `AIMessage` —— 现在给它打 `additional_kwargs[LLM_ERROR_MARKER="deerflow_llm_error"]=reason`(model_dump 保留,经 checkpointer 序列化仍在)。`is_llm_error_message(msg)` 据标记(回退按已知兜底文案正文)识别,返回 reason。
- **候选模型链**:`app/gateway/roundtable_model_fallback.py::fallback_model_chain(current)` 按 `config.yaml models[]` 顺序给出 `[当前模型, …其余]`(去重保序)。`reason_text(reason)` 翻译成简短中文。总开关 env `ROUNDTABLE_MODEL_FALLBACK`(默认开,`0/false/off` 关 → 单模型旧行为);`ROUNDTABLE_MODEL_FALLBACK_MAX`(0=试完全部)。
- **触发换模型的情形**:① 单轮 run 抛异常(start_run 失败 / idle 卡死 → `timeout`,其余 `exception`);② 兜底错误文案(`is_llm_error_message`);③ 交付无效(仅 seat/leader;leader 还含「既无正文又无派活」)。席位**只认剥掉 `<think>` 后的可见正文**——思考-only / 空串 / 断流都不算已交付(`deerflow.agents.roundtable_orchestrator.delivery`:`visible_seat_text` / `classify_seat_delivery`)。前台 `_special_run` 对此 yield `status:"invalid_delivery"` 且不广播;`GET /last-ai-message` 回 `{delivered, content, length, invalid_reason}`(思考-only 标 `thinking_only`,总控席位清单标 ⚠️)。后台引擎只把可见正文写入 `deliveries`,续轮用 `build_continue_prompt`:有无效席位时强制重派,禁止声称「已完成交付」。Step3 单例(report/summary/dashboard/ingest/结构化)的产物是落盘文件,**不**把「正文为空」当失败,只对真模型错误换模型。
- **候选模型链**:保留真正模型故障后的自动换模,但“仅有思考 / 暂时无正文 / 空交付”不再判作模型故障,绝不据此切换用户选择的模型。`app/gateway/roundtable_model_fallback.py::fallback_model_chain(current)` 按 `config.yaml models[]` 顺序给出 `[当前模型, …其余]`(去重保序)。`reason_text(reason)` 翻译成简短中文。总开关 env `ROUNDTABLE_MODEL_FALLBACK`(默认开,`0/false/off` 关);`ROUNDTABLE_MODEL_FALLBACK_MAX`(0=试完全部)。
- **触发换模型的情形**:① 单轮 run 抛异常(start_run 失败 / idle 卡死 → `timeout`,其余 `exception`);② 兜底错误文案(`is_llm_error_message`)。交付无效、仅有思考、空串以及普通总控轮既无正文又无派活**均不触发换模**;唯一例外是无工具的最终收口请求已经完整结束仍无正文,此时结果客观不可交付,才继续自动换下一个模型。席位仍只认剥掉 `<think>` 后的可见正文(`deerflow.agents.roundtable_orchestrator.delivery`:`visible_seat_text` / `classify_seat_delivery`):前台 `_special_run` 对无效交付 yield `status:"invalid_delivery"` 且不广播;`GET /last-ai-message` 回 `{delivered, content, length, invalid_reason}`(思考-only 标 `thinking_only`,总控席位清单标 ⚠️)。后台引擎只把可见正文写入 `deliveries`,无效席位继续走既有未交付处理,不切换模型。Step3 单例(report/summary/dashboard/ingest/结构化)的产物是落盘文件,同样只对真模型错误换模型。
- **Step2 总控工具与可配置收口轮数**:普通总控使用真正白名单,只允许 `agent_orchestration` / `ask_clarification`;`write_file`、`present_files`、技能目录与文件/命令工具均不可见。`system_settings.json -> roundtable_room_limit.max_cycles`(默认 8,范围 2..100;管理员在设置弹窗「会商并发与收口」中修改)控制 recommend 模式到第几轮切换为显式 `finalize_only`:空工具白名单、注入已有有效席位全文、要求直接输出最终总结(失败席位如实标缺口),不能表现为“已停止”。一次会商启动时锁定当时值,运行中修改从下一次研讨生效;前台实时会商与后台作业读取同一设置。同一页的「会商文件流式兼容」(`write_file_compatibility_enabled`,默认关) 只作用于会商总控、席位、第三步和后台作业:全局文件流式兼容开着时会商一并使用;全局关着而本开关开着时,只有这些会商 run 改走 `buffered_gateway`。普通对话不读这个开关。chain / dag 的最终总结仍在既定席位完成后走同一纯正文路径;LangGraph 单轮 recursion limit 仍为 1000。
- **后台**:`InProcessRoundtableGateway._run_turn_with_fallback(...)` 包住 `_run_agent_turn`,逐候选模型试;换模型落 `model_switch` 诊断、全失败落 `model_fallback_exhausted`。`run_leader/run_seat/run_report/run_summary/run_dashboard_data` 全走它(report/summary/dashboard 的产物自愈重试保留,并记住已切到的可用模型用于后续续写轮)。各 Turn/Result 带回 `switches: list[ModelSwitch]`,引擎 `_emit_switches` → `JobProgressWriter.model_switch(...)` 往研讨时间线插一条 `role="system"` 系统提示气泡。
- **前台**:`_special_run`/`_leader_run` stream 收尾后检测失败 → yield `{status:"model_switch", role, agent_name, failed_model, next_model, reason}` 帧并用下一个模型重新流式;终态帧带 `model_used`。结构化抽取 `flow_extract.py` 的 round1 `model.astream` 也包了同款换模型循环(emit `event:"model_switch"` 帧)。
- **前台**:`_special_run`/`_leader_run` stream 收尾后仅检测明确的模型错误标记;命中才 yield `{status:"model_switch", role, agent_name, failed_model, next_model, reason}` 帧并用下一个模型重新流式,思考-only/空正文不进入该分支;终态帧带 `model_used`。结构化抽取 `flow_extract.py` 的 round1 `model.astream` 也包了同款换模型循环(emit `event:"model_switch"` 帧)。
- **Step2 单席位隔离**:`engine._run_recommend/_run_chain/_run_dag` 的每个席位执行包 try/except —— 一个席位彻底失败(落 `seat_failed` 诊断 + 置空交付)不拖垮整批/整链/整个作业。
- **前端**:`multi-agent.ts` 解析 `model_switch` 帧 → `onModelSwitch` 回调(清空失败尝试的半截气泡 + 累计的 tool_call delta,phase 退回 connected);`useStep2Orchestration` 弹 toast + 时间线插「系统提示」气泡;`useStep3Summary/Report/Dashboard` + `flow-extract.ts` 重置对应气泡并在步骤卡留可见提示;后台路径经 `job-live.ts::toStep2Dialogue` 把 `role="system"` 渲染成 amber「系统提示」气泡。
- Tests: `tests/test_roundtable_model_fallback.py`。
@@ -1502,6 +1758,29 @@ from the selected space names and hides generic recommendations while these
questions are present. Design and remaining graph/profile phases are in
`docs/KNOWLEDGE_SPACE_RETRIEVAL_AND_WIKI_EXPANSION_ZH.md`.
**Knowledge-base research bookshelf:** `/page/workspace/knowledge/:id/research`
is ordinary Lead Agent Q&A constrained to one existing knowledge base; it does
not invoke or reuse the legacy Deep Research workflow. Any account with read
access may create a private session/draft, while only the existing KB writer
may publish or overwrite its Markdown Wiki article. Published snapshots follow
the KB's read permission, but the session/thread/draft and the continuation
link remain creator-only (administrators do not inherit another user's private
chat). Every run resolves the immutable thread binding from
`knowledge_research_sessions` (registered on `deerflow.persistence.models` so
startup `create_all` builds the table; `get_by_thread` treats a missing table
as no binding so ordinary `/runs/stream` is not aborted), reauthorizes KB read access, accepts exactly one
text-only human turn, forces classic single-KB retrieval and disables tools,
memory, prompt prefixes, planning and subagents; fixed-answer shortcuts are
bypassed. Saving manually upserts a stable
`knowledge-research/{publication_id}` Wiki slug under the reserved root
「深度研究」 without enabling automatic Wiki generation, uses a saved hash for
optimistic overwrite checks and idempotent retries, and keeps the editable
publication title separate from the Q&A title. REST endpoints live in
`app/gateway/routers/llmwiki.py`, runtime enforcement in
`app/gateway/knowledge_research.py`, persistence/migration in
`deerflow.persistence.knowledge_research` / `20261022_01`, and regression
coverage in `tests/test_knowledge_research.py`.
**Embedding ownership:** WeKnora's per-knowledge-base `embedding_model_id`
indexes uploaded source documents; `llmwiki.local_wiki_index.embedding` indexes
the generated Wiki-page mirror inside DeerFlow. Do not couple these two model
@@ -1742,7 +2021,10 @@ Automatic conversation summarization when approaching token limits:
`conversation_context.recent_turn_window_enabled` is a deployment-wide,
default-off alternative to summarization. Administrators manage it in
「设置 → 上下文管理」 through
`GET/PUT /api/system-settings/conversation-context`. When enabled, each lead
`GET/PUT /api/system-settings/conversation-context`. The same block stores
`max_conversation_rounds` (default 20, range 1–50), which `ChatPage` uses as
the maximum number of real user turns in one ordinary chat; missing values in
older settings files retain the 20-turn default. When enabled, each lead
agent model request retains only the current turn and the two preceding real
user turns, together with their assistant/tool continuations and any hidden
context attached to the retained boundary. The operation is request-only:
@@ -2641,3 +2923,24 @@ calls. The active sandbox skill mirror must be synchronized with the canonical c
Gateway detail writes emitted by `fs-collect` and `fs-detail-submit` log a token-masked request and
the corresponding response so an interrupted chain can be distinguished from a failed persistence
call without exposing the gateway bearer token.
### Task-detail custom skill password authentication (2026-10-26)
`skills/custom/task-context` and `skills/custom/task-detail-update` authenticate detail reads/writes
with a short-lived token minted by `POST /api/v1/auth/login/local`, using the runtime-only
`TASK_DETAIL_AGENT_USERNAME` / `TASK_DETAIL_AGENT_PASSWORD` pair and `TASK_DETAIL_GATEWAY_URL`.
They must never use general `DEERFLOW_AUTH_TOKEN` or `TOOLS_TOKEN`, nor emit the password or JWT in
an input, trace, or model-visible output. `/api/parallel-agents/auth/token` is only a legacy fallback:
it follows `sso_enabled` and returns 403 when external SSO is disabled. Password-login failures other
than the policy 403 must fail closed; do not fall back to identity-only token minting. Keep the
canonical custom directories and `.deer-flow/.sandbox-online-skills` mirror synchronized through the
custom-skill reconciliation flow. `scripts/setup_biz_detail_assistant.py` logs in through the same
formal endpoint and accepts `DEERFLOW_SETUP_BASE`, `DEERFLOW_SETUP_USERNAME`,
`DEERFLOW_SETUP_PASSWORD` for deployment overrides; it may fall back to the legacy username route
only for the formal login policy 403, never for a credential or rate-limit failure.
`biz-detail-assistant` must retain the native `ask_clarification` tool. Do not special-case it by
adding that tool to `requested_excluded_tools`, and do not mount `DetailConfirmMiddleware` to forge
confirmation cards from a plain model response: those cards are not genuine model tool calls and break
the standard pause/resume interaction. The final `ClarificationMiddleware` is the single authority for
all missing-information, scope, approach and risk-confirmation questions.
+166 -6
View File
@@ -1,5 +1,28 @@
# DeerFlow Backend
## 首次问答初始化响应性(2026-10-30)
普通问答、开放问答接口和工作流智能体节点的模型 SDK 加载、客户端初始化及
智能体构建统一在独立初始化线程执行,
同时进入的构建请求异步排队。首次问答仍需等待初始化完成,但不会因此占住
其他请求的处理主循环,已有对话可继续输出。用户身份、工具发现状态及 MCP
连接仍按原会话隔离;初始化期间取消会等待当前构建收尾,随后结束,不再发起模型调用。
AI 写作、子任务及深度研究的工具初始化也使用同一通道,避免首次发现或刷新
MCP 工具时与其他问答互相等待。
该优化只对重新加载代码的进程生效;已运行的子 Worker 可以继续使用旧版本,
不会自动获得此优化。启用 Worker 池的生产主服务重启还涉及原有镜像轮换和
流式连接断开策略,不能把这项修复当作不停机升级保证。
## 多文件上传响应性(2026-10-30)
会话附件的写入、格式校验和沙箱同步在独立工作池执行,每个后端进程最多同时
处理 2 批上传,其余异步等待。PDF/Office 文档解析进一步放到最多 2 个独立进程,
减少解析器对聊天请求的 CPU 争用。同一会话的上传按批次串行;列表和删除操作等待
该批次完成或回滚。上传成功仍表示整批文件已处理完成,可以直接用于问答。
文件数量、大小、类型限制以及失败回滚规则不变。更新后需重启后端生效。
首次文档转换需要启动解析进程并加载依赖,后续复用进程;此时其他请求仍可响应。
沙箱管理器的首次构造也有并发保护,避免两批上传同时首次进入时创建重复实例。
## 普通知识库智能检索双检索面(2026-10-16)
智能检索全局开关默认关闭,且只保存在数据库。管理员开启后,新对话默认使用智能检索,
@@ -18,6 +41,10 @@
并在建库前调用模型调试接口做一次真实可用性检查;调试失败时不会创建一个随后把全部文档送入
Wiki 死信的知识库。模型默认项、知识库摘要模型和 Wiki 合成模型均由数据库保存,不写入业务配置文件。
知识库深度研究会话表 `knowledge_research_sessions` 由启动 `create_all` 创建。
每次 `/api/threads/{id}/runs/stream` 都会按 thread 查这张表以判断是否绑定单库研究;
表尚未建好时按普通问答处理,不会让流式接口因缺表而失败。
## 公文助手与网页报告校验优化(2026-10-07)
公文助手新增预置 `run_gongwen.py deliver` / `verify-file`,一次交付调用完成
@@ -41,6 +68,22 @@ Python 脚本与重复检查。结构检查不替代事实核实和真实分页
「当前文件正在生成,请稍等片刻,完成后就可以切换查看其他文件。」
自动切换静默跳过,生成完成、停止或离开会话后解除保护。
## 网页报告校验优化(2026-10-07)
网页报告写入和校验时自动补齐深色组件及图表文字颜色。校验返回自动修复项、
完整错误列表和 `repair_checklist`,要求集中完成局部修复后再校验,避免逐条
循环和整页重写。遗留小字号声明不再单独拦截。
## 沙箱流式展示与切换保护(2026-10-07)
普通问答和智能体问答的 `write_file` 没有有效路径时,不创建占位制品来打开
空沙箱,而在步骤条内用绿色小字流式展示正文;后续完整路径到达后恢复正常
文件预览,正常文件的自动打开与沙箱实时展示保留。
当前预览文件正在生成或全文改写时,沙箱阻止切换到其他文件。用户操作时提示:
「当前文件正在生成,请稍等片刻,完成后就可以切换查看其他文件。」
自动切换静默跳过,生成完成、停止或离开会话后解除保护。
## 技能检索文件展示(2026-10-07)
普通问答、智能体问答中,技能返回的会话文件路径、原文件链接或知识库文档标识,
@@ -77,6 +120,21 @@ OpenAI 兼容适配器对 `qwen3.8-flash-next` 自动启用按请求隔离的正
「单点登录(同账号仅一处在线)」由独立 `single_session_enabled` 控制,默认关闭;`sso_enabled` 仅表示既有外部单点/免登入口,两个功能分开。开启单处登录后,每次成功的账号密码、用户名口令、上游 token、并行面板、注册或初始化登录,都在数据库内原子递增该账号 `token_version` 后签新 JWT,旧会话立即返回 401,前端轮询 `/me` 后退出旧页面。失败登录不会踢人,关闭时允许多处会话并存;修改/重置密码仍始终撤销旧会话。并发登录通过同一行的原子递增保证最终仅最高版本会话有效;事务还比较已验证的密码/口令与权限快照,避免并发重置密码后旧凭据借新登录版本恢复。
### 任务详情技能免鉴权访问(2026-10-27)
右侧 `task-context` 与 `task-detail-update` 仅访问
`GET/PUT /api/force-situation/tasks/{taskId}/detail`,不再调用登录、领取 JWT、读取 token 环境变量
或发送 `Authorization`。两个脚本只保留 Gateway 地址配置;当前任务详情读写及版本读取路由在
Gateway 鉴权与 CSRF 中间件中直接开放,因此不依赖 `password_login_enabled`、`sso_enabled`、
账号密码、登录白名单或多实例 JWT 密钥配置。
既有定时详情技能仍会调用旧 `/api/parallel-agents/auth/token` 作为前置步骤。该接口在登录策略
拒绝时返回非 JWT 兼容值,使旧技能可以继续调用已开放的详情接口;兼容值不能访问其他受保护路由。
`biz-detail-assistant` 的所有补充信息、范围选择和写回确认都必须使用原生
`ask_clarification`;不得为该智能体排除该工具或由中间件伪造“用户协助”消息。原生澄清中间件负责
暂停本轮、输出标准卡片并在用户回复后恢复同一会话。
普通知识库默认详情页面:管理员可在知识库顶部选择“本系统 Wiki 索引”或“嵌入 WeKnora”,对应 `ordinary_knowledge_landing`(默认 `wiki`)。Wiki 库首次进入默认展示本系统索引,右上角“打开 WeKnora”切换到内嵌管理页;内嵌页可返回索引。非 Wiki 库仍进入 WeKnora 文档页。设置通过 `/api/system-settings/ordinary-knowledge-landing` 保存,仅管理员可修改;指定文章、文档、图谱的直达链接优先于默认设置。
助手知识库可独立于 WeKnora 使用:管理员进入助手知识库,选择或创建库后点击“上传文件生成 Wiki”。支持 Markdown/TXT/CSV、PDF、Word、Excel、PowerPoint(单文件 100 MB,解析正文上限 400 万字符)。文件先写入运行目录 `assistant-knowledge/files/{job_id}`,后台分段调用已配置的语言模型生成分类 Wiki、实体关系,再通过 DeerFlow 编码器对正文分段向量化,保存到助手库并汇入知识梳理总库。无需配置 WeKnora URL;配置后仍可直接上传,也保留 WeKnora 数据包导入。文件处理沿用管理员写入权限。日常界面默认采用 `knowledge_base_product_version=v1`,普通用户只展示普通知识库;管理员始终可管理助手知识库,并可在知识库顶部设置中切换到 `v2`,向普通用户开放助手知识库入口、选择器和关联跳转。该开关是可恢复的产品展示开关:隐藏时日常问答的新运行不带助手库选择,但不会删除已有助手库或历史绑定;助手知识的既有读/搜索接口契约不因该开关改变。
@@ -169,9 +227,9 @@ The single LangGraph agent (`lead_agent`) is the runtime entry point, created vi
- **Subagent delegation** for parallel task execution
- **System prompt** with skills injection, memory context, and working directory guidance
普通问答采用严格的输出载体门禁:只有本次请求明确要求 HTML、网页、页面、看板、大屏或 `.html` 文件时,才允许读取网页生成技能、调用页面模板工具或创建 HTML;“画流程图”“画图表”“画地图”“画思维导图”“做可视化”本身都不构成 HTML 意图。普通流程图直接在消息中输出 Mermaid,普通图表沿用既有消息内方式,普通地图输出 `map-json`。只有本次请求明确要求“思维导图 / 脑图 / 知识导图”或点名 `analysis-mindmap` 时,才会输出专用 `mindmap-json`;普通分析、总结、提纲和结构化 JSON 不会自动生成导图。未闭合的 `mindmap-json`(兼容历史 `mindmap` 围栏)会原样作为代码块流式显示,闭合且本地校验通过后才懒加载横向分析树预览。导图必须是一棵含唯一根节点的受限层级树:目标 12–24 个节点、硬上限 36 个,最多 4 层(含中心主题),中心主题至多 6 个一级维度、每个后续节点至多 3 个子节点;不得把报告的每个数据、段落或引用逐条转成节点。点击父节点本身,或其连线起点处的+/−,即可展开/收起子节点,且连接线明确可见;全量弹窗的右上角含关闭与视图工具、右下角含由实际布局绘制的全图预览,消息预览同样支持拖动、滚轮缩放和视图工具;消息卡片的单个下载图标会打开 PNG、SVG、JSON 选项。明确的 HTML 请求必须进入沙箱生成 `/mnt/user-data/outputs/*.html` 并通过 `present_files` 打开右侧制品预览,禁止用聊天中的完整 HTML 代码块代替文件。前端负责本地校验和渲染,并兼容模型误写成普通 `json` 围栏的合法地图契约;只有包含地图字段且通过完整 schema 校验的 JSON 才会按地图渲染,其他 JSON 保持原代码块。地图卡片占满消息内容区,与步骤条左右边界对齐;格式错误时显示可读错误与原始数据,不中断整条消息。
普通问答采用严格的输出载体门禁:只有本次请求明确要求 HTML、网页、页面、看板、大屏或 `.html` 文件时,才允许读取网页生成技能、调用页面模板工具或创建 HTML;“画流程图”“画图表”“画地图”“画思维导图”“做可视化”本身都不构成 HTML 意图。普通流程图直接在消息中输出 Mermaid,普通图表沿用既有消息内方式,普通地图输出 `map-json`。只有本次请求明确要求“思维导图 / 脑图 / 知识导图”或点名 `analysis-mindmap` 时,才会输出专用 `mindmap-json`;普通分析、总结、提纲和结构化 JSON 不会自动生成导图。未闭合的 `mindmap-json`(兼容历史 `mindmap` 围栏)会原样作为代码块流式显示,闭合且本地校验通过后才懒加载横向分析树预览。导图必须是一棵含唯一根节点的受限层级树:目标 12–24 个节点、硬上限 36 个,最多 4 层(含中心主题),中心主题至多 6 个一级维度、每个后续节点至多 3 个子节点;不得把报告的每个数据、段落或引用逐条转成节点。点击父节点本身,或其连线起点处的+/−,即可展开/收起子节点,且连接线明确可见;全量弹窗的右上角含关闭与视图工具、右下角含由实际布局绘制的全图预览,消息预览同样支持拖动、滚轮缩放和视图工具;消息卡片的单个下载图标会打开 PNG、SVG、JSON 选项。明确的 HTML 请求必须进入沙箱生成 `/mnt/user-data/outputs/*.html` 并通过 `present_files` 打开右侧制品预览,禁止用聊天中的完整 HTML 代码块代替文件。前端负责本地校验和渲染,并兼容模型误写成普通 `json` 围栏的合法地图契约;只有包含地图字段且通过完整 schema 校验的 JSON 才会按地图渲染,其他 JSON 保持原代码块。解析前会把常见的 `coords`/`from`/`to`/`lng`/`lat` 写法归一成契约字段;标点可按 `color` 着色(未写则自动配色)。只有用户明确要求连线时才输出 `lines`,此时连线带箭头并沿线循环流动;无法恢复的连线会被丢弃,标点仍可渲染。地图卡片占满消息内容区,与步骤条左右边界对齐;格式错误时显示可读错误与原始数据,不中断整条消息。
生成 `.html` 网页、看板或大屏时使用独立的离线绘图契约:流程图使用 `data-df-flow` + AntV G6(不使用 Mermaid),图表使用 `data-df-chart`,地图使用本地 GeoJSON + ECharts Canvas 的 `data-df-geo`。Gateway 从公开路径 `/api/public/html-runtime/` 提供浏览器版 `g6.min.js`、`echarts.min.js`、`bootstrap.min.css`、`bootstrap.bundle.min.js`、`alpine.min.js` 和统一初始化脚本 `deerflow-runtime.js`,以及中国省级、美国州级、日本都道府县、台湾地区县市四份地图资源(`china-provinces` / `us-states` / `japan-prefectures` / `taiwan-counties`)。Bootstrap 负责布局与标准弹窗,Alpine 负责轻量状态和动态列表;生成的 HTML 源码会直接引用这些后台文件,不依赖前端 React 构建产物或公网 CDN。设置里的「HTML 地图」可选 ECharts 本地 GeoJSON,或地图网页(Leaflet);地图网页的瓦片模板是 `config.yaml -> html_runtime.map_tile_url`。运行时会等隐藏的逻辑页真实可见后再初始化 ECharts,并通过 `ResizeObserver` 跟随沙箱和页面容器尺寸变化,避免地图或图表首屏以零宽度渲染。区域/标点的 `description` 必须为纯文本,点击后由运行时显示详情浮层;需要筛选或切到明细页时监听 `deerflow:geo-map-click`,不能把链接、HTML 或 `onclick` 写进 `description`。深色 HTML 需声明 `data-df-theme="dark"`,其 ECharts 的全局、标题、图例、坐标轴和可见系列标签都必须显式使用浅色文字,避免 ECharts 默认黑字。这些文件作为 `packages/harness/deerflow/html_runtime/assets/` 的包资源随 harness wheel 一起发布,第三方许可同目录保存。`config.yaml -> html_runtime.base_url` 必须填写浏览器可访问的 Gateway Runtime 地址(本机默认 `http://localhost:8001/api/public/html-runtime`),运行时据此解析本地 GeoJSON 地址;内网部署只需把它替换为实际 Gateway 地址。用户上传的栅格图片若要作为生成 HTML 的背景图,智能体会使用同一 Gateway 源下的签名地址 `/api/public/html-images/{token}` 写入 `background-image`,不要写沙箱路径或制品接口。该能力不改变普通问答消息列表,普通问答地图仍走原有 `map-json` 前端瓦片渲染与 `frontend-web/public/runtime-config.js` 的 `VITE_MAP_TILE_URL`。地图大屏使用“中间地图舞台”而非整页地图背景:中间地图保持可见和可点击,两侧为窄信息栏、底部为摘要面板;提到的城市和行政区必须分别写入 `markers` 与高亮 `regions`。
生成 `.html` 网页、看板或大屏时使用独立的离线绘图契约:流程图使用 `data-df-flow` + AntV G6(不使用 Mermaid),图表使用 `data-df-chart`,地图使用本地 GeoJSON + ECharts Canvas 的 `data-df-geo`。Gateway 从公开路径 `/api/public/html-runtime/` 提供浏览器版 `g6.min.js`、`echarts.min.js`、`bootstrap.min.css`、`bootstrap.bundle.min.js`、`alpine.min.js` 和统一初始化脚本 `deerflow-runtime.js`,以及中国省级、美国州级、日本都道府县、台湾地区县市四份地图资源(`china-provinces` / `us-states` / `japan-prefectures` / `taiwan-counties`)。Bootstrap 负责布局与标准弹窗,Alpine 负责轻量状态和动态列表;生成的 HTML 源码会直接引用这些后台文件,不依赖前端 React 构建产物或公网 CDN。设置里的「HTML 地图」可选 ECharts 本地 GeoJSON,或地图网页(Leaflet);地图网页的瓦片模板在设置 → 外观的「地图瓦片地址」中配置。运行时会等隐藏的逻辑页真实可见后再初始化 ECharts,并通过 `ResizeObserver` 跟随沙箱和页面容器尺寸变化,避免地图或图表首屏以零宽度渲染。区域/标点的 `description` 必须为纯文本,点击后由运行时显示详情浮层;需要筛选或切到明细页时监听 `deerflow:geo-map-click`,不能把链接、HTML 或 `onclick` 写进 `description`。深色 HTML 需声明 `data-df-theme="dark"`,其 ECharts 的全局、标题、图例、坐标轴和可见系列标签都必须显式使用浅色文字,避免 ECharts 默认黑字。这些文件作为 `packages/harness/deerflow/html_runtime/assets/` 的包资源随 harness wheel 一起发布,第三方许可同目录保存。设置 → 外观的「HTML 运行库地址」填写浏览器可访问的 Gateway Runtime 地址(未配置时默认 `http://localhost:8001/api/public/html-runtime`),运行时据此解析本地 GeoJSON 地址;内网部署在设置弹窗里改成用户能够打开的域名即可。用户上传的栅格图片若要作为生成 HTML 的背景图,智能体会使用同一 Gateway 源下的签名地址 `/api/public/html-images/{token}` 写入 `background-image`,不要写沙箱路径或制品接口。该能力不改变普通问答消息列表,普通问答地图仍走原有 `map-json` 前端瓦片渲染与 `frontend-web/public/runtime-config.js` 的 `VITE_MAP_TILE_URL`。地图大屏使用“中间地图舞台”而非整页地图背景:中间地图保持可见和可点击,两侧为窄信息栏、底部为摘要面板;提到的城市和行政区必须分别写入 `markers` 与高亮 `regions`。
从已有 Markdown 报告生成 HTML 时,必须根据材料盘点 Mermaid/流程图、图表及地图,并在 `page_report_template validate` 的 `required_features` 和 `page_brief` 中如实声明。流程图必须包含 `g6.min.js`、`deerflow-runtime.js`、`.df-flow` 与 `data-df-flow`,并拒绝 Mermaid、手写 HTML/CSS 流程节点以及页面内自行初始化 `G6.Graph`;图表和地图分别检查 ECharts 资源与 `data-df-chart` / `data-df-geo` 声明式数据块。`data-df-chart` 必须是严格 JSON,校验会拒绝函数、箭头函数和其他 JavaScript 回调;颜色使用字面量或数据项 `itemStyle`,提示文本使用字符串模板。地图只能使用上述四个内置 GeoJSON,校验会拒绝 Leaflet、`data-df-map`、瓦片和页面内自行初始化 ECharts。默认页面从零开始直接写入最终 outputs HTML,不读取或预览模板;只有用户明确要求模板时,prepare 才会把要求持久化到同名 brief,validate 会自动恢复并与调用参数合并。校验失败时不得交付,必须修复后重新校验。
@@ -292,7 +350,7 @@ FastAPI application providing REST endpoints for frontend integration:
| `GET/POST /api/agents` | List visible agents or create a user-owned agent |
| `GET/PUT/DELETE /api/agents/{id}` | Read, update, or delete an id-addressed agent |
| `GET/POST/PUT/DELETE /api/roundtable-chains` | Manage user-owned roundtable business chains; each seat may optionally carry `position_id` for the separate position-collaboration workspace, plus `human_validation` to mark a Step 2 client-side pause checkpoint after that seat/stage completes (the original roundtable has no extra validation card) |
| `GET/PUT /api/business-mapping` | Read global business mappings (seeded 3Q/6BF/7BF/8BF/AFX plus independent slots 3q-0…3q-6, 6-1…6-4, 7-1…7-4). Updates to 3Q/6BF/7BF and the independent slots require an administrator; the 8BF public business-chain mapping is maintained exclusively by the trusted `lqq` account (derived from the authenticated email prefix). |
| `GET/PUT /api/business-mapping` | Read global business mappings (seeded 3Q/6BF/7BF/8BF/AFX plus independent slots 3q-0…3q-6, 6-1…6-4, 7-1…7-4). `3q-1…3q-6` seed as custom-type rows and stay off the configuration-page list. Updates to 3Q/6BF/7BF and the independent slots require an administrator; the 8BF public business-chain mapping is maintained exclusively by the trusted `lqq` account (derived from the authenticated email prefix). |
| `GET/POST/PUT /api/position-roundtable/sessions` | Standalone position-collaboration sessions: freeze task/intent/chain snapshots, index one direct-agent node per seat, build upstream handoff context (including bounded UTF-8 excerpts of text artifacts), and make archived sessions read-only without invoking the original roundtable coordinator or jobs. Built-in summary/action-plan completion mirrors visible report text into a Markdown artifact when a model omits its final `write_file` call. `POST /sessions/{id}/nodes/{nodeKey}/reject` accepts an optional reason, marks the rejected delivery for rework, and invalidates affected downstream deliveries so they are regenerated in order. The same router is also mounted at `/api/multi-agent/position-roundtable/*` for intranet gateways that only forward the established multi-agent namespace; the compatibility mount is intentionally omitted from OpenAPI. |
| `GET /api/memory` | Retrieve memory data |
| `POST /api/memory/reload` | Force memory reload |
@@ -1579,6 +1637,99 @@ Gateway 启动时会尽力执行应用 ORM、LangGraph Checkpointer 和 Store
`Automatic ORM schema creation failed`、`in-memory fallback` 或启动填充失败信息修复
数据库权限和表结构,并重启服务恢复持久化能力。
## Thread query performance and PostgreSQL ORM deadlines
Thread search/count now push common metadata and system-thread filters into SQL,
preserving private ownership and shared taskId + agent/action filtering. Nested
filters, the openGauss dialect and PG/MySQL numeric filters retain the exact
compatibility path. MySQL string identifiers compare case and trailing spaces
exactly. These changes reduce application-side scanning; JSON indexes still need
to be selected from the deployed database's query plan.
For PostgreSQL ORM connections, `database.pool_recycle` defaults to 300 seconds
when omitted, and `database.command_timeout` defaults to 30 seconds. Set the latter
to a positive finite number or `null` to disable. Explicit pool settings remain
effective; MySQL/openGauss keep their 1800-second recycle default. The asyncpg
deadline is also applied when rebuilding the engine after database creation.
MySQL/SQLite drivers do not receive asyncpg arguments. These ORM settings do not
remove checkpoint saver lock waits or configure the separate psycopg pool.
Thread creation now defaults to metadata-only persistence: the first graph run
creates its checkpoint. `initialize_checkpoint: true` retains eager initialization
for integration clients. Authorized empty threads expose an empty state/history;
private ownership, task sharing and agent/action partitioning remain unchanged.
Thread metadata GET uses the business database; `include_state=true` opts into
checkpoint values, and checkpoint-only legacy threads retain their fallback.
The frontend now releases the composer on an explicit stream end and refreshes
final history in the background. Thread/run/generation guards reject late results
after another send or navigation, including stale replica snapshots. Sync timeout
or failure preserves the streamed answer; EOF without an end still uses recovery.
Login, identity, user management and SQL business repositories use `database`;
with `backend: mysql` they do not query the PG checkpointer. Runtime initialization,
legacy LangGraph Store migration and explicit conversation/agent-state operations
can still use PG. This change does not migrate those stores or existing data.
See [thread latency implementation and database separation](docs/THREAD_LATENCY_OPTIMIZATION_ZH.md)
for configuration precedence, MySQL-only thread creation, background history
refresh, compatibility limits and validation scope.
Artifact directory scans, skill ZIP previews, content sniffing and text writes now
use a bounded file executor separate from login's default executor. Completed runs
leave the live registry after a five-minute grace period only once their tasks
have stopped and their final snapshots have been verified in the RunStore. SQL
history, checkpoints and the existing SSE replay buffer are not deleted.
The `run_events.backend: memory` compatibility setting now uses a local SQLite
event spool at `${DEER_FLOW_HOME}/run-events/events.sqlite3` (default
`.deer-flow/run-events/events.sqlite3`). Full messages and events stay on disk;
there is no automatic history TTL or content truncation. Keep that runtime
directory on a writable persistent local volume, including its WAL files.
Explicit `db` continues to use the business database (MySQL when configured),
and `jsonl` keeps its existing files while moving disk IO off the event loop.
No existing SQL/JSONL events or conversation checkpoints are migrated.
Background subagents and roundtable turns use user round-robin queues with both
global and per-user admission limits. Queued work occupies no execution slot;
cancellation must finish the actual child before releasing its slot. Optional
`config.yaml` budgets (effective after restart):
```yaml
runtime_contention:
completed_run_retention_seconds: 300
run_sweep_seconds: 10
subagent_concurrency: 3
subagent_per_user: 2
background_concurrency: 4
background_per_user: 2
queue_limit: 256
```
The existing `ROUNDTABLE_BACKGROUND_SLOTS_PER_USER` variable still overrides the
roundtable per-user budget, capped by its global limit. Each user also has a
bounded pending queue; overload returns an explicit failure rather than growing
an unlimited backlog. A subagent deadline includes its queue wait.
Multi-agent roundtable step 2 has a separate room cap. Administrators change
it under Settings → 会商并发 (`roundtable_room_limit`, default on, 3 rooms)
without restarting. The count is the number of live roundtable executions
across every account (foreground leases/live LangGraph runs plus executing
background jobs), not the current login. Merely opening a tab or viewing chat
history does not count, but two tabs that both run the same saved draft count
as two rooms. All seats and Step 3 work in one execution share one room;
foreground-to-background handoff keeps one room. A full pool keeps the user on
step 1.
Roundtable seat/coordinator creation is metadata-only too. The authoritative seat
list is included in the first real leader input, including for older clients;
ordinary messages, broadcasts and resumable execution retain their persistence.
The master integration also retains dedicated task deep-link history cards and
their indexed queries, plus bounded display snapshots for long conversations.
See [multi-user contention audit and implementation](docs/MULTI_USER_CONTENTION_AUDIT_ZH.md)
for regression scope, remaining shared resources and production metrics. Local
test results do not establish production capacity.
## openGauss / GaussDB application database
The application ORM can use a PG-compatible openGauss/GaussDB database through
@@ -1661,12 +1812,14 @@ Beta(8,2) 先验抑制小样本极值;零样本返回 `score: null` 和
`VITE_COMPACT_MENU_TYPE` 隔离 A/B 两套菜单。
## 技能读取、模型日志与线程池并发优化
实际加载的 `config.yaml`(`DEER_FLOW_CONFIG_PATH` 指定文件优先)支持以下四个独立开关。
实际加载的 `config.yaml`(`DEER_FLOW_CONFIG_PATH` 指定文件优先)支持以下独立开关。
`skill_view_reuse_enabled` 和 `skill_view_cache_enabled` 在配置里不写时默认开启;
要恢复每次工具调用都全量刷新技能视图,把对应项写成 `false` 后重启。另外两项仍默认关闭:
要恢复每次工具调用都全量刷新技能视图,把对应项写成 `false` 后重启。
模型计时与线程池开关默认关闭,工具统计写入也默认暂停:
```yaml
runtime_performance:
tool_metrics_enabled: false
skill_view_reuse_enabled: true
skill_view_cache_enabled: true
model_timing_nonblocking_enabled: true
@@ -1674,6 +1827,13 @@ runtime_performance:
asyncio_thread_pool_size: 100
```
- `tool_metrics_enabled`:工具/技能调用统计写入开关,**缺省为 false(暂停)**,旧部署配置
没有此项时也暂停。主智能体和子智能体不再装载工具统计中间件,不创建统计写库任务、
不执行技能统计合并查询,从源头减少与登录共用业务数据库连接池的竞争。
正常工具调用、错误处理、问答消息/checkpoint 和模型调用统计继续运行;已有
`tool_call_metrics` 记录与查询/导出接口保留。暂停期间工具/技能审计明细及排行榜的
工具/技能调用计数不再新增,且不缓存或事后补录。恢复时设为 `true` 并重启后台。
部署新代码并重启后才对所有运行实例生效;这不消除历史统计查询或其他数据库操作的开销。
- `skill_view_reuse_enabled`:一次沙箱工具调用共用一份已验证技能视图,路径检查、
本地/AIO 沙箱初始化、命令转换和输出脱敏不再反复刷新。下一次工具调用重新验证,
不跨问答永久沿用启停状态。
@@ -1694,7 +1854,7 @@ runtime_performance:
开启时选中变量的无效值使启动失败。线程按需创建,不会在启动时立即创建 100 个线程。
修改后重启后台以确保全部运行实例使用一致配置;回退时将相应项设为 `false` 并重启,
无需删除技能或缓存目录。启动/配置加载日志 `RUNTIME_PERFORMANCE` 会打印四项开关及 YAML 中的上限。
无需删除技能或缓存目录。启动/配置加载日志 `RUNTIME_PERFORMANCE` 会打印开关及 YAML 中的上限。
`GATEWAY_ASYNCIO_EXECUTOR` 才是线程池最终生效值(含环境变量优先级)及来源;线程池改动必须重启生效。
技能日志 `SKILL_VIEW_CACHE` 含 `status`、`lock_wait_ms`、`refresh_ms`、`total_ms`、
源/目标条目数及启用技能数;正常命中为 DEBUG,重建/合并等待为 INFO,配合
@@ -0,0 +1,290 @@
"""Bounded retention cleanup for administrator audit and analytics data.
This deliberately removes only data written for operational inspection:
provider/tool call metrics, compact terminal run summaries, and precomputed
leaderboard snapshots. It does not touch user threads or LangGraph
checkpoints, which are user conversation history and require a separate,
explicit retention policy.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime, timedelta
from time import monotonic
from typing import Any
from sqlalchemy import and_, delete, or_, select
from app.gateway.admin_query_window import ADMIN_AUDIT_RETENTION_DAYS
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
from deerflow.persistence.types import BEIJING_TZ
logger = logging.getLogger(__name__)
_DEFAULT_BATCH_SIZE = 100
_MAX_BATCH_SIZE = 500
_MAX_SNAPSHOT_BATCH_SIZE = 200
_DEFAULT_MAX_BATCHES_PER_STORE = 4
_MAX_BATCHES_PER_STORE = 16
_DEFAULT_INTER_BATCH_PAUSE_SECONDS = 0.05
_DEFAULT_MAX_SWEEP_SECONDS = 8.0
def _bounded_int(value: int, *, default: int, maximum: int) -> int:
"""Return a safe positive integer for background maintenance work."""
try:
return max(1, min(int(value), maximum))
except (TypeError, ValueError):
return default
def _bounded_seconds(value: float, *, default: float, maximum: float) -> float:
"""Return a non-negative, finite-enough scheduler duration."""
try:
return max(0.0, min(float(value), maximum))
except (TypeError, ValueError):
return default
async def _purge_store_batch(
store: Any,
cutoff: datetime,
*,
batch_size: int,
method_name: str = "purge_before",
) -> int:
"""Delete one committed, primary-key-bounded batch from an audit store."""
if store is None:
return 0
purge = getattr(store, method_name, None)
if purge is None:
return 0
return int(await purge(cutoff, batch_size=batch_size) or 0)
async def _purge_snapshot_batch(
session_factory,
cutoff_date: str,
*,
batch_size: int,
) -> int:
"""Delete one short transaction of old leaderboard rows.
Snapshot rows use a composite primary key. Selecting those keys first is
deliberately more conservative than deleting whole historical dates: a
single date can have many configuration hashes, and deleting all of them
in one transaction could otherwise hold a write lock for too long.
"""
if session_factory is None:
return 0
size = min(batch_size, _MAX_SNAPSHOT_BATCH_SIZE)
async with session_factory() as session:
keys = list(
(
await session.execute(
select(
AdminLeaderboardDailyStatRow.stat_date,
AdminLeaderboardDailyStatRow.settings_hash,
)
.where(AdminLeaderboardDailyStatRow.stat_date < cutoff_date)
.order_by(
AdminLeaderboardDailyStatRow.stat_date.asc(),
AdminLeaderboardDailyStatRow.settings_hash.asc(),
)
.limit(size)
)
).all()
)
if not keys:
return 0
result = await session.execute(
delete(AdminLeaderboardDailyStatRow).where(
or_(
*(
and_(
AdminLeaderboardDailyStatRow.stat_date == stat_date,
AdminLeaderboardDailyStatRow.settings_hash == settings_hash,
)
for stat_date, settings_hash in keys
)
)
)
)
await session.commit()
rowcount = result.rowcount
return len(keys) if rowcount is None or rowcount < 0 else int(rowcount)
async def sweep_admin_audit_retention(
*,
session_factory,
llm_metrics_store: Any = None,
tool_metrics_store: Any = None,
run_store: Any = None,
retention_days: int = ADMIN_AUDIT_RETENTION_DAYS,
now: datetime | None = None,
batch_size: int = _DEFAULT_BATCH_SIZE,
max_batches_per_store: int = _DEFAULT_MAX_BATCHES_PER_STORE,
inter_batch_pause_seconds: float = _DEFAULT_INTER_BATCH_PAUSE_SECONDS,
max_sweep_seconds: float = _DEFAULT_MAX_SWEEP_SECONDS,
should_stop: Callable[[], bool] | None = None,
on_progress: Callable[[dict[str, Any]], Awaitable[None] | None] | None = None,
) -> dict[str, int]:
"""Run one safe retention pass and return deletion counts.
Every delete is a small independent transaction. The sources are visited
round-robin, and the scheduler checks its wall-clock budget between
committed batches while cooperatively pausing between them. A historic
backlog is therefore drained over later sweeps rather than monopolising
the database or keeping a long write lock.
"""
days = max(1, int(retention_days))
cutoff = (now or datetime.now(UTC)) - timedelta(days=days)
cutoff_date = cutoff.astimezone(BEIJING_TZ).date().isoformat()
safe_batch_size = _bounded_int(
batch_size,
default=_DEFAULT_BATCH_SIZE,
maximum=_MAX_BATCH_SIZE,
)
safe_max_batches = _bounded_int(
max_batches_per_store,
default=_DEFAULT_MAX_BATCHES_PER_STORE,
maximum=_MAX_BATCHES_PER_STORE,
)
safe_pause_seconds = _bounded_seconds(
inter_batch_pause_seconds,
default=_DEFAULT_INTER_BATCH_PAUSE_SECONDS,
maximum=1.0,
)
safe_max_sweep_seconds = _bounded_seconds(
max_sweep_seconds,
default=_DEFAULT_MAX_SWEEP_SECONDS,
maximum=60.0,
)
if safe_max_sweep_seconds <= 0:
safe_max_sweep_seconds = _DEFAULT_MAX_SWEEP_SECONDS
async def purge_llm_metrics() -> int:
return await _purge_store_batch(
llm_metrics_store,
cutoff,
batch_size=safe_batch_size,
)
async def purge_tool_metrics() -> int:
return await _purge_store_batch(
tool_metrics_store,
cutoff,
batch_size=safe_batch_size,
)
async def purge_run_summaries() -> int:
return await _purge_store_batch(
run_store,
cutoff,
batch_size=safe_batch_size,
method_name="purge_terminal_before",
)
async def purge_snapshots() -> int:
return await _purge_snapshot_batch(
session_factory,
cutoff_date,
batch_size=safe_batch_size,
)
sources: tuple[tuple[str, Callable[[], Awaitable[int]], int], ...] = (
("llm_metrics", purge_llm_metrics, safe_batch_size),
("tool_metrics", purge_tool_metrics, safe_batch_size),
("run_summaries", purge_run_summaries, safe_batch_size),
("leaderboard_snapshots", purge_snapshots, min(safe_batch_size, _MAX_SNAPSHOT_BATCH_SIZE)),
)
result = {name: 0 for name, _purge, _full_batch_size in sources}
active = {name: True for name in result}
deadline = monotonic() + safe_max_sweep_seconds
exhausted_budget = False
async def report(*, status: str, round_number: int, current_source: str | None) -> None:
if on_progress is None:
return
payload: dict[str, Any] = {
"status": status,
"round": round_number,
"max_rounds": safe_max_batches,
"current_source": current_source,
"deleted": dict(result),
}
try:
pending = on_progress(payload)
if pending is not None:
await pending
except Exception:
logger.warning("Unable to publish admin audit retention progress", exc_info=True)
stopped = False
for _round in range(safe_max_batches):
any_deletes = False
for name, purge, full_batch_size in sources:
if should_stop is not None and should_stop():
stopped = True
break
if not active[name]:
continue
if monotonic() >= deadline:
exhausted_budget = True
break
await report(status="running", round_number=_round + 1, current_source=name)
try:
count = await purge()
except Exception:
# A busy/temporarily unavailable table must not keep this
# maintenance sweep alive, nor prevent other audit tables
# from receiving their small cleanup turn. The next scheduled
# sweep will retry it.
logger.warning(
"admin audit retention skipped %s this sweep; will retry later",
name,
exc_info=True,
)
active[name] = False
continue
result[name] += count
await report(status="running", round_number=_round + 1, current_source=name)
any_deletes = any_deletes or count > 0
if count < full_batch_size:
active[name] = False
if count and safe_pause_seconds:
# The transaction above has committed. Yielding here gives
# interactive reads/writes a chance to acquire the connection
# before this low-priority maintenance work continues.
await asyncio.sleep(safe_pause_seconds)
if stopped or exhausted_budget or not any(active.values()) or not any_deletes:
break
if stopped or (should_stop is not None and should_stop()):
await report(status="paused", round_number=min(_round + 1, safe_max_batches), current_source=None)
more_work_likely = any(active.values())
if any(result.values()) or exhausted_budget or more_work_likely:
logger.info(
"admin audit retention sweep: llm_metrics=%d tool_metrics=%d run_summaries=%d "
"leaderboard_snapshots=%d cutoff=%s batch_size=%d max_batches=%d "
"time_budget_seconds=%.2f budget_exhausted=%s more_work_likely=%s",
result["llm_metrics"],
result["tool_metrics"],
result["run_summaries"],
result["leaderboard_snapshots"],
cutoff.isoformat(),
safe_batch_size,
safe_max_batches,
safe_max_sweep_seconds,
exhausted_budget,
more_work_likely,
)
return result
__all__ = ["sweep_admin_audit_retention"]
@@ -0,0 +1,59 @@
"""Shared on-disk control and progress state for admin audit retention."""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
def status_path(runtime_root: Path) -> Path:
return runtime_root / ".admin_audit_retention.status.json"
def pause_path(runtime_root: Path) -> Path:
return runtime_root / ".admin_audit_retention.paused"
def is_paused(runtime_root: Path) -> bool:
return pause_path(runtime_root).exists()
def set_paused(runtime_root: Path, paused: bool) -> None:
runtime_root.mkdir(parents=True, exist_ok=True)
marker = pause_path(runtime_root)
if paused:
marker.touch(exist_ok=True)
else:
marker.unlink(missing_ok=True)
def read_status(runtime_root: Path) -> dict[str, Any]:
path = status_path(runtime_root)
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
return {
"status": "scheduled",
"current_source": None,
"round": 0,
"max_rounds": 4,
"deleted": {},
"last_sweep": None,
"started_at": None,
"next_run_at": None,
"retention_days": 90,
}
return value if isinstance(value, dict) else {}
def write_status(runtime_root: Path, value: dict[str, Any]) -> None:
runtime_root.mkdir(parents=True, exist_ok=True)
path = status_path(runtime_root)
temporary = path.with_name(f"{path.name}.{os.getpid()}.tmp")
temporary.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
os.replace(temporary, path)
__all__ = ["is_paused", "read_status", "set_paused", "write_status"]
@@ -0,0 +1,104 @@
"""Shared time bounds for expensive administrator analytics reads.
The administrative audit, leaderboard, and metric pages are intentionally a
recent operational view rather than an unbounded reporting warehouse. Keep
the policy in one small module so every route (including downloads) applies
the same rolling window before it reaches SQL.
"""
from __future__ import annotations
from datetime import datetime, time, timedelta
from fastapi import HTTPException
from deerflow.persistence.types import BEIJING_TZ
ADMIN_QUERY_WINDOW_DAYS = 30
ADMIN_AUDIT_RETENTION_DAYS = 90
def _as_beijing(value: datetime) -> datetime:
"""Treat timezone-free query parameters as local Beijing time."""
if value.tzinfo is None:
return value.replace(tzinfo=BEIJING_TZ)
return value.astimezone(BEIJING_TZ)
def _parse_datetime(value: str, *, field_name: str) -> datetime:
text = value.strip()
if not text:
raise HTTPException(status_code=422, detail=f"{field_name} must be an ISO 8601 datetime")
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
return _as_beijing(datetime.fromisoformat(text))
except ValueError as exc:
raise HTTPException(
status_code=422,
detail=f"{field_name} must be an ISO 8601 datetime",
) from exc
def resolve_admin_query_window(
*,
days: int = ADMIN_QUERY_WINDOW_DAYS,
since: str | None = None,
until: str | None = None,
now: datetime | None = None,
) -> tuple[datetime, datetime, int]:
"""Return a validated, at-most-30-calendar-day recent query range.
Hand-crafted URLs cannot bypass this guard: the whole range must fall in
the rolling 30-day window, not merely have a duration shorter than 30 days.
A future ``until`` is reduced to the current instant so normal date inputs
that represent the rest of today remain convenient.
"""
if not 1 <= days <= ADMIN_QUERY_WINDOW_DAYS:
raise HTTPException(
status_code=422,
detail=f"管理员查询仅支持最近 {ADMIN_QUERY_WINDOW_DAYS} 天的数据",
)
current = _as_beijing(now or datetime.now(BEIJING_TZ))
end = _parse_datetime(until, field_name="until") if until else current
if end > current:
end = current
earliest_date = current.date() - timedelta(days=ADMIN_QUERY_WINDOW_DAYS - 1)
default_start_date = end.date() - timedelta(days=days - 1)
start = (
_parse_datetime(since, field_name="since")
if since
else datetime.combine(default_start_date, time.min, tzinfo=BEIJING_TZ)
)
if start > end:
raise HTTPException(status_code=422, detail="since must be before until")
if start.date() < earliest_date or end.date() < earliest_date:
raise HTTPException(
status_code=422,
detail=f"管理员查询仅支持最近 {ADMIN_QUERY_WINDOW_DAYS} 天的数据",
)
range_days = (end.date() - start.date()).days + 1
if range_days > ADMIN_QUERY_WINDOW_DAYS:
raise HTTPException(
status_code=422,
detail=f"管理员查询仅支持最近 {ADMIN_QUERY_WINDOW_DAYS} 天的数据",
)
return start, end, range_days
def recent_admin_query_cutoff(*, now: datetime | None = None) -> datetime:
"""Return the inclusive earliest timestamp in the current admin window."""
start, _end, _days = resolve_admin_query_window(now=now)
return start
__all__ = [
"ADMIN_AUDIT_RETENTION_DAYS",
"ADMIN_QUERY_WINDOW_DAYS",
"recent_admin_query_cutoff",
"resolve_admin_query_window",
]
@@ -0,0 +1,25 @@
"""Explicit, reviewable catalogue of Redis-backed read APIs."""
API_CACHE_CATALOG = (
("agents", "智能体列表", ["/api/agents"], "按用户、权限及筛选条件隔离"),
("skills", "技能列表", ["/api/skills"], "按用户、权限及筛选条件隔离"),
("sensitive_words", "展示脱敏词", ["/api/sensitive-words"], "全局配置"),
("user_preferences", "个人偏好", ["/api/user-preferences"], "按用户隔离"),
("user_prompts", "常用提示词", ["/api/user-prompts"], "按用户隔离"),
("recommended_questions", "推荐问题", ["/api/recommended-questions"], "按用户及筛选条件隔离"),
("light_apps", "轻应用目录", ["/api/light-apps", "/api/light-apps/sidebar", "/api/light-apps/{app_id}"], "全局共享;按类型、菜单、搜索及标签筛选隔离"),
("menu_overrides", "完整菜单配置", ["/api/menu-overrides"], "按用户及管理员角色隔离"),
("compact_menu_overrides", "简洁菜单配置", ["/api/compact-menu-overrides"], "按用户、管理员角色及 A/B 菜单类型隔离"),
("task_buttons", "任务跳转按钮", ["/api/task-buttons"], "全局配置"),
("business_mapping", "业务智能体映射", ["/api/business-mapping"], "全局配置"),
(
"html_templates", "HTML 模板目录",
["/api/html-templates", "/api/html-templates/{template_id}", "/api/html-templates/{template_id}/content", "/api/scheduled-tasks/html-templates"],
"全局共享;分别缓存列表、详情和正文",
),
("models", "模型能力目录", ["/api/models", "/api/models/{model_name}"], "仅缓存数据库能力目录;模型配置实时读取"),
("tags", "标签目录", ["/api/tags", "/api/tags/assignments/{target_type}/{target_id}"], "全局共享;按标签类型及目标资源隔离"),
("browser_status", "浏览器状态", ["/api/browser-context/status"], "全局配置;访问开关优先"),
)
API_READ_CACHE_COLLECTIONS = tuple(item[0] for item in API_CACHE_CATALOG)
@@ -0,0 +1,674 @@
"""Small, optional Redis cache for the homepage agent and skill catalogues.
MySQL remains the source of truth. Redis can use the dedicated
``api_read_cache`` connection, or reuse ``concurrency.redis`` when explicitly
enabled; failures and missing configuration fall through to the normal
MySQL-backed request path.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import secrets
import time
from typing import Any
from weakref import WeakKeyDictionary
from app.gateway.api_cache_catalog import API_CACHE_CATALOG, API_READ_CACHE_COLLECTIONS
logger = logging.getLogger(__name__)
_CACHE_SUFFIX = "api_read_cache:v1"
_DEFAULT_CACHE_TTL_SECONDS = 300
_MIN_CACHE_TTL_SECONDS = 1
_MAX_CACHE_TTL_SECONDS = 3600
_clients: dict[tuple[str, str, int, int], Any] = {}
_last_warning_at: dict[str, float] = {}
_VERSION_UNSET = object()
_api_read_cache_settings_cache: tuple[float, int | None, bool, int, dict[str, bool], bool, str] | None = None
_enabled_cache_ttl_seconds = 2.0
_save_locks: WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock] = WeakKeyDictionary()
_enabled_locks: dict[tuple[int, int], asyncio.Lock] = {}
_fill_events: dict[tuple[int, str], asyncio.Event] = {}
_fill_local_payloads: dict[tuple[int, str], tuple[float, str, bytes]] = {}
def _redis_settings(config: Any, *, enabled: bool | None = None) -> tuple[str, str | None, str] | None:
concurrency = getattr(config, "concurrency", None)
concurrency_redis = getattr(concurrency, "redis", None)
api_cache = getattr(config, "api_read_cache", None)
api_enabled = bool(getattr(api_cache, "enabled", False)) if enabled is None else enabled
concurrency_enabled = bool(getattr(concurrency_redis, "enabled", False))
if not api_enabled:
return None
url = str(getattr(api_cache, "url", None) or (getattr(concurrency_redis, "url", None) if concurrency_enabled else None) or "").strip()
if not url:
return None
namespace = str(getattr(api_cache, "namespace", "cmzs_deerflow:homepage") if api_enabled else f"{getattr(concurrency_redis, 'namespace', 'cmzs_deerflow:concurrency')}:homepage")
password = getattr(api_cache, "password", None)
if not getattr(api_cache, "url", None) and concurrency_enabled:
password = password or getattr(concurrency_redis, "password", None)
return url, password, namespace.strip().rstrip(":")
def _client(config: Any, *, enabled: bool | None = None, epoch: str | None = None) -> tuple[Any, str] | None:
settings = _redis_settings(config, enabled=enabled)
if settings is None:
return None
url, password, namespace = settings
api_cache = getattr(config, "api_read_cache", None)
redis_config = getattr(getattr(config, "concurrency", None), "redis", None)
timeout_config = api_cache if (enabled if enabled is not None else getattr(api_cache, "enabled", False)) else redis_config
connect_timeout_ms = int(getattr(timeout_config, "socket_connect_timeout_ms", 250) or 250)
socket_timeout_ms = int(getattr(timeout_config, "socket_timeout_ms", 750) or 750)
identity = (url, str(password or ""), connect_timeout_ms, socket_timeout_ms)
client = _clients.get(identity)
if client is None:
try:
from redis.asyncio import Redis
except ImportError:
_warn_once(namespace, "redis package is not installed")
return None
client = Redis.from_url(
url,
password=password,
socket_connect_timeout=max(0.001, connect_timeout_ms / 1000),
socket_timeout=max(0.001, socket_timeout_ms / 1000),
decode_responses=False,
# Homepage bursts commonly arrive in parallel (e.g. 70 users).
# 70 users can load several homepage APIs at once; keep enough
# headroom for the combined burst instead of queuing Redis reads.
max_connections=256,
)
_clients[identity] = client
return client, f"{namespace}:{_CACHE_SUFFIX}:epoch:{_cache_epoch(config) if epoch is None else epoch}"
def _cache_epoch(config: Any) -> str:
current = _api_read_cache_settings_cache
if current is not None and current[1] in (None, id(config)):
return current[6]
return "0"
def _scoped_version(epoch: str, generation: str) -> str:
# Initial deployments keep the original generation value; later saves use
# an opaque epoch marker so an in-flight old fill cannot enter a new cache.
return generation if epoch == "0" else f"{epoch}.{generation}"
async def get_api_read_cache_settings(
config: Any,
*,
force_refresh: bool = False,
) -> tuple[bool, int]:
"""Read the admin toggle and TTL from MySQL, refreshing at most every 2s.
The YAML flag is retained as the compatibility default for deployments that
have not yet created the settings row. Once an administrator saves the
setting, MySQL is authoritative across restarts and worker containers.
"""
global _api_read_cache_settings_cache
now = time.monotonic()
if not force_refresh and _api_read_cache_settings_cache is not None and now - _api_read_cache_settings_cache[0] < _enabled_cache_ttl_seconds and _api_read_cache_settings_cache[1] in (None, id(config)):
return _api_read_cache_settings_cache[2], _api_read_cache_settings_cache[3]
loop = asyncio.get_running_loop()
lock_key = (id(loop), id(config))
setting_lock = _enabled_locks.get(lock_key)
if setting_lock is None:
setting_lock = _enabled_locks[lock_key] = asyncio.Lock()
if len(_enabled_locks) > 64:
_enabled_locks.pop(next(iter(_enabled_locks)))
async with setting_lock:
now = time.monotonic()
if not force_refresh and _api_read_cache_settings_cache is not None and now - _api_read_cache_settings_cache[0] < _enabled_cache_ttl_seconds and _api_read_cache_settings_cache[1] in (None, id(config)):
return _api_read_cache_settings_cache[2], _api_read_cache_settings_cache[3]
configured_default = bool(getattr(getattr(config, "api_read_cache", None), "enabled", False))
enabled = configured_default
ttl_seconds = _cache_ttl_seconds(config)
collection_overrides: dict[str, bool] = {}
browser_status_enabled = True
cache_epoch = "0"
try:
from deerflow.persistence.api_read_cache_settings.model import ApiReadCacheSettingRow
from deerflow.persistence.engine import get_session_factory
session_factory = get_session_factory()
if session_factory is not None:
async with session_factory() as session:
row = await session.get(ApiReadCacheSettingRow, "default")
if row is not None:
enabled = bool(row.enabled)
ttl_seconds = _cache_ttl_seconds(
config,
getattr(row, "ttl_seconds", ttl_seconds),
)
collection_overrides = dict(getattr(row, "collection_overrides", None) or {})
browser_status_enabled = bool(getattr(row, "browser_status_enabled", True))
cache_epoch = str(getattr(row, "cache_epoch", None) or "0")
except Exception as exc:
_warn_once("api_read_cache_settings", f"setting read failed ({type(exc).__name__})")
if _api_read_cache_settings_cache is not None and _api_read_cache_settings_cache[1] in (None, id(config)):
enabled = _api_read_cache_settings_cache[2]
ttl_seconds = _api_read_cache_settings_cache[3]
collection_overrides = _api_read_cache_settings_cache[4]
browser_status_enabled = _api_read_cache_settings_cache[5]
cache_epoch = _api_read_cache_settings_cache[6]
if _api_read_cache_settings_cache is not None and _api_read_cache_settings_cache[0] > now and _api_read_cache_settings_cache[1] in (None, id(config)):
# An administrator saved while this database read was in flight.
# Do not replace the just-saved switch/epoch with its older row.
return _api_read_cache_settings_cache[2], _api_read_cache_settings_cache[3]
_api_read_cache_settings_cache = (now, id(config), enabled, ttl_seconds, collection_overrides, browser_status_enabled, cache_epoch)
return enabled, ttl_seconds
async def is_api_read_cache_enabled(config: Any, *, force_refresh: bool = False) -> bool:
return (await get_api_read_cache_settings(config, force_refresh=force_refresh))[0]
async def is_collection_cache_enabled(config: Any, collection: str) -> bool:
enabled, _ = await get_api_read_cache_settings(config)
return enabled and bool(_api_read_cache_settings_cache[4].get(collection, True))
async def is_browser_status_enabled(config: Any) -> bool:
await get_api_read_cache_settings(config)
return _api_read_cache_settings_cache[5]
async def get_api_cache_controls(config: Any) -> dict[str, Any]:
_, ttl = await get_api_read_cache_settings(config)
overrides = _api_read_cache_settings_cache[4]
return {
"browser_status_enabled": _api_read_cache_settings_cache[5],
"collections": [{"id": key, "label": label, "paths": paths, "scope": scope, "enabled": overrides.get(key, True), "ttl_seconds": ttl} for key, label, paths, scope in API_CACHE_CATALOG],
}
def set_api_read_cache_settings_local(
enabled: bool,
ttl_seconds: int,
*,
collection_overrides: dict[str, bool] | None = None,
browser_status_enabled: bool | None = None,
cache_epoch: str | None = None,
) -> None:
"""Make just-saved settings effective in this Gateway process immediately."""
global _api_read_cache_settings_cache
previous = _api_read_cache_settings_cache
_api_read_cache_settings_cache = (
time.monotonic(),
None,
bool(enabled),
_cache_ttl_seconds(None, ttl_seconds),
dict(collection_overrides if collection_overrides is not None else (previous[4] if previous else {})),
browser_status_enabled if browser_status_enabled is not None else (previous[5] if previous else True),
cache_epoch if cache_epoch is not None else (previous[6] if previous else "0"),
)
if previous is not None and previous[6] != _api_read_cache_settings_cache[6]:
# Wake old-generation waiters immediately; their Redis locks naturally
# expire and cannot block reads in the new persisted epoch.
for event in _fill_events.values():
event.set()
_fill_events.clear()
_fill_local_payloads.clear()
def set_api_read_cache_enabled_local(enabled: bool) -> None:
"""Backward-compatible local switch update that retains the cached TTL."""
ttl_seconds = _api_read_cache_settings_cache[3] if _api_read_cache_settings_cache is not None else _DEFAULT_CACHE_TTL_SECONDS
set_api_read_cache_settings_local(enabled, ttl_seconds)
async def save_api_read_cache_settings(
enabled: bool | None,
ttl_seconds: int | None = None,
*,
config: Any = None,
collection_overrides: dict[str, bool] | None = None,
browser_status_enabled: bool | None = None,
) -> tuple[bool, int]:
"""Serialize same-process patches; row locks protect concurrent DB workers."""
loop = asyncio.get_running_loop()
lock = _save_locks.setdefault(loop, asyncio.Lock())
async with lock:
return await _persist_api_read_cache_settings(
enabled,
ttl_seconds,
config=config,
collection_overrides=collection_overrides,
browser_status_enabled=browser_status_enabled,
)
async def _persist_api_read_cache_settings(
enabled: bool | None,
ttl_seconds: int | None,
*,
config: Any,
collection_overrides: dict[str, bool] | None,
browser_status_enabled: bool | None,
) -> tuple[bool, int]:
"""Persist the administrator toggle and optional TTL in the business DB."""
from deerflow.persistence.api_read_cache_settings.model import ApiReadCacheSettingRow
from deerflow.persistence.engine import get_session_factory
session_factory = get_session_factory()
if session_factory is None:
raise RuntimeError("MySQL persistence is required to save the Redis cache setting")
if collection_overrides is not None and set(collection_overrides) - set(API_READ_CACHE_COLLECTIONS):
raise ValueError("Unknown API cache collection")
from sqlalchemy.exc import IntegrityError
for attempt in range(2):
try:
async with session_factory() as session:
row = await session.get(ApiReadCacheSettingRow, "default", with_for_update=True)
if row is None:
saved_enabled = bool(getattr(getattr(config, "api_read_cache", None), "enabled", False)) if enabled is None else enabled
saved_ttl = _cache_ttl_seconds(config, ttl_seconds)
row = ApiReadCacheSettingRow(
key="default",
enabled=saved_enabled,
ttl_seconds=saved_ttl,
collection_overrides={},
browser_status_enabled=True,
)
session.add(row)
else:
if enabled is not None:
row.enabled = bool(enabled)
saved_ttl = _cache_ttl_seconds(
config,
ttl_seconds if ttl_seconds is not None else getattr(row, "ttl_seconds", None),
)
row.ttl_seconds = saved_ttl
saved_enabled = bool(row.enabled)
saved_overrides = {**(getattr(row, "collection_overrides", None) or {}), **(collection_overrides or {})}
saved_browser_status = browser_status_enabled if browser_status_enabled is not None else bool(getattr(row, "browser_status_enabled", True))
row.collection_overrides = saved_overrides
row.browser_status_enabled = saved_browser_status
saved_epoch = secrets.token_hex(16)
row.cache_epoch = saved_epoch
await session.commit()
break
except IntegrityError:
# Another worker may have inserted the singleton settings row.
# Reload it under the row lock and merge this patch exactly once.
if attempt:
raise
set_api_read_cache_settings_local(saved_enabled, saved_ttl, collection_overrides=saved_overrides, browser_status_enabled=saved_browser_status, cache_epoch=saved_epoch)
return saved_enabled, saved_ttl
async def save_api_read_cache_enabled(enabled: bool) -> None:
"""Backward-compatible switch update; preserves an existing TTL."""
await save_api_read_cache_settings(enabled)
def api_read_cache_redis_configured(config: Any) -> bool:
return _redis_settings(config, enabled=True) is not None
def _cache_ttl_seconds(config: Any, ttl_seconds: int | None = None) -> int:
value = int(ttl_seconds if ttl_seconds is not None else getattr(getattr(config, "api_read_cache", None), "ttl_seconds", _DEFAULT_CACHE_TTL_SECONDS) or _DEFAULT_CACHE_TTL_SECONDS)
return max(_MIN_CACHE_TTL_SECONDS, min(_MAX_CACHE_TTL_SECONDS, value))
def _redis_operation_timeout_seconds(config: Any) -> float:
cache = getattr(config, "api_read_cache", None)
connect_ms = int(getattr(cache, "socket_connect_timeout_ms", 250) or 250)
socket_ms = int(getattr(cache, "socket_timeout_ms", 750) or 750)
# Keep Redis optional and bounded, while allowing a private-network cache
# to absorb short bursts before falling back to MySQL.
return min(1.0, max(0.15, max(connect_ms, socket_ms) * 1.25 / 1000))
def _max_cache_value_bytes(config: Any) -> int:
value = int(getattr(getattr(config, "api_read_cache", None), "max_value_bytes", 512 * 1024) or 512 * 1024)
return max(1024, min(5 * 1024 * 1024, value))
def _warn_once(key: str, message: str) -> None:
now = time.monotonic()
if now - _last_warning_at.get(key, 0.0) >= 60:
_last_warning_at[key] = now
logger.warning("Homepage Redis cache unavailable; using MySQL: %s", message)
def _version_key(prefix: str, collection: str) -> str:
return f"{prefix}:{collection}:version"
def _item_key(prefix: str, collection: str, version: str, logical_key: str) -> str:
digest = hashlib.sha256(logical_key.encode("utf-8")).hexdigest()
return f"{prefix}:{collection}:v{version}:{digest}"
async def get_cached_json(
config: Any,
collection: str,
logical_key: str,
*,
version: str | None | object = _VERSION_UNSET,
) -> Any | None:
raw = await get_cached_json_payload(config, collection, logical_key, version=version)
if raw is None:
return None
try:
return json.loads(raw)
except Exception as exc:
_warn_once(f"{collection}:decode", f"cached JSON decode failed ({type(exc).__name__})")
return None
async def get_cached_json_payload(
config: Any,
collection: str,
logical_key: str,
*,
version: str | None | object = _VERSION_UNSET,
) -> bytes | None:
"""Return the stored JSON bytes so HTTP routes can bypass revalidation."""
if not await is_collection_cache_enabled(config, collection):
return None
client_info = _client(config, enabled=True)
if client_info is None:
return None
client, prefix = client_info
try:
if version is _VERSION_UNSET:
version = await get_cache_version(config, collection)
if version is None:
return None
async with asyncio.timeout(_redis_operation_timeout_seconds(config)):
raw = await client.get(_item_key(prefix, collection, str(version), logical_key))
if raw is None:
return None
return raw if isinstance(raw, bytes) else str(raw).encode("utf-8")
except Exception as exc: # Redis is an optimization; MySQL remains authoritative.
_warn_once(prefix, f"read failed ({type(exc).__name__})")
return None
async def get_cache_version(config: Any, collection: str) -> str | None:
"""Read the collection generation used to guard against stale writes."""
if not await is_collection_cache_enabled(config, collection):
return None
client_info = _client(config, enabled=True)
if client_info is None:
return None
client, prefix = client_info
epoch = _cache_epoch(config)
try:
async with asyncio.timeout(_redis_operation_timeout_seconds(config)):
version = await client.get(_version_key(prefix, collection))
generation = version.decode("ascii") if isinstance(version, bytes) else str(version or "0")
return _scoped_version(epoch, generation)
except Exception as exc:
_warn_once(prefix, f"version read failed ({type(exc).__name__})")
return None
async def wait_for_cached_json_or_claim_fill(
config: Any,
collection: str,
logical_key: str,
*,
version: str | None,
wait_ms: int = 1000,
) -> tuple[Any | None, str | None]:
"""Wait briefly for a concurrent miss to fill Redis, or claim the fill.
The returned token must be passed to :func:`release_cache_fill` in a
``finally`` block. This prevents a burst of identical requests from all
rebuilding the same large agent/skill response at once.
"""
return await _wait_for_cached_value_or_claim_fill(
config,
collection,
logical_key,
version=version,
wait_ms=wait_ms,
raw_payload=False,
)
async def wait_for_cached_json_payload_or_claim_fill(
config: Any,
collection: str,
logical_key: str,
*,
version: str | None,
wait_ms: int = 6000,
) -> tuple[bytes | None, str | None]:
"""Wait for an in-flight fill and return the cached HTTP JSON bytes."""
value, token = await _wait_for_cached_value_or_claim_fill(
config,
collection,
logical_key,
version=version,
wait_ms=wait_ms,
raw_payload=True,
)
return value, token
async def _wait_for_cached_value_or_claim_fill(
config: Any,
collection: str,
logical_key: str,
*,
version: str | None,
wait_ms: int,
raw_payload: bool,
) -> tuple[Any | bytes | None, str | None]:
if version is None or not await is_collection_cache_enabled(config, collection):
return None, None
client_info = _client(config, enabled=True)
if client_info is None:
return None, None
client, prefix = client_info
digest = hashlib.sha256(logical_key.encode("utf-8")).hexdigest()
lock_key = f"{prefix}:{collection}:fill:{digest}"
event_key = (id(asyncio.get_running_loop()), lock_key)
token = f"{_cache_epoch(config)}:{secrets.token_urlsafe(18)}"
try:
async with asyncio.timeout(_redis_operation_timeout_seconds(config)):
if await client.set(lock_key, token, ex=15, nx=True):
_fill_events[event_key] = asyncio.Event()
return None, token
except Exception as exc:
_warn_once(prefix, f"fill lock unavailable ({type(exc).__name__})")
return None, None
wait_seconds = max(0, min(wait_ms, 8000)) / 1000
local_event = _fill_events.get(event_key)
if local_event is not None:
try:
async with asyncio.timeout(wait_seconds):
await local_event.wait()
except TimeoutError:
pass
if not await is_collection_cache_enabled(config, collection) or await get_cache_version(config, collection) != version:
return None, None
local_payload = _fill_local_payloads.get(event_key)
if local_payload is not None:
expires_at, payload_version, payload = local_payload
if expires_at > time.monotonic() and payload_version == version:
if raw_payload:
return payload, None
try:
return json.loads(payload), None
except Exception as exc:
_warn_once(f"{collection}:decode", f"local fill JSON decode failed ({type(exc).__name__})")
else:
_fill_local_payloads.pop(event_key, None)
if raw_payload:
cached = await get_cached_json_payload(config, collection, logical_key, version=version)
else:
cached = await get_cached_json(config, collection, logical_key, version=version)
if cached is not None:
return cached, None
else:
# Cross-process waiters cannot share the local event. Check Redis at a
# low frequency instead of polling every 50-100ms under a cold burst.
deadline = time.monotonic() + wait_seconds
while time.monotonic() < deadline:
await asyncio.sleep(min(1.0, max(0.0, deadline - time.monotonic())))
if not await is_collection_cache_enabled(config, collection) or await get_cache_version(config, collection) != version:
return None, None
if raw_payload:
cached = await get_cached_json_payload(config, collection, logical_key, version=version)
else:
cached = await get_cached_json(config, collection, logical_key, version=version)
if cached is not None:
return cached, None
# The original filler may have failed. A single waiter gets one chance to
# take over; remaining requests fall through to MySQL if the cache is down.
if not await is_collection_cache_enabled(config, collection) or await get_cache_version(config, collection) != version:
return None, None
try:
async with asyncio.timeout(_redis_operation_timeout_seconds(config)):
if await client.set(lock_key, token, ex=15, nx=True):
_fill_events[event_key] = asyncio.Event()
return None, token
except Exception as exc:
_warn_once(prefix, f"fill lock retry failed ({type(exc).__name__})")
return None, None
async def release_cache_fill(config: Any, collection: str, logical_key: str, token: str | None) -> None:
"""Release a miss lock only when it is still owned by this request."""
if not token:
return
digest = hashlib.sha256(logical_key.encode("utf-8")).hexdigest()
owner_epoch, separator, _ = token.partition(":")
client_info = _client(config, enabled=True, epoch=owner_epoch if separator else None)
if client_info is not None:
client, prefix = client_info
lock_key = f"{prefix}:{collection}:fill:{digest}"
event = _fill_events.pop((id(asyncio.get_running_loop()), lock_key), None)
else:
lock_key = ""
event = None
if event is not None:
event.set()
if client_info is None:
return
try:
async with asyncio.timeout(_redis_operation_timeout_seconds(config)):
await client.eval(
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end",
1,
lock_key,
token,
)
except Exception as exc:
_warn_once(prefix, f"fill lock release failed ({type(exc).__name__})")
def _json_default(value: Any) -> Any:
model_dump = getattr(value, "model_dump", None)
if callable(model_dump):
return model_dump(mode="json", by_alias=True)
isoformat = getattr(value, "isoformat", None)
if callable(isoformat):
return isoformat()
raise TypeError(f"Cannot cache {type(value).__name__}")
async def set_cached_json(
config: Any,
collection: str,
logical_key: str,
value: Any,
*,
expected_version: str | None | object = _VERSION_UNSET,
ttl_seconds: int | None = None,
) -> None:
enabled, configured_ttl = await get_api_read_cache_settings(config)
if not enabled or not await is_collection_cache_enabled(config, collection):
return
client_info = _client(config, enabled=True)
if client_info is None:
return
client, prefix = client_info
try:
if expected_version is _VERSION_UNSET:
expected_version = await get_cache_version(config, collection)
if expected_version is None:
return
encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=_json_default).encode("utf-8")
if len(encoded) > _max_cache_value_bytes(config):
return
async with asyncio.timeout(_redis_operation_timeout_seconds(config)):
version = await client.get(_version_key(prefix, collection))
version_text = version.decode("ascii") if isinstance(version, bytes) else str(version or "0")
if _scoped_version(_cache_epoch(config), version_text) != expected_version:
return
await client.set(
_item_key(prefix, collection, expected_version, logical_key),
encoded,
ex=_cache_ttl_seconds(
config,
ttl_seconds if ttl_seconds is not None else configured_ttl,
),
)
loop_id = id(asyncio.get_running_loop())
fill_digest = hashlib.sha256(logical_key.encode("utf-8")).hexdigest()
fill_event_key = (loop_id, f"{prefix}:{collection}:fill:{fill_digest}")
if fill_event_key in _fill_events:
now = time.monotonic()
for key, (expires_at, _, _) in list(_fill_local_payloads.items()):
if expires_at <= now:
_fill_local_payloads.pop(key, None)
_fill_local_payloads[fill_event_key] = (now + 2.0, str(expected_version), encoded)
if len(_fill_local_payloads) > 128:
_fill_local_payloads.pop(next(iter(_fill_local_payloads)))
except Exception as exc:
_warn_once(prefix, f"write failed ({type(exc).__name__})")
async def invalidate_cached_collection(config: Any, collection: str, *, force: bool = False) -> None:
"""Bump a generation; old keys expire naturally, with no Redis key scan."""
if not force and not await is_api_read_cache_enabled(config):
return
# A forced invalidation is used after an admin edits sensitive words while
# Redis reads are disabled. The generation still advances so re-enabling
# the cache cannot resurrect that older value within its one-hour TTL.
client_info = _client(config, enabled=True)
if client_info is None:
return
client, prefix = client_info
try:
async with asyncio.timeout(_redis_operation_timeout_seconds(config)):
await client.incr(_version_key(prefix, collection))
except Exception as exc:
_warn_once(prefix, f"invalidation failed ({type(exc).__name__})")
async def close_api_read_cache() -> None:
clients = list(_clients.values())
_clients.clear()
for client in clients:
try:
await client.aclose()
except Exception:
logger.debug("Failed to close homepage Redis cache client", exc_info=True)
@@ -1,3 +1,5 @@
# ruff: noqa: E402, I001
# The dotenv load below must precede application imports that read environment.
import asyncio
import logging
import os
@@ -7,11 +9,16 @@ import threading
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
from time import perf_counter
from typing import Any
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
# Load .env BEFORE any module that reads os.environ at import time.
# Without this, GATEWAY_CORS_ORIGINS / AUTH_JWT_SECRET / DEER_FLOW_AUTH_DISABLED
@@ -28,16 +35,20 @@ from app.gateway.deps import langgraph_runtime
from app.gateway.public_knowledge_cors import PublicKnowledgeCORSMiddleware
from app.gateway.routers import (
admin_active_runs,
admin_audit_retention as admin_audit_retention_routes,
admin_users,
agents,
ai_writing,
app_identity,
artifacts,
assistant_knowledge,
assistants_compat,
auth,
service_tokens,
browser_context,
business_mapping,
channels,
chat_archive,
checkpoint_migration,
compact_menu_overrides,
dashboard_sessions,
@@ -52,6 +63,7 @@ from app.gateway.routers import (
html_images,
html_page_favorites,
html_runtime,
html_templates,
intent,
knowledge,
knowledge_ingest,
@@ -62,8 +74,8 @@ from app.gateway.routers import (
mcp,
memory,
menu_overrides,
model_management,
model_exchanges,
model_management,
models,
multi_agent,
notifications,
@@ -89,11 +101,10 @@ from app.gateway.routers import (
roundtable_draft_shares,
roundtable_drafts,
roundtable_jobs,
roundtable_rooms,
roundtable_task_drafts,
runs,
scheduled_tasks,
task_analysis,
task_analysis_qa,
sensitive_words,
sentiment_agent,
skill_addresses,
@@ -101,10 +112,14 @@ from app.gateway.routers import (
skill_view_diagnostics,
skills,
strategy_agents,
stream_truncations,
suggestions,
system_settings,
tags,
task_analysis,
task_analysis_qa,
task_buttons,
task_deeplink_details,
task_deeplink_threads,
task_reports,
taskcop_tasks,
@@ -112,8 +127,8 @@ from app.gateway.routers import (
thread_shares,
threads,
tool_metrics,
uploads,
upload_policy,
uploads,
user_preferences,
user_prompts,
web_app_sites,
@@ -144,6 +159,11 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
# Swagger UI and ReDoc are vendored so the API documentation works in isolated
# intranets. The route intentionally lives below /docs so it is covered by the
# existing anonymous docs allow-list in the auth middleware.
_DOCS_STATIC_DIR = Path(__file__).with_name("static") / "docs"
# Upper bound (seconds) each lifespan shutdown hook is allowed to run.
# Bounds worker exit time so uvicorn's reload supervisor does not keep
# firing signals into a worker that is stuck waiting for shutdown cleanup.
@@ -242,7 +262,7 @@ async def _iter_store_items(store, namespace, *, page_size: int = 500):
offset += page_size
async def _sync_legacy_skills(app: FastAPI) -> None:
async def _sync_legacy_skills(app: FastAPI) -> list[Any] | None:
"""Idempotently register existing on-disk ``skills/custom/*`` directories
in the ``skills`` DB table, owned by the first admin and published=True.
@@ -262,11 +282,11 @@ async def _sync_legacy_skills(app: FastAPI) -> None:
sf = get_session_factory()
if sf is None:
return
return None
skill_store = getattr(app.state, "skill_store", None)
if skill_store is None:
return
return None
async with sf() as session:
stmt = select(UserRow).where(UserRow.system_role == "admin").limit(1)
@@ -284,7 +304,7 @@ async def _sync_legacy_skills(app: FastAPI) -> None:
skills = storage.load_skills(enabled_only=False)
except Exception:
logger.exception("_sync_legacy_skills: could not list on-disk skills")
return
return None
inserted = 0
for skill in skills:
@@ -302,6 +322,7 @@ async def _sync_legacy_skills(app: FastAPI) -> None:
logger.warning("_sync_legacy_skills: could not register %s", skill.name, exc_info=True)
if inserted:
logger.info("Skill ownership migration: registered %d legacy custom skill(s)", inserted)
return skills
async def _migrate_orphaned_threads(store, admin_user_id: str) -> int:
@@ -548,6 +569,35 @@ _CONCURRENCY_SAMPLE_RETENTION_HOURS = _env_float(
"CONCURRENCY_SAMPLE_RETENTION_HOURS",
168.0, # 7 days
)
# Administrative audit data is operational rather than archival. The actual
# cutoff lives with the shared query policy; these controls intentionally make
# cleanup a low-priority, incremental task instead of a large startup delete.
_ADMIN_AUDIT_RETENTION_INTERVAL_SECONDS = _env_float(
"ADMIN_AUDIT_RETENTION_INTERVAL_SECONDS",
15 * 60,
minimum=60.0,
)
_ADMIN_AUDIT_RETENTION_STARTUP_DELAY_SECONDS = _env_float(
"ADMIN_AUDIT_RETENTION_STARTUP_DELAY_SECONDS",
300.0,
minimum=0.0,
)
_ADMIN_AUDIT_RETENTION_BATCH_SIZE = min(
500,
_env_int("ADMIN_AUDIT_RETENTION_BATCH_SIZE", 100, minimum=1),
)
_ADMIN_AUDIT_RETENTION_MAX_BATCHES_PER_STORE = min(
16,
_env_int("ADMIN_AUDIT_RETENTION_MAX_BATCHES_PER_STORE", 4, minimum=1),
)
_ADMIN_AUDIT_RETENTION_INTER_BATCH_PAUSE_SECONDS = min(
1.0,
_env_float("ADMIN_AUDIT_RETENTION_INTER_BATCH_PAUSE_SECONDS", 0.05, minimum=0.0),
)
_ADMIN_AUDIT_RETENTION_MAX_SWEEP_SECONDS = min(
60.0,
_env_float("ADMIN_AUDIT_RETENTION_MAX_SWEEP_SECONDS", 8.0, minimum=0.1),
)
# Hour (local Beijing time, 0-23) after which the scheduler force-recomputes the
# *previous* day's snapshot once, so records that landed near midnight are fully
# captured. Default 1 ? "???? 1 ????????".
@@ -638,10 +688,11 @@ async def _time_leaderboard_scheduler_db_operation(step: str, operation, *, sql:
def _recent_leaderboard_stat_dates(days: int) -> list[str]:
from datetime import datetime, timedelta
from app.gateway.admin_query_window import ADMIN_QUERY_WINDOW_DAYS
from deerflow.persistence.types import BEIJING_TZ
today = datetime.now(BEIJING_TZ).date()
start = today - timedelta(days=max(1, days) - 1)
start = today - timedelta(days=min(ADMIN_QUERY_WINDOW_DAYS, max(1, days)) - 1)
return [(start + timedelta(days=i)).isoformat() for i in range((today - start).days + 1)]
@@ -946,6 +997,141 @@ async def _concurrency_sampler_loop(app: FastAPI) -> None:
_release_singleton_lock(lock_handle)
async def _admin_audit_retention_loop(app: FastAPI) -> None:
"""Keep bounded admin-audit tables at the three-month retention target."""
from app.gateway.admin_audit_retention import sweep_admin_audit_retention
from app.gateway.admin_audit_retention_control import (
is_paused,
read_status,
write_status,
)
from app.gateway.admin_query_window import ADMIN_AUDIT_RETENTION_DAYS
from deerflow.config.runtime_paths import runtime_home
from deerflow.persistence.engine import get_session_factory
lock_handle = _acquire_singleton_lock(runtime_home() / ".admin_audit_retention.lock")
if lock_handle is None:
logger.info("Admin audit retention: another gateway worker holds the lock; this worker skips")
return
root = runtime_home()
status = read_status(root)
status.update(
{
"retention_days": ADMIN_AUDIT_RETENTION_DAYS,
"interval_seconds": _ADMIN_AUDIT_RETENTION_INTERVAL_SECONDS,
"startup_delay_seconds": _ADMIN_AUDIT_RETENTION_STARTUP_DELAY_SECONDS,
"batch_size": _ADMIN_AUDIT_RETENTION_BATCH_SIZE,
"max_rounds": _ADMIN_AUDIT_RETENTION_MAX_BATCHES_PER_STORE,
"max_sweep_seconds": _ADMIN_AUDIT_RETENTION_MAX_SWEEP_SECONDS,
}
)
def save_status(**updates: Any) -> None:
status.update(updates)
write_status(root, dict(status))
async def publish_progress(progress: dict[str, Any]) -> None:
await asyncio.to_thread(save_status, **progress)
logger.info(
"Admin audit retention started: interval=%ss startup_delay=%ss batch_size=%s "
"max_batches_per_store=%s max_sweep_seconds=%s",
_ADMIN_AUDIT_RETENTION_INTERVAL_SECONDS,
_ADMIN_AUDIT_RETENTION_STARTUP_DELAY_SECONDS,
_ADMIN_AUDIT_RETENTION_BATCH_SIZE,
_ADMIN_AUDIT_RETENTION_MAX_BATCHES_PER_STORE,
_ADMIN_AUDIT_RETENTION_MAX_SWEEP_SECONDS,
)
try:
if _ADMIN_AUDIT_RETENTION_STARTUP_DELAY_SECONDS:
# Do not compete with migrations, warmup, or users arriving just
# after a Gateway restart. The scheduled passes are deliberately
# maintenance work, not a startup requirement.
save_status(
status="scheduled",
current_source=None,
next_run_at=(
datetime.now().astimezone().timestamp()
+ _ADMIN_AUDIT_RETENTION_STARTUP_DELAY_SECONDS
),
)
await asyncio.sleep(_ADMIN_AUDIT_RETENTION_STARTUP_DELAY_SECONDS)
while True:
if is_paused(root):
save_status(status="paused", current_source=None, next_run_at=None)
await asyncio.sleep(min(2.0, _ADMIN_AUDIT_RETENTION_INTERVAL_SECONDS))
continue
started_at = datetime.now().astimezone().isoformat(timespec="seconds")
save_status(
status="running",
current_source=None,
round=0,
deleted={
"llm_metrics": 0,
"tool_metrics": 0,
"run_summaries": 0,
"leaderboard_snapshots": 0,
},
started_at=started_at,
next_run_at=None,
last_error=None,
)
try:
result = await sweep_admin_audit_retention(
session_factory=get_session_factory(),
llm_metrics_store=getattr(app.state, "llm_metrics_store", None),
tool_metrics_store=getattr(app.state, "tool_metrics_store", None),
run_store=getattr(app.state, "run_store", None),
batch_size=_ADMIN_AUDIT_RETENTION_BATCH_SIZE,
max_batches_per_store=_ADMIN_AUDIT_RETENTION_MAX_BATCHES_PER_STORE,
inter_batch_pause_seconds=_ADMIN_AUDIT_RETENTION_INTER_BATCH_PAUSE_SECONDS,
max_sweep_seconds=_ADMIN_AUDIT_RETENTION_MAX_SWEEP_SECONDS,
should_stop=lambda: is_paused(root),
on_progress=publish_progress,
)
finished_at = datetime.now().astimezone().isoformat(timespec="seconds")
paused = is_paused(root)
last_sweep = {
"started_at": started_at,
"finished_at": finished_at,
"deleted": result,
"stopped_by_admin": paused,
}
save_status(
status="paused" if paused else "scheduled",
current_source=None,
round=status.get("round", 0),
deleted=result,
started_at=started_at,
finished_at=finished_at,
last_sweep=last_sweep,
next_run_at=None if paused else (
datetime.now().astimezone().timestamp()
+ _ADMIN_AUDIT_RETENTION_INTERVAL_SECONDS
),
)
except Exception:
save_status(
status="error",
current_source=None,
last_error="清理轮次发生异常,请查看后台日志",
finished_at=datetime.now().astimezone().isoformat(timespec="seconds"),
)
logger.exception("Admin audit retention sweep failed (non-fatal)")
# Wake periodically so an administrator can pause/resume without
# waiting for the full 15-minute cleanup interval.
remaining = _ADMIN_AUDIT_RETENTION_INTERVAL_SECONDS
while remaining > 0 and not is_paused(root):
sleep_for = min(2.0, remaining)
await asyncio.sleep(sleep_for)
remaining -= sleep_for
finally:
_release_singleton_lock(lock_handle)
def _warn_if_multi_worker() -> None:
"""?? uvicorn / gunicorn ? worker ???? ERROR ???
@@ -1000,7 +1186,7 @@ def _warn_if_multi_worker() -> None:
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
async def _gateway_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Application lifespan handler."""
# ?????? worker ?? ?? ?? AI ?? / ? agent ???????
@@ -1017,6 +1203,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
error_msg = f"Failed to load configuration during gateway startup: {e}"
logger.exception(error_msg)
raise RuntimeError(error_msg) from e
config = get_gateway_config()
logger.info(f"Starting API Gateway on {config.host}:{config.port}")
@@ -1069,11 +1256,26 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# Migrate "ownerless" on-disk skills into the new SkillStore table ?
# makes the multitenancy switch transparent for existing deployments.
catalog_skills = None
try:
await _sync_legacy_skills(app)
catalog_skills = await _sync_legacy_skills(app)
except Exception:
logger.exception("Skill ownership migration failed (non-fatal)")
# Project the compact per-skill catalog metadata into MySQL before the
# Gateway accepts requests. List APIs read indexed scalar columns and
# never load skill markdown, display samples, or usage sidecars.
try:
from app.gateway.skill_catalog_cache import refresh_skill_catalog_projection
catalog_size = await refresh_skill_catalog_projection(
app.state.config,
skills=catalog_skills,
)
logger.info("Skill MySQL catalog projection synchronized: skills=%d", catalog_size)
except Exception:
logger.exception("Skill MySQL catalog projection sync failed (non-fatal)")
# ????? agent(intent / recommender / coordinator)???????
# ??? _sync_legacy_agents **??**??????????????????
# ??? upsert ? agents ?,?????? /api/agents ?????,
@@ -1133,6 +1335,17 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"Template-builder agent seeding failed at startup (non-fatal)"
)
# HTML 模板库:把随包内置模板 seed 进 {base_dir}/html-templates/,模板管理页
# 与定时任务下拉即可见(缺失才复制,绝不覆盖管理页/线上修改)。
try:
from deerflow.runtime.scheduler import template_store
template_store.ensure_seeded()
except Exception:
logger.exception(
"HTML template store seeding failed at startup (non-fatal)"
)
# 实验性「页面研报设计师」:在 _sync_legacy_agents 前补齐 agent 目录与
# 公共页面生成技能,首次启动即可在智能体管理页和普通对话中复用。
try:
@@ -1328,6 +1541,15 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
except Exception:
logger.exception("Legacy agent sync failed at startup (non-fatal)")
# Refresh after database initialization and all built-in skill seeders;
# import-time priming may have run before either was ready.
try:
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
await refresh_skills_system_prompt_cache_async()
except Exception:
logger.exception("Skills prompt cache warm-up failed at startup (non-fatal)")
# ?? skill curator ???:??????,?????????????
# (per-user interval_hours ??????????????)?
try:
@@ -1358,6 +1580,13 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
except Exception:
logger.exception("Concurrency sampler failed to start (non-fatal)")
try:
app.state.admin_audit_retention_task = asyncio.create_task(
_admin_audit_retention_loop(app)
)
except Exception:
logger.exception("Admin audit retention scheduler failed to start (non-fatal)")
# Start IM channel service if any channels are configured
try:
from app.channels.service import start_channel_service
@@ -1399,6 +1628,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
except Exception:
logger.exception("Task analysis service failed to start")
if getattr(app.state, "worker_pool", None) is not None:
app.state.worker_pool.start()
yield
artifact_library_tasks = list(
@@ -1466,6 +1697,16 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
except Exception:
logger.exception("Concurrency sampler shutdown error")
admin_audit_retention_task = getattr(app.state, "admin_audit_retention_task", None)
if admin_audit_retention_task is not None:
admin_audit_retention_task.cancel()
try:
await admin_audit_retention_task
except asyncio.CancelledError:
pass
except Exception:
logger.exception("Admin audit retention scheduler shutdown error")
# ?? 7 ????AI ?? graph ???? checkpointer / in-flight task?
# thread ??? LangGraph Server ? checkpointer ???lifespan ???
# ``langgraph_runtime()`` ? contextmanager ???????????????
@@ -1505,6 +1746,25 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
logger.info("Shutting down API Gateway")
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
from app.gateway.worker_pool.lifecycle import managed_controller
from app.gateway.perf_logging import event_loop_watchdog, register_stack_dump_signal
register_stack_dump_signal()
watchdog = asyncio.create_task(event_loop_watchdog(app), name="gateway-event-loop-watchdog")
try:
async with managed_controller(app):
async with _gateway_lifespan(app):
yield
finally:
watchdog.cancel()
await asyncio.gather(watchdog, return_exceptions=True)
from app.gateway.api_read_cache import close_api_read_cache
await close_api_read_cache()
def create_app() -> FastAPI:
"""Create and configure the FastAPI application.
@@ -1512,7 +1772,12 @@ def create_app() -> FastAPI:
Configured FastAPI application instance.
"""
config = get_gateway_config()
docs_kwargs = {"docs_url": "/docs", "redoc_url": "/redoc", "openapi_url": "/openapi.json"} if config.enable_docs else {"docs_url": None, "redoc_url": None, "openapi_url": None}
logger.info("API documentation endpoints enabled: %s", config.enable_docs)
docs_kwargs = {
"docs_url": None,
"redoc_url": None,
"openapi_url": "/openapi.json",
} if config.enable_docs else {"docs_url": None, "redoc_url": None, "openapi_url": None}
app = FastAPI(
title="DeerFlow API Gateway",
@@ -1598,7 +1863,83 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
],
)
@app.middleware("http")
async def invalidate_catalog_cache_after_write(request: Request, call_next):
response = await call_next(request)
if request.method.upper() in {"POST", "PUT", "PATCH", "DELETE"} and response.status_code < 400:
path = request.url.path.rstrip("/")
collections: set[str] = set()
if path == "/api/agents" or path.startswith("/api/agents/"):
collections.update(("agents", "tags"))
if path == "/api/skills" or path.startswith("/api/skills/"):
collections.update(("skills", "tags"))
if path == "/api/tags" or path.startswith("/api/tags/"):
collections.update(("agents", "skills", "recommended_questions", "light_apps", "tags"))
if path == "/api/positions" or path.startswith("/api/positions/"):
collections.update(("agents", "skills", "recommended_questions"))
if path == "/api/user-preferences" or path.startswith("/api/user-preferences/"):
collections.add("user_preferences")
if path == "/api/user-prompts" or path.startswith("/api/user-prompts/"):
collections.add("user_prompts")
if path.startswith("/api/recommended-questions"):
collections.update(("recommended_questions", "tags"))
if path == "/api/scheduled-tasks" or path.startswith("/api/scheduled-tasks/"):
collections.add("tags")
if path == "/api/light-apps" or path.startswith("/api/light-apps/"):
collections.add("tags")
if path == "/api/sensitive-words":
collections.add("sensitive_words")
config = getattr(request.app.state, "config", None)
if config is not None and collections:
from app.gateway.api_read_cache import invalidate_cached_collection
await asyncio.gather(
*(
invalidate_cached_collection(
config,
collection,
force=True,
)
for collection in collections
)
)
return response
if config.enable_docs:
# Keep these paths relative to the page URL. Nginx strips the
# /deerflow deployment prefix before forwarding to this app, and
# root-relative URLs would otherwise escape that prefix in browsers.
app.mount(
"/docs/static",
StaticFiles(directory=_DOCS_STATIC_DIR),
name="docs-static",
)
@app.get("/docs", include_in_schema=False)
async def swagger_ui() -> HTMLResponse:
return get_swagger_ui_html(
openapi_url="./openapi.json",
title=f"{app.title} - Swagger UI",
swagger_js_url="./docs/static/swagger-ui-bundle.js",
swagger_css_url="./docs/static/swagger-ui.css",
swagger_favicon_url="./docs/static/favicon-32x32.png",
swagger_ui_parameters=app.swagger_ui_parameters,
)
@app.get("/redoc", include_in_schema=False)
async def redoc() -> HTMLResponse:
return get_redoc_html(
openapi_url="./openapi.json",
title=f"{app.title} - ReDoc",
redoc_js_url="./docs/static/redoc.standalone.js",
redoc_favicon_url="./docs/static/favicon-32x32.png",
with_google_fonts=False,
)
# Auth: reject unauthenticated requests to non-public paths (fail-closed safety net)
from app.gateway.worker_pool.proxy import WorkerProxy
app.add_middleware(WorkerProxy)
app.add_middleware(AuthMiddleware)
# CSRF: Double Submit Cookie pattern for state-changing requests
@@ -1756,8 +2097,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
app.include_router(analysis_projects.router)
app.include_router(threads.router)
# Dedicated taskId + goPath history lookup for 3qfx/rwfx/xdfx single-agent chats.
# Dedicated taskId + goPath history lookup for single-agent task deep-link chats.
app.include_router(task_deeplink_threads.router)
app.include_router(task_deeplink_details.router)
# Agents API is mounted at /api/agents
app.include_router(agents.router)
app.include_router(strategy_agents.router)
@@ -1866,6 +2208,7 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Roundtable-planning background jobs at /api/roundtable-jobs
app.include_router(roundtable_jobs.router)
app.include_router(roundtable_rooms.router)
app.include_router(roundtable_diagnostics.router)
# Deep Research (深度研究) API at /api/deep-research
@@ -1904,6 +2247,8 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
app.include_router(html_runtime.router)
app.include_router(html_images.router)
app.include_router(html_page_favorites.router)
# HTML 模板管理(模板管理页;读开放,写仅 admin)。
app.include_router(html_templates.router)
# Product knowledge base API is mounted at /api/knowledge
app.include_router(knowledge.router)
@@ -1916,6 +2261,11 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# System settings API is mounted at /api/system-settings
app.include_router(system_settings.router)
app.include_router(app_identity.router)
app.include_router(app_identity.public_router)
from app.gateway.worker_pool.router import router as worker_pool_router
app.include_router(worker_pool_router)
# Admin LLM metrics audit + export
app.include_router(llm_metrics.router)
@@ -1923,6 +2273,7 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Admin window into other users' threads / messages / memory
app.include_router(admin_users.router)
app.include_router(admin_users.self_router)
app.include_router(admin_audit_retention_routes.router)
app.include_router(admin_active_runs.router)
app.include_router(skill_view_diagnostics.router)
@@ -1931,17 +2282,23 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Admin tool / skill call metrics audit + export
app.include_router(tool_metrics.router)
# Admin model-stream truncation event log
app.include_router(stream_truncations.router)
# Channels API is mounted at /api/channels
app.include_router(channels.router)
# Admin checkpoint migration API: SQLite legacy checkpoints -> Postgres.
app.include_router(checkpoint_migration.router)
# Admin monthly archive/rollback for PostgreSQL chat checkpoints.
app.include_router(chat_archive.router)
# Assistants compatibility API (LangGraph Platform stub)
app.include_router(assistants_compat.router)
# Auth API is mounted at /api/v1/auth
app.include_router(auth.router)
app.include_router(service_tokens.router)
# Feedback API is mounted at /api/threads/{thread_id}/runs/{run_id}/feedback
app.include_router(feedback.router)
@@ -2012,6 +2369,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
app.include_router(llmwiki.proxy_router, prefix="/deerflow")
app.include_router(llmwiki.proxy_router)
from app.gateway.worker_pool.telemetry import RequestTelemetryMiddleware
app.add_middleware(RequestTelemetryMiddleware)
return app
@@ -1,63 +1,77 @@
"""JWT token creation and verification."""
from datetime import UTC, datetime, timedelta
from typing import Literal
import jwt
from pydantic import BaseModel, ValidationError
from app.gateway.auth.config import get_auth_config
from app.gateway.auth.errors import TokenError
"""JWT token creation and verification."""
AuthMethod = Literal["password", "passphrase", "sso"]
class TokenPayload(BaseModel):
"""JWT token payload."""
sub: str # user_id
exp: datetime
iat: datetime | None = None
ver: int = 0 # token_version — must match User.token_version
from datetime import UTC, datetime, timedelta
from typing import Literal
import jwt
from pydantic import BaseModel, Field, ValidationError
from app.gateway.auth.config import get_auth_config
from app.gateway.auth.errors import TokenError
AuthMethod = Literal["password", "passphrase", "sso", "service"]
class TokenPayload(BaseModel):
"""JWT token payload."""
sub: str # user_id
exp: datetime
iat: datetime | None = None
ver: int = 0 # token_version — must match User.token_version
auth_method: AuthMethod | None = None # None is historical/unverified identity-only login.
def create_access_token(user_id: str, expires_delta: timedelta | None = None, token_version: int = 0, *, auth_method: AuthMethod | None = None) -> str:
"""Create a JWT access token.
Args:
user_id: The user's UUID as string
expires_delta: Optional custom expiry, defaults to 7 days
token_version: User's current token_version for invalidation
client_id: str | None = None
scope: list[str] = Field(default_factory=list)
def create_access_token(
user_id: str,
expires_delta: timedelta | None = None,
token_version: int = 0,
*,
auth_method: AuthMethod | None = None,
client_id: str | None = None,
scope: list[str] | None = None,
) -> str:
"""Create a JWT access token.
Args:
user_id: The user's UUID as string
expires_delta: Optional custom expiry, defaults to 7 days
token_version: User's current token_version for invalidation
auth_method: Explicit proof verified by the issuing login path. Never
defaults to a trusted method for old or identity-only callers.
Returns:
Encoded JWT string
"""
config = get_auth_config()
expiry = expires_delta or timedelta(days=config.token_expiry_days)
now = datetime.now(UTC)
payload = {"sub": user_id, "exp": now + expiry, "iat": now, "ver": token_version}
Returns:
Encoded JWT string
"""
config = get_auth_config()
expiry = expires_delta or timedelta(days=config.token_expiry_days)
now = datetime.now(UTC)
payload = {"sub": user_id, "exp": now + expiry, "iat": now, "ver": token_version}
if auth_method is not None:
payload["auth_method"] = auth_method
return jwt.encode(payload, config.jwt_secret, algorithm="HS256")
def decode_token(token: str) -> TokenPayload | TokenError:
"""Decode and validate a JWT token.
Returns:
TokenPayload if valid, or a specific TokenError variant.
"""
config = get_auth_config()
try:
payload = jwt.decode(token, config.jwt_secret, algorithms=["HS256"])
return TokenPayload(**payload)
except jwt.ExpiredSignatureError:
return TokenError.EXPIRED
except jwt.InvalidSignatureError:
return TokenError.INVALID_SIGNATURE
if client_id is not None:
payload["client_id"] = client_id
if scope:
payload["scope"] = scope
return jwt.encode(payload, config.jwt_secret, algorithm="HS256")
def decode_token(token: str) -> TokenPayload | TokenError:
"""Decode and validate a JWT token.
Returns:
TokenPayload if valid, or a specific TokenError variant.
"""
config = get_auth_config()
try:
payload = jwt.decode(token, config.jwt_secret, algorithms=["HS256"])
return TokenPayload(**payload)
except jwt.ExpiredSignatureError:
return TokenError.EXPIRED
except jwt.InvalidSignatureError:
return TokenError.INVALID_SIGNATURE
except (jwt.PyJWTError, ValidationError):
return TokenError.MALFORMED
return TokenError.MALFORMED
@@ -3,7 +3,7 @@
import logging
from app.gateway.auth.models import User
from app.gateway.auth.password import hash_password_async, verify_account_password
from app.gateway.auth.password import hash_password_async, verify_account_password
from app.gateway.auth.providers import AuthProvider
from app.gateway.auth.repositories.base import UserRepository
@@ -40,7 +40,7 @@ class LocalAuthProvider(AuthProvider):
if user is None:
return None
if not await verify_account_password(password, user):
if not await verify_account_password(password, user):
return None
return user
@@ -49,6 +49,10 @@ class LocalAuthProvider(AuthProvider):
"""Get user by ID."""
return await self._repo.get_user_by_id(user_id)
async def get_users_by_ids(self, user_ids: list[str]) -> list[User]:
"""Resolve many account owners with one repository read."""
return await self._repo.get_users_by_ids(user_ids)
async def find_user_by_email_local_part(self, local_part: str) -> User | None:
"""Find a user whose email starts with ``local_part@`` regardless of domain.
@@ -86,7 +90,7 @@ class LocalAuthProvider(AuthProvider):
user = User(
email=email,
password_hash=password_hash,
login_password=password_hash,
login_password=password_hash,
system_role=system_role,
needs_setup=needs_setup,
approved=approved,
@@ -96,11 +100,11 @@ class LocalAuthProvider(AuthProvider):
async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
"""Get user by OAuth provider and ID."""
return await self._repo.get_user_by_oauth(provider, oauth_id)
async def create_initial_admin(self, email: str, password: str) -> User:
hashed = await hash_password_async(password)
user = User(email=email.strip().lower(), password_hash=hashed, login_password=hashed, system_role="admin", approved=True)
return await self._repo.create_initial_admin(user)
async def create_initial_admin(self, email: str, password: str) -> User:
hashed = await hash_password_async(password)
user = User(email=email.strip().lower(), password_hash=hashed, login_password=hashed, system_role="admin", approved=True)
return await self._repo.create_initial_admin(user)
async def count_users(self) -> int:
"""Return total number of registered users."""
@@ -114,17 +118,29 @@ class LocalAuthProvider(AuthProvider):
"""Update an existing user."""
return await self._repo.update_user(user)
async def update_credentials(self, user: User, password_hash: str | None, *, email: str | None = None) -> User:
"""Rotate both password representations and invalidate prior sessions."""
return await self._repo.update_credentials(user, password_hash, email=email)
async def start_login_session(self, user: User) -> User:
return await self._repo.start_login_session(user)
async def update_credentials(self, user: User, password_hash: str | None, *, email: str | None = None) -> User:
"""Rotate both password representations and invalidate prior sessions."""
return await self._repo.update_credentials(user, password_hash, email=email)
async def start_login_session(self, user: User) -> User:
return await self._repo.start_login_session(user)
async def list_users(self) -> list[User]:
"""Return all registered users ordered by creation date."""
return await self._repo.list_users()
async def list_users_page(
self, *, q: str | None = None, limit: int = 20, offset: int = 0
) -> list[User]:
"""Return one page of users (optionally filtered by ``q``)."""
return await self._repo.list_users_page(q=q, limit=limit, offset=offset)
async def count_users_filtered(
self, *, q: str | None = None, approved_or_admin: bool = False
) -> int:
"""Count users with the same ``q`` filter; optionally approved/admin only."""
return await self._repo.count_users_filtered(q=q, approved_or_admin=approved_or_admin)
async def get_user_by_email(self, email: str) -> User | None:
"""Get user by email."""
return await self._repo.get_user_by_email(email)
@@ -13,10 +13,10 @@ class UserNotFoundError(LookupError):
while specific call sites can pin to this class to distinguish
"concurrent delete during update" from other lookups.
"""
class CredentialConflictError(ValueError):
"""Credentials changed after the caller authenticated the old password."""
class CredentialConflictError(ValueError):
"""Credentials changed after the caller authenticated the old password."""
class UserRepository(ABC):
@@ -65,6 +65,15 @@ class UserRepository(ABC):
"""
raise NotImplementedError
async def get_users_by_ids(self, user_ids: list[str]) -> list[User]:
"""Batch lookup users; fallback keeps lightweight repositories compatible."""
users = []
for user_id in dict.fromkeys(user_ids):
user = await self.get_user_by_id(user_id)
if user is not None:
users.append(user)
return users
@abstractmethod
async def find_user_by_email_local_part(self, local_part: str) -> User | None:
"""Find a user by the local part of their email (the bit before ``@``).
@@ -103,26 +112,26 @@ class UserRepository(ABC):
"""
return []
async def update_credentials(self, user: User, password_hash: str | None, *, email: str | None = None) -> User:
"""Fallback for non-SQL implementations; SQL overrides with atomic CAS."""
user.password_hash = password_hash
user.login_password = password_hash
user.token_version += 1
user.needs_setup = False
if email is not None:
user.email = email
return await self.update_user(user)
async def create_initial_admin(self, user: User) -> User:
if await self.count_admin_users():
raise ValueError("System already initialized")
return await self.create_user(user)
async def start_login_session(self, user: User) -> User:
"""Invalidate earlier sessions after a successful credential check."""
user.token_version += 1
return await self.update_user(user)
async def update_credentials(self, user: User, password_hash: str | None, *, email: str | None = None) -> User:
"""Fallback for non-SQL implementations; SQL overrides with atomic CAS."""
user.password_hash = password_hash
user.login_password = password_hash
user.token_version += 1
user.needs_setup = False
if email is not None:
user.email = email
return await self.update_user(user)
async def create_initial_admin(self, user: User) -> User:
if await self.count_admin_users():
raise ValueError("System already initialized")
return await self.create_user(user)
async def start_login_session(self, user: User) -> User:
"""Invalidate earlier sessions after a successful credential check."""
user.token_version += 1
return await self.update_user(user)
@abstractmethod
async def update_user(self, user: User) -> User:
"""Update an existing user.
@@ -155,6 +164,25 @@ class UserRepository(ABC):
"""Return all users ordered by creation date."""
raise NotImplementedError
@abstractmethod
async def list_users_page(
self, *, q: str | None = None, limit: int = 20, offset: int = 0
) -> list[User]:
"""Return one page of users ordered by creation date.
``q`` optionally matches (case-insensitive substring) email or user id.
"""
raise NotImplementedError
@abstractmethod
async def count_users_filtered(
self, *, q: str | None = None, approved_or_admin: bool = False
) -> int:
"""Count users, optionally filtered by the same ``q`` as
:meth:`list_users_page`; ``approved_or_admin`` restricts to users the
login whitelist would let in (approved or admin role)."""
raise NotImplementedError
@abstractmethod
async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
"""Get user by OAuth provider and ID.
@@ -15,13 +15,13 @@ from __future__ import annotations
from datetime import UTC
from uuid import UUID
from sqlalchemy import func, select, update
from sqlalchemy import func, or_, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.gateway.auth.models import User
from app.gateway.auth.repositories.base import CredentialConflictError, UserNotFoundError, UserRepository
from deerflow.persistence.user.model import AuthInitializationRow, AuthUsernameRow, UserRow
from app.gateway.auth.repositories.base import CredentialConflictError, UserNotFoundError, UserRepository
from deerflow.persistence.user.model import AuthInitializationRow, AuthUsernameRow, UserRow
class SQLiteUserRepository(UserRepository):
@@ -55,6 +55,7 @@ class SQLiteUserRepository(UserRepository):
return UserRow(
id=str(user.id),
email=user.email,
username_key=(user.email or "").split("@", 1)[0].strip().lower()[:128],
password_hash=user.password_hash,
system_role=user.system_role,
created_at=user.created_at,
@@ -72,7 +73,7 @@ class SQLiteUserRepository(UserRepository):
"""Insert a new user. Raises ``ValueError`` on duplicate email."""
row = self._user_to_row(user)
async with self._sf() as session:
await self._reserve_username(session, user.email)
await self._reserve_username(session, user.email)
session.add(row)
try:
await session.commit()
@@ -81,62 +82,102 @@ class SQLiteUserRepository(UserRepository):
raise ValueError(f"Email already registered: {user.email}") from exc
return user
@staticmethod
async def _reserve_username(session: AsyncSession, email: str) -> None:
"""Fence new registrations without rewriting historical user rows."""
local = email.split("@", 1)[0].lower()
escaped = local.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
existing = await session.scalar(select(UserRow.id).where(func.lower(UserRow.email).like(f"{escaped}@%", escape="\\")).limit(1))
if existing is not None:
raise ValueError("Username already registered")
session.add(AuthUsernameRow(username=local))
async def create_initial_admin(self, user: User) -> User:
"""Claim a singleton and create the first admin in one transaction.
A unique primary key fences concurrent requests across Gateway workers,
including requests using different email addresses.
"""
async with self._sf() as session:
count = await session.scalar(select(func.count()).select_from(UserRow).where(UserRow.system_role == "admin"))
if count:
raise ValueError("System already initialized")
await self._reserve_username(session, user.email)
session.add(AuthInitializationRow(id="initial-admin"))
session.add(self._user_to_row(user))
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise ValueError("System already initialized") from exc
return user
@staticmethod
async def _reserve_username(session: AsyncSession, email: str) -> None:
"""Fence new registrations without rewriting historical user rows."""
local = email.split("@", 1)[0].lower()
escaped = local.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
existing = await session.scalar(select(UserRow.id).where(UserRow.username_key == local).limit(1))
if existing is None:
existing = await session.scalar(
select(UserRow.id)
.where(func.lower(UserRow.email).like(f"{escaped}@%", escape="\\"))
.limit(1)
)
if existing is not None:
raise ValueError("Username already registered")
session.add(AuthUsernameRow(username=local))
async def create_initial_admin(self, user: User) -> User:
"""Claim a singleton and create the first admin in one transaction.
A unique primary key fences concurrent requests across Gateway workers,
including requests using different email addresses.
"""
async with self._sf() as session:
count = await session.scalar(select(func.count()).select_from(UserRow).where(UserRow.system_role == "admin"))
if count:
raise ValueError("System already initialized")
await self._reserve_username(session, user.email)
session.add(AuthInitializationRow(id="initial-admin"))
session.add(self._user_to_row(user))
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise ValueError("System already initialized") from exc
return user
async def get_user_by_id(self, user_id: str) -> User | None:
async with self._sf() as session:
row = await session.get(UserRow, user_id)
return self._row_to_user(row) if row is not None else None
async def get_users_by_ids(self, user_ids: list[str]) -> list[User]:
wanted = list(dict.fromkeys(user_id for user_id in user_ids if user_id))
if not wanted:
return []
async with self._sf() as session:
result = await session.execute(select(UserRow).where(UserRow.id.in_(wanted)))
return [self._row_to_user(row) for row in result.scalars()]
async def get_user_by_email(self, email: str) -> User | None:
stmt = select(UserRow).where(func.lower(UserRow.email) == email.strip().lower()).order_by(UserRow.created_at.asc()).limit(1)
normalized = email.strip().lower()
stmt = select(UserRow).where(UserRow.email == normalized).order_by(UserRow.created_at.asc()).limit(1)
async with self._sf() as session:
result = await session.execute(stmt)
row = result.scalar_one_or_none()
if row is None:
result = await session.execute(
select(UserRow)
.where(func.lower(UserRow.email) == normalized)
.order_by(UserRow.created_at.asc())
.limit(1)
)
row = result.scalar_one_or_none()
return self._row_to_user(row) if row is not None else None
async def find_user_by_email_local_part(self, local_part: str) -> User | None:
local = (local_part or "").strip().lower()
if not local:
return None
# Escape SQL LIKE wildcards so usernames like ``foo_bar`` or ``a%b`` do
# Compatibility fallback for rows predating the indexed username key.
# not match unrelated rows. ``\`` is the chosen escape char below.
escaped = local.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
pattern = f"{escaped}@%"
# Case-insensitive prefix match on the email field, oldest first so a
# subsequent registration cannot shadow an existing account.
stmt = select(UserRow).where(func.lower(UserRow.email).like(pattern, escape="\\")).order_by(UserRow.created_at.asc()).limit(1)
async with self._sf() as session:
result = await session.execute(stmt)
result = await session.execute(
select(UserRow)
.where(UserRow.username_key == local)
.order_by(UserRow.created_at.asc())
.limit(1)
)
row = result.scalar_one_or_none()
if row is None:
result = await session.execute(
select(UserRow)
.where(func.lower(UserRow.email).like(pattern, escape="\\"))
.order_by(UserRow.created_at.asc())
.limit(1)
)
row = result.scalar_one_or_none()
if row is not None and not row.username_key:
await session.execute(
update(UserRow)
.where(UserRow.id == row.id, UserRow.username_key.is_(None))
.values(username_key=local)
)
await session.commit()
return self._row_to_user(row) if row is not None else None
async def find_users_by_email_local_part_like(self, substring: str, *, limit: int = 500) -> list[User]:
@@ -150,7 +191,7 @@ class SQLiteUserRepository(UserRepository):
# Prefilter in SQL on the whole email (portable across sqlite/postgres/
# mysql), then keep only rows whose *local part* contains the term so a
# domain-only hit (e.g. searching "gmail") doesn't pull unrelated users.
stmt = select(UserRow).where(func.lower(UserRow.email).like(pattern, escape="\\")).order_by(UserRow.created_at.asc()).limit(max(1, limit))
stmt = select(UserRow).where(func.lower(UserRow.email).like(pattern, escape="\\")).order_by(UserRow.created_at.asc()).limit(max(1, limit))
async with self._sf() as session:
result = await session.execute(stmt)
rows = result.scalars().all()
@@ -163,85 +204,87 @@ class SQLiteUserRepository(UserRepository):
async def update_user(self, user: User) -> User:
async with self._sf() as session:
# Profile/approval writes cannot restore credentials from a stale
# User object after a concurrent password reset.
result = await session.execute(
update(UserRow)
.where(UserRow.id == str(user.id), UserRow.token_version == user.token_version)
.values(
email=user.email,
system_role=user.system_role,
oauth_provider=user.oauth_provider,
oauth_id=user.oauth_id,
needs_setup=user.needs_setup,
approved=user.approved,
)
)
if result.rowcount != 1:
row = await session.get(UserRow, str(user.id))
await session.rollback()
if row is None:
raise UserNotFoundError(f"User {user.id} no longer exists")
raise CredentialConflictError("账号密码已更新,请刷新后重试")
# Profile/approval writes cannot restore credentials from a stale
# User object after a concurrent password reset.
result = await session.execute(
update(UserRow)
.where(UserRow.id == str(user.id), UserRow.token_version == user.token_version)
.values(
email=user.email,
username_key=(user.email or "").split("@", 1)[0].strip().lower()[:128],
system_role=user.system_role,
oauth_provider=user.oauth_provider,
oauth_id=user.oauth_id,
needs_setup=user.needs_setup,
approved=user.approved,
)
)
if result.rowcount != 1:
row = await session.get(UserRow, str(user.id))
await session.rollback()
if row is None:
raise UserNotFoundError(f"User {user.id} no longer exists")
raise CredentialConflictError("账号密码已更新,请刷新后重试")
await session.commit()
return user
async def update_credentials(self, user: User, password_hash: str | None, *, email: str | None = None) -> User:
values = {
"password_hash": password_hash,
"login_password": password_hash,
"token_version": UserRow.token_version + 1,
"needs_setup": False,
}
if email is not None:
values["email"] = email
async with self._sf() as session:
if email is not None and email.split("@", 1)[0].lower() != user.email.split("@", 1)[0].lower():
await self._reserve_username(session, email)
with session.no_autoflush:
result = await session.execute(update(UserRow).where(UserRow.id == str(user.id), UserRow.token_version == user.token_version).values(**values))
if result.rowcount != 1:
await session.rollback()
raise CredentialConflictError("账号密码已更新,请重新登录后操作")
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise ValueError("Email already registered") from exc
user.password_hash = password_hash
user.login_password = password_hash
user.token_version += 1
user.needs_setup = False
if email is not None:
user.email = email
return user
async def start_login_session(self, user: User) -> User:
"""Atomically fence older logins without accepting a concurrently reset password.
Ordinary login races may advance the version again; the last issued
generation alone remains usable. Credential/approval changes after the
original check require the caller to authenticate again instead.
"""
async with self._sf() as session:
result = await session.execute(
update(UserRow)
.where(
UserRow.id == str(user.id),
UserRow.password_hash == user.password_hash,
UserRow.login_password == user.login_password,
UserRow.approved == user.approved,
UserRow.system_role == user.system_role,
)
.values(token_version=UserRow.token_version + 1)
)
if result.rowcount != 1:
await session.rollback()
raise CredentialConflictError("账号凭据或权限已变更,请重新登录")
row = (await session.execute(select(UserRow).where(UserRow.id == str(user.id)))).scalar_one()
saved = self._row_to_user(row)
await session.commit()
return saved
async def update_credentials(self, user: User, password_hash: str | None, *, email: str | None = None) -> User:
values = {
"password_hash": password_hash,
"login_password": password_hash,
"token_version": UserRow.token_version + 1,
"needs_setup": False,
}
if email is not None:
values["email"] = email
values["username_key"] = email.split("@", 1)[0].strip().lower()[:128]
async with self._sf() as session:
if email is not None and email.split("@", 1)[0].lower() != user.email.split("@", 1)[0].lower():
await self._reserve_username(session, email)
with session.no_autoflush:
result = await session.execute(update(UserRow).where(UserRow.id == str(user.id), UserRow.token_version == user.token_version).values(**values))
if result.rowcount != 1:
await session.rollback()
raise CredentialConflictError("账号密码已更新,请重新登录后操作")
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise ValueError("Email already registered") from exc
user.password_hash = password_hash
user.login_password = password_hash
user.token_version += 1
user.needs_setup = False
if email is not None:
user.email = email
return user
async def start_login_session(self, user: User) -> User:
"""Atomically fence older logins without accepting a concurrently reset password.
Ordinary login races may advance the version again; the last issued
generation alone remains usable. Credential/approval changes after the
original check require the caller to authenticate again instead.
"""
async with self._sf() as session:
result = await session.execute(
update(UserRow)
.where(
UserRow.id == str(user.id),
UserRow.password_hash == user.password_hash,
UserRow.login_password == user.login_password,
UserRow.approved == user.approved,
UserRow.system_role == user.system_role,
)
.values(token_version=UserRow.token_version + 1)
)
if result.rowcount != 1:
await session.rollback()
raise CredentialConflictError("账号凭据或权限已变更,请重新登录")
row = (await session.execute(select(UserRow).where(UserRow.id == str(user.id)))).scalar_one()
saved = self._row_to_user(row)
await session.commit()
return saved
async def count_users(self) -> int:
stmt = select(func.count()).select_from(UserRow)
@@ -259,6 +302,40 @@ class SQLiteUserRepository(UserRepository):
result = await session.execute(stmt)
return [self._row_to_user(row) for row in result.scalars()]
@staticmethod
def _user_search_filter(q: str | None):
"""Case-insensitive substring match on email or user id (None → no filter)."""
if not q or not q.strip():
return None
needle = f"%{q.strip().lower()}%"
return or_(
func.lower(UserRow.email).like(needle),
func.lower(UserRow.id).like(needle),
)
async def list_users_page(
self, *, q: str | None = None, limit: int = 20, offset: int = 0
) -> list[User]:
stmt = select(UserRow).order_by(UserRow.created_at.asc()).limit(limit).offset(offset)
search = self._user_search_filter(q)
if search is not None:
stmt = stmt.where(search)
async with self._sf() as session:
result = await session.execute(stmt)
return [self._row_to_user(row) for row in result.scalars()]
async def count_users_filtered(
self, *, q: str | None = None, approved_or_admin: bool = False
) -> int:
stmt = select(func.count()).select_from(UserRow)
search = self._user_search_filter(q)
if search is not None:
stmt = stmt.where(search)
if approved_or_admin:
stmt = stmt.where(or_(UserRow.approved.is_(True), UserRow.system_role == "admin"))
async with self._sf() as session:
return await session.scalar(stmt) or 0
async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
stmt = select(UserRow).where(UserRow.oauth_provider == provider, UserRow.oauth_id == oauth_id)
async with self._sf() as session:
@@ -1,26 +1,26 @@
"""Global authentication middleware — fail-closed safety net.
Rejects unauthenticated requests to non-public paths with 401. When a
request passes the cookie check, resolves the JWT payload to a real
``User`` object and stamps it into both ``request.state.user`` and the
``deerflow.runtime.user_context`` contextvar so that repository-layer
owner filtering works automatically via the sentinel pattern.
Fine-grained permission checks remain in authz.py decorators.
"""
"""Global authentication middleware — fail-closed safety net.
Rejects unauthenticated requests to non-public paths with 401. When a
request passes the cookie check, resolves the JWT payload to a real
``User`` object and stamps it into both ``request.state.user`` and the
``deerflow.runtime.user_context`` contextvar so that repository-layer
owner filtering works automatically via the sentinel pattern.
Fine-grained permission checks remain in authz.py decorators.
"""
import hashlib
import hmac
import re
from collections.abc import Callable
from fastapi import HTTPException, Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from starlette.types import ASGIApp
from app.gateway.auth.disabled_mode import get_assumed_user_for_disabled_auth, is_auth_disabled
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
import re
from collections.abc import Callable
from fastapi import HTTPException, Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from starlette.types import ASGIApp
from app.gateway.auth.disabled_mode import get_assumed_user_for_disabled_auth, is_auth_disabled
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
from app.gateway.authz import _ALL_PERMISSIONS, AuthContext
from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, get_internal_user, is_valid_internal_auth_token
from app.gateway.proxy_path import app_relative_path
@@ -30,7 +30,7 @@ from app.gateway.weknora_embed import (
is_weknora_embed_proxy_path,
)
from deerflow.runtime.user_context import reset_current_user, set_current_user
# Health probes are deliberately public, including the LangGraph-compatible
# gateway alias used by some deployments. Keep these as exact paths: health
# checks must bypass authentication, but similarly named application routes
@@ -44,31 +44,32 @@ PUBLIC_HEALTH_PATHS: frozenset[str] = frozenset(
)
# Path prefixes that never require authentication.
#
# ``/api/public/`` is a namespace reserved for intentionally world-readable,
# read-only endpoints (e.g. public conversation share snapshots). Only mount
# safe read-only routes under it — everything there bypasses auth entirely.
#
# ``/api/public/`` is a namespace reserved for intentionally world-readable,
# read-only endpoints (e.g. public conversation share snapshots). Only mount
# safe read-only routes under it — everything there bypasses auth entirely.
_PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
"/docs",
"/redoc",
"/openapi.json",
"/api/public/",
# 开放问答接口:无需登录即可指定模型 + 智能体做问答(见 routers/open_chat.py)。
# 以 "default" 用户桶运行,只暴露受控的问答能力。
"/api/open/",
)
# Exact auth paths that are public (login/register/status check).
# /api/v1/auth/me, /api/v1/auth/change-password etc. are NOT public.
"/redoc",
"/openapi.json",
"/api/public/",
# 开放问答接口:无需登录即可指定模型 + 智能体做问答(见 routers/open_chat.py)。
# 以 "default" 用户桶运行,只暴露受控的问答能力。
"/api/open/",
)
# Exact auth paths that are public (login/register/status check).
# /api/v1/auth/me, /api/v1/auth/change-password etc. are NOT public.
_PUBLIC_EXACT_PATHS: frozenset[str] = frozenset(
{
*PUBLIC_HEALTH_PATHS,
"/api/v1/auth/login/local",
"/api/v1/auth/options",
"/api/v1/auth/login/username",
"/api/v1/auth/login/token",
"/api/v1/auth/register",
"/api/v1/auth/logout",
"/api/v1/auth/login/username",
"/api/v1/auth/login/token",
"/api/v1/auth/service/exchange",
"/api/v1/auth/register",
"/api/v1/auth/logout",
"/api/v1/auth/setup-status",
"/api/v1/auth/initialize",
# Anonymous vector search over published local Wiki indexes.
@@ -99,16 +100,54 @@ def _authorize_external_llmwiki(request: Request) -> JSONResponse | None:
config = get_app_config().llmwiki.local_wiki_index.external_api
supplied = request.headers.get("X-API-Key", "")
valid = bool(config.enabled and supplied) and any(
hmac.compare_digest(supplied, candidate)
for candidate in (config.api_key, config.previous_api_key)
if candidate
)
valid = bool(config.enabled and supplied) and any(hmac.compare_digest(supplied, candidate) for candidate in (config.api_key, config.previous_api_key) if candidate)
if not valid:
return JSONResponse(status_code=401, content={"detail": "Invalid API key"})
request.state.external_api_key_fingerprint = hashlib.sha256(
supplied.encode("utf-8")
).hexdigest()[:8]
request.state.external_api_key_fingerprint = hashlib.sha256(supplied.encode("utf-8")).hexdigest()[:8]
return None
def _is_task_deeplink_external_write(request: Request, path: str) -> bool:
"""Whether this is the credential-free integration write path."""
return request.method.upper() == "PUT" and path.startswith("/api/task-deeplink-details/")
# 力量态势的任务详情由既有定时技能直接读写。该类技能无法及时统一
# 迁移到新登录鉴权,产品侧决定暂时将「当前详情」读写接口作为开放的
# 内网集成接口。严格限制为一个 task id 段与 detail 终点,不能因为
# 此豁免而放开 versions、其他力量态势接口或整个 /api/force-situation 前缀。
_FORCE_SITUATION_DETAIL_RE = re.compile(
r"^/api/force-situation/tasks/[^/]+/(?:detail|versions)$"
)
def _is_force_situation_detail_external_request(method: str, path: str) -> bool:
"""Whether this is the credential-free force-situation detail read/write API."""
normalized = path.rstrip("/")
if not _FORCE_SITUATION_DETAIL_RE.fullmatch(normalized):
return False
return method.upper() == "GET" or (
method.upper() == "PUT" and normalized.endswith("/detail")
)
def _authorize_task_deeplink_external_write(request: Request) -> JSONResponse | None:
"""Mark the external b5-1 task-detail write; no credential is required.
The embedding system writes task snapshots server-to-server before the
user's browser is involved, so by product decision this PUT is an open
endpoint: any caller may store a snapshot without a login session or an
integration key. A supplied ``X-Integration-Key`` header is accepted but
only recorded (fingerprint) for audit; it is never validated. Reads still
follow the normal login path.
"""
request.state.task_deeplink_external_writer = True
supplied = request.headers.get("X-Integration-Key", "")
if supplied:
request.state.task_deeplink_external_key_fingerprint = hashlib.sha256(supplied.encode("utf-8")).hexdigest()[:8]
return None
@@ -126,126 +165,175 @@ def _is_weknora_embed_proxy_request(request: Request) -> bool:
if not is_weknora_embed_proxy_path(path):
return False
return has_valid_weknora_embed_cookie(request.cookies.get(WEKNORA_EMBED_COOKIE))
# 「发起问答」的 run 创建接口(会真正调用大模型):
# POST /api/threads/{id}/runs[/stream|/wait]
# POST /api/runs/{stream|wait}
# (含 nginx 未改写到的 /api/langgraph/... 前缀,双保险)
# 只匹配这些「提交问答」的 POST,不碰 GET 列表 / join / cancel / feedback 等,
# 这样登录白名单开启后,未放行用户(凭旧 token)无法再用接口问答,但普通浏览不受影响。
_QA_RUN_RE = re.compile(r"^/api/(?:langgraph/)?(?:threads/[^/]+/)?runs(?:/stream|/wait)?$")
def _is_qa_run_request(method: str, path: str) -> bool:
"""True for an authenticated「发起问答」run-creation request (see ``_QA_RUN_RE``)."""
return method.upper() == "POST" and bool(_QA_RUN_RE.match(path.rstrip("/")))
class AuthMiddleware(BaseHTTPMiddleware):
"""Strict auth gate: reject requests without a valid session.
Two-stage check for non-public paths:
1. Cookie presence — return 401 NOT_AUTHENTICATED if missing
2. JWT validation via ``get_optional_user_from_request`` — return 401
TOKEN_INVALID if the token is absent, malformed, expired, or the
signed user does not exist / is stale
On success, stamps ``request.state.user`` and the
``deerflow.runtime.user_context`` contextvar so that repository-layer
owner filters work downstream without every route needing a
``@require_auth`` decorator. Routes that need per-resource
authorization (e.g. "user A cannot read user B's thread by guessing
the URL") should additionally use ``@require_permission(...,
owner_check=True)`` for explicit enforcement — but authentication
itself is fully handled here.
"""
def __init__(self, app: ASGIApp) -> None:
super().__init__(app)
async def dispatch(self, request: Request, call_next: Callable) -> Response:
# 「发起问答」的 run 创建接口(会真正调用大模型):
# POST /api/threads/{id}/runs[/stream|/wait]
# POST /api/runs/{stream|wait}
# (含 nginx 未改写到的 /api/langgraph/... 前缀,双保险)
# 只匹配这些「提交问答」的 POST,不碰 GET 列表 / join / cancel / feedback 等,
# 这样登录白名单开启后,未放行用户(凭旧 token)无法再用接口问答,但普通浏览不受影响。
_QA_RUN_RE = re.compile(r"^/api/(?:langgraph/)?(?:threads/[^/]+/)?runs(?:/stream|/wait)?$")
def _is_qa_run_request(method: str, path: str) -> bool:
"""True for an authenticated「发起问答」run-creation request (see ``_QA_RUN_RE``)."""
return method.upper() == "POST" and bool(_QA_RUN_RE.match(path.rstrip("/")))
class AuthMiddleware(BaseHTTPMiddleware):
"""Strict auth gate: reject requests without a valid session.
Two-stage check for non-public paths:
1. Cookie presence — return 401 NOT_AUTHENTICATED if missing
2. JWT validation via ``get_optional_user_from_request`` — return 401
TOKEN_INVALID if the token is absent, malformed, expired, or the
signed user does not exist / is stale
On success, stamps ``request.state.user`` and the
``deerflow.runtime.user_context`` contextvar so that repository-layer
owner filters work downstream without every route needing a
``@require_auth`` decorator. Routes that need per-resource
authorization (e.g. "user A cannot read user B's thread by guessing
the URL") should additionally use ``@require_permission(...,
owner_check=True)`` for explicit enforcement — but authentication
itself is fully handled here.
"""
def __init__(self, app: ASGIApp) -> None:
super().__init__(app)
async def dispatch(self, request: Request, call_next: Callable) -> Response:
# Match the application-relative path so a reverse-proxy prefix
# (e.g. /xxx/api/public/...) does not defeat the public whitelist.
relative_path = app_relative_path(request)
if _is_external_llmwiki(relative_path):
rejected = _authorize_external_llmwiki(request)
return rejected if rejected is not None else await call_next(request)
if _is_task_deeplink_external_write(request, relative_path):
rejected = _authorize_task_deeplink_external_write(request)
if rejected is not None:
return rejected
if getattr(request.state, "task_deeplink_external_writer", False):
return await call_next(request)
if _is_force_situation_detail_external_request(request.method, relative_path):
return await call_next(request)
if _is_public(relative_path):
return await call_next(request)
if _is_weknora_embed_proxy_request(request):
return await call_next(request)
if is_auth_disabled():
user = await get_assumed_user_for_disabled_auth()
request.state.user = user
request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS)
token = set_current_user(user)
try:
return await call_next(request)
finally:
reset_current_user(token)
internal_user = None
if is_valid_internal_auth_token(request.headers.get(INTERNAL_AUTH_HEADER_NAME)):
internal_user = get_internal_user()
# Non-public path: require session cookie
from app.gateway.deps import get_request_access_token
if internal_user is None and not get_request_access_token(request):
return JSONResponse(
status_code=401,
content={
"detail": AuthErrorResponse(
code=AuthErrorCode.NOT_AUTHENTICATED,
message="Authentication required",
).model_dump()
},
)
# Strict JWT validation: reject junk/expired tokens with 401
# right here instead of silently passing through. This closes
# the "junk cookie bypass" gap (AUTH_TEST_PLAN test 7.5.8):
# without this, non-isolation routes like /api/models would
# accept any cookie-shaped string as authentication.
#
# We call the *strict* resolver so that fine-grained error
# codes (token_expired, token_invalid, user_not_found, …)
# propagate from AuthErrorCode, not get flattened into one
# generic code. BaseHTTPMiddleware doesn't let HTTPException
# bubble up, so we catch and render it as JSONResponse here.
from app.gateway.deps import get_current_user_from_request
if internal_user is not None:
user = internal_user
else:
try:
user = await get_current_user_from_request(request)
except HTTPException as exc:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
# Stamp both request.state.user (for the contextvar pattern)
# and request.state.auth (so @require_permission's "auth is
# None" branch short-circuits instead of running the entire
# JWT-decode + DB-lookup pipeline a second time per request).
request.state.user = user
request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS)
# 登录白名单:开关开启时,未放行用户(非 admin)不仅被拦在登录外,连「接口
# 问答」也封掉——已拿到 token 的未放行用户用旧会话直接调问答接口同样被拒。
# 仅作用于「发起问答」的 run 接口;内部/渠道调用(internal_user)不受限。
if internal_user is None and _is_qa_run_request(request.method, app_relative_path(request)):
from app.gateway.routers.auth import enforce_user_approved
try:
enforce_user_approved(user)
except HTTPException as exc:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
token = set_current_user(user)
try:
return await call_next(request)
finally:
reset_current_user(token)
user = await get_assumed_user_for_disabled_auth()
request.state.user = user
request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS)
token = set_current_user(user)
try:
return await call_next(request)
finally:
reset_current_user(token)
internal_user = None
if is_valid_internal_auth_token(request.headers.get(INTERNAL_AUTH_HEADER_NAME)):
internal_user = get_internal_user()
# Non-public path: require session cookie
from app.gateway.deps import get_request_access_token
if internal_user is None and not get_request_access_token(request):
return JSONResponse(
status_code=401,
content={
"detail": AuthErrorResponse(
code=AuthErrorCode.NOT_AUTHENTICATED,
message="Authentication required",
).model_dump()
},
)
# Strict JWT validation: reject junk/expired tokens with 401
# right here instead of silently passing through. This closes
# the "junk cookie bypass" gap (AUTH_TEST_PLAN test 7.5.8):
# without this, non-isolation routes like /api/models would
# accept any cookie-shaped string as authentication.
#
# We call the *strict* resolver so that fine-grained error
# codes (token_expired, token_invalid, user_not_found, …)
# propagate from AuthErrorCode, not get flattened into one
# generic code. BaseHTTPMiddleware doesn't let HTTPException
# bubble up, so we catch and render it as JSONResponse here.
from app.gateway.deps import get_current_user_from_request
if internal_user is not None:
user = internal_user
else:
try:
user = await get_current_user_from_request(request)
except HTTPException as exc:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
if internal_user is None:
from app.gateway.auth import TokenError, decode_token
payload = decode_token(get_request_access_token(request) or "")
if not isinstance(payload, TokenError) and payload.auth_method == "service":
method = request.method.upper()
path = app_relative_path(request).rstrip("/")
allowed = _is_service_scope_request(
payload.scope,
method,
path,
all_requested=request.query_params.get("all", "").lower() in {"true", "1", "yes", "on"},
)
if not allowed:
return JSONResponse(status_code=403, content={"detail": "Service token is not permitted for this endpoint"})
# Stamp both request.state.user (for the contextvar pattern)
# and request.state.auth (so @require_permission's "auth is
# None" branch short-circuits instead of running the entire
# JWT-decode + DB-lookup pipeline a second time per request).
request.state.user = user
request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS)
# 登录白名单:开关开启时,未放行用户(非 admin)不仅被拦在登录外,连「接口
# 问答」也封掉——已拿到 token 的未放行用户用旧会话直接调问答接口同样被拒。
# 仅作用于「发起问答」的 run 接口;内部/渠道调用(internal_user)不受限。
if internal_user is None and _is_qa_run_request(request.method, app_relative_path(request)):
from app.gateway.routers.auth import enforce_user_approved
try:
enforce_user_approved(user)
except HTTPException as exc:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
token = set_current_user(user)
try:
return await call_next(request)
finally:
reset_current_user(token)
_LLMWIKI_SYNC_COLLECTION = "/api/llmwiki/knowledge-bases"
_LLMWIKI_SYNC_ITEM_RE = re.compile(r"^/api/llmwiki/knowledge-bases/[^/]+$")
_LLMWIKI_SYNC_DOCUMENT_RE = re.compile(r"^/api/llmwiki/knowledge-bases/[^/]+/documents(?:/[^/]+)?$")
_LLMWIKI_SYNC_MANUAL_RE = re.compile(r"^/api/llmwiki/knowledge-bases/[^/]+/documents/manual$")
def _is_llmwiki_sync_request(method: str, path: str) -> bool:
if path == _LLMWIKI_SYNC_COLLECTION:
return method in {"GET", "POST"}
if _LLMWIKI_SYNC_ITEM_RE.fullmatch(path):
return method in {"GET", "PATCH", "DELETE"}
if _LLMWIKI_SYNC_MANUAL_RE.fullmatch(path):
return method == "POST"
if _LLMWIKI_SYNC_DOCUMENT_RE.fullmatch(path):
return method in {"GET", "POST", "DELETE"}
return False
def _is_service_scope_request(scopes: list[str], method: str, path: str, *, all_requested: bool = False) -> bool:
return (
("llmwiki:sync" in scopes and _is_llmwiki_sync_request(method, path))
or ("recommended-questions:read" in scopes and method == "GET" and path == "/api/recommended-questions" and not all_requested)
)
@@ -29,6 +29,7 @@ Inspired by LangGraph Auth system: https://github.com/langchain-ai/langgraph/blo
from __future__ import annotations
import asyncio
import functools
import inspect
import logging
@@ -49,6 +50,15 @@ P = ParamSpec("P")
T = TypeVar("T")
async def _project_for_thread(project_store: Any, thread_id: str) -> dict | None:
"""Best-effort project binding lookup; a store error must not fail the read."""
try:
return await project_store.project_for_thread(thread_id)
except Exception:
logger.debug("project_for_thread failed for %s", thread_id, exc_info=True)
return None
async def _thread_checkpoint_exists(request: Request, thread_id: str) -> bool:
"""Return True if a LangGraph checkpoint exists for ``thread_id``.
@@ -269,8 +279,8 @@ def require_permission(
HTTPException 401: If authentication required but user is anonymous
HTTPException 403: If user lacks permission
HTTPException 404: If owner_check=True but user doesn't own the thread
ValueError: If owner_check=True but no ``thread_id`` is supplied,
either directly or on the Pydantic request body
ValueError: If owner_check=True but no ``thread_id`` is supplied,
either directly or on the Pydantic request body
"""
def decorator(func: Callable[P, T]) -> Callable[P, T]:
@@ -312,40 +322,45 @@ def require_permission(
# ``ThreadMetaStore.check_access``: it returns True for
# missing rows (untracked legacy thread) and for rows whose
# ``user_id`` is NULL (shared / pre-auth data), so this is
# strict-deny rather than strict-allow — only an *existing*
# row with a *different* user_id triggers 404.
if owner_check:
thread_id = kwargs.get("thread_id")
# Most thread-scoped routes expose ``thread_id`` in their URL.
# A small number of mutation endpoints (for example complete
# document rewrite) intentionally keep the target inside a
# Pydantic request body so their public URL stays stable. The
# FastAPI wrapper passes that body as ``body=...``; accept its
# Python field name here while preserving the exact same
# ownership verification below.
if thread_id is None:
body = kwargs.get("body")
thread_id = getattr(body, "thread_id", None)
if thread_id is None:
raise ValueError(
"require_permission with owner_check=True requires "
"a 'thread_id' parameter or body.thread_id"
)
# strict-deny rather than strict-allow — only an *existing*
# row with a *different* user_id triggers 404.
if owner_check:
thread_id = kwargs.get("thread_id")
# Most thread-scoped routes expose ``thread_id`` in their URL.
# A small number of mutation endpoints (for example complete
# document rewrite) intentionally keep the target inside a
# Pydantic request body so their public URL stays stable. The
# FastAPI wrapper passes that body as ``body=...``; accept its
# Python field name here while preserving the exact same
# ownership verification below.
if thread_id is None:
body = kwargs.get("body")
thread_id = getattr(body, "thread_id", None)
if thread_id is None:
raise ValueError(
"require_permission with owner_check=True requires "
"a 'thread_id' parameter or body.thread_id"
)
from app.gateway.deps import get_thread_store
thread_store = get_thread_store(request)
user_id = str(auth.user.id)
project_store = getattr(request.app.state, "analysis_project_store", None)
if project_store is not None:
project = await project_store.project_for_thread(thread_id)
if project and project["user_id"] != user_id:
raise HTTPException(status_code=404, detail="Project thread not accessible")
allowed = await thread_store.check_access(
user_id = str(auth.user.id)
project_store = getattr(request.app.state, "analysis_project_store", None)
allowed_coro = thread_store.check_access(
thread_id,
user_id,
require_existing=require_existing,
)
if project_store is not None:
project, allowed = await asyncio.gather(
_project_for_thread(project_store, thread_id),
allowed_coro,
)
if project and project["user_id"] != user_id:
raise HTTPException(status_code=404, detail="Project thread not accessible")
else:
allowed = await allowed_coro
if not allowed:
# Tell apart three denial causes so the client gets an
# accurate message and so DB-switch orphans can be adopted
@@ -0,0 +1,66 @@
"""Shared Redis read-through for small configuration catalogues.
Callers authorize before entering this helper and include any user/query scope
in the key. Only successful reads are stored; Redis failures keep the original
database/file-backed path available.
"""
from __future__ import annotations
import json
from collections.abc import Awaitable, Callable
from typing import Any
from fastapi import Request
from pydantic import BaseModel, ValidationError
from app.gateway import api_read_cache
def catalog_key(*parts: Any) -> str:
return json.dumps(parts, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
async def cached_catalog[T](
request: Request,
collection: str,
key: str,
load: Callable[[], Awaitable[T]],
model: type[BaseModel] | None = None,
) -> T:
config = getattr(request.app.state, "config", None)
version = await api_read_cache.get_cache_version(config, collection)
cached = await api_read_cache.get_cached_json(config, collection, key, version=version)
def decode(value):
try:
return model.model_validate(value) if model is not None else value
except (ValidationError, TypeError):
# A stale schema or malformed entry must not break the read path.
return None
if cached is not None:
decoded = decode(cached)
if decoded is not None:
return decoded
cached, fill_token = await api_read_cache.wait_for_cached_json_or_claim_fill(
config, collection, key, version=version,
)
try:
if cached is not None:
decoded = decode(cached)
if decoded is not None:
return decoded
value = await load()
await api_read_cache.set_cached_json(config, collection, key, value, expected_version=version)
return value
finally:
await api_read_cache.release_cache_fill(config, collection, key, fill_token)
async def invalidate_catalog(request: Request, collection: str) -> None:
# Writes while caching is disabled must also invalidate old generations,
# otherwise re-enabling the collection could resurrect stale values.
await api_read_cache.invalidate_cached_collection(
getattr(request.app.state, "config", None), collection, force=True,
)
@@ -2,6 +2,8 @@ import os
from pydantic import BaseModel, Field
from deerflow.config.app_config import get_app_config
class GatewayConfig(BaseModel):
"""Configuration for the API Gateway."""
@@ -16,14 +18,22 @@ _gateway_config: GatewayConfig | None = None
def get_gateway_config() -> GatewayConfig:
"""Get gateway config, loading from environment if available."""
"""Get gateway config, preferring the shared ``config.yaml`` settings."""
global _gateway_config
if _gateway_config is None:
cors_origins_str = os.getenv("CORS_ORIGINS", "http://localhost:3000")
try:
# Documentation routes are part of the service surface, so the
# versioned deployment config is authoritative. In particular,
# a stale shell-level GATEWAY_ENABLE_DOCS=false must not silently
# suppress the intranet docs configured in config.yaml.
enable_docs = get_app_config().gateway.enable_docs
except Exception: # noqa: BLE001 - retain the legacy env fallback if config cannot load
enable_docs = os.getenv("GATEWAY_ENABLE_DOCS", "true").lower() == "true"
_gateway_config = GatewayConfig(
host=os.getenv("GATEWAY_HOST", "0.0.0.0"),
port=int(os.getenv("GATEWAY_PORT", "8001")),
cors_origins=cors_origins_str.split(","),
enable_docs=os.getenv("GATEWAY_ENABLE_DOCS", "true").lower() == "true",
enable_docs=enable_docs,
)
return _gateway_config
@@ -5,6 +5,7 @@ State-changing operations require CSRF protection.
"""
import secrets
import re
from collections.abc import Callable
from fastapi import Request, Response
@@ -25,6 +26,24 @@ CSRF_HEADER_NAME = "X-CSRF-Token"
CSRF_TOKEN_LENGTH = 64 # bytes
# Must stay as narrow as AuthMiddleware's credential-free force-situation
# detail integration route: only GET/PUT of the current detail document are
# public. The version history and other force-situation endpoints keep their
# normal authentication and CSRF policy.
_FORCE_SITUATION_DETAIL_RE = re.compile(
r"^/api/force-situation/tasks/[^/]+/(?:detail|versions)$"
)
def _is_force_situation_detail_external_request(method: str, path: str) -> bool:
normalized = path.rstrip("/")
if not _FORCE_SITUATION_DETAIL_RE.fullmatch(normalized):
return False
return method.upper() == "GET" or (
method.upper() == "PUT" and normalized.endswith("/detail")
)
def is_secure_request(request: Request) -> bool:
"""Detect whether the original client request was made over HTTPS."""
return request.headers.get("x-forwarded-proto", request.url.scheme) == "https"
@@ -66,6 +85,17 @@ def should_check_csrf(request: Request) -> bool:
# 双提交 Cookie 既无 token 可比、也无会话可被伪造,豁免之。
if path.startswith("/api/open/"):
return False
# b5-1 task details are written by a non-browser integration over a
# credential-free open endpoint (see AuthMiddleware): there is no cookie
# session for a cross-site request to ride, and the write itself is open
# anyway, so the Double-Submit Cookie check is meaningless here.
if request.method == "PUT" and path.startswith("/api/task-deeplink-details/"):
return False
# The force-situation current-detail API is a credential-free internal
# integration surface. With no browser login session to protect, CSRF
# validation would only reject existing scheduler/skill writers.
if _is_force_situation_detail_external_request(request.method, path):
return False
if is_coze_studio_compat_path(path):
return False
if is_weknora_embed_proxy_request(request):
@@ -1738,6 +1738,7 @@ class DeepResearchJobExecutor:
from deerflow.agents.deep_research.adapters.image import build_image_provider
from deerflow.agents.deep_research.adapters.llm import DeerFlowCompletionBackend
from deerflow.agents.deep_research.adapters.material_provider import DeerFlowMaterialProvider
from deerflow.utils.background_init import run_background_init
# Resolve / create the hidden runtime thread for artifacts.
runtime_thread_id = await self._ensure_runtime_thread(session_id, user_id)
@@ -1828,7 +1829,8 @@ class DeepResearchJobExecutor:
cancellation=cancel_token,
)
else:
material_provider = DeerFlowMaterialProvider(
material_provider = await run_background_init(
DeerFlowMaterialProvider,
user_id=user_id,
session_id=session_id,
runtime_thread_id=runtime_thread_id,
@@ -78,15 +78,14 @@ async def _enter_runtime_backend_or_fallback[BackendT](
return await stack.enter_async_context(context_manager)
except Exception:
logger.exception(
"%s initialization failed; continuing Gateway startup with an "
"in-memory fallback",
"%s initialization failed; continuing Gateway startup with an in-memory fallback",
label,
)
return fallback_factory()
@asynccontextmanager
async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
async def langgraph_runtime(app: FastAPI, *, background: bool = True) -> AsyncGenerator[None, None]:
"""Bootstrap and tear down all LangGraph runtime singletons.
Usage in ``app.py``::
@@ -158,6 +157,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.thread_store,
)
from deerflow.persistence.task_deeplink_details import make_task_deeplink_detail_store
app.state.task_deeplink_detail_store = make_task_deeplink_detail_store(sf)
from deerflow.persistence.artifact_library import make_artifact_library_store
app.state.artifact_library_store = make_artifact_library_store(sf)
@@ -186,6 +189,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.tool_metrics_store = make_tool_metrics_store(sf)
from deerflow.persistence.stream_truncation import make_stream_truncation_store
app.state.stream_truncation_store = make_stream_truncation_store(sf)
from deerflow.persistence.agents import make_agent_store
app.state.agent_store = make_agent_store(sf)
@@ -251,7 +258,7 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.ai_writing_job_executor = AIWritingJobExecutor(app)
# 进程重启续跑:把仍标记为 background 的会话重新拉起驱动(best-effort,不阻塞启动)。
if app.state.ai_writing_session_store is not None:
if background and app.state.ai_writing_session_store is not None:
from app.gateway.ai_writing_job_executor import STATUS_BACKGROUND
async def _reconcile_ai_writing_background() -> None:
@@ -342,9 +349,7 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
try:
await app.state.position_role_store.ensure_seed(POSITION_ROLE_SEED)
except Exception:
logger.exception(
"Position-role seeding failed at startup; continuing without seed refresh"
)
logger.exception("Position-role seeding failed at startup; continuing without seed refresh")
from deerflow.persistence.business_mapping import make_business_mapping_store
@@ -380,7 +385,7 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.report_collaboration_executor = None
app.state.report_collaboration_dispatcher = None
rc_cfg = getattr(config, "report_collaboration", None)
if rc_cfg is not None and rc_cfg.enabled and rc_cfg.worker_enabled:
if background and rc_cfg is not None and rc_cfg.enabled and rc_cfg.worker_enabled:
from app.report_collaboration.agentscope_runtime.member_kernel import AgentScopeMemberKernel
from app.report_collaboration.execution.dispatcher import ReportCollaborationDispatcher
from app.report_collaboration.execution.executor import ReportCollaborationExecutor
@@ -440,23 +445,17 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.upload_policy_store = make_upload_policy_store(sf)
fallback_upload_policy = default_upload_policy()
try:
upload_policy_row = await app.state.upload_policy_store.ensure_seed(
fallback_upload_policy
)
upload_policy_row = await app.state.upload_policy_store.ensure_seed(fallback_upload_policy)
set_runtime_upload_policy(upload_policy_row["policy"])
except Exception:
logger.exception(
"Upload-policy seeding failed at startup; using process-local defaults"
)
logger.exception("Upload-policy seeding failed at startup; using process-local defaults")
set_runtime_upload_policy(fallback_upload_policy)
from deerflow.persistence.knowledge_retrieval_settings import (
make_knowledge_retrieval_settings_store,
)
app.state.knowledge_retrieval_settings_store = (
make_knowledge_retrieval_settings_store(sf)
)
app.state.knowledge_retrieval_settings_store = make_knowledge_retrieval_settings_store(sf)
from deerflow.persistence.task_analysis import make_task_analysis_store
@@ -466,6 +465,12 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.analysis_project_store = AnalysisProjectStore(sf)
from deerflow.persistence.knowledge_research import (
make_knowledge_research_store,
)
app.state.knowledge_research_store = make_knowledge_research_store(sf)
from app.gateway.analysis_project_memory import (
AnalysisProjectMemoryDispatcher,
AnalysisProjectMemoryExecutor,
@@ -501,7 +506,7 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.assistant_knowledge_store = make_assistant_knowledge_store(sf)
register_assistant_knowledge_store(app.state.assistant_knowledge_store)
app.state.skill_knowledge_store = make_skill_knowledge_store(sf)
if app.state.skill_knowledge_store is not None and app.state.assistant_knowledge_store is not None:
if background and app.state.skill_knowledge_store is not None and app.state.assistant_knowledge_store is not None:
from app.gateway.skill_knowledge_job_dispatcher import SkillKnowledgeJobDispatcher
from app.gateway.skill_knowledge_job_executor import SkillKnowledgeJobExecutor
@@ -544,7 +549,8 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
# Start only after the optional embedding runtime is ready. Otherwise
# persisted jobs reclaimed during startup would be permanently stored
# as lexical-only even when phase-two vector recall is configured.
app.state.analysis_project_memory_dispatcher.start()
if background:
app.state.analysis_project_memory_dispatcher.start()
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
@@ -575,6 +581,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
)
app.state.roundtable_job_store = make_roundtable_job_store(sf)
from deerflow.persistence.roundtable_rooms import make_roundtable_room_store
app.state.roundtable_room_store = make_roundtable_room_store(sf)
# 让 LangGraph Server 路径下的圆桌编排图节点能跨 app/harness 边界拿到 repo
# (Phase 2 后台编排图写进度用)。
_register_roundtable_job_default_store(app.state.roundtable_job_store)
@@ -659,7 +669,8 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
draft_store=getattr(app.state, "roundtable_draft_store", None),
task_draft_store=getattr(app.state, "roundtable_task_draft_store", None),
)
app.state.roundtable_job_dispatcher.start()
if background:
app.state.roundtable_job_dispatcher.start()
else:
app.state.roundtable_job_executor = None
app.state.roundtable_job_dispatcher = None
@@ -727,7 +738,8 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.deep_research_job_store,
app.state.deep_research_executor,
)
app.state.deep_research_dispatcher.start()
if background:
app.state.deep_research_dispatcher.start()
else:
app.state.deep_research_executor = None
app.state.deep_research_dispatcher = None
@@ -758,7 +770,8 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
live_publisher=app.state.workflow_live_hub.publish,
config=workflow_cfg,
)
app.state.workflow_dispatcher.start()
if background:
app.state.workflow_dispatcher.start()
from app.gateway.workflow_retention import WorkflowRetentionCleaner
app.state.workflow_retention = WorkflowRetentionCleaner(
@@ -766,7 +779,8 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.workflow_event_store,
config=workflow_cfg,
)
app.state.workflow_retention.start()
if background:
app.state.workflow_retention.start()
else:
app.state.workflow_live_hub = None
app.state.workflow_executor = None
@@ -779,6 +793,8 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
# RunManager with store backing for persistence
app.state.run_manager = RunManager(store=app.state.run_store)
retention = config.runtime_contention
app.state.run_manager.start_retention(retention_seconds=retention.completed_run_retention_seconds, interval_seconds=retention.run_sweep_seconds)
from deerflow.runtime import make_redis_concurrency_gate
app.state.concurrency_gate = make_redis_concurrency_gate(config)
@@ -786,12 +802,11 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
try:
yield
finally:
await app.state.run_manager.aclose()
# Project-memory extraction can use the shared local Wiki encoder.
# Stop its jobs before retiring that runtime to avoid an embedding
# call racing shutdown.
analysis_project_memory_dispatcher = getattr(
app.state, "analysis_project_memory_dispatcher", None
)
analysis_project_memory_dispatcher = getattr(app.state, "analysis_project_memory_dispatcher", None)
if analysis_project_memory_dispatcher is not None:
await analysis_project_memory_dispatcher.stop()
from app.gateway.assistant_file_ingest import stop_file_jobs
@@ -894,6 +909,14 @@ def get_task_deeplink_thread_store(request: Request):
return val
def get_task_deeplink_detail_store(request: Request):
"""Return the external task-detail store used by task deep links."""
val = getattr(request.app.state, "task_deeplink_detail_store", None)
if val is None:
raise HTTPException(status_code=503, detail="Task deep-link detail store not available")
return val
def get_scheduled_task_store(request: Request) -> ScheduledTaskStore:
"""Return the scheduled-task store."""
val = getattr(request.app.state, "scheduled_task_store", None)
@@ -917,6 +940,11 @@ def get_tool_metrics_store(request: Request):
return getattr(request.app.state, "tool_metrics_store", None)
def get_stream_truncation_store(request: Request):
"""Return the stream-truncation event store, or None when not configured."""
return getattr(request.app.state, "stream_truncation_store", None)
def get_agent_store(request: Request) -> AgentStore:
"""Return the custom-agent store."""
val = getattr(request.app.state, "agent_store", None)
@@ -1125,7 +1153,10 @@ async def get_current_user_from_request(request: Request):
detail=AuthErrorResponse(code=token_error_to_code(payload), message=f"Token error: {payload.value}").model_dump(),
)
if policy_error := session_policy_error(payload):
# Scoped service tokens are authorized by AuthMiddleware's strict path
# allow-list. They are intentionally not accepted by the separate
# LangGraph auth path, which still applies session_policy_error directly.
if payload.auth_method != "service" and (policy_error := session_policy_error(payload)):
raise HTTPException(
status_code=401,
detail=AuthErrorResponse(code=AuthErrorCode.TOKEN_INVALID, message=policy_error).model_dump(),
@@ -0,0 +1,285 @@
"""Server-owned run policy for knowledge-base research conversations."""
from __future__ import annotations
from copy import deepcopy
from typing import Any
from fastapi import HTTPException, Request
from langchain_core.messages import HumanMessage
_SAFE_GENERATION_PREFERENCE_KEYS = frozenset(
{
"model_name",
"mode",
"reasoning_effort",
"thinking_enabled",
"thinking_force_disabled",
}
)
def _text_only_content(value: Any) -> Any:
"""Remove file/image/audio blocks while preserving the user's text."""
if isinstance(value, str):
return value
if isinstance(value, dict):
block_type = str(value.get("type") or "text").strip().lower()
text = value.get("text") if block_type in {"text", "input_text"} else None
if not isinstance(text, str) and block_type in {"text", "input_text"}:
text = value.get("content")
return (
[{"type": "text", "text": text}]
if isinstance(text, str)
else ""
)
if not isinstance(value, list):
return ""
kept: list[Any] = []
for block in value:
if isinstance(block, str):
kept.append(block)
continue
if not isinstance(block, dict):
continue
block_type = str(block.get("type") or "text").strip().lower()
if block_type not in {"text", "input_text"}:
continue
text = block.get("text")
if not isinstance(text, str):
text = block.get("content")
if isinstance(text, str):
kept.append({"type": "text", "text": text})
return kept
def _content_text(value: Any) -> str:
if isinstance(value, str):
return value
if not isinstance(value, list):
return ""
parts: list[str] = []
for block in value:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict):
text = block.get("text") or block.get("content")
if isinstance(text, str):
parts.append(text)
return "\n".join(parts)
def _sanitize_messages(body: Any) -> None:
payload = getattr(body, "input", None)
messages = payload.get("messages") if isinstance(payload, dict) else None
if not isinstance(messages, list):
if isinstance(payload, dict):
body.input = {"messages": []}
return
latest_user_message: Any | None = None
for message in messages:
if isinstance(message, HumanMessage):
latest_user_message = HumanMessage(
content=_text_only_content(message.content)
)
continue
if not isinstance(message, dict):
continue
role = str(message.get("role") or message.get("type") or "").lower()
if role not in {"human", "user"}:
# Checkpoint state is the only source of prior assistant/system/tool
# turns. The current request may contribute user text only.
continue
latest_user_message = {
"role": "user",
"content": _text_only_content(message.get("content")),
}
# The checkpoint is the only source of conversation history. A run may
# contribute exactly one current user turn, so callers cannot fabricate
# earlier user messages either.
body.input = {
"messages": [latest_user_message] if latest_user_message is not None else []
}
def _safe_generation_preferences(body: Any) -> dict[str, Any]:
"""Copy the ordinary-chat model controls, and no other caller context."""
sources: list[dict[str, Any]] = []
config = getattr(body, "config", None)
if isinstance(config, dict):
for scope in ("configurable", "context"):
nested = config.get(scope)
if isinstance(nested, dict):
sources.append(nested)
context = getattr(body, "context", None)
if isinstance(context, dict):
# Public body.context has the same precedence it has in ordinary Q&A.
sources.append(context)
preferences: dict[str, Any] = {}
for source in sources:
for key in _SAFE_GENERATION_PREFERENCE_KEYS:
value = source.get(key)
if key in {"thinking_enabled", "thinking_force_disabled"}:
if isinstance(value, bool):
preferences[key] = value
elif isinstance(value, str) and value.strip():
preferences[key] = value.strip()[:255]
return preferences
async def prepare_knowledge_research_run(
body: Any,
thread_id: str,
request: Request,
) -> tuple[Any, dict[str, Any] | None]:
"""Bind every run to its persisted owner and one authorized knowledge base.
Thread metadata is observability only. The database binding is resolved on
every turn, and read authorization is rechecked before any retrieval runs.
"""
store = getattr(request.app.state, "knowledge_research_store", None)
if store is None:
return body, None
session = await store.get_by_thread(thread_id)
if session is None:
return body, None
# Local import avoids a module cycle: analysis_projects invokes this hook.
from app.gateway.analysis_projects import actor
user_id, is_admin = await actor(request)
if session["user_id"] != user_id:
# Administrators do not inherit access to another user's private chat.
raise HTTPException(status_code=404, detail="研究会话不存在或无权访问")
mapping_store = getattr(request.app.state, "llmwiki_store", None)
if mapping_store is None:
raise HTTPException(status_code=503, detail="知识库服务暂不可用")
mapping = await mapping_store.get_authorized(
session["knowledge_base_id"],
user_id,
write=False,
is_admin=is_admin,
)
if mapping is None:
raise HTTPException(status_code=404, detail="知识库不存在或已无访问权限")
prepared = body.model_copy(deep=True) if hasattr(body, "model_copy") else deepcopy(body)
generation_preferences = _safe_generation_preferences(prepared)
# Research accepts user text, not arbitrary run context/metadata. Rebuild
# both from server state so task/workbench/notebook/peer context cannot be
# smuggled into this otherwise strict single-KB surface.
prepared.context = {}
prepared.config = {}
prepared.metadata = {}
_sanitize_messages(prepared)
messages = (prepared.input or {}).get("messages")
if not isinstance(messages, list) or not messages:
raise HTTPException(status_code=422, detail="知识库研究仅接受非空文本问题")
latest = messages[-1]
latest_content = (
latest.content if isinstance(latest, HumanMessage) else latest.get("content")
)
if not _content_text(latest_content).strip():
raise HTTPException(status_code=422, detail="知识库研究不支持纯附件提问")
policy = {
**generation_preferences,
"llmwiki_knowledge_base_ids": [session["knowledge_base_id"]],
"assistant_knowledge_base_ids": [],
"knowledge_retrieval_mode": "classic",
"knowledge_space_strict": True,
"rag_mode_enabled": True,
# Retrieval is injected by the Gateway; do not let the graph open a
# second, client-configurable retrieval path.
"knowledge_rag_enabled": False,
"agent_id": None,
"agent_name": None,
"writing_mode": False,
"writing_artifact_path": None,
"is_plan_mode": False,
"subagent_enabled": False,
"is_bootstrap": False,
"is_writing_setup": False,
"is_report_structure_setup": False,
"memory_recall_disabled": True,
"memory_injection_enabled": False,
"prompt_prefix_enabled": False,
"disable_tools": True,
"excluded_tools": ["memory", "hindsight_recall"],
"skill_stop_names": None,
"skill_stop_message": None,
"knowledge_research_session_id": session["id"],
"knowledge_research_knowledge_base_id": session["knowledge_base_id"],
}
prepared.assistant_id = None
prepared.excluded_tools = None
prepared.skill_stop_names = None
prepared.skill_stop_message = None
prepared.context.update(policy)
# Pass exactly one LangGraph runtime options container. build_run_config
# gives ``context`` precedence when both are present; emitting both here
# would discard configurable.thread_id and detach the checkpoint.
prepared.config = {
"configurable": {
"thread_id": thread_id,
**policy,
}
}
prepared.command = None
prepared.checkpoint_id = None
prepared.checkpoint = None
prepared.webhook = None
prepared.metadata.update(
{
"system": True,
"thread_type": "knowledge_research",
"knowledge_research_session_id": session["id"],
"knowledge_research_knowledge_base_id": session["knowledge_base_id"],
"llmwiki_knowledge_base_ids": [session["knowledge_base_id"]],
"assistant_knowledge_base_ids": [],
"knowledge_retrieval_mode": "classic",
"knowledge_space_strict": True,
"rag_mode_enabled": True,
"rag_mode": True,
}
)
marker = {
"id": session["id"],
"mode": "knowledge_research",
"knowledge_base_id": session["knowledge_base_id"],
"user_id": session["user_id"],
}
return prepared, marker
def inject_validated_knowledge_research_context(
config: dict[str, Any],
context: dict[str, Any] | None,
) -> None:
"""Write non-public research policy into both final runtime scopes."""
if not context or not context.get("knowledge_research_session_id"):
return
authoritative = {
"disable_tools": True,
"memory_recall_disabled": True,
"memory_injection_enabled": False,
"prompt_prefix_enabled": False,
"writing_mode": False,
"is_plan_mode": False,
"subagent_enabled": False,
}
for scope in ("configurable", "context"):
target = config.setdefault(scope, {})
if isinstance(target, dict):
target.update(authoritative)
__all__ = [
"inject_validated_knowledge_research_context",
"prepare_knowledge_research_run",
]
@@ -17,7 +17,6 @@ _MUTATION_SYNC_DELAYS_SECONDS = (1.0, 8.0, 30.0, 90.0, 300.0)
_MUTATION_SYNC_MAX_SECONDS = 10 * 60.0
_MUTATION_SYNC_CONFIRM_GRACE_SECONDS = 30.0
_FULL_SCAN_FALLBACK_SECONDS = 10 * 60.0
_FOCUS_RECONCILE_SECONDS = 5 * 60.0
_WIKI_CONFIG_UPGRADE_RETRY_SECONDS = 5 * 60.0
@@ -200,6 +199,13 @@ async def _upgrade_wiki_generation_configs_once(
return {"eligible": len(targets), "upgraded": upgraded, "failed": failed}
def _has_directory_constraints(mapping: dict[str, Any]) -> bool:
return bool(
mapping.get("wiki_directory_paths")
or mapping.get("wiki_parent_directories")
)
def _knowledge_base_is_processing(remote: dict[str, Any]) -> bool:
"""Normalize processing markers across old/new WeKnora response shapes."""
@@ -727,71 +733,6 @@ async def _refresh_local_wiki_config(app: FastAPI):
return await rebuild_local_wiki_index_runtime(app)
async def _reconcile_focus_constraints_once(app: FastAPI) -> None:
"""Resume strict focus filtering after process restarts."""
mapping_store = getattr(app.state, "llmwiki_store", None)
list_visible = getattr(mapping_store, "list_visible", None)
if not callable(list_visible):
return
mappings = [
row
for row in await list_visible(
"system",
scope="all",
is_admin=True,
)
if _has_focus_constraint(row)
]
if not mappings:
return
service = getattr(app.state, "llmwiki_sync_service", None)
client = getattr(service, "client", None)
if client is None:
from deerflow.integrations.weknora.runtime import (
build_weknora_client,
get_resolved_llmwiki_runtime,
)
runtime = get_resolved_llmwiki_runtime()
if not runtime.weknora_enabled:
return
client = build_weknora_client(runtime)
from app.gateway.wiki_directory_constraints import (
reconcile_fixed_parent_directories,
)
semaphore = asyncio.Semaphore(4)
async def run(mapping: dict[str, Any]) -> None:
async with semaphore:
try:
remote = await client.get_knowledge_base(
str(mapping.get("weknora_id") or "")
)
if not isinstance(remote, dict):
return
processing = await _remote_generation_is_processing(
client,
mapping,
remote=remote,
)
if processing is not False:
return
await reconcile_fixed_parent_directories(client, mapping)
except asyncio.CancelledError:
raise
except Exception:
logger.warning(
"Periodic Wiki focus reconciliation failed mapping_id=%s",
mapping.get("id"),
exc_info=True,
)
await asyncio.gather(*(run(mapping) for mapping in mappings))
def _auto_sync_ready(app: FastAPI, config: Any) -> bool:
return bool(
config.enabled
@@ -915,7 +856,6 @@ async def local_wiki_index_scheduler_loop(app: FastAPI) -> None:
last_sync_started_at: float | None = None
last_synced_runtime_signature: str | None = None
last_full_scan_started_at: float | None = None
last_focus_reconcile_started_at: float | None = None
last_prompt_schema_upgrade_started_at: float | None = None
while True:
try:
@@ -924,30 +864,22 @@ async def local_wiki_index_scheduler_loop(app: FastAPI) -> None:
# cadence. Provider/URL changes therefore take effect promptly.
config = await _refresh_local_wiki_config(app)
now = monotonic()
if (
last_prompt_schema_upgrade_started_at is None
or now - last_prompt_schema_upgrade_started_at
>= _WIKI_CONFIG_UPGRADE_RETRY_SECONDS
):
last_prompt_schema_upgrade_started_at = now
await _upgrade_wiki_generation_configs_once(app)
auto_sync_ready = _auto_sync_ready(app, config)
if (
not auto_sync_ready
and (
last_focus_reconcile_started_at is None
or now - last_focus_reconcile_started_at
>= _FOCUS_RECONCILE_SECONDS
)
):
last_focus_reconcile_started_at = now
await _reconcile_focus_constraints_once(app)
if not auto_sync_ready:
# A later disabled -> enabled transition is a new first valid
# configuration and should receive an immediate scan.
# With automatic sync off, the scheduler must remain local-only:
# no periodic WeKnora page scan, prompt rewrite, or directory
# reconciliation. Explicit user/API calls remain responsible
# for remote refreshes.
last_sync_started_at = None
last_synced_runtime_signature = None
else:
if (
last_prompt_schema_upgrade_started_at is None
or now - last_prompt_schema_upgrade_started_at
>= _WIKI_CONFIG_UPGRADE_RETRY_SECONDS
):
last_prompt_schema_upgrade_started_at = now
await _upgrade_wiki_generation_configs_once(app)
interval = float(config.sync_interval_seconds)
runtime_signature = getattr(
app.state,
@@ -2092,7 +2092,12 @@ async def build_llmwiki_smart_reasoning_context(
)
agent_id = _assistant_agent_id(body)
if agent_id and agent_id not in {"default", "lead_agent"}:
# Built-in research agents use the caller-selected scope, like general chat.
# Each mapping still passes get_authorized below; custom agents retain their
# configured binding restriction.
if agent_id and agent_id not in {
"default", "lead_agent", "deep-research-planner", "deep-research-collector",
}:
allowed_ids = set(_agent_bound_ids(body))
if any(mapping_id not in allowed_ids for mapping_id in selected_ids):
raise HTTPException(
@@ -0,0 +1,125 @@
"""Low-overhead performance traces for diagnosing slow Gateway requests."""
from __future__ import annotations
import asyncio
import faulthandler
import logging
import os
import signal
import sys
import time
from typing import Any
logger = logging.getLogger(__name__)
_VERBOSE_PATHS = {
"/api/v1/auth/options",
"/api/agents",
"/api/llmwiki/knowledge-bases",
}
_STACK_DUMP_REGISTERED = False
def slow_request_threshold_ms() -> float:
"""Return the warning threshold, defaulting to half a second."""
try:
return max(0.0, min(float(os.getenv("DEERFLOW_SLOW_REQUEST_LOG_MS", "500")), 60_000.0))
except (TypeError, ValueError):
return 500.0
def verbose_request_logging() -> bool:
return os.getenv("DEERFLOW_PERF_DIAGNOSTICS", "").strip().lower() in {"1", "true", "yes", "on"}
def is_verbose_path(path: str) -> bool:
return path in _VERBOSE_PATHS or (
path.startswith("/api/llmwiki/agents/")
and path.endswith("/knowledge-bases")
)
def should_log_request(
*,
elapsed_ms: float,
headers_ms: float | None,
first_body_ms: float | None,
is_stream: bool,
) -> bool:
threshold = slow_request_threshold_ms()
if is_stream:
return bool(
(headers_ms is not None and headers_ms >= threshold)
or (first_body_ms is not None and first_body_ms >= threshold)
)
return elapsed_ms >= threshold
def trace_request(request: Any, endpoint: str) -> dict[str, Any]:
"""Start an endpoint trace attached to the ASGI request state."""
trace: dict[str, Any] = {
"endpoint": endpoint,
"started": time.perf_counter(),
"stages": {},
"details": {},
}
try:
request.state.perf_trace = trace
except AttributeError:
# Direct router calls in isolated tests may use a minimal request stub.
pass
return trace
def trace_stage(trace: dict[str, Any], name: str, started: float) -> float:
"""Record one endpoint stage and return its completion timestamp."""
ended = time.perf_counter()
trace["stages"][name] = round((ended - started) * 1000, 2)
return ended
def finish_trace(trace: dict[str, Any]) -> None:
trace["endpoint_ms"] = round((time.perf_counter() - trace["started"]) * 1000, 2)
def register_stack_dump_signal() -> None:
"""Allow SIGUSR1 to dump all Python thread stacks to container stderr."""
global _STACK_DUMP_REGISTERED
if _STACK_DUMP_REGISTERED:
return
if os.getenv("DEERFLOW_ENABLE_STACK_DUMP", "1").strip().lower() in {"0", "false", "no", "off"}:
logger.info("PERF Python stack dump signal disabled by configuration")
return
stack_signal = getattr(signal, "SIGUSR1", None)
register = getattr(faulthandler, "register", None)
if stack_signal is None or register is None:
logger.info("PERF Python stack dump signal is unavailable on this platform")
return
try:
register(stack_signal, file=sys.stderr, all_threads=True, chain=False)
except (OSError, RuntimeError, ValueError):
logger.exception("PERF unable to register Python stack dump signal")
return
_STACK_DUMP_REGISTERED = True
logger.info("PERF Python stack dump ready signal=SIGUSR1 all_threads=true")
async def event_loop_watchdog(app: Any) -> None:
"""Log substantial event-loop stalls without requiring the diagnostics UI."""
loop = asyncio.get_running_loop()
last_warning = 0.0
while True:
expected = loop.time() + 1.0
await asyncio.sleep(1.0)
lag_ms = round(max(0.0, loop.time() - expected) * 1000, 2)
app.state.perf_event_loop_lag_ms = lag_ms
now = time.monotonic()
if lag_ms >= 200 and now - last_warning >= 5:
logger.warning("PERF event_loop_stall lag_ms=%.2f threshold_ms=200", lag_ms)
last_warning = now
@@ -18,12 +18,9 @@ import asyncio
import logging
import os
import uuid
from collections.abc import Callable
from datetime import UTC, datetime
from types import SimpleNamespace
from typing import Any
from langgraph.checkpoint.base import empty_checkpoint
from weakref import WeakKeyDictionary
from app.gateway.roundtable_model_fallback import (
fallback_model_chain,
@@ -39,6 +36,7 @@ from app.gateway.routers._roundtable_seed import (
SUMMARY_AGENT_ID,
ensure_roundtable_functional_agents,
)
from app.gateway.routers.multi_agent import _roundtable_file_stream_context
from app.gateway.services import RUN_SCOPE_BACKGROUND_CHILD
from deerflow.agents.roundtable_orchestrator.delivery import classify_seat_delivery, visible_seat_text
from deerflow.agents.roundtable_orchestrator.report_json import extract_report_json
@@ -51,8 +49,11 @@ from deerflow.agents.roundtable_orchestrator.types import (
SummaryResult,
)
from deerflow.config.paths import get_paths
from deerflow.config.runtime_contention_config import get_runtime_contention_config
from deerflow.persistence.roundtable_diagnostics import DiagnosticsRecorder, get_default_store
from deerflow.runtime.fair_queue import FairAsyncGate
from deerflow.runtime.serialization import serialize_channel_values
from deerflow.utils.file_io import file_io
from deerflow.utils.tool_visibility import is_internal_tool_name
logger = logging.getLogger(__name__)
@@ -86,49 +87,30 @@ _SUMMARY_FILE = "方案总结报告.md"
# 这里漏禁了 web_search,导致后台席位 / 报告能联网搜索而前端不能,现由 policy 统一。
# ── 后台作业「按用户限流」(2026-06-16)─────────────────────────────────────────
# 痛点:进 2+ 个后台挂起后,整个网关变慢——建立智能体连接、等待模型响应都慢。
# 根因:① checkpointer 用单连接 aiosqlite(AsyncSqliteSaver,内部一把 asyncio.Lock 串
# 行全进程的 checkpoint 读写);② 后台作业无并发上限——每个作业进程内驱动真实 leader/
# seat/report run,DAG/recommend 的 stage 还用 asyncio.gather 把席位真并行铺开。多个后台
# 作业同时把几十个 agent run 压到同一把 SQLite 锁 + 同一个离线模型端点上,前台实时会商
# 的 checkpoint 读写 / 模型调用只能排队 → 「干啥都慢」。
#
# 解法:给**每个用户**一个并发额度(默认 2),后台每个 agent 轮次(leader/seat/report/
# summary/dashboard)真正开跑前先取一个额度,跑完释放。同一用户的所有后台作业共享这 2 个
# 额度——随便挂多少个作业都能立刻建好,但同一瞬间真正在打模型/写 checkpoint 的后台轮次
# 至多 2 个,不会把共享资源打满。**前台实时路径(multi_agent._leader_run/_special_run、
# 普通聊天)不经过本文件的 `_run_agent_turn`,永不取这个额度,故前台不受限。**
_BACKGROUND_SLOTS_PER_USER = max(1, int(os.getenv("ROUNDTABLE_BACKGROUND_SLOTS_PER_USER", "2") or "2"))
# 按 user_id 缓存 Semaphore。网关全程跑在同一个事件循环上,普通 dict 取/建即可(无跨线程)。
_user_background_sems: dict[str, asyncio.Semaphore] = {}
# Global and per-user budgets share round-robin admission. A busy user's
# backlog cannot fill the model/checkpoint path before another user is served.
def _user_background_semaphore(user_id: str) -> asyncio.Semaphore:
"""取该用户的后台并发额度信号量(懒建,每用户一把)。"""
sem = _user_background_sems.get(user_id)
if sem is None:
sem = asyncio.Semaphore(_BACKGROUND_SLOTS_PER_USER)
_user_background_sems[user_id] = sem
return sem
_background_gates: WeakKeyDictionary = WeakKeyDictionary()
def _background_gate() -> FairAsyncGate:
loop = asyncio.get_running_loop()
if loop not in _background_gates:
limits = get_runtime_contention_config()
per_user = max(1, int(os.getenv("ROUNDTABLE_BACKGROUND_SLOTS_PER_USER", str(limits.background_per_user))))
_background_gates[loop] = FairAsyncGate(limits.background_concurrency, per_user, limits.queue_limit)
return _background_gates[loop]
# ── 进程内底层操作 ───────────────────────────────────────────────────────────
async def _create_thread(app) -> str:
"""同进程建一个带圆桌 metadata 的 thread + 空 checkpoint(镜像 _create_thread_direct)。"""
checkpointer = app.state.checkpointer
"""Create metadata only; the first graph run initializes its checkpoint."""
thread_store = app.state.thread_store
thread_id = str(uuid.uuid4())
await thread_store.create(thread_id, assistant_id=None, metadata=dict(_ROUNDTABLE_THREAD_METADATA))
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
await checkpointer.aput(
config,
empty_checkpoint(),
{"step": -1, "source": "input", "writes": None, "parents": {}, "created_at": datetime.now(UTC).isoformat()},
{},
)
return thread_id
@@ -345,6 +327,8 @@ async def _run_agent_turn(
skill_stop_names: list[str],
thinking_enabled: bool,
reasoning_effort: str,
allowed_tools: list[str] | None = None,
recursion_limit: int = 1000,
subagent_enabled: bool = False,
diag: DiagnosticsRecorder | None = None,
stage: str = "step2_seat",
@@ -356,9 +340,8 @@ async def _run_agent_turn(
镜像 multi_agent ``_run_payload`` 的 body 形状:agent 通过 ``context.agent_name`` 选,
assistant_id 固定 lead_agent;关闭 memory recall / subagent / plan mode(临时研讨任务)。
并发受**该用户的后台额度**约束(``_user_background_semaphore``):真正开跑(start_run +
await run task)前先取额度,跑完释放。同一用户的所有后台作业共享这几个额度,避免 2+
后台挂起把单连接 SQLite checkpointer / 离线模型打满、拖垮前台实时会商。
全局与每用户后台额度由 ``_background_gate`` 统一按用户轮转分配。排队不占运行名额,
真正开跑前取额度;取消时等待实际 run 停止后才释放,避免拖垮前台实时会商。
"""
from app.gateway.routers.thread_runs import RunCreateRequest
from app.gateway.services import start_run
@@ -367,7 +350,7 @@ async def _run_agent_turn(
assistant_id="lead_agent",
input={"messages": [{"type": "human", "content": [{"type": "text", "text": message}], "additional_kwargs": {}}]},
metadata={"run_scope": RUN_SCOPE_BACKGROUND_CHILD},
config={"recursion_limit": 1000},
config={"recursion_limit": recursion_limit},
context={
"agent_name": agent_name,
"model_name": model or "",
@@ -382,6 +365,8 @@ async def _run_agent_turn(
# 与 multi_agent._run_payload 对齐:关 builtin 记忆注入,避免单例总控/席位
# 被上轮会话 memory.json 带偏(臆造席位 / 直接干活)。详见该处注释。
"memory_injection_enabled": False,
"allowed_tools": allowed_tools,
**_roundtable_file_stream_context(),
},
excluded_tools=excluded_tools,
skill_stop_names=skill_stop_names,
@@ -393,7 +378,7 @@ async def _run_agent_turn(
request = SimpleNamespace(app=app, state=SimpleNamespace(user=None, auth=None))
# 取该用户的后台额度后再开跑:start_run + await task 是最重的部分(建 agent、调模型、
# 落 checkpoint)。额度满时本轮在此 await 排队,等同用户某个在跑轮次释放额度后再开。
async with _user_background_semaphore(user_id):
async with _background_gate().slot(user_id):
try:
record = await start_run( # type: ignore[arg-type]
body,
@@ -410,16 +395,23 @@ async def _run_agent_turn(
agent_name=agent_name, cycle=cycle,
)
raise
if on_started is not None:
await on_started(record)
# idle 超时守护:慢但持续产出的生成一直等;只有连续 _SEAT_IDLE_TIMEOUT_S 秒**毫无输出**
# (真卡死)才取消并抛错。绝不因「这轮总耗时长」而打断(除非显式配了 _SEAT_MAX_TOTAL_S)。
await _await_run_with_idle_guard(
app, record,
idle_timeout=_SEAT_IDLE_TIMEOUT_S,
max_total=_SEAT_MAX_TOTAL_S,
diag=diag, stage=stage, agent_name=agent_name, cycle=cycle,
)
try:
if on_started is not None:
await on_started(record)
# 持续产出的慢生成继续等,只有连续无输出或显式总时限才中断。
await _await_run_with_idle_guard(
app, record,
idle_timeout=_SEAT_IDLE_TIMEOUT_S,
max_total=_SEAT_MAX_TOTAL_S,
diag=diag, stage=stage, agent_name=agent_name, cycle=cycle,
)
finally:
# Cancelling the parent or its idle watcher is not proof that the
# child has stopped writing checkpoints. Keep admission until ack.
task = getattr(record, "task", None)
if task is not None and not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
return await _read_thread_messages(app, thread_id)
@@ -568,36 +560,6 @@ def _exception_reason(exc: BaseException) -> str:
return "exception"
def _seat_empty_reason(messages: list[dict[str, Any]]) -> str | None:
"""席位/取数轮校验:本轮无可见正文 → ``empty`` / ``thinking_only``(触发换模型)。"""
saw_thinking = False
for m in reversed(messages):
if not isinstance(m, dict):
continue
role = m.get("type") or m.get("role")
if role in {"human", "user"}:
if _is_broadcast_human_text(_message_text(m)):
continue
break
if role not in {"ai", "assistant"}:
continue
kind = classify_seat_delivery(_message_text(m))
if kind == "valid":
return None
if kind == "thinking_only":
saw_thinking = True
break
return "thinking_only" if saw_thinking else "empty"
def _leader_failed_reason(messages: list[dict[str, Any]]) -> str | None:
"""总控轮校验:既无正文、也无任何工具调用(派活/澄清)→ ``empty``(视为模型没干活)。"""
ai = _latest_ai_message(messages)
has_text = bool(_latest_ai_text(messages).strip())
has_tools = bool(_normalize_tool_calls((ai or {}).get("tool_calls")))
return None if (has_text or has_tools) else "empty"
# ── 网关实现 ─────────────────────────────────────────────────────────────────
@@ -670,20 +632,24 @@ class InProcessRoundtableGateway:
message: str,
requested_model: str | None,
excluded_tools: list[str],
allowed_tools: list[str] | None,
skill_stop_names: list[str],
thinking_enabled: bool,
reasoning_effort: str,
recursion_limit: int,
subagent_enabled: bool = False,
require_visible_text: bool = False,
stage: str,
cycle: int | None = None,
validate: Callable[[list[dict[str, Any]]], str | None] | None = None,
) -> tuple[list[dict[str, Any]], list[ModelSwitch], str | None]:
"""跑一轮 agent,并在「模型出问题」时自动切到下一个模型重试。
触发换模型的三种情形(与用户选择一致):
触发换模型的两种情形:
① ``_run_agent_turn`` 抛异常(start_run 失败 / idle 卡死超时);
② 返回了中间件兜底错误文案(``is_llm_error_message`` 命中);
③ 调用方 ``validate`` 判定无效(如席位交付为空 / 总控既无正文又无派活)。
仅有思考、暂无可见正文或空交付不是模型故障,不能触发自动换模;只有调用方明确
``require_visible_text=True`` 的最终收口轮,在请求完整结束后无正文才视为不可交付。
候选模型链按 ``config.yaml models[]`` 顺序(``fallback_model_chain``),首个为
``requested_model``(空则作业级 ``self._model``,再空则配置默认)。返回
@@ -721,9 +687,11 @@ class InProcessRoundtableGateway:
message=message,
model=candidate,
excluded_tools=excluded_tools,
allowed_tools=allowed_tools,
skill_stop_names=skill_stop_names,
thinking_enabled=thinking_enabled,
reasoning_effort=reasoning_effort,
recursion_limit=recursion_limit,
subagent_enabled=subagent_enabled,
diag=self._diag,
stage=stage,
@@ -739,10 +707,10 @@ class InProcessRoundtableGateway:
detail={"reason": prev_reason, "tried": chain}, agent_name=agent_name, cycle=cycle,
)
return messages, switches, used_model
# 跑通了:再看是否「兜底错误文案」或 validate 判定无效。
# 普通轮只识别带明确错误标记的兜底文案;最终收口轮完整结束后必须有正文。
reason = is_llm_error_message(_latest_ai_message(messages))
if reason is None and validate is not None:
reason = validate(messages)
if reason is None and require_visible_text and not _latest_ai_text(messages).strip():
reason = "empty"
if reason is None:
return messages, switches, used_model
prev_model, prev_reason = candidate, reason
@@ -755,27 +723,46 @@ class InProcessRoundtableGateway:
return messages, switches, used_model
return messages, switches, used_model
async def run_leader(self, *, thread_ids, coordinator_name, message, model) -> LeaderTurn:
async def run_leader(
self,
*,
thread_ids,
coordinator_name,
message,
model,
finalize_only: bool = False,
) -> LeaderTurn:
msg = message
if self._first_leader and self._seat_intro:
msg = self._seat_intro + "\n\n" + message
self._first_leader = False
policy = roundtable_run_policy("leader")
if finalize_only:
msg += (
"\n\n【最终收口·纯正文】本轮所有工具均已关闭。请直接输出一份合并后的最终结论正文;"
"不要按席位逐份复述原文,也不要反复改写同一份总结。"
"不要调用或尝试 write_file、present_files、agent_orchestration、skill_view 或任何其它工具,"
"不要再派活,也不要只描述你准备做什么。"
)
messages, switches, _used = await self._run_turn_with_fallback(
thread_id=thread_ids[coordinator_name],
agent_name=coordinator_name,
message=msg,
requested_model=model,
excluded_tools=policy.excluded_tools,
skill_stop_names=policy.skill_stop_names,
allowed_tools=[] if finalize_only else getattr(policy, "allowed_tools", None),
skill_stop_names=[] if finalize_only else policy.skill_stop_names,
thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort,
recursion_limit=int(getattr(policy, "recursion_limit", 1000)),
require_visible_text=finalize_only,
stage="step2_leader",
validate=_leader_failed_reason,
)
ai = _latest_ai_message(messages)
# tool_calls 取最后一条 AI 消息(派活就在这条);正文取最后一条有文字的 AI 消息。
final_text = _latest_ai_text(messages)
if finalize_only and not final_text.strip():
raise RuntimeError("总控最终收口未产出正文")
tool_calls = (ai.get("tool_calls") if ai else None) or []
# 本轮工具调用(派活 / 澄清),带进 LeaderTurn 供草稿 run 重建步骤卡。
turn_tool_calls = _normalize_tool_calls(tool_calls)
@@ -830,6 +817,7 @@ class InProcessRoundtableGateway:
message=seat_task,
requested_model=model,
excluded_tools=policy.excluded_tools,
allowed_tools=getattr(policy, "allowed_tools", None),
skill_stop_names=policy.skill_stop_names,
thinking_enabled=(
policy.thinking_enabled if seat_thinking is None else seat_thinking
@@ -837,9 +825,9 @@ class InProcessRoundtableGateway:
reasoning_effort=(
policy.reasoning_effort if seat_reasoning is None else seat_reasoning
),
recursion_limit=int(getattr(policy, "recursion_limit", 1000)),
subagent_enabled=seat_subagent,
stage="step2_seat",
validate=_seat_empty_reason,
)
return SeatTurn(
final_text=_latest_ai_text(messages),
@@ -867,15 +855,17 @@ class InProcessRoundtableGateway:
message=message,
requested_model=current_model,
excluded_tools=policy.excluded_tools,
allowed_tools=getattr(policy, "allowed_tools", None),
skill_stop_names=policy.skill_stop_names,
thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort,
recursion_limit=int(getattr(policy, "recursion_limit", 1000)),
stage="step3_report",
)
switches.extend(sw)
current_model = used or current_model
summary = _latest_ai_text(messages) or summary
html = _read_report_html(self._app, report_thread_id, self._user_id)
html = await file_io(_read_report_html, self._app, report_thread_id, self._user_id)
if _html_complete(html):
break
if attempt >= _ARTIFACT_RETRY_LIMIT:
@@ -925,15 +915,17 @@ class InProcessRoundtableGateway:
message=message,
requested_model=current_model,
excluded_tools=policy.excluded_tools,
allowed_tools=getattr(policy, "allowed_tools", None),
skill_stop_names=policy.skill_stop_names,
thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort,
recursion_limit=int(getattr(policy, "recursion_limit", 1000)),
stage="step3_summary",
)
switches.extend(sw)
current_model = used or current_model
closing = _latest_ai_text(messages) or closing
md = _read_summary_md(self._app, summary_thread_id, self._user_id)
md = await file_io(_read_summary_md, self._app, summary_thread_id, self._user_id)
if md and md.strip():
break
if attempt >= _ARTIFACT_RETRY_LIMIT:
@@ -981,9 +973,11 @@ class InProcessRoundtableGateway:
message=message,
requested_model=current_model,
excluded_tools=policy.excluded_tools,
allowed_tools=getattr(policy, "allowed_tools", None),
skill_stop_names=policy.skill_stop_names,
thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort,
recursion_limit=int(getattr(policy, "recursion_limit", 1000)),
stage="step3_dashboard",
)
current_model = used or current_model
@@ -30,6 +30,8 @@ from app.gateway.roundtable_job_executor import (
WORKER_ID,
params_from_snapshot,
)
from deerflow.config.system_settings import get_roundtable_room_limit_settings
from deerflow.persistence.roundtable_rooms.sql import NON_OCCUPYING_JOB_PHASES
logger = logging.getLogger(__name__)
@@ -125,6 +127,7 @@ class RoundtableJobDispatcher:
async def dispatch_once(self) -> int:
"""扫一轮可领取作业并逐个条件抢占。返回本轮成功启动的作业数。"""
now = datetime.now(UTC)
await self._expire_overdue_step2_rooms(now)
await self._reap_cancel_requested(now)
try:
candidate_ids = await self._store.list_claimable(now=now, limit=_CLAIM_BATCH)
@@ -172,6 +175,35 @@ class RoundtableJobDispatcher:
)
return started
async def _expire_overdue_step2_rooms(self, now: datetime) -> int:
"""Persist hard-timeout cancellation even when no browser is connected."""
max_minutes = getattr(
get_roundtable_room_limit_settings(),
"max_hold_minutes",
30,
)
request_cancel_expired = getattr(
self._store,
"request_cancel_expired_step2",
None,
)
if request_cancel_expired is None:
return 0
try:
expired = await request_cancel_expired(
created_before=now - timedelta(minutes=max_minutes),
non_occupying_phases=tuple(NON_OCCUPYING_JOB_PHASES),
)
except Exception: # noqa: BLE001
logger.warning("[roundtable-jobs] hard-timeout scan failed", exc_info=True)
return 0
if expired:
logger.info(
"[roundtable-jobs] requested cancellation for %d overdue Step-2 room(s)",
expired,
)
return expired
async def _reap_cancel_requested(self, now: datetime) -> int:
"""收口持租 worker 崩溃后遗留的 ``cancel_requested`` 作业。
@@ -29,8 +29,10 @@ from deerflow.agents.roundtable_orchestrator import (
SeatRef,
run_orchestration,
)
from deerflow.config.system_settings import get_roundtable_room_limit_settings
from deerflow.persistence.roundtable_diagnostics import DiagnosticsRecorder, get_default_store
from deerflow.persistence.roundtable_drafts.sql import DraftConcurrentWriteError
from deerflow.persistence.roundtable_rooms.sql import NON_OCCUPYING_JOB_PHASES
from deerflow.runtime.user_context import reset_current_user, set_current_user
logger = logging.getLogger(__name__)
@@ -67,6 +69,26 @@ _DRAFT_WRITE_RETRIES = 3
ROUNDTABLE_BACKGROUND_REPORT_ENABLED = True
def _background_step2_room_expired(row: dict[str, Any] | None) -> bool:
"""Whether a background job exceeded the configurable Step-2 hard limit."""
if not row or str(row.get("phase") or "") in NON_OCCUPYING_JOB_PHASES:
return False
raw_created = row.get("created_at")
if isinstance(raw_created, datetime):
created_at = raw_created
elif isinstance(raw_created, str) and raw_created:
try:
created_at = datetime.fromisoformat(raw_created)
except ValueError:
return False
else:
return False
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=UTC)
max_minutes = getattr(get_roundtable_room_limit_settings(), "max_hold_minutes", 30)
return datetime.now(UTC) >= created_at + timedelta(minutes=max_minutes)
class _JobUser:
"""最小 CurrentUser:满足 user_context 的 ``.id`` 结构协议。"""
@@ -439,6 +461,11 @@ class RoundtableJobExecutor:
self._watch_cancellation(params.job_id, main_task, params.lease_owner)
)
try:
# 全局设置在作业真正启动时读取并锁定;运行中修改只影响下一次会商,
# 避免本作业在半途因配置变化突然提前或延后收口。
max_cycles = int(
getattr(get_roundtable_room_limit_settings(), "max_cycles", 8)
)
result = await run_orchestration(
gateway=gateway,
progress=progress,
@@ -453,6 +480,7 @@ class RoundtableJobExecutor:
plan=params.orchestration_plan,
gather_first=params.gather_first,
business_code=params.business_code,
max_cycles=max_cycles,
)
finally:
watcher.cancel()
@@ -559,6 +587,22 @@ class RoundtableJobExecutor:
except Exception: # noqa: BLE001 — DB 读失败不中断看门狗,下轮重试
continue
status = (row.get("status") or "") if row is not None else ""
if status in {"queued", "running", "awaiting_input"} and _background_step2_room_expired(row):
logger.info(
"roundtable job %s reached the Step-2 room hard limit; requesting cancellation",
job_id,
)
try:
requested = await self._store.request_cancel(job_id, user_id=None)
if requested is not None:
row = requested
status = str(requested.get("status") or "")
except Exception: # noqa: BLE001
logger.warning(
"roundtable job %s hard-limit cancellation failed",
job_id,
exc_info=True,
)
if status == "cancel_requested":
logger.info(
"roundtable job %s: detected cancel_requested in DB, finalizing "
@@ -1,9 +1,11 @@
"""圆桌会商「模型自动容错(换下一个模型)」共享助手。
会商的第二步(leader / seat)与第三步(summary / report / dashboard / structure)单轮执行时,
若所用模型出现问题(抛异常 / idle 卡死 / 中间件兜底错误文案 / 交付为空),应自动切到
若所用模型出现真实调用问题(抛异常 / idle 卡死 / 中间件兜底错误文案),应自动切到
**下一个可用模型**继续,并把「『X』模型异常 → 已切换『Y』模型」告知用户。
仅有思考、暂时没有可见正文或交付为空不属于模型故障,不得据此切换模型。
本模块只放**与执行路径无关**的纯逻辑,供前台(``multi_agent.py``)与后台
(``roundtable_inprocess_gateway.py``)两条路径共用:
@@ -55,8 +57,8 @@ def reason_text(reason: str | None) -> str:
def model_fallback_enabled() -> bool:
"""模型自动容错总开关(默认开;设 ``ROUNDTABLE_MODEL_FALLBACK=0/false/off`` 关闭)。
关闭时 ``fallback_model_chain`` 只返回单个候选(原模型),即恢复旧行为:模型失败就失败,
不再尝试其它模型。
该开关只控制真实模型错误后的换模。仅有 reasoning、没有可见正文不再作为换模条件。
关闭时 ``fallback_model_chain`` 只返回单个候选(原模型)。
"""
raw = (os.getenv("ROUNDTABLE_MODEL_FALLBACK", "1") or "1").strip().lower()
return raw not in {"0", "false", "no", "off"}
@@ -85,7 +87,7 @@ def fallback_model_chain(current: str | None) -> list[str]:
- ``current`` 为空/None → 从 ``models[0]`` 起(即后端默认模型),后接其余;
- ``current`` 在配置内 → 它排第一,其余按配置顺序补齐;
- ``current`` 不在配置内(自定义/未知名)→ 仍排第一,再补齐全部配置模型;
- 总开关关闭 → 只返回单个候选(``current`` 或默认模型),等同旧行为;
- 总开关关闭 → 只返回单个候选(``current`` 或默认模型);
- 拿不到任何配置模型 → 返回 ``[current]``(current 为空则空列表,调用方自行退化为不换模型)。
"""
all_names = _config_model_names()
@@ -0,0 +1,432 @@
"""Admission helper shared by step-2 init and background job start.
The durable database lease is the source of truth. Gateway run inspection is
kept as a reconciliation fallback for legacy/untracked runs. When the limit is
enabled, missing room storage fails closed so a deployment error cannot bypass
the cap.
"""
from __future__ import annotations
from typing import Any
from fastapi import HTTPException, Request
from sqlalchemy import select
from deerflow.config.system_settings import get_roundtable_room_limit_settings
from deerflow.persistence.roundtable_jobs.sql import compute_active_dedupe_key
from deerflow.runtime.user_context import get_effective_user_id
def _room_key_from_payload(meta: Any, owner_user_id: str | None = None) -> str | None:
"""Resolve the live-execution key from thread/run metadata."""
if not isinstance(meta, dict):
return None
stamped = str(meta.get("roundtable_room_key") or "").strip()
if stamped:
return stamped
draft_id = str(meta.get("draft_id") or "").strip() or None
if not draft_id:
return None
task_id = str(meta.get("task_id") or meta.get("taskId") or "").strip() or None
return compute_active_dedupe_key(draft_id, owner_user_id, task_id)
def _room_scope_key_from_payload(
meta: Any,
owner_user_id: str | None = None,
) -> str | None:
"""Legacy persisted scope used only to de-duplicate fallback run evidence."""
if not isinstance(meta, dict):
return None
draft_id = str(meta.get("draft_id") or "").strip() or None
if not draft_id:
return None
task_id = str(meta.get("task_id") or meta.get("taskId") or "").strip() or None
owner = owner_user_id or str(meta.get("user_id") or "").strip() or None
return compute_active_dedupe_key(draft_id, owner, task_id)
def _thread_id_of(record: Any) -> str:
return str(getattr(record, "thread_id", "") or "").strip()
async def _all_active_run_records(request: Request) -> list[Any]:
"""Every in-flight Gateway run, regardless of which account started it."""
records: list[Any] = []
seen_ids: set[str] = set()
def _append(items: list[Any] | None) -> None:
for item in items or []:
task = getattr(item, "task", None)
if task is not None and callable(getattr(task, "done", None)) and task.done():
# A terminal asyncio task with a lagging pending/running status
# is cleanup residue, not a live room. This also prevents one
# exceptional worker-finalization path from pinning capacity.
continue
run_id = str(getattr(item, "run_id", "") or "")
thread_id = _thread_id_of(item)
marker = run_id or thread_id
if not marker or marker in seen_ids:
continue
seen_ids.add(marker)
records.append(item)
gate = getattr(request.app.state, "concurrency_gate", None)
if gate is not None and getattr(gate, "enabled", False):
try:
leases = await gate.list_active()
except Exception:
leases = None
if leases:
_append(list(leases))
run_mgr = getattr(request.app.state, "run_manager", None)
if run_mgr is not None:
try:
_append(await run_mgr.list_active())
except Exception:
pass
return records
async def _room_keys_from_thread_rows(
thread_ids: list[str],
) -> tuple[dict[str, str | None], set[str]]:
"""Load room keys for *all* owners. Returns (keys, unresolved thread ids)."""
remaining = {tid for tid in thread_ids if tid}
keys: dict[str, str | None] = {}
if not remaining:
return keys, remaining
try:
from deerflow.persistence.engine import get_session_factory
from deerflow.persistence.thread_meta.model import ThreadMetaRow
session_factory = get_session_factory()
if session_factory is None:
return keys, remaining
async with session_factory() as session:
rows = (
await session.execute(
select(
ThreadMetaRow.thread_id,
ThreadMetaRow.user_id,
ThreadMetaRow.metadata_json,
).where(ThreadMetaRow.thread_id.in_(list(remaining)))
)
).all()
for thread_id, user_id, metadata_json in rows:
remaining.discard(thread_id)
key = _room_key_from_payload(metadata_json, user_id)
if key:
keys[key] = _room_scope_key_from_payload(metadata_json, user_id)
except Exception:
return keys, remaining
return keys, remaining
async def extra_live_roundtable_keys(request: Request) -> dict[str, str | None]:
"""Room keys of step-2 sessions whose LangGraph runs are still in flight.
Scans the whole Gateway process: every user's runs are included. Several
seat runs in one session share ``roundtable_room_key``. Intent and
recommend threads have no such key and are ignored.
"""
records = await _all_active_run_records(request)
keys: dict[str, str | None] = {}
missing_tids: list[str] = []
seen_threads: set[str] = set()
for rec in records:
tid = _thread_id_of(rec)
if not tid or tid in seen_threads:
continue
seen_threads.add(tid)
meta = getattr(rec, "metadata", None)
owner = str(getattr(rec, "user_id", "") or "").strip() or None
if owner is None and isinstance(meta, dict):
owner = str(meta.get("user_id") or "").strip() or None
key = _room_key_from_payload(meta if isinstance(meta, dict) else None, owner)
if key:
keys[key] = _room_scope_key_from_payload(meta, owner)
continue
missing_tids.append(tid)
if not missing_tids:
return keys
from_rows, remaining = await _room_keys_from_thread_rows(missing_tids)
keys.update(from_rows)
thread_store = getattr(request.app.state, "thread_store", None)
if thread_store is None or not remaining:
return keys
for tid in remaining:
try:
row = await thread_store.get(tid, user_id=None)
except Exception:
continue
if not isinstance(row, dict):
continue
owner = str(row.get("user_id") or "").strip() or None
key = _room_key_from_payload(row.get("metadata"), owner)
if key:
keys[key] = _room_scope_key_from_payload(row.get("metadata"), owner)
return keys
async def backend_occupancy_keys(request: Request) -> dict[str, str | None]:
"""Live Gateway runs used to reconcile legacy leases, all accounts."""
return await extra_live_roundtable_keys(request)
def current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is not None and getattr(user, "id", None):
return str(user.id)
return get_effective_user_id()
def _store(request: Request):
return getattr(request.app.state, "roundtable_room_store", None)
async def admit_roundtable_room(
request: Request,
*,
draft_id: str | None,
task_id: str | None,
room_session_id: str | None = None,
) -> dict[str, Any]:
settings = get_roundtable_room_limit_settings()
store = _store(request)
extra_keys = await backend_occupancy_keys(request)
clean_room_session_id = (room_session_id or "").strip() or None
if settings.enabled and clean_room_session_id is None:
return {
"ok": False,
"enabled": True,
"max_rooms": settings.max_rooms,
"active_count": len(extra_keys),
"renewed": False,
"code": "ROOM_SESSION_REQUIRED",
"message": "会商会话标识缺失,请刷新页面后重试。",
}
if store is None:
return {
"ok": not settings.enabled,
"enabled": settings.enabled,
"max_rooms": settings.max_rooms,
"active_count": len(extra_keys),
"renewed": False,
"code": "" if not settings.enabled else "ROOM_STORE_UNAVAILABLE",
"message": "" if not settings.enabled else "会商并发服务暂不可用,请稍后再试。",
}
user_id = current_user_id(request)
clean_draft = (draft_id or "").strip() or None
clean_task = (task_id or "").strip() or None
return await store.admit(
draft_id=clean_draft,
user_id=user_id,
task_id=clean_task,
room_session_id=clean_room_session_id,
enabled=settings.enabled,
max_rooms=settings.max_rooms,
max_hold_minutes=getattr(settings, "max_hold_minutes", 30),
extra_keys=extra_keys,
)
async def renew_roundtable_room(
request: Request,
*,
draft_id: str | None,
task_id: str | None,
room_session_id: str | None = None,
) -> dict[str, Any]:
"""Renew an existing browser lease; never admit or recreate a room."""
settings = get_roundtable_room_limit_settings()
store = _store(request)
extra_keys = await backend_occupancy_keys(request)
if store is None:
return {
"ok": not settings.enabled,
"enabled": settings.enabled,
"max_rooms": settings.max_rooms,
"active_count": len(extra_keys),
"renewed": False,
"code": "" if not settings.enabled else "ROOM_STORE_UNAVAILABLE",
"message": "" if not settings.enabled else "会商并发服务暂不可用,请稍后再试。",
}
return await store.renew(
draft_id=(draft_id or "").strip() or None,
user_id=current_user_id(request),
task_id=(task_id or "").strip() or None,
room_session_id=(room_session_id or "").strip() or None,
enabled=settings.enabled,
max_rooms=settings.max_rooms,
max_hold_minutes=getattr(settings, "max_hold_minutes", 30),
extra_keys=extra_keys,
)
async def release_roundtable_room(
request: Request,
*,
draft_id: str | None,
task_id: str | None,
room_session_id: str | None = None,
) -> bool:
"""Best-effort release used by both the API route and disconnect cleanup."""
store = _store(request)
if store is None:
return False
return await store.release(
draft_id=(draft_id or "").strip() or None,
user_id=current_user_id(request),
task_id=(task_id or "").strip() or None,
room_session_id=(room_session_id or "").strip() or None,
)
async def enforce_roundtable_room(
request: Request,
*,
draft_id: str | None,
task_id: str | None,
room_session_id: str | None = None,
) -> dict[str, Any]:
"""Admit the room or raise 409 before any step-2 model work starts."""
decision = await admit_roundtable_room(
request,
draft_id=draft_id,
task_id=task_id,
room_session_id=room_session_id,
)
if not decision.get("ok", False):
raise HTTPException(status_code=409, detail=decision)
return decision
async def enforce_existing_roundtable_room(
request: Request,
*,
draft_id: str | None,
task_id: str | None,
room_session_id: str | None = None,
) -> dict[str, Any]:
"""Require and renew an existing lease without performing admission."""
decision = await renew_roundtable_room(
request,
draft_id=draft_id,
task_id=task_id,
room_session_id=room_session_id,
)
if not decision.get("ok", False):
raise HTTPException(status_code=409, detail=decision)
return decision
async def _thread_room_scope(
request: Request,
thread_ids: list[str],
) -> tuple[str, str | None, str | None, str | None] | None:
"""Resolve ``(room_key, draft_id, owner_user_id, task_id)`` from metadata."""
wanted = [tid for tid in dict.fromkeys(thread_ids) if tid]
if not wanted:
return None
thread_store = getattr(request.app.state, "thread_store", None)
if thread_store is not None:
for tid in wanted:
try:
row = await thread_store.get(tid, user_id=None)
except Exception:
continue
if not isinstance(row, dict):
continue
meta = row.get("metadata")
owner = str(row.get("user_id") or "").strip() or None
key = _room_key_from_payload(meta, owner)
if key:
data = meta if isinstance(meta, dict) else {}
draft_id = str(data.get("draft_id") or "").strip() or None
task_id = str(data.get("task_id") or data.get("taskId") or "").strip() or None
return key, draft_id, owner, task_id
try:
from deerflow.persistence.engine import get_session_factory
from deerflow.persistence.thread_meta.model import ThreadMetaRow
session_factory = get_session_factory()
if session_factory is None:
return None
async with session_factory() as session:
rows = (
await session.execute(
select(
ThreadMetaRow.user_id,
ThreadMetaRow.metadata_json,
).where(ThreadMetaRow.thread_id.in_(wanted))
)
).all()
for owner, meta in rows:
key = _room_key_from_payload(meta, owner)
if key:
data = meta if isinstance(meta, dict) else {}
draft_id = str(data.get("draft_id") or "").strip() or None
task_id = str(data.get("task_id") or data.get("taskId") or "").strip() or None
return key, draft_id, owner, task_id
except Exception:
return None
return None
async def enforce_roundtable_run(
request: Request,
*,
draft_id: str | None,
task_id: str | None,
room_session_id: str | None = None,
thread_ids: list[str],
) -> dict[str, Any] | None:
"""Enforce the room cap at the final model-producing entry point.
Current clients send the draft scope on every run. A model run may only
renew the room that was admitted when Step 2 began; it must never create a
new room while the user resumes, answers a clarification, or intervenes.
Thread metadata is a compatibility fallback for initialized sessions.
"""
clean_draft = (draft_id or "").strip() or None
clean_task = (task_id or "").strip() or None
if clean_draft:
return await enforce_existing_roundtable_room(
request,
draft_id=clean_draft,
task_id=clean_task,
room_session_id=(room_session_id or "").strip() or None,
)
scope = await _thread_room_scope(request, thread_ids)
if scope is None:
return None
room_key, _stored_draft, _owner_user_id, _stored_task = scope
settings = get_roundtable_room_limit_settings()
store = _store(request)
if store is None:
if settings.enabled:
raise HTTPException(
status_code=503,
detail="Roundtable room store not available",
)
return None
decision = await store.renew_room_key(
room_key=room_key,
holder_user_id=current_user_id(request),
enabled=settings.enabled,
max_rooms=settings.max_rooms,
max_hold_minutes=getattr(settings, "max_hold_minutes", 30),
extra_keys=await backend_occupancy_keys(request),
)
if not decision.get("ok", False):
raise HTTPException(status_code=409, detail=decision)
return decision
@@ -10,12 +10,13 @@
角色(``role``):
- ``leader`` —— 总控派活:短决策,关 thinking + low reasoning(首字节快);
**只保留 ``agent_orchestration`` / ``ask_clarification`` / ``present_files``**,
**只保留 ``agent_orchestration`` / ``ask_clarification``**,
其余「干活类」工具(``write_file`` / ``bash`` / ``str_replace`` / ``read_file`` /
``ls`` / ``view_image`` / ``task`` / ``write_todos`` / ``memory`` / ``hindsight_*``)
一律禁用——否则弱模型(如 deepseek-chat)会概率性地**自己写文件、跑 bash 干活**
而不是派活(已在生产日志里观测到总控 ``wc -l outputs/*.md`` 自检自己写的报告)。
总控的唯一职责是「派活 / 澄清 / 收口呈现」,没有任何理由动文件系统或执行命令。
总控的唯一职责是「派活 / 澄清 / 文字收口」,没有任何理由动文件系统、呈现文件
或执行命令。最终收口轮会进一步使用空白名单,物理禁用全部工具。
并配合 ``SkillStopMiddleware`` 在 ``agent_orchestration`` 调用后停图,便于网关从
AIMessage.tool_calls 解析派活列表。
- ``seat`` —— 子智能体交付:开 thinking + medium reasoning;**可写文件**
@@ -61,11 +62,14 @@ from dataclasses import dataclass
# 注意:``seat`` / ``report`` 都包含 ``web_search`` —— 圆桌研讨里联网搜索一律禁用
# (与前端 ``_special_run`` 的 excluded_tools 对齐)。
#
# ``leader`` 用「白名单」思路反推黑名单:总控**只允许** agent_orchestration(派活)、
# ask_clarification(澄清)、present_files(收口呈现子智能体产出的文件)三件事,
# 因此把其余所有工具全部禁掉。关键是 write_file / bash / str_replace —— 留着它们,
# 弱模型会绕过派活直接自己写报告(生产日志已实锤)。memory / hindsight_* 一并禁,
# 配合 ``memory_injection_enabled=False`` 让单例总控不被上轮会话的记忆带偏席位。
# ``leader`` 必须使用真正的白名单。历史上的「白名单思路反推黑名单」会在新增工具时
# 自动泄漏能力,曾导致总控循环调用 page_report_template / glob / grep / skill_manage 等
# 一百余次。``_LEADER_EXCLUDED`` 继续保留为兼容和纵深防御,但安全边界由
# ``_LEADER_ALLOWED`` 决定。
_LEADER_ALLOWED: list[str] = [
"agent_orchestration",
"ask_clarification",
]
_LEADER_EXCLUDED: list[str] = [
"web_search",
"view_image",
@@ -81,6 +85,7 @@ _LEADER_EXCLUDED: list[str] = [
"hindsight_reflect",
"hindsight_retain",
"read_peer_delivery", # 总控靠 _collect_delivery_status 直读各席位,不需要这个席位侧工具
"present_files",
]
# seat:席位**可以写文件**(write_file / str_replace)产出 md 等成品文档,**bash 放开**
# (2026-06 决策,业务需要 bash 跑知识库检索 / Python 辅助分析)。禁派活 / 澄清 /
@@ -175,14 +180,16 @@ _LEADER_SKILL_STOP: list[str] = ["agent_orchestration"]
class RoundtableRunPolicy:
"""一个角色单轮 run 的归一化参数。
与 ``multi_agent._run_payload`` 的入参一一对应:``excluded_tools`` /
``skill_stop_names`` / ``thinking_enabled`` / ``reasoning_effort``。
与 ``multi_agent._run_payload`` 的入参一一对应。``allowed_tools`` 为
``None`` 时沿用普通工具解析;空列表表示物理禁用全部普通工具。
"""
excluded_tools: list[str]
allowed_tools: list[str] | None
skill_stop_names: list[str]
thinking_enabled: bool
reasoning_effort: str
recursion_limit: int
def roundtable_run_policy(role: str) -> RoundtableRunPolicy:
@@ -194,59 +201,75 @@ def roundtable_run_policy(role: str) -> RoundtableRunPolicy:
if role == "leader":
return RoundtableRunPolicy(
excluded_tools=list(_LEADER_EXCLUDED),
allowed_tools=list(_LEADER_ALLOWED),
skill_stop_names=list(_LEADER_SKILL_STOP),
thinking_enabled=False,
reasoning_effort="low",
recursion_limit=1000,
)
if role == "seat":
return RoundtableRunPolicy(
excluded_tools=list(_SEAT_EXCLUDED),
allowed_tools=None,
skill_stop_names=[],
thinking_enabled=True,
reasoning_effort="medium",
recursion_limit=1000,
)
if role == "seat_parallel":
# 同 seat(thinking + medium),但额外禁写文件 / 跑命令(防并行沙箱打架)。
return RoundtableRunPolicy(
excluded_tools=list(_SEAT_PARALLEL_EXCLUDED),
allowed_tools=None,
skill_stop_names=[],
thinking_enabled=True,
reasoning_effort="medium",
recursion_limit=1000,
)
if role == "report":
return RoundtableRunPolicy(
excluded_tools=list(_REPORT_EXCLUDED),
allowed_tools=None,
skill_stop_names=[],
thinking_enabled=True,
reasoning_effort="medium",
recursion_limit=1000,
)
if role == "summary":
return RoundtableRunPolicy(
excluded_tools=list(_SUMMARY_EXCLUDED),
allowed_tools=None,
skill_stop_names=[],
thinking_enabled=True,
reasoning_effort="medium",
recursion_limit=1000,
)
if role == "action_plan":
return RoundtableRunPolicy(
excluded_tools=list(_ACTION_PLAN_EXCLUDED),
allowed_tools=None,
skill_stop_names=[],
thinking_enabled=True,
reasoning_effort="medium",
recursion_limit=1000,
)
if role == "dashboard":
return RoundtableRunPolicy(
excluded_tools=list(_DASHBOARD_EXCLUDED),
allowed_tools=None,
skill_stop_names=[],
thinking_enabled=True,
reasoning_effort="medium",
recursion_limit=1000,
)
if role == "ingest":
return RoundtableRunPolicy(
excluded_tools=list(_INGEST_EXCLUDED),
allowed_tools=None,
skill_stop_names=[],
thinking_enabled=True,
reasoning_effort="medium",
recursion_limit=1000,
)
raise ValueError(
f"unknown roundtable role: {role!r} (expected leader/seat/seat_parallel/report/summary/action_plan/dashboard/ingest)"
@@ -1,7 +1,8 @@
description: AI 写作流程的素材收集专家,负责为写作任务生成检索关键词、从全部启用技能中自由编排检索、并对检索回来的素材按相关度排序筛选
description: AI 写作流程的素材收集专家,负责为写作任务生成检索关键词、用配置的技能编排检索、并对检索回来的素材按相关度排序筛选
id: ai-writing-researcher
name: 素材收集专家
# 通用检索不再使用这里的 skills 白名单:素材收集从全部启用技能里由模型按
# 写作意图自由挑选(内置「知识库检索」始终保底)。此字段仅保留兼容旧版。
# 通用检索使用的技能白名单(按顺序编排)。技能目录动辄数百个,全量列进
# 选择提示词会把模型上下文撑爆,因此候选池限定在这里配置的技能;内置
# 「知识库检索」始终保底。admin 可在智能体管理页替换或追加。
skills:
- knowledge-base-search
@@ -10,6 +10,8 @@
4. 按统一量表独立评分,再给出横向比较和推荐组合。
5. 只使用输入中的 `agentId`,不得改名或虚构不存在的候选项。
本阶段没有用户中途回答入口。不得调用 `ask_clarification`,也不得在正文中向用户提问、索要材料、要求确认或要求重试。若证据不足,采用保守假设、降低对应维度评分,并将缺口写入“数据限制与待核验”,仍须一次性完成所有候选项的评判。
## 百分制量表
- 任务匹配度:25
@@ -24,7 +26,7 @@
## 输出要求
先用简洁 Markdown 给出总体评价、主要差异、推荐组合和需要人工确认的冲突。随后在回复末尾输出:
先用简洁 Markdown 给出总体评价、主要差异、推荐组合和需要人工确认的冲突,不要重复复述同一结论或输出多个评判版本。随后在回复末尾输出:
````markdown
[JUDGEMENT_READY]
@@ -42,6 +42,18 @@ _DEFAULT_LOOKBACK_HOURS = {"minute": 3, "hour": 48}
# assistant_id values that are not real custom-agent ids (the default lead agent).
_DEFAULT_ASSISTANT_IDS = {"", "default", "lead_agent", "agent"}
_DEFAULT_AGENT_NAME = "默认助手"
_UNKNOWN_USER_NAME = "未知用户"
def _display_agent_name(assistant_id: str, agent_names: dict[str, str]) -> str:
if assistant_id in _DEFAULT_ASSISTANT_IDS:
return _DEFAULT_AGENT_NAME
return agent_names.get(assistant_id) or assistant_id
def _display_user_name(user_id: str, user_emails: dict[str, str]) -> str:
return user_emails.get(user_id) or user_id or _UNKNOWN_USER_NAME
class ActiveRunItem(BaseModel):
@@ -244,10 +256,7 @@ async def list_active_runs(request: Request) -> ActiveRunsResponse:
user_id = r.user_id or (meta_row.user_id if meta_row else "") or ""
metadata = (meta_row.metadata_json if meta_row else None) or {}
assistant_id = r.assistant_id or ""
if assistant_id in _DEFAULT_ASSISTANT_IDS:
agent_name = "榛樿鍔╂墜"
else:
agent_name = agent_names.get(assistant_id) or assistant_id
agent_name = _display_agent_name(assistant_id, agent_names)
items.append(
ActiveRunItem(
run_id=r.run_id,
@@ -256,7 +265,7 @@ async def list_active_runs(request: Request) -> ActiveRunsResponse:
created_at=datetime.fromtimestamp(r.created_at, tz=UTC).isoformat() if r.created_at else "",
elapsed_seconds=_elapsed_seconds_from_epoch(r.created_at, now),
user_id=user_id,
user_name=user_emails.get(user_id) or user_id or "鏈煡鐢ㄦ埛",
user_name=_display_user_name(user_id, user_emails),
agent_id=assistant_id,
agent_name=agent_name,
model_name=r.model_name,
@@ -272,10 +281,7 @@ async def list_active_runs(request: Request) -> ActiveRunsResponse:
user_id = (meta_row.user_id if meta_row else "") or ""
metadata = (meta_row.metadata_json if meta_row else None) or r.metadata or {}
assistant_id = r.assistant_id or ""
if assistant_id in _DEFAULT_ASSISTANT_IDS:
agent_name = "默认助手"
else:
agent_name = agent_names.get(assistant_id) or assistant_id
agent_name = _display_agent_name(assistant_id, agent_names)
items.append(
ActiveRunItem(
run_id=r.run_id,
@@ -284,7 +290,7 @@ async def list_active_runs(request: Request) -> ActiveRunsResponse:
created_at=r.created_at,
elapsed_seconds=_elapsed_seconds(r.created_at, now),
user_id=user_id,
user_name=user_emails.get(user_id) or user_id or "未知用户",
user_name=_display_user_name(user_id, user_emails),
agent_id=assistant_id,
agent_name=agent_name,
model_name=str((r.metadata or {}).get("model_name") or ""),
@@ -0,0 +1,74 @@
"""Admin view and pause control for historical audit-data cleanup."""
from __future__ import annotations
import asyncio
from datetime import datetime
from time import time
from typing import Any
from fastapi import APIRouter, Request
from pydantic import BaseModel
from app.gateway.admin_audit_retention_control import (
is_paused,
read_status,
set_paused,
write_status,
)
from app.gateway.routers.admin_users import _require_admin
from deerflow.config.runtime_paths import runtime_home
from deerflow.persistence.types import BEIJING_TZ
router = APIRouter(prefix="/api/admin/audit-retention", tags=["admin-audit-retention"])
class RetentionControlRequest(BaseModel):
paused: bool
def _status_payload() -> dict[str, Any]:
root = runtime_home()
value = read_status(root)
paused = is_paused(root)
value["paused"] = paused
value["pause_requested"] = paused and value.get("status") == "running"
if value["pause_requested"]:
value["display_status"] = "pausing"
elif paused:
value["display_status"] = "paused"
else:
value["display_status"] = value.get("status", "scheduled")
return value
def _apply_control(paused: bool) -> dict[str, Any]:
root = runtime_home()
set_paused(root, paused)
value = read_status(root)
if paused and value.get("status") != "running":
value.update(status="paused", current_source=None, next_run_at=None)
elif not paused and value.get("status") == "paused":
value.update(status="scheduled", next_run_at=time())
write_status(root, value)
return _status_payload()
@router.get("")
async def get_retention_status(request: Request) -> dict[str, Any]:
await _require_admin(request)
return await asyncio.to_thread(_status_payload)
@router.put("/control")
async def update_retention_control(
payload: RetentionControlRequest,
request: Request,
) -> dict[str, Any]:
await _require_admin(request)
value = await asyncio.to_thread(_apply_control, payload.paused)
value["control_updated_at"] = datetime.now(BEIJING_TZ).isoformat(timespec="seconds")
return value
__all__ = ["router"]
@@ -20,34 +20,37 @@ import os
import re
from collections.abc import AsyncIterator
from contextvars import ContextVar
from datetime import UTC, datetime, timedelta
from datetime import time as datetime_time
from hashlib import sha256
from time import perf_counter
from typing import Any
from datetime import UTC, datetime, timedelta, time as datetime_time
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from langchain_core.messages import HumanMessage, SystemMessage
from pydantic import BaseModel, Field
from sqlalchemy import and_, case, desc, func, not_, or_, select, text
from langchain_core.messages import HumanMessage, SystemMessage
from app.gateway.admin_query_window import (
ADMIN_QUERY_WINDOW_DAYS,
resolve_admin_query_window,
)
from app.gateway.deps import (
get_config,
get_agent_store,
get_checkpointer,
get_config,
get_current_user_from_request,
get_thread_store,
)
from app.gateway.routers.memory import _build_v1_provider, _is_v1
from deerflow.agents.memory import BuiltinFileProvider
from deerflow.config.app_config import AppConfig
from deerflow.config.paths import get_paths
from deerflow.config.system_settings import load_system_settings
from deerflow.models import create_chat_model
from deerflow.runtime import serialize_channel_values
from app.gateway.routers.memory import _build_v1_provider, _is_v1
from deerflow.persistence.engine import get_session_factory
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
from deerflow.persistence.engine import get_session_factory
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
from deerflow.persistence.run.model import RunRow
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskRunRow
@@ -55,6 +58,7 @@ from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.tool_metrics.model import ToolCallMetricRow
from deerflow.persistence.types import BEIJING_TZ
from deerflow.persistence.user.model import UserRow
from deerflow.runtime import serialize_channel_values
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin/users", tags=["admin-users"])
@@ -92,6 +96,13 @@ _LEADERBOARD_LOCK_WAIT_SECONDS = max(
1,
int(os.getenv("ADMIN_LEADERBOARD_LOCK_WAIT_SECONDS", "5")),
)
# The checkpoint fallback exists for installations that predate compact run
# summaries. Keep its work deliberately small: current run summaries are the
# indexed source of truth for an administrator's recent audit view.
_ADMIN_QA_CHECKPOINT_THREAD_LIMIT = 25
_ADMIN_QA_CHECKPOINT_MESSAGE_LIMIT = 100
_ADMIN_QA_CHECKPOINT_ITEM_LIMIT = 500
_ADMIN_QA_RUN_CANDIDATE_LIMIT = 1_000
_LEADERBOARD_QUEUE_ON_REQUEST_ENABLED = os.getenv(
"ADMIN_LEADERBOARD_QUEUE_ON_REQUEST_ENABLED",
os.getenv("ADMIN_LEADERBOARD_WEB_QUEUE_ENABLED", "true"),
@@ -307,20 +318,6 @@ class AdminLeaderboardResponse(BaseModel):
trends: list[AdminLeaderboardTrendPoint] = Field(default_factory=list)
class AdminUserAnalyticsResponse(BaseModel):
range_days: int
since: str
until: str
user: AdminLeaderboardUser
overview: AdminLeaderboardOverview
skills: list[AdminLeaderboardSkill] = Field(default_factory=list)
tools: list[AdminLeaderboardSkill] = Field(default_factory=list)
models: list[AdminLeaderboardDimension] = Field(default_factory=list)
agents: list[AdminLeaderboardDimension] = Field(default_factory=list)
run_statuses: list[AdminLeaderboardDimension] = Field(default_factory=list)
trends: list[AdminLeaderboardTrendPoint] = Field(default_factory=list)
class AdminLeaderboardReportResponse(BaseModel):
report: str
generated_at: str
@@ -352,7 +349,7 @@ class AdminLeaderboardSnapshotStatusResponse(BaseModel):
class AdminLeaderboardSnapshotBackfillRequest(BaseModel):
days: int = Field(default=30, ge=1, le=365)
days: int = Field(default=30, ge=1, le=ADMIN_QUERY_WINDOW_DAYS)
since: str | None = Field(default=None, description="Inclusive range start, ISO datetime")
until: str | None = Field(default=None, description="Inclusive range end, ISO datetime")
force: bool = Field(default=False, description="Queue dates even when a ready snapshot already exists")
@@ -1739,7 +1736,7 @@ async def _refresh_daily_leaderboard_snapshot(
datetime.now(BEIJING_TZ).isoformat(),
(perf_counter() - snapshot_start) * 1000,
)
except asyncio.TimeoutError:
except TimeoutError:
logger.warning("Daily leaderboard snapshot timed out: %s", job_key)
await _store_daily_snapshot(
session_factory,
@@ -1845,7 +1842,7 @@ async def _load_or_schedule_daily_leaderboard(
@router.get("/leaderboard", response_model=AdminLeaderboardResponse)
async def get_user_leaderboard(
request: Request,
days: int = Query(default=30, ge=1, le=365),
days: int = Query(default=30, ge=1, le=ADMIN_QUERY_WINDOW_DAYS),
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
exclude_user_ids: list[str] | None = Query(default=None, description="User ids excluded from this leaderboard aggregation"),
@@ -1861,7 +1858,11 @@ async def get_user_leaderboard(
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
settings = load_system_settings().leaderboard
since_dt, until_dt, _range_days = _resolve_range(days, since, until)
since_dt, until_dt, _range_days = resolve_admin_query_window(
days=days,
since=since,
until=until,
)
return await _load_or_schedule_daily_leaderboard(
session_factory,
settings,
@@ -1878,7 +1879,7 @@ async def get_user_leaderboard(
@router.get("/leaderboard/snapshots", response_model=AdminLeaderboardSnapshotStatusResponse)
async def get_leaderboard_snapshot_status(
request: Request,
days: int = Query(default=30, ge=1, le=365),
days: int = Query(default=30, ge=1, le=ADMIN_QUERY_WINDOW_DAYS),
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
) -> AdminLeaderboardSnapshotStatusResponse:
@@ -1889,7 +1890,11 @@ async def get_leaderboard_snapshot_status(
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
settings = load_system_settings().leaderboard
since_dt, until_dt, _range_days = _resolve_range(days, since, until)
since_dt, until_dt, _range_days = resolve_admin_query_window(
days=days,
since=since,
until=until,
)
stat_dates = _stat_dates_for_range(since_dt, until_dt)
*_scope, settings_hash = _resolve_leaderboard_snapshot_scope(settings)
rows = await _load_daily_snapshot_rows(session_factory, stat_dates, settings_hash)
@@ -1940,7 +1945,11 @@ async def queue_leaderboard_snapshot_backfill(
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
settings = load_system_settings().leaderboard
since_dt, until_dt, _range_days = _resolve_range(body.days, body.since, body.until)
since_dt, until_dt, _range_days = resolve_admin_query_window(
days=body.days,
since=body.since,
until=body.until,
)
stat_dates = _stat_dates_for_range(since_dt, until_dt)
*_scope, settings_hash = _resolve_leaderboard_snapshot_scope(settings)
queued_dates = await _queue_daily_leaderboard_snapshots(
@@ -1963,7 +1972,7 @@ async def queue_leaderboard_snapshot_backfill(
@router.post("/leaderboard/refresh-today", response_model=AdminLeaderboardResponse)
async def refresh_today_leaderboard(
request: Request,
days: int = Query(default=30, ge=1, le=365),
days: int = Query(default=30, ge=1, le=ADMIN_QUERY_WINDOW_DAYS),
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
exclude_user_ids: list[str] | None = Query(default=None, description="User ids excluded from this leaderboard aggregation"),
@@ -1986,6 +1995,11 @@ async def refresh_today_leaderboard(
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
settings = load_system_settings().leaderboard
since_dt, until_dt, _range_days = resolve_admin_query_window(
days=days,
since=since,
until=until,
)
(
excluded,
resolved_include_scheduled,
@@ -2012,7 +2026,6 @@ async def refresh_today_leaderboard(
include_failed=resolved_include_failed,
)
since_dt, until_dt, _range_days = _resolve_range(days, since, until)
return await _load_or_schedule_daily_leaderboard(
session_factory,
settings,
@@ -2079,7 +2092,7 @@ async def _build_leaderboard_report_prompt(
@router.post("/leaderboard/report", response_model=AdminLeaderboardReportResponse)
async def generate_user_leaderboard_report(
request: Request,
days: int = Query(default=30, ge=1, le=365),
days: int = Query(default=30, ge=1, le=ADMIN_QUERY_WINDOW_DAYS),
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
limit: int = Query(default=30, ge=10, le=100),
@@ -2093,7 +2106,11 @@ async def generate_user_leaderboard_report(
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
settings = load_system_settings().leaderboard
since_dt, until_dt, _range_days = _resolve_range(days, since, until)
since_dt, until_dt, _range_days = resolve_admin_query_window(
days=days,
since=since,
until=until,
)
resolved_model_name = _resolve_report_model_name(config, model_name)
cache_key = _report_cache_key(since=since_dt, until=until_dt, limit=limit, model_name=resolved_model_name, settings=settings)
cached = _cached_report(cache_key)
@@ -2135,7 +2152,7 @@ async def generate_user_leaderboard_report(
@router.post("/leaderboard/report/stream")
async def stream_user_leaderboard_report(
request: Request,
days: int = Query(default=30, ge=1, le=365),
days: int = Query(default=30, ge=1, le=ADMIN_QUERY_WINDOW_DAYS),
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
limit: int = Query(default=30, ge=10, le=100),
@@ -2149,7 +2166,11 @@ async def stream_user_leaderboard_report(
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
settings = load_system_settings().leaderboard
since_dt, until_dt, _range_days = _resolve_range(days, since, until)
since_dt, until_dt, _range_days = resolve_admin_query_window(
days=days,
since=since,
until=until,
)
resolved_model_name = _resolve_report_model_name(config, model_name)
cache_key = _report_cache_key(since=since_dt, until=until_dt, limit=limit, model_name=resolved_model_name, settings=settings)
@@ -2203,62 +2224,6 @@ async def stream_user_leaderboard_report(
)
@router.get("/{user_id}/analytics", response_model=AdminUserAnalyticsResponse)
async def get_user_analytics(
user_id: str,
request: Request,
days: int = Query(default=30, ge=1, le=365),
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
limit: int = Query(default=20, ge=5, le=100),
) -> AdminUserAnalyticsResponse:
"""Return one user's admin analytics profile."""
await _require_admin(request)
session_factory = get_session_factory()
if session_factory is None:
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
settings = load_system_settings().leaderboard
common = {
"include_scheduled": settings.include_scheduled,
"include_admins": settings.include_admins,
"include_failed": settings.include_failed,
}
tool_common = {
"include_scheduled": settings.include_scheduled,
"include_admins": settings.include_admins,
}
since_dt, until_dt, range_days = _resolve_range(days, since, until)
async with session_factory() as session:
await _set_analytics_read_timeout(session)
users = await _query_user_leaderboard(session, since_dt, until_dt, user_id=user_id, **common)
if not users:
raise HTTPException(status_code=404, detail="User not found")
overview = await _query_overview(session, since_dt, until_dt, user_id=user_id, **common)
skills = await _query_call_rankings(session, since_dt, until_dt, user_id=user_id, field="skill", limit=limit, **tool_common)
tools = await _query_call_rankings(session, since_dt, until_dt, user_id=user_id, field="tool", limit=limit, **tool_common)
models = await _query_model_rankings(session, since_dt, until_dt, user_id=user_id, limit=limit, **common)
if not models:
models = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="model", limit=limit, **common)
agents = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="agent", limit=limit, **common)
run_statuses = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="status", limit=limit, **common)
trends = await _query_trends(session, since_dt, until_dt, user_id=user_id, **common)
return AdminUserAnalyticsResponse(
range_days=range_days,
since=_iso(since_dt),
until=_iso(until_dt),
user=users[0],
overview=overview,
skills=skills,
tools=tools,
models=models,
agents=agents,
run_statuses=run_statuses,
trends=trends,
)
# ── Q&A audit list ─────────────────────────────────────────────────────────
@@ -2328,7 +2293,12 @@ def _qa_message_text(message: Any) -> str:
return _message_content_to_text(_message_value(message, "content")).strip()
async def _read_thread_checkpoint_messages(checkpointer, thread_id: str) -> list[dict]:
async def _read_thread_checkpoint_messages(
checkpointer,
thread_id: str,
*,
max_messages: int | None = None,
) -> list[dict]:
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
try:
checkpoint_tuple = await checkpointer.aget_tuple(config)
@@ -2341,7 +2311,9 @@ async def _read_thread_checkpoint_messages(checkpointer, thread_id: str) -> list
channel_values = checkpoint.get("channel_values", {}) if isinstance(checkpoint, dict) else {}
values = serialize_channel_values(channel_values)
messages = values.get("messages", [])
return messages if isinstance(messages, list) else []
if not isinstance(messages, list):
return []
return messages[-max_messages:] if max_messages else messages
def _qa_items_from_checkpoint_messages(
@@ -2409,47 +2381,97 @@ async def _list_thread_checkpoint_qa_items(
*,
request: Request,
user_id: str,
since: str | None = None,
until: str | None = None,
recent_only: bool = False,
) -> list[AdminUserQaItem]:
repo = get_thread_store(request)
checkpointer = get_checkpointer(request)
items: list[AdminUserQaItem] = []
offset = 0
page_size = 200
while True:
rows = await repo.search(limit=page_size, offset=offset, user_id=user_id)
if not rows:
break
for row in rows:
thread_id = str(row.get("thread_id") or "")
if not thread_id:
continue
messages = await _read_thread_checkpoint_messages(checkpointer, thread_id)
if not messages:
continue
metadata = row.get("metadata") if isinstance(row.get("metadata"), dict) else {}
context = row.get("context") if isinstance(row.get("context"), dict) else {}
assistant_id = str(
row.get("assistant_id")
or (context or {}).get("agent_id")
or (metadata or {}).get("agent_id")
or (context or {}).get("agent_name")
or (metadata or {}).get("agent_name")
or ""
)
thread_time = str(row.get("updated_at") or row.get("created_at") or "")
items.extend(
_qa_items_from_checkpoint_messages(
user_id=user_id,
thread_id=thread_id,
thread_title=str(row.get("display_name") or ""),
assistant_id=assistant_id,
thread_time=thread_time,
messages=messages,
rows: list[dict]
if recent_only:
# Checkpoints do not reliably carry a timestamp for every historical
# message. Limit the fallback to threads *created* in the effective
# audit window, so an old long-running conversation cannot leak a full
# history into a recent-only administrator query. Run summaries remain
# the complete, indexed source for recent messages in older threads.
session_factory = get_session_factory()
since_dt = _parse_optional_datetime(since)
until_dt = _parse_optional_datetime(until)
if session_factory is None or since_dt is None or until_dt is None:
return items
async with session_factory() as session:
await _set_analytics_read_timeout(session)
result = await session.execute(
select(ThreadMetaRow)
.where(
ThreadMetaRow.user_id == user_id,
ThreadMetaRow.created_at >= since_dt,
ThreadMetaRow.created_at <= until_dt,
)
.order_by(ThreadMetaRow.updated_at.desc(), ThreadMetaRow.thread_id.desc())
.limit(_ADMIN_QA_CHECKPOINT_THREAD_LIMIT)
)
if len(rows) < page_size:
break
offset += len(rows)
rows = [
{
"thread_id": row.thread_id,
"assistant_id": row.assistant_id,
"display_name": row.display_name,
"metadata": row.metadata_json or {},
"created_at": _iso(row.created_at),
"updated_at": _iso(row.updated_at),
}
for row in result.scalars()
]
else:
# Preserve the regular user's existing history behavior. The bounded
# administrator route always takes the SQL path above.
repo = get_thread_store(request)
rows = []
offset = 0
page_size = 200
while True:
page = await repo.search(limit=page_size, offset=offset, user_id=user_id)
if not page:
break
rows.extend(page)
if len(page) < page_size:
break
offset += len(page)
for row in rows:
thread_id = str(row.get("thread_id") or "")
if not thread_id:
continue
messages = await _read_thread_checkpoint_messages(
checkpointer,
thread_id,
max_messages=_ADMIN_QA_CHECKPOINT_MESSAGE_LIMIT if recent_only else None,
)
if not messages:
continue
metadata = row.get("metadata") if isinstance(row.get("metadata"), dict) else {}
context = row.get("context") if isinstance(row.get("context"), dict) else {}
assistant_id = str(
row.get("assistant_id")
or (context or {}).get("agent_id")
or (metadata or {}).get("agent_id")
or (context or {}).get("agent_name")
or (metadata or {}).get("agent_name")
or ""
)
thread_time = str(row.get("updated_at") or row.get("created_at") or "")
items.extend(
_qa_items_from_checkpoint_messages(
user_id=user_id,
thread_id=thread_id,
thread_title=str(row.get("display_name") or ""),
assistant_id=assistant_id,
thread_time=thread_time,
messages=messages,
)
)
if recent_only and len(items) >= _ADMIN_QA_CHECKPOINT_ITEM_LIMIT:
return items[:_ADMIN_QA_CHECKPOINT_ITEM_LIMIT]
return items
@@ -2459,44 +2481,61 @@ async def _list_run_summary_qa_items(
user_id: str,
since: str | None,
until: str | None,
q: str | None = None,
candidate_limit: int | None = None,
) -> list[AdminUserQaItem]:
filters = [
RunRow.user_id == user_id,
RunRow.first_human_message.is_not(None),
RunRow.first_human_message != "",
]
if since or until:
since_dt, until_dt, _range_days = _resolve_range(365, since, until)
filters.append(_run_time_filter(since_dt, until_dt))
if since:
filters.append(RunRow.created_at >= _parse_optional_datetime(since))
if until:
filters.append(RunRow.created_at <= _parse_optional_datetime(until))
needle = (q or "").strip().lower()
if needle:
like = f"%{needle}%"
filters.append(
or_(
func.lower(RunRow.run_id).like(like),
func.lower(RunRow.thread_id).like(like),
func.lower(func.coalesce(RunRow.assistant_id, "")).like(like),
func.lower(func.coalesce(RunRow.first_human_message, "")).like(like),
func.lower(func.coalesce(RunRow.last_ai_message, "")).like(like),
func.lower(func.coalesce(ThreadMetaRow.display_name, "")).like(like),
)
)
async with session_factory() as session:
await _set_analytics_read_timeout(session)
rows = (
await session.execute(
select(
RunRow.run_id,
RunRow.thread_id,
ThreadMetaRow.display_name,
RunRow.assistant_id,
RunRow.status,
RunRow.model_name,
RunRow.first_human_message,
RunRow.last_ai_message,
RunRow.total_tokens,
RunRow.created_at,
RunRow.updated_at,
)
.outerjoin(
ThreadMetaRow,
and_(
ThreadMetaRow.thread_id == RunRow.thread_id,
ThreadMetaRow.user_id == RunRow.user_id,
),
)
.where(*filters)
.order_by(RunRow.created_at.desc(), RunRow.run_id.desc())
stmt = (
select(
RunRow.run_id,
RunRow.thread_id,
ThreadMetaRow.display_name,
RunRow.assistant_id,
RunRow.status,
RunRow.model_name,
RunRow.first_human_message,
RunRow.last_ai_message,
RunRow.total_tokens,
RunRow.created_at,
RunRow.updated_at,
)
).all()
.outerjoin(
ThreadMetaRow,
and_(
ThreadMetaRow.thread_id == RunRow.thread_id,
ThreadMetaRow.user_id == RunRow.user_id,
),
)
.where(*filters)
.order_by(RunRow.created_at.desc(), RunRow.run_id.desc())
)
if candidate_limit is not None:
stmt = stmt.limit(max(1, min(int(candidate_limit), _ADMIN_QA_RUN_CANDIDATE_LIMIT)))
rows = (await session.execute(stmt)).all()
return [
AdminUserQaItem(
@@ -2561,19 +2600,39 @@ async def _list_user_qa_page(
since: str | None,
until: str | None,
include_checkpoints: bool = True,
recent_checkpoint_threads_only: bool = False,
) -> AdminUserQaPageResponse:
session_factory = get_session_factory()
if session_factory is None:
raise HTTPException(status_code=503, detail="Q&A audit requires database persistence")
candidate_limit = None
if recent_checkpoint_threads_only:
# Normal administrator pages need only a modest look-ahead beyond the
# requested page. This protects against a single high-frequency user
# materializing a whole month's question/answer bodies at once.
candidate_limit = min(
_ADMIN_QA_RUN_CANDIDATE_LIMIT,
max(200, page * page_size + page_size),
)
items = await _list_run_summary_qa_items(
session_factory=session_factory,
user_id=user_id,
since=since,
until=until,
q=q if candidate_limit is not None else None,
candidate_limit=candidate_limit,
)
if include_checkpoints:
items.extend(await _list_thread_checkpoint_qa_items(request=request, user_id=user_id))
items.extend(
await _list_thread_checkpoint_qa_items(
request=request,
user_id=user_id,
since=since,
until=until,
recent_only=recent_checkpoint_threads_only,
)
)
by_key: dict[tuple[str, str, str], AdminUserQaItem] = {}
for item in items:
@@ -2601,22 +2660,24 @@ async def _list_user_qa_page(
async def list_user_qa(
user_id: str,
request: Request,
page: int = Query(default=1, ge=1),
page: int = Query(default=1, ge=1, le=100),
page_size: int = Query(default=20, ge=1, le=100),
q: str | None = Query(default=None, description="Search question, answer, run id, or thread id"),
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
) -> AdminUserQaPageResponse:
"""Return one user's Q&A runs, including older checkpoint-only threads."""
"""Return one user's recent Q&A audit records."""
await _require_admin(request)
since_dt, until_dt, _range_days = resolve_admin_query_window(since=since, until=until)
return await _list_user_qa_page(
request=request,
user_id=user_id,
page=page,
page_size=page_size,
q=q,
since=since,
until=until,
since=_iso(since_dt),
until=_iso(until_dt),
recent_checkpoint_threads_only=True,
)
@@ -2659,11 +2720,41 @@ class AdminThreadItem(BaseModel):
async def list_user_threads(
user_id: str,
request: Request,
limit: int = Query(default=100, ge=1, le=500),
limit: int = Query(default=100, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> list[AdminThreadItem]:
"""List one user's conversation threads, newest first."""
"""List one user's threads active in the rolling administrator window."""
await _require_admin(request)
cutoff, _until, _range_days = resolve_admin_query_window()
session_factory = get_session_factory()
if session_factory is not None:
async with session_factory() as session:
await _set_analytics_read_timeout(session)
rows = (
await session.execute(
select(ThreadMetaRow)
.where(
ThreadMetaRow.user_id == user_id,
ThreadMetaRow.updated_at >= cutoff,
)
.order_by(ThreadMetaRow.updated_at.desc(), ThreadMetaRow.thread_id.desc())
.limit(limit)
.offset(offset)
)
).scalars().all()
return [
AdminThreadItem(
thread_id=row.thread_id,
display_name=row.display_name or "",
status=row.status or "idle",
created_at=_iso(row.created_at),
updated_at=_iso(row.updated_at),
)
for row in rows
]
# Memory-mode fallback has no SQL date predicate. It is only used when
# database persistence is unavailable, and still preserves the policy.
repo = get_thread_store(request)
rows = await repo.search(limit=limit, offset=offset, user_id=user_id)
return [
@@ -2675,6 +2766,7 @@ async def list_user_threads(
updated_at=str(r.get("updated_at") or ""),
)
for r in rows
if _qa_sort_datetime(str(r.get("updated_at") or "")) >= cutoff
]
@@ -2683,7 +2775,7 @@ async def list_user_thread_messages(
user_id: str,
thread_id: str,
request: Request,
limit: int = Query(default=400, ge=1, le=1000),
limit: int = Query(default=200, ge=1, le=200),
) -> list[dict]:
"""Return the conversation messages of one of the user's threads.
@@ -2702,6 +2794,12 @@ async def list_user_thread_messages(
owner = thread.get("user_id")
if owner is not None and owner != user_id:
raise HTTPException(status_code=404, detail="Thread does not belong to this user")
cutoff, _until, _range_days = resolve_admin_query_window()
if _qa_sort_datetime(str(thread.get("updated_at") or thread.get("created_at") or "")) < cutoff:
raise HTTPException(
status_code=404,
detail=f"仅可查看最近 {ADMIN_QUERY_WINDOW_DAYS} 天的审计会话",
)
checkpointer = get_checkpointer(request)
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,170 @@
"""Application chrome name and logo.
Stored separately from ``generated_page_branding`` and the
``generated-page-branding/`` directory. An empty name or a missing logo leaves
the built-in title and static theme logos in place.
"""
from __future__ import annotations
import asyncio
from uuid import uuid4
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel
from app.gateway.routers.system_settings import _require_admin
from deerflow.config.runtime_paths import runtime_home
from deerflow.config.system_settings import (
AppIdentitySettings,
app_identity_logo_path,
load_system_settings,
save_system_settings,
)
router = APIRouter(prefix="/api/system-settings", tags=["system-settings"])
public_router = APIRouter(prefix="/api/public/app-identity", tags=["app-identity"])
_MAX_LOGO_BYTES = 10 * 1024 * 1024
_PUBLIC_LOGO_PATH = "/api/public/app-identity/logo"
class AppIdentityResponse(BaseModel):
system_name: str = ""
logo_url: str | None = None
logo_version: str | None = None
has_logo: bool = False
def _detect_logo(data: bytes) -> tuple[str, str]:
if data.startswith(b"\x89PNG\r\n\x1a\n"):
return ".png", "image/png"
if data.startswith(b"\xff\xd8\xff"):
return ".jpg", "image/jpeg"
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return ".webp", "image/webp"
raise HTTPException(status_code=422, detail="Logo 仅支持 PNG、JPG 或 WebP 图片")
def _normalize_system_name(value: str | None) -> str:
normalized = " ".join(str(value or "").split())
if len(normalized) > 80:
raise HTTPException(status_code=422, detail="系统名称不能超过 80 个字符")
return normalized
def _response(settings: AppIdentitySettings) -> AppIdentityResponse:
path = app_identity_logo_path(settings)
version = settings.logo_version if path is not None else None
return AppIdentityResponse(
system_name=settings.system_name,
logo_url=f"{_PUBLIC_LOGO_PATH}?v={version}" if version else None,
logo_version=version,
has_logo=path is not None,
)
def _write_logo(data: bytes, suffix: str) -> str:
directory = runtime_home() / "app-identity"
directory.mkdir(parents=True, exist_ok=True)
filename = f"logo-{uuid4().hex}{suffix}"
destination = directory / filename
temporary = directory / f".{filename}.tmp"
try:
temporary.write_bytes(data)
temporary.replace(destination)
finally:
temporary.unlink(missing_ok=True)
return filename
@router.get("/app-identity", response_model=AppIdentityResponse)
async def get_app_identity_settings() -> AppIdentityResponse:
"""Return the application name and uploaded logo. Any logged-in user may read it."""
return _response(load_system_settings().app_identity)
@router.put("/app-identity", response_model=AppIdentityResponse)
async def update_app_identity_settings(
request: Request,
system_name: str = Form(default=""),
remove_logo: bool = Form(default=False),
logo: UploadFile | None = File(default=None),
) -> AppIdentityResponse:
"""Save the application chrome name and optional logo. Admin only."""
await _require_admin(request)
if logo is not None and remove_logo:
raise HTTPException(status_code=422, detail="上传 Logo 时不能同时恢复静态 Logo")
current = load_system_settings()
previous_logo = app_identity_logo_path(current.app_identity)
update: dict[str, object] = {"system_name": _normalize_system_name(system_name)}
new_logo_path = None
if logo is not None:
data = await logo.read(_MAX_LOGO_BYTES + 1)
await logo.close()
if not data:
raise HTTPException(status_code=422, detail="Logo 文件不能为空")
if len(data) > _MAX_LOGO_BYTES:
raise HTTPException(status_code=413, detail="Logo 文件不能超过 10MB")
suffix, media_type = _detect_logo(data)
filename = await asyncio.to_thread(_write_logo, data, suffix)
new_logo_path = runtime_home() / "app-identity" / filename
update.update(
{
"logo_filename": filename,
"logo_media_type": media_type,
"logo_version": uuid4().hex,
}
)
elif remove_logo:
update.update(
{
"logo_filename": None,
"logo_media_type": None,
"logo_version": None,
}
)
updated = current.app_identity.model_copy(update=update)
try:
saved = save_system_settings(current.model_copy(update={"app_identity": updated}))
except Exception:
if new_logo_path is not None:
await asyncio.to_thread(new_logo_path.unlink, missing_ok=True)
raise
if previous_logo is not None and previous_logo != app_identity_logo_path(saved.app_identity):
await asyncio.to_thread(previous_logo.unlink, missing_ok=True)
return _response(saved.app_identity)
@router.delete("/app-identity", response_model=AppIdentityResponse)
async def delete_app_identity_settings(request: Request) -> AppIdentityResponse:
"""Clear the application name and uploaded logo. Admin only."""
await _require_admin(request)
current = load_system_settings()
previous_logo = app_identity_logo_path(current.app_identity)
saved = save_system_settings(current.model_copy(update={"app_identity": AppIdentitySettings()}))
if previous_logo is not None:
await asyncio.to_thread(previous_logo.unlink, missing_ok=True)
return _response(saved.app_identity)
@public_router.get("/logo")
async def get_app_identity_logo() -> FileResponse:
"""Serve the uploaded application logo. Missing logos stay on the static assets."""
settings = load_system_settings().app_identity
path = app_identity_logo_path(settings)
if path is None:
raise HTTPException(status_code=404, detail="未上传系统 Logo")
return FileResponse(
path,
media_type=settings.logo_media_type or "image/png",
content_disposition_type="inline",
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
@@ -13,10 +13,13 @@ from fastapi.responses import FileResponse, PlainTextResponse, Response
from pydantic import BaseModel, Field
from app.gateway.authz import require_permission
from app.gateway.operation_timing import OperationTiming
from app.gateway.path_utils import aresolve_thread_virtual_path
from app.gateway.utils import sanitize_log_param
from deerflow.config.paths import get_paths
from deerflow.runtime.thread_paths import aresolve_path_user_id
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.utils.file_io import file_io
logger = logging.getLogger(__name__)
@@ -535,7 +538,7 @@ async def _scan_artifact_library(
user_id=path_user_id,
)
scan_errors: list[str] = []
files = await asyncio.to_thread(
files = await file_io(
collect_outputs_listing,
outputs_dir,
errors=scan_errors,
@@ -851,7 +854,7 @@ async def download_strategy_project_artifact(project_id: str, request: Request)
description="List all files under the thread's outputs directory (/mnt/user-data/outputs/). Returns an empty list if the thread has no outputs.",
)
@require_permission("threads", "read", owner_check=True)
async def list_artifacts(thread_id: str, request: Request) -> dict:
async def list_artifacts(thread_id: str, request: Request, response: Response = None) -> dict: # type: ignore[assignment]
"""列出 thread 在 ``outputs/`` 下生成的产物文件。
圆桌 Step 3(``HighFidelityReport``)对每个**子席位** thread_id 调用本接口,
@@ -865,8 +868,24 @@ async def list_artifacts(thread_id: str, request: Request) -> dict:
返回 ``{ "thread_id", "files": [{ path, name, size_bytes, mime_type, modified_at }] }``,
目录不存在时 ``files`` 为空数组(非 404)。
"""
outputs_dir = get_paths().sandbox_outputs_dir(thread_id, user_id=await aresolve_path_user_id(thread_id))
return {"thread_id": thread_id, "files": collect_outputs_listing(outputs_dir)}
with OperationTiming(
response,
logger,
"thread_artifacts",
thread_id=sanitize_log_param(thread_id),
) as timing:
with timing.phase("owner_resolve"):
user_id = await aresolve_path_user_id(thread_id)
outputs_dir = get_paths().sandbox_outputs_dir(thread_id, user_id=user_id)
# Probe outside the shared artifact-files pool so a background library
# scan cannot stall an empty hello-chat listing for several seconds.
with timing.phase("dir_probe"):
exists = await asyncio.to_thread(lambda: outputs_dir.exists() and outputs_dir.is_dir())
if not exists:
return {"thread_id": thread_id, "files": []}
with timing.phase("dir_scan"):
files = await file_io(collect_outputs_listing, outputs_dir)
return {"thread_id": thread_id, "files": files}
def _read_bounded_text_prefix(path: Path, max_chars: int) -> tuple[str, bool]:
@@ -922,7 +941,7 @@ async def preview_artifact(
raise HTTPException(status_code=404, detail=f"Artifact not found: {path}")
if not actual_path.is_file():
raise HTTPException(status_code=400, detail=f"Path is not a file: {path}")
if not await asyncio.to_thread(is_text_file_by_content, actual_path):
if not await file_io(is_text_file_by_content, actual_path):
raise HTTPException(status_code=415, detail="Bounded preview supports text content only")
from deerflow.uploads.inspection import inspect_text_file
@@ -936,7 +955,7 @@ async def preview_artifact(
total_bytes = actual_path.stat().st_size
if total_bytes >= threshold:
report = await asyncio.to_thread(
report = await file_io(
inspect_text_file,
actual_path,
max_result_chars=effective_limit,
@@ -953,7 +972,7 @@ async def preview_artifact(
warnings=[str(item) for item in report.get("warnings") or []],
)
content, truncated = await asyncio.to_thread(_read_bounded_text_prefix, actual_path, effective_limit)
content, truncated = await file_io(_read_bounded_text_prefix, actual_path, effective_limit)
warnings = ["预览达到字符上限,未读取的内容不会进入页面或模型上下文。"] if truncated else []
return ArtifactPreviewResponse(
path=path,
@@ -1023,7 +1042,7 @@ async def get_artifact(thread_id: str, path: str, request: Request, download: bo
raise HTTPException(status_code=400, detail=f"Path is not a file: {skill_file_path}")
# Extract the file from the .skill archive
content = _extract_file_from_skill_archive(actual_skill_path, internal_path)
content = await file_io(_extract_file_from_skill_archive, actual_skill_path, internal_path)
if content is None:
raise HTTPException(status_code=404, detail=f"File '{internal_path}' not found in skill archive")
@@ -1077,7 +1096,7 @@ async def get_artifact(thread_id: str, path: str, request: Request, download: bo
headers={"Content-Disposition": _build_content_disposition("inline", actual_path.name)},
)
if is_text_file_by_content(actual_path):
if await file_io(is_text_file_by_content, actual_path):
return FileResponse(
path=actual_path,
media_type=mime_type or "text/plain",
@@ -1129,5 +1148,5 @@ async def update_artifact(
if suffix not in EDITABLE_TEXT_SUFFIXES and not (mime_type and mime_type.startswith("text/")):
raise HTTPException(status_code=400, detail="Only text artifacts can be edited.")
actual_path.write_text(body.content, encoding="utf-8")
await file_io(actual_path.write_text, body.content, encoding="utf-8")
return PlainTextResponse(content=body.content, media_type=mime_type or "text/plain")
@@ -1,5 +1,7 @@
"""Authentication endpoints."""
import asyncio
import json
import logging
import os
import time
@@ -9,8 +11,9 @@ from typing import Literal
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, ConfigDict, EmailStr, Field, TypeAdapter, field_validator, model_validator
from pydantic import BaseModel, ConfigDict, EmailStr, Field, TypeAdapter, ValidationError, field_validator, model_validator
from app.gateway.admin_query_window import recent_admin_query_cutoff
from app.gateway.auth import (
UserResponse,
create_access_token,
@@ -18,18 +21,20 @@ from app.gateway.auth import (
from app.gateway.auth.config import get_auth_config
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
from app.gateway.auth.models import User
from app.gateway.auth.password import has_account_password, hash_password_async, is_password_hash, verify_account_password
from app.gateway.auth.repositories.base import CredentialConflictError
from app.gateway.auth.password import has_account_password, hash_password_async, is_password_hash, verify_account_password
from app.gateway.auth.repositories.base import CredentialConflictError
from app.gateway.csrf_middleware import is_secure_request
from app.gateway.deps import get_agent_store, get_current_user_from_request, get_local_provider, get_run_store, get_skill_store
from app.gateway.perf_logging import finish_trace, trace_request, trace_stage
from deerflow.config import get_app_config
from deerflow.config.system_settings import AuthenticationSettings, load_system_settings
from deerflow.config.system_settings import AuthenticationSettings, load_system_settings
from deerflow.persistence.agents.base import AgentStore
from deerflow.persistence.skills.base import SkillStore
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
_USER_NOT_LOOKED_UP = object()
# ── Request/Response Models ──────────────────────────────────────────────
@@ -108,34 +113,34 @@ def _validate_strong_password(value: str) -> str:
``@field_validator(field_name)`` without inheritance gymnastics.
"""
if _password_is_common(value):
raise ValueError("密码过于常见,请设置更安全的密码")
if value != value.strip():
raise ValueError("密码首尾不能包含空格")
raise ValueError("密码过于常见,请设置更安全的密码")
if value != value.strip():
raise ValueError("密码首尾不能包含空格")
return value
class RegisterRequest(BaseModel):
"""Request model for user registration."""
username: str | None = Field(default=None, min_length=1, max_length=128)
email: EmailStr | None = None
password: str = Field(..., min_length=8, max_length=255)
model_config = ConfigDict(extra="forbid")
username: str | None = Field(default=None, min_length=1, max_length=128)
email: EmailStr | None = None
password: str = Field(..., min_length=8, max_length=255)
model_config = ConfigDict(extra="forbid")
_strong_password = field_validator("password")(classmethod(lambda cls, v: _validate_strong_password(v)))
@model_validator(mode="after")
def validate_identity(self):
if bool(self.username) == bool(self.email):
raise ValueError("请填写用户名或邮箱(只需一项)")
if self.username is not None:
value = self.username.strip()
if "@" not in value and (not value or value[0] in "._-" or value[-1] in "._-" or any(not (ch.isalnum() or ch in "._-") for ch in value)):
raise ValueError("用户名仅支持字母、数字、点、下划线和短横线,首尾须为字母或数字")
self.username = value
if "@" in value:
TypeAdapter(EmailStr).validate_python(value)
return self
@model_validator(mode="after")
def validate_identity(self):
if bool(self.username) == bool(self.email):
raise ValueError("请填写用户名或邮箱(只需一项)")
if self.username is not None:
value = self.username.strip()
if "@" not in value and (not value or value[0] in "._-" or value[-1] in "._-" or any(not (ch.isalnum() or ch in "._-") for ch in value)):
raise ValueError("用户名仅支持字母、数字、点、下划线和短横线,首尾须为字母或数字")
self.username = value
if "@" in value:
TypeAdapter(EmailStr).validate_python(value)
return self
class UsernameLoginRequest(BaseModel):
@@ -179,8 +184,8 @@ class UsernameLoginResponse(BaseModel):
class ChangePasswordRequest(BaseModel):
"""Request model for password change (also handles setup flow)."""
current_password: str = Field(default="", max_length=255)
new_password: str = Field(..., min_length=8, max_length=255)
current_password: str = Field(default="", max_length=255)
new_password: str = Field(..., min_length=8, max_length=255)
new_email: EmailStr | None = None
_strong_password = field_validator("new_password")(classmethod(lambda cls, v: _validate_strong_password(v)))
@@ -190,12 +195,12 @@ class MessageResponse(BaseModel):
"""Generic message response."""
message: str
class PasswordChangeResponse(MessageResponse):
access_token: str
token_type: str = "bearer"
expires_in: int
class PasswordChangeResponse(MessageResponse):
access_token: str
token_type: str = "bearer"
expires_in: int
# ── Helpers ───────────────────────────────────────────────────────────────
@@ -237,28 +242,71 @@ def _username_to_email(username: str) -> str:
return f"{local}@{domain}"
async def _sync_user_position(request: Request, user_id: str) -> None:
"""Best-effort: materialize the user's 岗位 (position) grants on login.
# 后台岗位同步任务的强引用集合:防止 create_task 的任务被 GC 中途回收。
_position_sync_tasks: set = set()
_POSITION_SYNC_CACHE_SECONDS = 60.0
_MAX_POSITION_SYNC_CACHE_USERS = 10_000
_position_sync_completed_at: dict[str, float] = {}
_position_sync_inflight: dict[str, asyncio.Task[None]] = {}
def _sync_user_position(request: Request, user_id: str) -> None:
"""Best-effort: schedule the user's 岗位 (position) sync off the login path.
Ensures a user who has never been synced — e.g. a brand-new account that
inherits the ``is_default`` position — gets the default agents / tasks /
prompts loaded into their "我的" tables. Failures never block login.
prompts loaded into their "我的" tables. The sync is idempotent but touches
a dozen tables per user, so it runs as a fire-and-forget task instead of
blocking the login response; the materialized rows land a moment later and
failures are logged, never surfaced.
"""
normalized_user_id = str(user_id)
now = time.monotonic()
completed_at = _position_sync_completed_at.get(normalized_user_id)
if completed_at is not None and now - completed_at < _POSITION_SYNC_CACHE_SECONDS:
return
if normalized_user_id in _position_sync_inflight:
return
sync = getattr(request.app.state, "position_sync", None)
if sync is None:
return
async def _run() -> None:
try:
await sync.sync_user(normalized_user_id)
_position_sync_completed_at[normalized_user_id] = time.monotonic()
if len(_position_sync_completed_at) > _MAX_POSITION_SYNC_CACHE_USERS:
oldest = sorted(_position_sync_completed_at, key=_position_sync_completed_at.get)
for stale_user_id in oldest[: len(oldest) // 2]:
_position_sync_completed_at.pop(stale_user_id, None)
except Exception:
logger.debug("position sync on login failed for user %s", user_id, exc_info=True)
finally:
_position_sync_inflight.pop(normalized_user_id, None)
try:
sync = getattr(request.app.state, "position_sync", None)
if sync is not None:
await sync.sync_user(str(user_id))
except Exception:
logger.debug("position sync on login failed for user %s", user_id, exc_info=True)
task = asyncio.create_task(_run())
except RuntimeError:
# 防御:请求处理器内必有运行中的事件循环,这里理论上走不到。
logger.debug("position sync scheduling failed for user %s", user_id, exc_info=True)
return
_position_sync_tasks.add(task)
_position_sync_inflight[normalized_user_id] = task
task.add_done_callback(_position_sync_tasks.discard)
async def _get_or_create_user_by_username(username: str):
async def _get_or_create_user_by_username(
username: str,
*,
existing_user: User | None | object = _USER_NOT_LOOKED_UP,
):
"""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. Roles are persisted authorization data, never inferred from names.
the row. Roles are persisted authorization data, never inferred from names.
"""
provider = get_local_provider()
email = _username_to_email(username)
@@ -270,7 +318,12 @@ async def _get_or_create_user_by_username(username: str):
# also exists at today's canonical domain. Choosing the exact canonical
# row first made the same username alternate backend user_ids after a
# domain migration, which in turn made its existing threads inaccessible.
if local_part is not None:
if existing_user is not _USER_NOT_LOOKED_UP:
# /login/username already performed this lookup to validate the
# account-specific password. Reuse both hits and misses instead of
# issuing the same MySQL query twice during a login burst.
user = existing_user
elif local_part is not None:
user = await provider.find_user_by_email_local_part(local_part)
if user is not None and user.email != email:
logger.info("Matched username %r to stable legacy account %s", username, user.email)
@@ -281,7 +334,7 @@ async def _get_or_create_user_by_username(username: str):
try:
# 黑名单模式:从未登录过的新用户(此处即首次自动注册)默认放行(approved=True)。
# 管理员可在白名单管理页对个别用户「取消放行」来封禁——仅当登录白名单开关打开时生效。
user = await provider.create_user(email=email, password=None, system_role="user", needs_setup=False, approved=True)
user = await provider.create_user(email=email, password=None, system_role="user", needs_setup=False, approved=True)
created = True
logger.info("Auto-registered passwordless user: %s", email)
except ValueError:
@@ -290,7 +343,7 @@ async def _get_or_create_user_by_username(username: str):
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_409_CONFLICT, detail="该用户名已存在,请使用原账号登录")
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该用户名已存在,请使用原账号登录")
return user, created
@@ -555,134 +608,170 @@ def _record_login_success(ip: str) -> None:
# ── Endpoints ─────────────────────────────────────────────────────────────
def _require_login_method(method: str) -> None:
settings = load_system_settings().authentication
if not getattr(settings, method):
labels = {"password_login_enabled": "账号密码登录", "registration_enabled": "自助注册", "sso_enabled": "外部系统免登"}
raise HTTPException(status_code=403, detail=f"管理员尚未开启{labels[method]}")
def _login_response(user: User, token: str, *, username: str | None = None, created: bool = False) -> UsernameLoginResponse:
return UsernameLoginResponse(
access_token=token,
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,
username=username or user.email.split("@", 1)[0],
)
async def _prepare_login_session(user: User) -> User:
"""Apply the administrator's one-online-session policy after authentication."""
if not load_system_settings().authentication.single_session_enabled:
return user
try:
return await get_local_provider().start_login_session(user)
except CredentialConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
class AuthenticationOptions(AuthenticationSettings):
needs_setup: bool = False
@router.get("/options", response_model=AuthenticationOptions)
async def authentication_options(response: Response) -> AuthenticationOptions:
"""Anonymous, non-secret capability discovery for the login page."""
response.headers["Cache-Control"] = "no-store"
return AuthenticationOptions(
**load_system_settings().authentication.model_dump(),
needs_setup=await get_local_provider().count_admin_users() == 0,
)
@router.post("/login/local", response_model=UsernameLoginResponse)
def _require_login_method(method: str) -> None:
settings = load_system_settings().authentication
if not getattr(settings, method):
labels = {"password_login_enabled": "账号密码登录", "registration_enabled": "自助注册", "sso_enabled": "外部系统免登"}
raise HTTPException(status_code=403, detail=f"管理员尚未开启{labels[method]}")
def _login_response(user: User, token: str, *, username: str | None = None, created: bool = False) -> UsernameLoginResponse:
return UsernameLoginResponse(
access_token=token,
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,
username=username or user.email.split("@", 1)[0],
)
async def _prepare_login_session(user: User) -> User:
"""Apply the administrator's one-online-session policy after authentication."""
if not load_system_settings().authentication.single_session_enabled:
return user
try:
return await get_local_provider().start_login_session(user)
except CredentialConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
class AuthenticationOptions(AuthenticationSettings):
needs_setup: bool = False
@router.get("/options", response_model=AuthenticationOptions)
async def authentication_options(request: Request, response: Response) -> AuthenticationOptions:
"""Anonymous, non-secret capability discovery for the login page."""
response.headers["Cache-Control"] = "no-store"
trace = trace_request(request, "auth_options")
try:
stage_started = time.perf_counter()
authentication = load_system_settings().authentication.model_dump()
trace_stage(trace, "load_system_settings", stage_started)
stage_started = time.perf_counter()
needs_setup = await get_local_provider().count_admin_users() == 0
trace_stage(trace, "count_admin_users", stage_started)
return AuthenticationOptions(**authentication, needs_setup=needs_setup)
finally:
finish_trace(trace)
@router.post("/login/local", response_model=UsernameLoginResponse)
async def login_local(
request: Request,
response: Response,
form_data: OAuth2PasswordRequestForm = Depends(),
):
"""Username or email login with the account's unified password/passphrase."""
_require_login_method("password_login_enabled")
"""Username or email login with the account's unified password/passphrase."""
_require_login_method("password_login_enabled")
client_ip = _get_client_ip(request)
_check_rate_limit(client_ip)
user = await _lookup_user_by_username(form_data.username)
if user is not None and not await verify_account_password(form_data.password, user):
user = None
user = await _lookup_user_by_username(form_data.username)
if user is not None and not await verify_account_password(form_data.password, user):
user = None
if user is None:
_record_login_failure(client_ip)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="账号或密码错误").model_dump(),
detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="账号或密码错误").model_dump(),
)
_record_login_success(client_ip)
enforce_user_approved(user)
user = await _prepare_login_session(user)
token = create_access_token(str(user.id), token_version=user.token_version, auth_method="password")
user = await _prepare_login_session(user)
token = create_access_token(str(user.id), token_version=user.token_version, auth_method="password")
_set_session_cookie(response, token, request)
await _sync_user_position(request, user.id)
_sync_user_position(request, user.id)
return _login_response(user, token, username=form_data.username.strip())
return _login_response(user, token, username=form_data.username.strip())
def _username_login_request(username: object) -> UsernameLoginRequest:
if not isinstance(username, str):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="username is required")
try:
return UsernameLoginRequest(username=username)
except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="username is required") from exc
async def _read_username_login(request: Request) -> UsernameLoginRequest:
"""Accept the historical JSON body and a form body.
``application/x-www-form-urlencoded`` is a CORS simple content type. Old
Firefox aborts a credentialed JSON login during preflight and reports
NS_BINDING_ABORTED, so the browser client sends a form body instead.
"""
media_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
if media_type == "application/x-www-form-urlencoded":
form = await request.form()
return _username_login_request(form.get("username"))
try:
payload = await request.json()
except json.JSONDecodeError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="username is required") from exc
username = payload.get("username") if isinstance(payload, dict) else None
return _username_login_request(username)
@router.post("/login/username", response_model=UsernameLoginResponse)
async def login_by_username(
request: Request,
response: Response,
body: UsernameLoginRequest,
password: str | None = Query(default=None, description="Account password or shared passphrase; required when password login is enabled."),
password: str | None = Query(default=None, description="Account password or shared passphrase; required when password login is enabled."),
):
"""Compatibility username login, gated by the current credential policy.
Personal password/passphrase wins. With password login enabled, accounts
without personal credentials must supply the existing shared passphrase
(default 123ewq). Identity-only login is retained only while password login
is disabled and the historical shared-password gate is also disabled.
"""Compatibility username login, gated by the current credential policy.
Personal password/passphrase wins. With password login enabled, accounts
without personal credentials must supply the existing shared passphrase
(default 123ewq). Identity-only login is retained only while password login
is disabled and the historical shared-password gate is also disabled.
"""
_require_login_method("sso_enabled")
body = await _read_username_login(request)
_require_login_method("sso_enabled")
username_login_config = get_app_config().auth_login.username_login
password_login_enabled = load_system_settings().authentication.password_login_enabled
password_login_enabled = load_system_settings().authentication.password_login_enabled
# Per-account password takes precedence: look the account up (no create) and,
# if it carries a personal 登录口令, require an exact match — ignoring the
# shared gate entirely for that account.
existing = await _lookup_user_by_username(body.username)
credentials_verified = False
if has_account_password(existing) or password_login_enabled or username_login_config.require_password:
credentials_verified = False
if has_account_password(existing) or password_login_enabled or username_login_config.require_password:
client_ip = _get_client_ip(request)
_check_rate_limit(client_ip)
if not await verify_account_password(password or "", existing):
if not await verify_account_password(password or "", existing):
_record_login_failure(client_ip)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="口令错误").model_dump(),
)
credentials_verified = True
credentials_verified = True
_record_login_success(client_ip)
user, created = await _get_or_create_user_by_username(body.username)
user, created = await _get_or_create_user_by_username(body.username, existing_user=existing)
# A registration/reset may commit after the initial lookup/password gate.
# Never mint the new account/version's session using an earlier decision.
if has_account_password(user) and (existing is None or existing.id != user.id or existing.token_version != user.token_version):
client_ip = _get_client_ip(request)
_check_rate_limit(client_ip)
if not await verify_account_password(password or "", user):
_record_login_failure(client_ip)
raise HTTPException(status_code=401, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="口令错误").model_dump())
credentials_verified = True
# A registration/reset may commit after the initial lookup/password gate.
# Never mint the new account/version's session using an earlier decision.
if has_account_password(user) and (existing is None or existing.id != user.id or existing.token_version != user.token_version):
client_ip = _get_client_ip(request)
_check_rate_limit(client_ip)
if not await verify_account_password(password or "", user):
_record_login_failure(client_ip)
raise HTTPException(status_code=401, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="口令错误").model_dump())
credentials_verified = True
enforce_user_approved(user)
user = await _prepare_login_session(user)
token = create_access_token(str(user.id), token_version=user.token_version, auth_method="passphrase" if credentials_verified else None)
user = await _prepare_login_session(user)
token = create_access_token(str(user.id), token_version=user.token_version, auth_method="passphrase" if credentials_verified else None)
_set_session_cookie(response, token, request)
await _sync_user_position(request, user.id)
_sync_user_position(request, user.id)
return UsernameLoginResponse(
access_token=token,
@@ -714,7 +803,7 @@ async def login_by_token(
Returns the JWT access token in the response body and also sets it as a
session cookie for browser callers.
"""
_require_login_method("sso_enabled")
_require_login_method("sso_enabled")
config = get_app_config().auth_login.token_login
if not config.enabled:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="token 登录未开启")
@@ -734,10 +823,10 @@ async def login_by_token(
user, created = await _get_or_create_user_by_username(username)
enforce_user_approved(user)
user = await _prepare_login_session(user)
access_token = create_access_token(str(user.id), token_version=user.token_version, auth_method="sso")
user = await _prepare_login_session(user)
access_token = create_access_token(str(user.id), token_version=user.token_version, auth_method="sso")
_set_session_cookie(response, access_token, request)
await _sync_user_position(request, user.id)
_sync_user_position(request, user.id)
return UsernameLoginResponse(
access_token=access_token,
@@ -754,41 +843,41 @@ async def login_by_token(
)
@router.post("/register", response_model=UsernameLoginResponse, status_code=status.HTTP_201_CREATED)
@router.post("/register", response_model=UsernameLoginResponse, status_code=status.HTTP_201_CREATED)
async def register(request: Request, response: Response, body: RegisterRequest):
"""Register a new user account (always 'user' role).
Admin is auto-created on first boot. This endpoint creates regular users.
Auto-login by setting the session cookie.
"""
_require_login_method("password_login_enabled")
_require_login_method("registration_enabled")
client_ip = _get_client_ip(request)
_check_rate_limit(client_ip)
provider = get_local_provider()
username = body.username or str(body.email)
email = _username_to_email(username)
# Match every domain, even for explicit emails: a self-registration must
# never shadow the stable account selected by bare-username login.
duplicate = await provider.find_user_by_email_local_part(email.split("@", 1)[0])
_require_login_method("password_login_enabled")
_require_login_method("registration_enabled")
client_ip = _get_client_ip(request)
_check_rate_limit(client_ip)
provider = get_local_provider()
username = body.username or str(body.email)
email = _username_to_email(username)
# Match every domain, even for explicit emails: a self-registration must
# never shadow the stable account selected by bare-username login.
duplicate = await provider.find_user_by_email_local_part(email.split("@", 1)[0])
try:
if duplicate is not None:
raise ValueError("Duplicate username")
user = await provider.create_user(email=email, password=body.password, system_role="user", approved=True)
if duplicate is not None:
raise ValueError("Duplicate username")
user = await provider.create_user(email=email, password=body.password, system_role="user", approved=True)
except ValueError:
_record_login_failure(client_ip)
_record_login_failure(client_ip)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=AuthErrorResponse(code=AuthErrorCode.EMAIL_ALREADY_EXISTS, message="该用户名或邮箱已注册").model_dump(),
status_code=status.HTTP_409_CONFLICT,
detail=AuthErrorResponse(code=AuthErrorCode.EMAIL_ALREADY_EXISTS, message="该用户名或邮箱已注册").model_dump(),
)
enforce_user_approved(user)
user = await _prepare_login_session(user)
token = create_access_token(str(user.id), token_version=user.token_version, auth_method="password")
enforce_user_approved(user)
user = await _prepare_login_session(user)
token = create_access_token(str(user.id), token_version=user.token_version, auth_method="password")
_set_session_cookie(response, token, request)
await _sync_user_position(request, user.id)
_sync_user_position(request, user.id)
return _login_response(user, token, username=username, created=True)
return _login_response(user, token, username=username, created=True)
@router.post("/logout", response_model=MessageResponse)
@@ -798,7 +887,7 @@ async def logout(request: Request, response: Response):
return MessageResponse(message="Successfully logged out")
@router.post("/change-password", response_model=PasswordChangeResponse)
@router.post("/change-password", response_model=PasswordChangeResponse)
async def change_password(request: Request, response: Response, body: ChangePasswordRequest):
"""Change password for the currently authenticated user.
@@ -809,45 +898,45 @@ async def change_password(request: Request, response: Response, body: ChangePass
- Re-issues session cookie with new token_version
"""
user = await get_current_user_from_request(request)
client_ip = _get_client_ip(request)
_check_rate_limit(client_ip)
if not await verify_account_password(body.current_password, user):
_record_login_failure(client_ip)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="当前密码错误").model_dump())
client_ip = _get_client_ip(request)
_check_rate_limit(client_ip)
if not await verify_account_password(body.current_password, user):
_record_login_failure(client_ip)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="当前密码错误").model_dump())
provider = get_local_provider()
# Update email if provided
if body.new_email is not None:
existing = await provider.find_user_by_email_local_part(body.new_email.split("@", 1)[0])
existing = await provider.find_user_by_email_local_part(body.new_email.split("@", 1)[0])
if existing and str(existing.id) != str(user.id):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=AuthErrorResponse(code=AuthErrorCode.EMAIL_ALREADY_EXISTS, message="Email already in use").model_dump())
try:
user = await provider.update_credentials(user, await hash_password_async(body.new_password), email=body.new_email)
except ValueError as exc:
raise HTTPException(status_code=409, detail="账号信息已变更,请刷新后重试") from exc
_record_login_success(client_ip)
try:
user = await provider.update_credentials(user, await hash_password_async(body.new_password), email=body.new_email)
except ValueError as exc:
raise HTTPException(status_code=409, detail="账号信息已变更,请刷新后重试") from exc
_record_login_success(client_ip)
# Re-issue cookie with new token_version
token = create_access_token(str(user.id), token_version=user.token_version, auth_method="password")
token = create_access_token(str(user.id), token_version=user.token_version, auth_method="password")
_set_session_cookie(response, token, request)
return PasswordChangeResponse(message="密码已更新", access_token=token, expires_in=get_auth_config().token_expiry_days * 24 * 3600)
return PasswordChangeResponse(message="密码已更新", access_token=token, expires_in=get_auth_config().token_expiry_days * 24 * 3600)
@router.get("/me", response_model=UserResponse)
async def get_me(request: Request):
"""Get current authenticated user info."""
user = await get_current_user_from_request(request)
return UserResponse(
id=str(user.id),
email=user.email,
system_role=user.system_role,
needs_setup=user.needs_setup,
has_password=has_account_password(user),
requires_current_password=True,
uses_default_password=not has_account_password(user),
)
return UserResponse(
id=str(user.id),
email=user.email,
system_role=user.system_role,
needs_setup=user.needs_setup,
has_password=has_account_password(user),
requires_current_password=True,
uses_default_password=not has_account_password(user),
)
_SETUP_STATUS_COOLDOWN: dict[str, float] = {}
@@ -889,12 +978,12 @@ class InitializeAdminRequest(BaseModel):
"""Request model for first-boot admin account creation."""
email: EmailStr
password: str = Field(..., min_length=8, max_length=255)
password: str = Field(..., min_length=8, max_length=255)
_strong_password = field_validator("password")(classmethod(lambda cls, v: _validate_strong_password(v)))
@router.post("/initialize", response_model=UsernameLoginResponse, status_code=status.HTTP_201_CREATED)
@router.post("/initialize", response_model=UsernameLoginResponse, status_code=status.HTTP_201_CREATED)
async def initialize_admin(request: Request, response: Response, body: InitializeAdminRequest):
"""Create the first admin account on initial system setup.
@@ -912,7 +1001,7 @@ async def initialize_admin(request: Request, response: Response, body: Initializ
)
try:
user = await get_local_provider().create_initial_admin(email=str(body.email), password=body.password)
user = await get_local_provider().create_initial_admin(email=str(body.email), password=body.password)
except ValueError:
# DB unique-constraint race: another concurrent request beat us.
raise HTTPException(
@@ -920,11 +1009,11 @@ async def initialize_admin(request: Request, response: Response, body: Initializ
detail=AuthErrorResponse(code=AuthErrorCode.SYSTEM_ALREADY_INITIALIZED, message="System already initialized").model_dump(),
)
user = await _prepare_login_session(user)
token = create_access_token(str(user.id), token_version=user.token_version, auth_method="password")
user = await _prepare_login_session(user)
token = create_access_token(str(user.id), token_version=user.token_version, auth_method="password")
_set_session_cookie(response, token, request)
return _login_response(user, token, created=True)
return _login_response(user, token, created=True)
# ── Admin Endpoints ───────────────────────────────────────────────────────
@@ -938,54 +1027,99 @@ class UserListItem(BaseModel):
agent_count: int
qa_count: int = 0 # 问答次数: number of runs (one run = one question)
approved: bool = True # 登录白名单放行标记(白名单管理页据此放行/取消)
login_password: str | None = None # Legacy plaintext only; never return hashes.
has_login_password: bool = False
access_token: str | None = None
token_type: str = "bearer"
expires_in: int | None = None
def _legacy_login_password(user: User) -> str | None:
value = user.login_password
return value if value and not is_password_hash(value) else None
login_password: str | None = None # Legacy plaintext only; never return hashes.
has_login_password: bool = False
access_token: str | None = None
token_type: str = "bearer"
expires_in: int | None = None
@router.get("/users", response_model=list[UserListItem])
def _legacy_login_password(user: User) -> str | None:
value = user.login_password
return value if value and not is_password_hash(value) else None
class UserListPage(BaseModel):
"""Paged user list — returned when the caller passes ``limit``."""
items: list[UserListItem]
total: int
approved_count: int # approved 或 admin(搜索范围内)
limit: int
offset: int
_USER_LIST_TIMEOUT_SECONDS = 15
def _user_list_skill_store(request: Request, include_counts: bool = True) -> SkillStore | None:
return get_skill_store(request) if include_counts else None
def _user_list_agent_store(request: Request, include_counts: bool = True) -> AgentStore | None:
return get_agent_store(request) if include_counts else None
@router.get("/users", response_model=list[UserListItem] | UserListPage)
async def list_all_users(
request: Request,
skill_store: SkillStore = Depends(get_skill_store),
agent_store: AgentStore = Depends(get_agent_store),
include_counts: bool = Query(True, description="False=只查 users 表,跳过 runs 统计与 skill/agent 计数(白名单管理页用,避免慢查询)"),
q: str | None = Query(None, max_length=100, description="可选:按邮箱/用户ID 子串过滤(仅分页模式生效)"),
limit: int | None = Query(None, ge=1, le=200, description="传入即分页返回 UserListPage;不传保持旧的全量列表"),
offset: int = Query(0, ge=0),
skill_store: SkillStore | None = Depends(_user_list_skill_store),
agent_store: AgentStore | None = Depends(_user_list_agent_store),
):
"""List all users with their skill, agent and Q&A counts. Admin only."""
user = await get_current_user_from_request(request)
if user.system_role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
users = await get_local_provider().list_users()
# One GROUP BY query for everyone's 问答次数, so the list stays a single
# aggregate read instead of a per-user count. Best-effort: if the run store
# is unavailable the column simply renders as 0 rather than failing the list.
"""List all users with their skill, agent and Q&A counts. Admin only.
Passing ``limit`` switches to a paged response that reads one page of the
users table plus two COUNT queries; omitting it keeps the legacy flat list.
"""
try:
qa_counts = await get_run_store(request).count_by_user()
except Exception:
logger.warning("Failed to load per-user run counts for user list", exc_info=True)
qa_counts = {}
result = []
for u in users:
uid = str(u.id)
result.append(
UserListItem(
id=uid,
email=u.email,
system_role=u.system_role,
skill_count=await skill_store.count_owned(uid),
agent_count=await agent_store.count_owned(uid),
qa_count=qa_counts.get(uid, 0),
approved=getattr(u, "approved", True),
login_password=_legacy_login_password(u),
has_login_password=has_account_password(u),
async with asyncio.timeout(_USER_LIST_TIMEOUT_SECONDS if not include_counts else None):
user = await get_current_user_from_request(request)
if user.system_role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
provider = get_local_provider()
users = (
await provider.list_users()
if limit is None
else await provider.list_users_page(q=q, limit=limit, offset=offset)
)
)
return result
qa_counts: dict[str, int] = {}
# One recent-window GROUP BY for everyone's 问答次数. This keeps the admin
# user list from scanning the complete lifetime of the runs table.
if include_counts:
try:
qa_counts = await get_run_store(request).count_by_user(
since=recent_admin_query_cutoff()
)
except Exception:
logger.warning("Failed to load per-user run counts for user list", exc_info=True)
result = []
for u in users:
uid = str(u.id)
result.append(
UserListItem(
id=uid,
email=u.email,
system_role=u.system_role,
skill_count=(await skill_store.count_owned(uid)) if include_counts else 0,
agent_count=(await agent_store.count_owned(uid)) if include_counts else 0,
qa_count=qa_counts.get(uid, 0),
approved=getattr(u, "approved", True),
login_password=_legacy_login_password(u),
has_login_password=has_account_password(u),
)
)
if limit is None:
return result
total = await provider.count_users_filtered(q=q)
approved_count = await provider.count_users_filtered(q=q, approved_or_admin=True)
return UserListPage(items=result, total=total, approved_count=approved_count, limit=limit, offset=offset)
except TimeoutError as exc:
logger.warning("User list timed out waiting for account data")
raise HTTPException(status_code=503, detail="用户基础信息查询超时,请稍后重试") from exc
class UserApprovalRequest(BaseModel):
@@ -1008,10 +1142,10 @@ async def set_user_approval(user_id: str, body: UserApprovalRequest, request: Re
raise HTTPException(status_code=404, detail="用户不存在")
target.approved = bool(body.approved)
try:
saved = await provider.update_user(target)
except CredentialConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
try:
saved = await provider.update_user(target)
except CredentialConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return UserListItem(
id=str(saved.id),
email=saved.email,
@@ -1020,8 +1154,8 @@ async def set_user_approval(user_id: str, body: UserApprovalRequest, request: Re
agent_count=0,
qa_count=0,
approved=saved.approved,
login_password=_legacy_login_password(saved),
has_login_password=has_account_password(saved),
login_password=_legacy_login_password(saved),
has_login_password=has_account_password(saved),
)
@@ -1029,14 +1163,14 @@ class UserLoginPasswordRequest(BaseModel):
password: str | None = Field(
default=None,
max_length=255,
description="设置账号密码(与登录口令一致);不允许清除后退回免密码登录。",
description="设置账号密码(与登录口令一致);不允许清除后退回免密码登录。",
)
@router.put("/users/{user_id}/login-password", response_model=UserListItem)
@router.put("/users/{user_id}/reset-password", response_model=UserListItem)
async def set_user_login_password(user_id: str, body: UserLoginPasswordRequest, request: Request, response: Response) -> UserListItem:
"""Admin password reset; rotates both credential columns and all sessions."""
@router.put("/users/{user_id}/reset-password", response_model=UserListItem)
async def set_user_login_password(user_id: str, body: UserLoginPasswordRequest, request: Request, response: Response) -> UserListItem:
"""Admin password reset; rotates both credential columns and all sessions."""
admin = await get_current_user_from_request(request)
if admin.system_role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
@@ -1046,25 +1180,25 @@ async def set_user_login_password(user_id: str, body: UserLoginPasswordRequest,
if target is None:
raise HTTPException(status_code=404, detail="用户不存在")
pwd = body.password or ""
if not pwd:
raise HTTPException(status_code=400, detail="不能清除账号密码,请设置新密码")
if len(pwd) < 8:
raise HTTPException(status_code=400, detail="密码至少需要 8 个字符")
try:
_validate_strong_password(pwd)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
try:
saved = await provider.update_credentials(target, await hash_password_async(pwd))
except CredentialConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
token = None
expires_in = None
if str(admin.id) == str(saved.id):
token = create_access_token(str(saved.id), token_version=saved.token_version, auth_method="password")
_set_session_cookie(response, token, request)
expires_in = get_auth_config().token_expiry_days * 24 * 3600
pwd = body.password or ""
if not pwd:
raise HTTPException(status_code=400, detail="不能清除账号密码,请设置新密码")
if len(pwd) < 8:
raise HTTPException(status_code=400, detail="密码至少需要 8 个字符")
try:
_validate_strong_password(pwd)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
try:
saved = await provider.update_credentials(target, await hash_password_async(pwd))
except CredentialConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
token = None
expires_in = None
if str(admin.id) == str(saved.id):
token = create_access_token(str(saved.id), token_version=saved.token_version, auth_method="password")
_set_session_cookie(response, token, request)
expires_in = get_auth_config().token_expiry_days * 24 * 3600
return UserListItem(
id=str(saved.id),
email=saved.email,
@@ -1073,10 +1207,10 @@ async def set_user_login_password(user_id: str, body: UserLoginPasswordRequest,
agent_count=0,
qa_count=0,
approved=saved.approved,
login_password=None,
has_login_password=True,
access_token=token,
expires_in=expires_in,
login_password=None,
has_login_password=True,
access_token=token,
expires_in=expires_in,
)
@@ -1090,7 +1224,7 @@ async def oauth_login(provider: str):
Redirects to the OAuth provider's authorization URL.
Currently a placeholder - requires OAuth provider implementation.
"""
_require_login_method("sso_enabled")
_require_login_method("sso_enabled")
if provider not in ["github", "google"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -1110,7 +1244,7 @@ async def oauth_callback(provider: str, code: str, state: str):
Handles the OAuth provider's callback after user authorization.
Currently a placeholder.
"""
_require_login_method("sso_enabled")
_require_login_method("sso_enabled")
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="OAuth callback not yet implemented",
@@ -286,8 +286,23 @@ async def _sync_to_sandbox_if_needed(thread_id: str, virtual_path: str, file_pat
async def get_browser_context_status(
config: AppConfig = Depends(get_config),
) -> BrowserContextStatusResponse:
from app.gateway.api_read_cache import get_cache_version, get_cached_json, is_browser_status_enabled, set_cached_json
# Access control must precede even a warm Redis hit.
if not await is_browser_status_enabled(config):
raise HTTPException(
status_code=403,
detail={"code": "browser_status_disabled", "message": "管理员已关闭浏览器状态接口"},
headers={"Retry-After": "300", "Cache-Control": "no-store"},
)
settings = _settings(config)
return BrowserContextStatusResponse(
# Include the effective config so reloads cannot reuse obsolete capabilities.
cache_key = settings.model_dump_json()
version = await get_cache_version(config, "browser_status")
cached = await get_cached_json(config, "browser_status", cache_key, version=version)
if cached is not None:
return BrowserContextStatusResponse.model_validate(cached)
response = BrowserContextStatusResponse(
enabled=settings.enabled,
max_content_chars=settings.max_content_chars,
max_title_chars=settings.max_title_chars,
@@ -297,6 +312,8 @@ async def get_browser_context_status(
task_lease_seconds=settings.task_lease_seconds,
tool_result_max_chars=settings.tool_result_max_chars,
)
await set_cached_json(config, "browser_status", cache_key, response, expected_version=version)
return response
@router.get("/tasks/next", response_model=BrowserContextTaskNextResponse)
@@ -16,11 +16,13 @@ Routes (prefix ``/api/business-mapping``):
from __future__ import annotations
import logging
import re
from datetime import datetime
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.catalog_cache import cached_catalog, invalidate_catalog
from app.gateway.deps import get_agent_store
from deerflow.runtime.user_context import get_effective_user_id
@@ -37,6 +39,8 @@ _EIGHTBF_MANAGER_USERNAME = "lqq"
class BusinessMappingResponse(BaseModel):
business_code: str
label: str = ""
mapping_type: str = "内置业务"
is_custom: bool = False
agent_id: str | None = None
agent_name: str | None = None
chain_id: str | None = None
@@ -58,6 +62,12 @@ class BusinessMappingUpdateRequest(BaseModel):
chain_title: str | None = Field(default=None, max_length=512)
class BusinessMappingCreateRequest(BaseModel):
"""The name is both the display name and login ``goPath`` for a custom row."""
name: str = Field(min_length=1, max_length=32)
# ── helpers ──────────────────────────────────────────────────────────────
@@ -95,6 +105,23 @@ def _current_user_id(request: Request) -> str:
return get_effective_user_id()
_RESERVED_GOPATHS = frozenset({"3qfx", "rwfx", "xdfx", "agentfx", "b5-1"})
def _normalise_custom_mapping_input(body: BusinessMappingCreateRequest) -> str:
"""Validate a custom goPath without rewriting the administrator's label."""
name = re.sub(r"\s+", " ", body.name).strip()
if not name:
raise HTTPException(status_code=422, detail="名称不能为空")
# ``-A`` is a mode suffix, not part of a configurable name: ``name`` is
# single-agent and ``name-A`` is the same mapping's business-chain entry.
if name.lower().endswith("-a"):
raise HTTPException(status_code=422, detail="自定义名称不能以 -A 结尾")
if name.lower() in _RESERVED_GOPATHS:
raise HTTPException(status_code=422, detail="该名称已被内置 goPath 保留")
return name
async def _validate_public_chain_mapping(
request: Request,
fields: dict[str, object],
@@ -167,8 +194,32 @@ async def _validate_public_chain_mapping(
@router.get("", response_model=BusinessMappingListResponse)
async def list_mappings(request: Request) -> BusinessMappingListResponse:
store = _get_store(request)
rows = await store.list_mappings()
return BusinessMappingListResponse(mappings=[BusinessMappingResponse(**r) for r in rows])
async def load() -> BusinessMappingListResponse:
rows = await store.list_mappings()
return BusinessMappingListResponse(mappings=[BusinessMappingResponse(**r) for r in rows])
return await cached_catalog(request, "business_mapping", "all", load, BusinessMappingListResponse)
@router.post("", response_model=BusinessMappingResponse, status_code=201)
async def create_mapping(
request: Request,
body: BusinessMappingCreateRequest,
) -> BusinessMappingResponse:
"""Create a globally shared custom mapping (administrator only)."""
if not _is_admin(request):
raise HTTPException(status_code=403, detail="Only administrators can create business mappings")
name = _normalise_custom_mapping_input(body)
store = _get_store(request)
row = await store.create_custom_mapping(
business_code=name,
mapping_type="自定义类型",
)
if row is None:
raise HTTPException(status_code=409, detail="该名称已存在,请换一个名称")
await invalidate_catalog(request, "business_mapping")
return BusinessMappingResponse(**row)
@router.put("/{business_code}", response_model=BusinessMappingResponse)
@@ -183,4 +234,5 @@ async def update_mapping(request: Request, business_code: str, body: BusinessMap
row = await store.update_mapping(business_code, **fields)
if row is None:
raise HTTPException(status_code=404, detail="Business mapping not found")
await invalidate_catalog(request, "business_mapping")
return BusinessMappingResponse(**row)
@@ -0,0 +1,762 @@
"""Admin controls for resumable monthly PostgreSQL chat archives."""
from __future__ import annotations
import asyncio
import contextlib
import errno
import logging
import shutil
import sqlite3
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy import extract, func, select
from app.gateway.auth.disabled_mode import is_auth_disabled
from app.gateway.deps import get_current_user_from_request
from deerflow.persistence.engine import get_session_factory
from deerflow.persistence.thread_meta.base import is_excluded_system_thread
from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.types import BEIJING_TZ
from deerflow.runtime.checkpointer.chat_archive import (
archive_db_path,
archive_root,
ensure_catalog,
thread_archive_lock,
try_acquire_archive_job_lock,
)
from deerflow.runtime.checkpointer.checkpoint_migration import _sqlite_has_table, copy_thread_state
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin/chat-archive", tags=["chat-archive"])
_ACTIVE = {"queued", "running", "cancelling"}
_PAGE_SIZE = 40
_ARCHIVE_DISK_RESERVE_BYTES = 2 * 1024**3
_PURGE_THREAD_DELAY_SECONDS = 0.5
_PROCESS_STARTED_AT = datetime.now(BEIJING_TZ).isoformat(timespec="seconds")
class _ArchiveJobCancelled(Exception):
pass
class ChatArchiveJobResponse(BaseModel):
job_id: str
month: str
action: str
status: str
total_threads: int
processed_threads: int
failed_threads: int
current_thread_id: str | None = None
snapshot_at: str = ""
processed_through: str | None = None
message: str = ""
error: str | None = None
created_at: str
updated_at: str
async def _require_admin(request: Request) -> None:
if is_auth_disabled():
return
user = await get_current_user_from_request(request)
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="Chat archive management requires admin role")
def _now() -> str:
return datetime.now(BEIJING_TZ).isoformat(timespec="seconds")
def _parse_month(value: str) -> tuple[datetime, datetime]:
try:
start = datetime.strptime(value, "%Y-%m")
except ValueError as exc:
raise HTTPException(status_code=400, detail="month must use YYYY-MM format") from exc
if start.strftime("%Y-%m") != value:
raise HTTPException(status_code=400, detail="month must use YYYY-MM format")
if start.month == 12:
end = start.replace(year=start.year + 1, month=1)
else:
end = start.replace(month=start.month + 1)
return start, end
def _primary_checkpointer(request: Request):
checkpointer = getattr(request.app.state, "checkpointer", None)
primary = getattr(checkpointer, "primary", checkpointer)
if primary is None or not hasattr(getattr(primary, "conn", None), "connection"):
raise HTTPException(status_code=409, detail="当前聊天检查点不是 PostgreSQL,不能执行 PG 归档")
return checkpointer, primary
def _catalog_connect(root: Path) -> sqlite3.Connection:
ensure_catalog(root)
conn = sqlite3.connect(root / "catalog.db", timeout=30)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout=30000")
return conn
def _recover_jobs(root: Path, *, started_before: str | None = _PROCESS_STARTED_AT, owns_operation_lock: bool = False) -> None:
operation_lock = None if owns_operation_lock else try_acquire_archive_job_lock(root)
if not owns_operation_lock and operation_lock is None:
return # Another gateway/worker still owns the running job.
cutoff_sql = " AND created_at < ?" if started_before else ""
cutoff_values = (started_before,) if started_before else ()
with (contextlib.nullcontext() if owns_operation_lock else operation_lock), _catalog_connect(root) as conn:
now = _now()
conn.execute(
"UPDATE archive_jobs SET status='interrupted', message='执行进程已退出,已保留成功归档;可重新归档或清理已校验部分', "
"error=COALESCE(error, '归档执行进程已退出或服务重启,任务未完成'), updated_at=? "
"WHERE status IN ('queued','running','cancelling')" + cutoff_sql,
(now, *cutoff_values),
)
conn.execute(
"UPDATE archive_months SET status='interrupted', updated_at=? "
"WHERE status IN ('copying','deleting','restoring') AND current_job_id IN "
"(SELECT job_id FROM archive_jobs WHERE status='interrupted'" + cutoff_sql + ")",
(now, *cutoff_values),
)
def _month_start(month: str) -> tuple[datetime, datetime]:
start, end = _parse_month(month)
return start.replace(tzinfo=BEIJING_TZ), end.replace(tzinfo=BEIJING_TZ)
async def _month_thread_count(session_factory, month: str, *, snapshot_at: str | None = None) -> int:
start, end = _month_start(month)
conditions = [ThreadMetaRow.created_at >= start, ThreadMetaRow.created_at < end]
if snapshot_at:
conditions.append(ThreadMetaRow.created_at <= datetime.fromisoformat(snapshot_at))
async with session_factory() as session:
return int(await session.scalar(
select(func.count()).select_from(ThreadMetaRow).where(*conditions)
) or 0)
async def _thread_page(session_factory, month: str, offset: int, limit: int, *, snapshot_at: str | None = None):
start, end = _month_start(month)
conditions = [ThreadMetaRow.created_at >= start, ThreadMetaRow.created_at < end]
if snapshot_at:
conditions.append(ThreadMetaRow.created_at <= datetime.fromisoformat(snapshot_at))
async with session_factory() as session:
result = await session.execute(
select(
ThreadMetaRow.thread_id,
ThreadMetaRow.status,
ThreadMetaRow.metadata_json,
ThreadMetaRow.created_at,
)
.where(*conditions)
.order_by(ThreadMetaRow.created_at, ThreadMetaRow.thread_id)
.offset(offset)
.limit(limit)
)
return list(result.all())
async def _thread_status(session_factory, thread_id: str) -> str | None:
async with session_factory() as session:
value = await session.scalar(
select(ThreadMetaRow.status).where(ThreadMetaRow.thread_id == thread_id).limit(1)
)
return str(value).lower() if value is not None else None
def _archive_action_thread_count(root: Path, month: str, action: str) -> int:
"""Count only archived threads that the requested action will process."""
if not (root / "catalog.db").is_file():
return 0
if action == "purge":
statuses = ("copied", "deleted", "restored")
placeholders = ",".join("?" for _ in statuses)
query = f"SELECT COUNT(*) FROM archive_threads WHERE month=? AND status IN ({placeholders})"
params = (month, *statuses)
elif action == "rollback":
query = "SELECT COUNT(*) FROM archive_threads WHERE month=?"
params = (month,)
else:
return 0
with _catalog_connect(root) as conn:
row = conn.execute(query, params).fetchone()
return int(row[0] or 0)
async def _month_inventory(session_factory) -> list[dict[str, Any]]:
async with session_factory() as session:
dialect = session.get_bind().dialect.name
created_at = ThreadMetaRow.created_at
if dialect in {"postgresql", "opengauss"}:
local_created = func.timezone("Asia/Shanghai", created_at)
else:
local_created = created_at
year_expr = extract("year", local_created).label("year")
month_expr = extract("month", local_created).label("month")
rows = await session.execute(
select(year_expr, month_expr, func.count())
.select_from(ThreadMetaRow)
.group_by(year_expr, month_expr)
.order_by(year_expr.desc(), month_expr.desc())
)
return [
{"month": f"{int(year):04d}-{int(month):02d}", "thread_count": int(count)}
for year, month, count in rows.all()
if year is not None and month is not None
]
def _catalog_months(root: Path) -> dict[str, dict[str, Any]]:
if not (root / "catalog.db").exists():
return {}
with _catalog_connect(root) as conn:
months = {
row["month"]: dict(row)
for row in conn.execute("SELECT * FROM archive_months")
}
for row in conn.execute(
"SELECT j.* FROM archive_jobs j WHERE j.rowid=(SELECT newest.rowid FROM archive_jobs newest "
"WHERE newest.month=j.month ORDER BY newest.created_at DESC, newest.rowid DESC LIMIT 1)"
):
months.setdefault(row["month"], {})["latest_job"] = dict(row)
return months
def _save_job(root: Path, job: dict[str, Any]) -> None:
with _catalog_connect(root) as conn:
conn.execute(
"INSERT INTO archive_jobs(job_id,month,action,status,total_threads,processed_threads,failed_threads,"
"current_thread_id,snapshot_at,processed_through,message,error,created_at,updated_at) "
"VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
tuple((job.get(key) or job.get("created_at") or "") if key == "snapshot_at" else job.get(key) for key in (
"job_id", "month", "action", "status", "total_threads", "processed_threads",
"failed_threads", "current_thread_id", "snapshot_at", "processed_through", "message", "error", "created_at", "updated_at",
)),
)
def _claim_month_job(root: Path, job: dict[str, Any], month_status: str) -> bool:
"""Atomically allow only one heavy archive operation across processes."""
conn = _catalog_connect(root)
try:
conn.execute("BEGIN IMMEDIATE")
active = conn.execute(
"SELECT job_id FROM archive_jobs WHERE status IN ('queued','running','cancelling') LIMIT 1"
).fetchone()
if active:
conn.rollback()
return False
conn.execute(
"INSERT INTO archive_jobs(job_id,month,action,status,total_threads,processed_threads,failed_threads,"
"current_thread_id,snapshot_at,processed_through,message,error,created_at,updated_at) "
"VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
tuple((job.get(key) or job.get("created_at") or "") if key == "snapshot_at" else job.get(key) for key in (
"job_id", "month", "action", "status", "total_threads", "processed_threads",
"failed_threads", "current_thread_id", "snapshot_at", "processed_through", "message", "error", "created_at", "updated_at",
)),
)
conn.execute(
"INSERT INTO archive_months(month,status,total_threads,copied_threads,deleted_threads,restored_threads,current_job_id,updated_at) "
"VALUES(?,?,?,0,0,0,?,?) ON CONFLICT(month) DO UPDATE SET status=excluded.status, "
"total_threads=excluded.total_threads,current_job_id=excluded.current_job_id,updated_at=excluded.updated_at",
(job["month"], month_status, job["total_threads"], job["job_id"], _now()),
)
conn.commit()
return True
except Exception:
conn.rollback()
raise
finally:
conn.close()
def _patch_job(root: Path, job_id: str, **values) -> None:
values["updated_at"] = _now()
assignments = ",".join(f"{key}=?" for key in values)
with _catalog_connect(root) as conn:
conn.execute(f"UPDATE archive_jobs SET {assignments} WHERE job_id=?", (*values.values(), job_id))
async def _patch_job_async(root: Path, job_id: str, **values) -> None:
await asyncio.to_thread(_patch_job, root, job_id, **values)
def _job_dict(root: Path, job_id: str) -> dict[str, Any] | None:
if not (root / "catalog.db").exists():
return None
with _catalog_connect(root) as conn:
row = conn.execute("SELECT * FROM archive_jobs WHERE job_id=?", (job_id,)).fetchone()
return dict(row) if row else None
def _upsert_month(root: Path, month: str, *, status: str, total: int, job_id: str | None = None) -> None:
with _catalog_connect(root) as conn:
conn.execute(
"INSERT INTO archive_months(month,status,total_threads,copied_threads,deleted_threads,restored_threads,current_job_id,updated_at) "
"VALUES(?,?,?,0,0,0,?,?) ON CONFLICT(month) DO UPDATE SET status=excluded.status, "
"total_threads=excluded.total_threads,current_job_id=excluded.current_job_id,updated_at=excluded.updated_at",
(month, status, total, job_id, _now()),
)
async def _upsert_month_async(root: Path, month: str, *, status: str, total: int, job_id: str | None = None) -> None:
await asyncio.to_thread(_upsert_month, root, month, status=status, total=total, job_id=job_id)
def _mark_archived_thread(root: Path, thread_id: str, month: str, count: int, status: str = "copied") -> None:
now = _now()
with _catalog_connect(root) as conn:
conn.execute(
"INSERT INTO archive_threads(thread_id,month,status,checkpoint_count,archived_at,deleted_at,restored_at) "
"VALUES(?,?,?,?,?,?,?) ON CONFLICT(thread_id) DO UPDATE SET month=excluded.month,status=excluded.status,"
"checkpoint_count=excluded.checkpoint_count,archived_at=excluded.archived_at,"
"deleted_at=CASE WHEN excluded.status='deleted' THEN excluded.archived_at ELSE archive_threads.deleted_at END,"
"restored_at=CASE WHEN excluded.status='restored' THEN excluded.archived_at ELSE archive_threads.restored_at END",
(thread_id, month, status, count, now, now if status == "deleted" else None, now if status == "restored" else None),
)
conn.execute(
"UPDATE archive_months SET copied_threads=(SELECT COUNT(*) FROM archive_threads WHERE month=?), "
"deleted_threads=(SELECT COUNT(*) FROM archive_threads WHERE month=? AND status='deleted'), updated_at=? WHERE month=?",
(month, month, now, month),
)
async def _checkpoint_ids(saver, thread_id: str) -> list[str]:
config = {"configurable": {"thread_id": thread_id}}
ids: list[str] = []
async for tup in saver.alist(config):
configurable = (tup.config or {}).get("configurable", {}) or {}
checkpoint_id = configurable.get("checkpoint_id") or (tup.checkpoint or {}).get("id")
if checkpoint_id:
ids.append(str(checkpoint_id))
return ids
@contextlib.asynccontextmanager
async def _open_sqlite_archive(path: Path):
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
path.parent.mkdir(parents=True, exist_ok=True)
context = AsyncSqliteSaver.from_conn_string(str(path))
saver = await context.__aenter__()
try:
await saver.conn.execute("PRAGMA busy_timeout=30000")
if not await asyncio.to_thread(_sqlite_has_table, str(path), "checkpoints"):
await saver.conn.execute("PRAGMA journal_mode=WAL")
await saver.setup()
yield saver
finally:
await context.__aexit__(None, None, None)
class ChatArchiveRunner:
def __init__(self, root: Path, checkpointer, session_factory):
self.root = root
self.checkpointer = checkpointer
self.primary = getattr(checkpointer, "primary", checkpointer)
self.session_factory = session_factory
self._locks: dict[str, asyncio.Lock] = {}
self._tasks: dict[str, asyncio.Task] = {}
async def start(self, month: str, action: str) -> dict[str, Any]:
_parse_month(month)
if action not in {"archive", "purge", "rollback"}:
raise HTTPException(status_code=400, detail="未知归档操作")
current_month = datetime.now(BEIJING_TZ).strftime("%Y-%m")
if month > current_month:
raise HTTPException(status_code=409, detail="不能归档未来月份")
snapshot_at = _now()
if action == "archive":
total = await _month_thread_count(self.session_factory, month, snapshot_at=snapshot_at)
else:
total = await asyncio.to_thread(_archive_action_thread_count, self.root, month, action)
if not total:
raise HTTPException(status_code=409, detail="该月份尚无已校验归档,不能删除或回滚;请先归档")
job_id = uuid.uuid4().hex
job = {
"job_id": job_id,
"month": month,
"action": action,
"status": "queued",
"total_threads": total,
"processed_threads": 0,
"failed_threads": 0,
"current_thread_id": None,
"snapshot_at": snapshot_at,
"processed_through": None,
"message": "任务已排队",
"error": None,
"created_at": _now(),
"updated_at": _now(),
}
status = {"archive": "copying", "purge": "deleting", "rollback": "restoring"}[action]
operation_lock = try_acquire_archive_job_lock(self.root)
if operation_lock is None:
raise HTTPException(status_code=409, detail="已有月份归档、清理或回滚任务正在运行,请完成后再启动下一项")
try:
# The kernel lock is free only if no runner is alive, including
# one that died after this gateway process finished starting.
_recover_jobs(self.root, started_before=None, owns_operation_lock=True)
claimed = _claim_month_job(self.root, job, status)
if not claimed:
raise HTTPException(status_code=409, detail="已有月份归档、清理或回滚任务正在运行,请完成后再启动下一项")
task = asyncio.create_task(self._run(job_id, owns_operation_lock=True), name=f"chat-archive-{action}-{month}")
except BaseException:
operation_lock.close()
raise
self._tasks[job_id] = task
def completed(_task):
operation_lock.close()
self._tasks.pop(job_id, None)
task.add_done_callback(completed)
return job
async def request_cancel(self, job_id: str) -> dict[str, Any]:
job = await asyncio.to_thread(_job_dict, self.root, job_id)
if not job:
raise HTTPException(status_code=404, detail="归档任务不存在")
if job["status"] in _ACTIVE:
await _patch_job_async(self.root, job_id, status="cancelling", message="将在当前会话处理结束后停止")
return await asyncio.to_thread(_job_dict, self.root, job_id)
async def _check_cancelled(self, job_id: str) -> None:
job = await asyncio.to_thread(_job_dict, self.root, job_id) or {}
if job.get("status") == "cancelling":
raise _ArchiveJobCancelled()
async def _record_thread_failure(self, job_id: str, tid: str, exc: Exception) -> None:
# A corrupt/full archive volume cannot safely accept more threads.
# Row-specific failures may continue; infrastructure failures must stop.
sqlite_code = getattr(exc, "sqlite_errorcode", 0) or 0
if (
isinstance(exc, MemoryError)
or isinstance(exc, OSError) and exc.errno in {errno.ENOSPC, errno.EDQUOT}
or isinstance(exc, sqlite3.Error) and (sqlite_code & 0xFF) in {
sqlite3.SQLITE_FULL, sqlite3.SQLITE_IOERR, sqlite3.SQLITE_CORRUPT, sqlite3.SQLITE_NOTADB,
}
):
raise exc
logger.error("Chat archive thread failed; continuing: job=%s thread=%s", job_id, tid, exc_info=True)
job = await asyncio.to_thread(_job_dict, self.root, job_id) or {}
await _patch_job_async(
self.root, job_id,
failed_threads=int(job.get("failed_threads") or 0) + 1,
error=f"会话 {tid}: {type(exc).__name__}: {exc}"[:2000],
message="该会话处理失败,已记录错误,继续处理其他会话",
)
async def _run(self, job_id: str, *, owns_operation_lock: bool = False) -> None:
operation_lock = None if owns_operation_lock else try_acquire_archive_job_lock(self.root)
if not owns_operation_lock and operation_lock is None:
return
try:
await self._run_locked(job_id)
finally:
if operation_lock is not None:
operation_lock.close()
async def _run_locked(self, job_id: str) -> None:
job = _job_dict(self.root, job_id)
if not job:
return
month = job["month"]
action = job["action"]
lock = self._locks.setdefault(month, asyncio.Lock())
async with lock:
try:
await self._check_cancelled(job_id)
await _patch_job_async(self.root, job_id, status="running", message="正在处理,页面可安全关闭")
if action == "archive":
await self._archive_month(job_id, month)
elif action == "purge":
await self._purge_month(job_id, month)
else:
await self._rollback_month(job_id, month)
job = await asyncio.to_thread(_job_dict, self.root, job_id) or job
failed = int(job.get("failed_threads") or 0)
status = "completed" if failed == 0 else "partial"
month_status = {
"archive": "ready" if failed == 0 else "partial",
"purge": "deleted" if failed == 0 else "partial",
"rollback": "ready" if failed == 0 else "partial",
}[action]
await _patch_job_async(self.root, job_id, status=status, current_thread_id=None,
message="处理完成" if failed == 0 else f"完成但有 {failed} 个会话未处理")
await _upsert_month_async(self.root, month, status=month_status, total=int(job.get("total_threads") or 0), job_id=job_id)
except (_ArchiveJobCancelled, asyncio.CancelledError) as exc:
await _patch_job_async(self.root, job_id, status="cancelled" if isinstance(exc, _ArchiveJobCancelled) else "interrupted",
current_thread_id=None, message="任务已停止,已完成的归档保留,可重新执行",
error="管理员停止任务" if isinstance(exc, _ArchiveJobCancelled) else "执行任务被取消或服务正在退出")
await _upsert_month_async(self.root, month, status="interrupted", total=int(job.get("total_threads") or 0), job_id=job_id)
if isinstance(exc, asyncio.CancelledError):
raise
except Exception as exc:
logger.exception("Chat archive job failed: job=%s month=%s action=%s", job_id, month, action)
await _patch_job_async(self.root, job_id, status="failed", error=f"{type(exc).__name__}: {exc}"[:2000],
message="任务停止,已完成的归档保留;可查看错误并重新执行")
await _upsert_month_async(self.root, month, status="interrupted", total=int(job.get("total_threads") or 0), job_id=job_id)
async def _archive_month(self, job_id: str, month: str) -> None:
path = archive_db_path(self.root, month)
if (await asyncio.to_thread(shutil.disk_usage, self.root)).free < _ARCHIVE_DISK_RESERVE_BYTES:
raise RuntimeError("归档目录所在磁盘可用空间不足 2 GiB;请先扩容或清理磁盘,PostgreSQL 数据未删除")
job = await asyncio.to_thread(_job_dict, self.root, job_id) or {}
snapshot_at = str(job.get("snapshot_at") or job.get("created_at") or "")
total = int(job.get("total_threads") or 0)
await _upsert_month_async(self.root, month, status="copying", total=total, job_id=job_id)
processed = 0
offset = 0
async with _open_sqlite_archive(path) as archive:
while True:
rows = await _thread_page(
self.session_factory, month, offset, _PAGE_SIZE, snapshot_at=snapshot_at
)
if not rows:
break
offset += len(rows)
for thread_id, thread_status, metadata, created_at in rows:
await self._check_cancelled(job_id)
processed_through = created_at.isoformat() if created_at else None
if is_excluded_system_thread(metadata if isinstance(metadata, dict) else {}):
processed += 1
await _patch_job_async(
self.root, job_id, processed_threads=processed,
processed_through=processed_through, message="已跳过系统任务会话"
)
continue
tid = str(thread_id)
if (await asyncio.to_thread(shutil.disk_usage, self.root)).free < _ARCHIVE_DISK_RESERVE_BYTES:
raise RuntimeError("归档卷剩余空间低于 2 GiB,任务已暂停;PostgreSQL 数据保留")
await _patch_job_async(self.root, job_id, current_thread_id=tid, message="正在复制会话检查点")
if str(thread_status).lower() in {"running", "busy"}:
current_job = await asyncio.to_thread(_job_dict, self.root, job_id) or {}
processed += 1
await _patch_job_async(
self.root, job_id,
failed_threads=int(current_job.get("failed_threads") or 0) + 1,
processed_threads=processed, processed_through=processed_through,
message="正在跳过运行中的会话"
)
continue
try:
async with thread_archive_lock(self.root, tid, exclusive=True):
if await _thread_status(self.session_factory, tid) in {"running", "busy"}:
raise RuntimeError("会话正在问答中,已跳过;结束后可重新归档")
source_ids = await _checkpoint_ids(self.primary, tid)
if not source_ids:
processed += 1
await _patch_job_async(
self.root, job_id, processed_threads=processed,
processed_through=processed_through, message="已跳过无检查点的空会话"
)
continue
await copy_thread_state(self.primary, archive, tid)
copied_ids = await _checkpoint_ids(archive, tid)
if not set(source_ids).issubset(copied_ids):
raise RuntimeError(f"thread {tid} checkpoint verification failed: source={len(source_ids)} archived={len(copied_ids)}")
await asyncio.to_thread(_mark_archived_thread, self.root, tid, month, len(copied_ids), "copied")
except Exception as exc:
await self._record_thread_failure(job_id, tid, exc)
processed += 1
current_job = await asyncio.to_thread(_job_dict, self.root, job_id) or {}
await _patch_job_async(
self.root, job_id, processed_threads=processed,
failed_threads=int(current_job.get("failed_threads") or 0),
processed_through=processed_through,
)
await asyncio.sleep(0.03)
job = await asyncio.to_thread(_job_dict, self.root, job_id) or {}
if int(job.get("failed_threads") or 0):
await _patch_job_async(self.root, job_id, message="部分会话正忙或校验失败;可稍后重新归档")
async def _purge_month(self, job_id: str, month: str) -> None:
path = archive_db_path(self.root, month)
if not path.is_file():
raise RuntimeError(f"monthly SQLite archive is missing: {path}")
with _catalog_connect(self.root) as conn:
thread_ids = [str(row[0]) for row in conn.execute(
"SELECT thread_id FROM archive_threads WHERE month=? AND status IN ('copied','deleted','restored') ORDER BY thread_id",
(month,),
)]
processed = 0
async with _open_sqlite_archive(path) as archive:
for tid in thread_ids:
await self._check_cancelled(job_id)
await _patch_job_async(self.root, job_id, current_thread_id=tid, message="重新校验并清理 PostgreSQL 检查点")
skipped_running = False
try:
async with thread_archive_lock(self.root, tid, exclusive=True):
thread_status = await _thread_status(self.session_factory, tid)
if thread_status in {"running", "busy"}:
skipped_running = True
else:
pg_ids = await _checkpoint_ids(self.primary, tid)
if not pg_ids:
await asyncio.to_thread(
_mark_archived_thread,
self.root,
tid,
month,
len(await _checkpoint_ids(archive, tid)),
"deleted",
)
else:
await copy_thread_state(self.primary, archive, tid)
archived_ids = await _checkpoint_ids(archive, tid)
if not set(pg_ids).issubset(archived_ids):
raise RuntimeError(f"thread {tid} archive is incomplete; PostgreSQL rows were kept")
await self.primary.adelete_thread(tid)
remaining = await _checkpoint_ids(self.primary, tid)
if remaining:
raise RuntimeError(f"thread {tid} PostgreSQL cleanup verification failed")
await asyncio.to_thread(_mark_archived_thread, self.root, tid, month, len(archived_ids), "deleted")
except Exception as exc:
await self._record_thread_failure(job_id, tid, exc)
processed += 1
job_update: dict[str, Any] = {"processed_threads": processed}
if skipped_running:
job = await asyncio.to_thread(_job_dict, self.root, job_id) or {}
job_update.update(
failed_threads=int(job.get("failed_threads") or 0) + 1,
message="会话仍在问答中,已跳过;结束后可重新清理",
)
await _patch_job_async(self.root, job_id, **job_update)
await asyncio.sleep(_PURGE_THREAD_DELAY_SECONDS)
async def _rollback_month(self, job_id: str, month: str) -> None:
path = archive_db_path(self.root, month)
if not path.is_file():
raise RuntimeError(f"monthly SQLite archive is missing: {path}")
with _catalog_connect(self.root) as conn:
thread_ids = [str(row[0]) for row in conn.execute(
"SELECT thread_id FROM archive_threads WHERE month=? ORDER BY thread_id", (month,)
)]
processed = 0
async with _open_sqlite_archive(path) as archive:
for tid in thread_ids:
await self._check_cancelled(job_id)
await _patch_job_async(self.root, job_id, current_thread_id=tid, message="回滚检查点到 PostgreSQL")
try:
async with thread_archive_lock(self.root, tid, exclusive=True):
if await _thread_status(self.session_factory, tid) in {"running", "busy"}:
raise RuntimeError("会话正在问答中,已跳过;结束后可重新回滚")
archive_ids = await _checkpoint_ids(archive, tid)
await copy_thread_state(archive, self.primary, tid)
pg_ids = await _checkpoint_ids(self.primary, tid)
if not set(archive_ids).issubset(pg_ids):
raise RuntimeError(f"thread {tid} rollback verification failed")
await asyncio.to_thread(_mark_archived_thread, self.root, tid, month, len(archive_ids), "restored")
except Exception as exc:
await self._record_thread_failure(job_id, tid, exc)
processed += 1
await _patch_job_async(self.root, job_id, processed_threads=processed)
await asyncio.sleep(0.03)
async def _runner(request: Request) -> ChatArchiveRunner:
runner = getattr(request.app.state, "chat_archive_runner", None)
if runner is None:
checkpointer, _ = _primary_checkpointer(request)
session_factory = get_session_factory()
if session_factory is None:
raise HTTPException(status_code=503, detail="聊天元数据数据库未启用")
root = archive_root()
ensure_catalog(root)
await _ensure_archive_recovery_async(request, root)
runner = ChatArchiveRunner(root, checkpointer, session_factory)
request.app.state.chat_archive_runner = runner
return runner
async def _ensure_archive_recovery_async(request: Request, root: Path) -> None:
"""Recover abandoned work without interrupting another live process.
Recheck on inventory requests: another process may die after this process
starts or after an earlier recovery was skipped because the lock was held.
"""
if not (root / "catalog.db").is_file():
# Merely opening the settings page must not initialize archive storage.
return
lock = getattr(request.app.state, "chat_archive_recovery_lock", None)
if lock is None:
lock = asyncio.Lock()
request.app.state.chat_archive_recovery_lock = lock
async with lock:
await asyncio.to_thread(_recover_jobs, root, started_before=None)
@router.get("/months")
async def list_archive_months(request: Request):
await _require_admin(request)
_, _primary = _primary_checkpointer(request)
session_factory = get_session_factory()
if session_factory is None:
raise HTTPException(status_code=503, detail="聊天元数据数据库未启用")
root = archive_root()
await _ensure_archive_recovery_async(request, root)
inventory = await _month_inventory(session_factory)
catalog = _catalog_months(root)
disk_path = root
while not disk_path.exists():
disk_path = disk_path.parent
disk = await asyncio.to_thread(shutil.disk_usage, disk_path)
months = []
for item in inventory:
month = item["month"]
state = catalog.get(month, {})
file_path = archive_db_path(root, month)
months.append({
**item,
"status": state.get("status", "not_archived"),
"copied_threads": int(state.get("copied_threads") or 0),
"deleted_threads": int(state.get("deleted_threads") or 0),
"sqlite_bytes": file_path.stat().st_size if file_path.is_file() else 0,
"latest_job": state.get("latest_job"),
})
return {
"archive_dir": str(root),
"disk_total_bytes": disk.total,
"disk_free_bytes": disk.free,
"month_basis": "会话创建月份(按业务数据库 threads_meta 时间)",
"warning": "页面只读取轻量会话元数据,不创建归档文件或扫描 PostgreSQL 检查点表;只有管理员启动归档、清理或回滚任务时才会读写归档数据。",
"months": months,
}
async def _start_action(month: str, action: str, request: Request):
await _require_admin(request)
_primary_checkpointer(request)
return await (await _runner(request)).start(month, action)
@router.post("/months/{month}/archive")
async def start_month_archive(month: str, request: Request):
return await _start_action(month, "archive", request)
@router.post("/months/{month}/delete-postgres")
async def delete_archived_month_from_postgres(month: str, request: Request):
return await _start_action(month, "purge", request)
@router.post("/months/{month}/rollback")
async def rollback_month_to_postgres(month: str, request: Request):
return await _start_action(month, "rollback", request)
@router.get("/jobs/{job_id}", response_model=ChatArchiveJobResponse)
async def get_chat_archive_job(job_id: str, request: Request):
await _require_admin(request)
job = _job_dict(archive_root(), job_id)
if job is None:
raise HTTPException(status_code=404, detail="归档任务不存在")
return job
@@ -17,6 +17,7 @@ from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.auth.disabled_mode import is_auth_disabled
from app.gateway.catalog_cache import cached_catalog, catalog_key, invalidate_catalog
from app.gateway.deps import get_optional_user_from_request
from deerflow.persistence.compact_menu_overrides import (
DEFAULT_COMPACT_MENU_TYPE,
@@ -32,6 +33,7 @@ class CompactMenuOverride(BaseModel):
placement: Literal["sidebar", "settings", "toolbox"] = "sidebar"
sort_order: int = 0
label: str | None = Field(default=None, max_length=255)
disabled: bool = False
class CompactMenuOverrideResponse(CompactMenuOverride):
@@ -99,9 +101,11 @@ async def _read_response(
request: Request,
store: CompactMenuOverrideStore,
menu_type: str,
*,
actor: tuple[str | None, bool] | None = None,
) -> CompactMenuOverrideListResponse:
user_id, is_admin = actor if actor is not None else await _actor(request)
default_rows = await store.list_overrides(menu_type)
user_id, is_admin = await _actor(request)
if is_admin or user_id is None:
return CompactMenuOverrideListResponse(
menu_type=menu_type,
@@ -133,8 +137,12 @@ async def list_compact_menu_overrides(
),
) -> CompactMenuOverrideListResponse:
t = normalize_compact_menu_type(menu_type)
actor = await _actor(request)
store = _get_store(request)
return await _read_response(request, store, t)
return await cached_catalog(
request, "compact_menu_overrides", catalog_key(*actor, t),
lambda: _read_response(request, store, t, actor=actor), CompactMenuOverrideListResponse,
)
@router.put("", response_model=CompactMenuOverrideListResponse)
@@ -150,6 +158,7 @@ async def replace_compact_menu_overrides(
await store.replace_all(payload, menu_type=t, updated_by=user_id)
else:
await store.replace_user_overrides(user_id, payload, menu_type=t, updated_by=user_id)
await invalidate_catalog(request, "compact_menu_overrides")
return await _read_response(request, store, t)
@@ -166,4 +175,5 @@ async def reset_compact_menu_overrides(
await store.replace_all([], menu_type=t, updated_by=user_id)
else:
await store.replace_user_overrides(user_id, [], menu_type=t, updated_by=user_id)
await invalidate_catalog(request, "compact_menu_overrides")
return await _read_response(request, store, t)
@@ -865,8 +865,9 @@ async def get_capabilities() -> dict[str, Any]:
"""Advertise available modes, material channels, and export formats (§16.7)."""
from deerflow.agents.deep_research.adapters.image import ImageGenerationSettings
from deerflow.agents.deep_research.adapters.material_provider import DeerFlowMaterialProvider
from deerflow.utils.background_init import run_background_init
provider = DeerFlowMaterialProvider()
provider = await run_background_init(DeerFlowMaterialProvider)
image_settings = ImageGenerationSettings.from_app_config()
channels = provider.material_channels
return {
@@ -4,7 +4,7 @@
(整体存储、整体替换,数据库不定死字段)。文档内容由消费方与产出方约定,
当前包含时间轴(重要时间节点)、任务衔接、页面单块内容等区块。
Routes (authenticated by the Gateway middleware):
Routes (credential-free internal integration endpoints):
GET /api/force-situation/tasks/{task_id}/detail
PUT /api/force-situation/tasks/{task_id}/detail
"""
@@ -10,8 +10,7 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse, Response
from deerflow.config import get_app_config
from deerflow.config.html_runtime_config import DEFAULT_HTML_MAP_TILE_URL
from deerflow.html_runtime.addresses import html_map_tile_url, html_runtime_base_url
router = APIRouter(prefix="/api/public/html-runtime", tags=["html-runtime"])
@@ -60,9 +59,8 @@ async def get_deerflow_html_runtime() -> Response:
path = _RUNTIME_DIR / "deerflow-runtime.js"
if not path.is_file():
raise HTTPException(status_code=503, detail="HTML runtime is not installed")
config = get_app_config().html_runtime
base_url = config.base_url.rstrip("/")
tile_url = str(getattr(config, "map_tile_url", "") or "").strip() or DEFAULT_HTML_MAP_TILE_URL
base_url = html_runtime_base_url()
tile_url = html_map_tile_url()
runtime_config = {
"geoMaps": {
"china-provinces": f"{base_url}/maps/china-provinces.geojson",
@@ -0,0 +1,137 @@
"""HTML 模板管理 API(模板管理页)。
后端存储 :mod:`deerflow.runtime.scheduler.template_store`(文件型,
``{base_dir}/html-templates/<id>/``),启动时把随包内置模板 seed 进去(缺失才复制)。
读对所有登录用户开放(定时任务表单下拉也要用),写(创建/编辑/上传覆盖/删除/重置)
仅管理员——与敏感词管理同一鉴权口径。旧端点 ``GET /api/scheduled-tasks/html-templates``
保持不变(内部同样读存储),本路由是它的管理超集。
"""
from __future__ import annotations
import asyncio
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.catalog_cache import cached_catalog, catalog_key, invalidate_catalog
from app.gateway.deps import get_optional_user_from_request
from deerflow.runtime.scheduler import template_store
router = APIRouter(prefix="/api/html-templates", tags=["html-templates"])
async def _require_admin(request: Request) -> None:
"""写操作仅管理员;关闭鉴权时(request 无用户)放行,与 scheduled_tasks 口径一致。"""
user = await get_optional_user_from_request(request)
if user is None:
return
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="仅管理员可管理 HTML 模板")
def _not_found(template_id: str) -> HTTPException:
return HTTPException(status_code=404, detail=f"模板不存在:{template_id}")
class TemplateCreateRequest(BaseModel):
template_id: str = Field(..., description="模板 id(字母/数字/./-/_,创建后不可改)")
name: str = Field(..., description="显示名称,如 no.28")
description: str = ""
html: str = Field(..., description="完整 HTML 模板正文")
class TemplateUpdateRequest(BaseModel):
name: str | None = None
description: str | None = None
html: str | None = Field(default=None, description="新的模板正文(上传覆盖走这里)")
@router.get("")
async def list_html_templates(request: Request) -> dict:
"""模板清单(含 builtin/convertible/size/时间),供管理页与定时任务表单共用。"""
async def load() -> dict:
return {"templates": await asyncio.to_thread(template_store.list_templates)}
return await cached_catalog(request, "html_templates", "admin-list", load)
@router.get("/{template_id}")
async def get_html_template(template_id: str, request: Request) -> dict[str, Any]:
async def load() -> dict[str, Any]:
meta = await asyncio.to_thread(template_store.get_template_meta, template_id)
if meta is None:
raise _not_found(template_id)
return meta
return await cached_catalog(request, "html_templates", catalog_key("meta", template_id), load)
@router.get("/{template_id}/content")
async def get_html_template_content(template_id: str, request: Request) -> dict:
async def load() -> dict:
try:
html = await asyncio.to_thread(template_store.read_template_html, template_id)
except template_store.TemplateStoreError:
raise _not_found(template_id)
return {"template_id": template_id, "html": html}
return await cached_catalog(request, "html_templates", catalog_key("content", template_id), load)
@router.post("")
async def create_html_template(body: TemplateCreateRequest, request: Request) -> dict[str, Any]:
await _require_admin(request)
try:
meta = template_store.create_template(
template_id=body.template_id,
name=body.name,
description=body.description,
html=body.html,
)
except template_store.TemplateStoreError as exc:
raise HTTPException(status_code=400, detail=str(exc))
await invalidate_catalog(request, "html_templates")
return meta
@router.put("/{template_id}")
async def update_html_template(
template_id: str, body: TemplateUpdateRequest, request: Request
) -> dict[str, Any]:
await _require_admin(request)
try:
meta = template_store.update_template(
template_id,
name=body.name,
description=body.description,
html=body.html,
)
except template_store.TemplateStoreError as exc:
raise HTTPException(status_code=400, detail=str(exc))
await invalidate_catalog(request, "html_templates")
return meta
@router.delete("/{template_id}")
async def delete_html_template(template_id: str, request: Request) -> dict:
await _require_admin(request)
try:
template_store.delete_template(template_id)
except template_store.TemplateStoreError as exc:
raise HTTPException(status_code=400, detail=str(exc))
await invalidate_catalog(request, "html_templates")
return {"ok": True, "template_id": template_id}
@router.post("/{template_id}/reset")
async def reset_html_template(template_id: str, request: Request) -> dict[str, Any]:
"""把内置模板正文重置回随包原版(自建模板无原版,400)。"""
await _require_admin(request)
try:
meta = template_store.reset_template(template_id)
except template_store.TemplateStoreError as exc:
raise HTTPException(status_code=400, detail=str(exc))
await invalidate_catalog(request, "html_templates")
return meta
@@ -31,6 +31,7 @@ from datetime import datetime
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.catalog_cache import cached_catalog, catalog_key, invalidate_catalog
from app.gateway.deps import get_current_user, get_optional_user_from_request
from deerflow.persistence.light_apps import DuplicateLightAppNameError, LightAppStore
@@ -77,6 +78,17 @@ class LightAppListResponse(BaseModel):
apps: list[LightAppResponse]
class LightAppSidebarItem(BaseModel):
app_id: str
name: str
mount_location: str | None = None
sort_order: int = 0
class LightAppSidebarResponse(BaseModel):
apps: list[LightAppSidebarItem]
class LightAppCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=127)
description: str | None = Field(default=None, max_length=1000)
@@ -167,15 +179,34 @@ async def list_light_apps(
tag_ids: list[str] | None = Query(default=None, description="Keep apps carrying any of these tags (OR)"),
) -> LightAppListResponse:
store = _get_store(request)
rows = await store.list_apps(app_type=app_type, mount_location=mount_location, enabled_only=enabled_only)
if search:
needle = search.strip().lower()
rows = [r for r in rows if needle in str(r.get("name", "")).lower() or needle in str(r.get("description") or "").lower()]
responses = await _attach_tags(request, [LightAppResponse(**r) for r in rows])
if tag_ids:
wanted = set(tag_ids)
responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
return LightAppListResponse(apps=responses)
async def load() -> LightAppListResponse:
rows = await store.list_apps(app_type=app_type, mount_location=mount_location, enabled_only=enabled_only)
if search:
needle = search.strip().lower()
rows = [r for r in rows if needle in str(r.get("name", "")).lower() or needle in str(r.get("description") or "").lower()]
responses = await _attach_tags(request, [LightAppResponse(**r) for r in rows])
if tag_ids:
wanted = set(tag_ids)
responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
return LightAppListResponse(apps=responses)
return await cached_catalog(
request, "light_apps", catalog_key("list", app_type, mount_location, enabled_only, search, sorted(tag_ids or [])),
load, LightAppListResponse,
)
@router.get("/sidebar", response_model=LightAppSidebarResponse)
async def list_sidebar_light_apps(request: Request) -> LightAppSidebarResponse:
"""Return the small set of fields required to build the shared sidebar."""
store = _get_store(request)
async def load() -> LightAppSidebarResponse:
rows = await store.list_sidebar_apps()
return LightAppSidebarResponse(apps=[LightAppSidebarItem(**row) for row in rows])
return await cached_catalog(request, "light_apps", "sidebar", load, LightAppSidebarResponse)
@router.post("", response_model=LightAppResponse)
@@ -201,6 +232,7 @@ async def create_light_app(request: Request, body: LightAppCreateRequest) -> Lig
)
except DuplicateLightAppNameError:
raise HTTPException(status_code=409, detail="应用名称已存在")
await invalidate_catalog(request, "light_apps")
attached = await _attach_tags(request, [LightAppResponse(**row)])
return attached[0]
@@ -208,11 +240,15 @@ async def create_light_app(request: Request, body: LightAppCreateRequest) -> Lig
@router.get("/{app_id}", response_model=LightAppResponse)
async def get_light_app(request: Request, app_id: str) -> LightAppResponse:
store = _get_store(request)
row = await store.get_app(app_id)
if row is None:
raise HTTPException(status_code=404, detail="应用不存在")
attached = await _attach_tags(request, [LightAppResponse(**row)])
return attached[0]
async def load() -> LightAppResponse:
row = await store.get_app(app_id)
if row is None:
raise HTTPException(status_code=404, detail="应用不存在")
attached = await _attach_tags(request, [LightAppResponse(**row)])
return attached[0]
return await cached_catalog(request, "light_apps", catalog_key("detail", app_id), load, LightAppResponse)
@router.put("/{app_id}", response_model=LightAppResponse)
@@ -242,6 +278,7 @@ async def update_light_app(request: Request, app_id: str, body: LightAppUpdateRe
raise HTTPException(status_code=409, detail="应用名称已存在")
if row is None:
raise HTTPException(status_code=404, detail="应用不存在")
await invalidate_catalog(request, "light_apps")
attached = await _attach_tags(request, [LightAppResponse(**row)])
return attached[0]
@@ -254,4 +291,5 @@ async def delete_light_app(request: Request, app_id: str) -> dict:
if not deleted:
raise HTTPException(status_code=404, detail="应用不存在")
await _unassign_tags(request, app_id)
await invalidate_catalog(request, "light_apps")
return {"success": True}
@@ -17,6 +17,7 @@ from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.gateway.admin_query_window import resolve_admin_query_window
from app.gateway.deps import get_llm_metrics_store, get_local_provider, get_optional_user_from_request
from deerflow.persistence.llm_metrics.base import LlmMetricsQuery, LlmMetricsStore
@@ -68,9 +69,11 @@ def _require_store(request: Request) -> LlmMetricsStore:
return store
def _parse_dt(value: str | None) -> datetime | None:
def _parse_dt(value: str | datetime | None) -> datetime | None:
if not value:
return None
if isinstance(value, datetime):
return value
try:
# Accept both ``Z`` suffix and ``+00:00``.
return datetime.fromisoformat(value.replace("Z", "+00:00"))
@@ -177,8 +180,8 @@ def _build_query(
user_id: str | None,
model_name: str | None,
status: str | None,
since: str | None,
until: str | None,
since: str | datetime | None,
until: str | datetime | None,
limit: int,
offset: int,
user_ids: list[str] | None = None,
@@ -215,13 +218,14 @@ async def list_llm_metrics(
if not user_ids:
# No username matches → no rows; short-circuit the global scan.
return LlmMetricsListResponse(items=[], total=0, limit=limit, offset=offset)
since_dt, until_dt, _range_days = resolve_admin_query_window(since=since, until=until)
query = _build_query(
user_id=user_id,
user_ids=user_ids,
model_name=model_name,
status=status,
since=since,
until=until,
since=since_dt,
until=until_dt,
limit=limit,
offset=offset,
)
@@ -266,6 +270,7 @@ async def export_llm_metrics(
# No matching user — sentinel id that matches nothing, so the export
# still produces an empty xlsx (matching the list view's empty state).
user_ids = ["__no_such_user__"]
since_dt, until_dt, _range_days = resolve_admin_query_window(since=since, until=until)
try:
from openpyxl import Workbook
@@ -277,8 +282,8 @@ async def export_llm_metrics(
user_ids=user_ids,
model_name=model_name,
status=status,
since=since,
until=until,
since=since_dt,
until=until_dt,
limit=1, # unused: iter_all paginates internally
offset=0,
)
@@ -18,6 +18,7 @@ from io import BytesIO
from types import SimpleNamespace
from typing import Any, Literal
from urllib.parse import quote, unquote, urlencode, urlsplit
from uuid import uuid4
import httpx
from fastapi import APIRouter, BackgroundTasks, File, HTTPException, Query, Request, UploadFile
@@ -26,7 +27,7 @@ from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
from app.gateway.csrf_middleware import is_secure_request
from app.gateway.deps import get_agent_store, get_optional_user_from_request, get_thread_store
from app.gateway.deps import get_agent_store, get_checkpointer, get_optional_user_from_request, get_thread_store
from app.gateway.llmwiki_deposit import (
CONVERSATION_DEPOSIT_KB_DESCRIPTION,
CONVERSATION_DEPOSIT_KB_NAME,
@@ -36,6 +37,7 @@ from app.gateway.llmwiki_deposit import (
ensure_conversation_deposit_mapping,
)
from app.gateway.llmwiki_rag import build_llmwiki_rag_context
from app.gateway.perf_logging import finish_trace, trace_request, trace_stage
from app.gateway.weknora_embed import (
WEKNORA_EMBED_API_PREFIX as _WEKNORA_EMBED_API_PREFIX,
)
@@ -60,6 +62,7 @@ from app.gateway.weknora_embed import (
from app.gateway.weknora_static import StaticAsset, StaticAssetCache, prepare_static_asset
from app.gateway.wiki_directory_constraints import (
directory_roots,
ensure_directory_paths,
normalize_directory_paths,
paths_from_parent_directories,
resolve_directory_paths,
@@ -88,6 +91,7 @@ from deerflow.integrations.weknora.runtime import build_weknora_client, get_reso
from deerflow.persistence.llmwiki import LlmWikiStore
logger = logging.getLogger(__name__)
KNOWLEDGE_RESEARCH_DIRECTORY = "深度研究"
@asynccontextmanager
@@ -346,6 +350,20 @@ class WikiPageUpdate(BaseModel):
wiki_path: str | None = Field(default=None, max_length=1024)
class KnowledgeResearchSessionCreate(BaseModel):
question: str = Field(..., min_length=1, max_length=20_000)
class KnowledgeResearchSessionUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=255)
draft_markdown: str | None = Field(default=None, max_length=2_000_000)
class KnowledgeResearchPublicationSave(BaseModel):
title: str = Field(..., min_length=1, max_length=255)
expected_saved_hash: str | None = Field(default=None, min_length=64, max_length=64)
class ConversationDepositCreate(BaseModel):
thread_id: str = Field(..., min_length=1, max_length=128)
question: str = Field(default="", max_length=200_000)
@@ -505,6 +523,28 @@ def _select_embedding_model(models: list[dict[str, Any]], requested_id: str | No
return str(selected["id"])
def _select_knowledge_qa_model(models: list[dict[str, Any]]) -> str:
available = [
item
for item in models
if str(item.get("type") or "").strip().lower() == "knowledgeqa"
and str(item.get("id") or "").strip()
and str(item.get("status") or "active").strip().lower() == "active"
]
available.sort(
key=lambda item: (
0 if item.get("is_default") is True else 1,
str(item.get("id") or "").strip(),
)
)
if not available:
raise WeKnoraError(
"WeKnora has no active KnowledgeQA model; configure one before creating a Wiki",
status_code=400,
)
return str(available[0]["id"]).strip()
def _json_response(payload: Any, *, status_code: int = 200) -> Response:
return Response(
content=json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
@@ -2326,7 +2366,7 @@ def _mapping_view(row: dict[str, Any], remote: dict[str, Any] | None, *, actor_u
})
return {
"id": row["id"],
"remote_status": "available" if remote is not None else "missing",
"remote_status": "cached" if source.get("_local_cache_only") else "available" if remote is not None else "missing",
"name": display_name,
"description": description,
"test_questions": resolved_test_questions(profile_row),
@@ -3466,42 +3506,76 @@ async def list_knowledge_bases(
scope: Literal["personal", "public", "all"] = Query(default="all"),
refresh: bool = False,
) -> dict[str, Any]:
client = _client_or_503()
user_id, is_admin = await _actor(request)
trace = trace_request(request, "llmwiki_knowledge_bases")
try:
remote_items = await client.list_knowledge_bases(refresh=refresh)
remote = {str(item.get("id")): item for item in remote_items if item.get("id")}
except WeKnoraError as exc:
_raise_upstream(exc)
deposit_remote = next(
(item for item in remote_items if str(item.get("name") or "").strip() == CONVERSATION_DEPOSIT_KB_NAME),
None,
)
if deposit_remote is not None:
previous_mapping = await _store(request).get_by_weknora_id(str(deposit_remote.get("id") or ""))
deposit_mapping = await ensure_conversation_deposit_mapping(
_store(request),
client,
remote_knowledge_bases=remote_items,
)
description_was_synchronized = previous_mapping is None or not _is_conversation_deposit_mapping(previous_mapping) or str(previous_mapping.get("description") or "") != CONVERSATION_DEPOSIT_KB_DESCRIPTION
if deposit_mapping is not None and description_was_synchronized:
remote_id = str(deposit_mapping["weknora_id"])
remote[remote_id] = {
**remote.get(remote_id, deposit_remote),
"description": CONVERSATION_DEPOSIT_KB_DESCRIPTION,
}
rows = await _store(request).list_visible(user_id, scope=scope, is_admin=False)
knowledge_bases: list[dict[str, Any]] = []
for row in rows:
remote_item = remote.get(str(row["weknora_id"]))
# A successful remote list is authoritative for every scope. Keep the
# mapping for existing references, but never display a deleted base.
if remote_item is None:
continue
row, remote_item = await _sync_conversation_deposit_view(_store(request), client, row, remote_item)
knowledge_bases.append(_mapping_view(row, remote_item, actor_user_id=user_id, is_admin=is_admin))
return {"knowledge_bases": knowledge_bases}
stage_started = time.perf_counter()
user_id, is_admin = await _actor(request)
trace_stage(trace, "actor_and_access", stage_started)
stage_started = time.perf_counter()
store = _store(request)
rows = await store.list_visible(user_id, scope=scope, is_admin=False)
trace_stage(trace, "local_knowledge_base_query_db", stage_started)
client: WeKnoraClient | None = None
remote_items: list[dict[str, Any]] = []
remote: dict[str, dict[str, Any]] = {}
if refresh:
stage_started = time.perf_counter()
client = _client_or_503()
try:
remote_items = await client.list_knowledge_bases(refresh=True)
remote = {str(item.get("id")): item for item in remote_items if item.get("id")}
except WeKnoraError as exc:
_raise_upstream(exc)
trace_stage(trace, "weknora_explicit_refresh_wait", stage_started)
deposit_remote = next(
(item for item in remote_items if str(item.get("name") or "").strip() == CONVERSATION_DEPOSIT_KB_NAME),
None,
)
if deposit_remote is not None:
previous_mapping = await store.get_by_weknora_id(str(deposit_remote.get("id") or ""))
deposit_mapping = await ensure_conversation_deposit_mapping(
store,
client,
remote_knowledge_bases=remote_items,
)
description_was_synchronized = previous_mapping is None or not _is_conversation_deposit_mapping(previous_mapping) or str(previous_mapping.get("description") or "") != CONVERSATION_DEPOSIT_KB_DESCRIPTION
if deposit_mapping is not None and description_was_synchronized:
remote_id = str(deposit_mapping["weknora_id"])
remote[remote_id] = {
**remote.get(remote_id, deposit_remote),
"description": CONVERSATION_DEPOSIT_KB_DESCRIPTION,
}
trace_stage(trace, "conversation_deposit_mapping_sync", stage_started)
stage_started = time.perf_counter()
knowledge_bases: list[dict[str, Any]] = []
for row in rows:
if refresh:
remote_item = remote.get(str(row["weknora_id"]))
# An explicit successful refresh is authoritative: do not show
# mappings whose remote knowledge base has been deleted.
if remote_item is None:
continue
assert client is not None
row, remote_item = await _sync_conversation_deposit_view(store, client, row, remote_item)
else:
# Normal page loads are served entirely from the local SQL
# mapping. A remote check happens only after an explicit refresh.
remote_item = {
"id": str(row.get("weknora_id") or ""),
"name": str(row.get("name") or ""),
"description": str(row.get("description") or ""),
"_local_cache_only": True,
}
knowledge_bases.append(_mapping_view(row, remote_item, actor_user_id=user_id, is_admin=is_admin))
trace_stage(trace, "map_visible_knowledge_bases", stage_started)
trace["details"].update({"scope": scope, "refresh": refresh, "remote_count": len(remote_items) if refresh else None, "local_count": len(rows), "result_count": len(knowledge_bases)})
return {"knowledge_bases": knowledge_bases}
finally:
finish_trace(trace)
@router.get("/api/llmwiki/embedding-models")
@@ -3523,37 +3597,62 @@ async def list_embedding_models(request: Request) -> dict[str, Any]:
async def list_agent_knowledge_bases(request: Request, agent_id: str) -> dict[str, Any]:
"""Return the agent-bound bases still visible to the current DeerFlow user."""
user_id, is_admin = await _actor(request)
agent_store = get_agent_store(request)
agent = await (agent_store.get_any(agent_id) if is_admin else agent_store.get_visible(agent_id, user_id))
if agent is None:
raise HTTPException(status_code=404, detail="Agent not found")
trace = trace_request(request, "llmwiki_agent_knowledge_bases")
try:
config = load_agent_config(agent_id)
except (FileNotFoundError, ValueError):
config = None
bound_ids = list(config.llmwiki_knowledge_base_ids or []) if config else []
rows: list[dict[str, Any]] = []
for mapping_id in bound_ids:
row = await _store(request).get_authorized(
mapping_id,
user_id,
write=False,
is_admin=is_admin,
)
if row is not None:
rows.append(row)
try:
remote = {str(item.get("id")): item for item in await _client_or_503().list_knowledge_bases() if item.get("id")}
except WeKnoraError as exc:
_raise_upstream(exc)
return {"knowledge_bases": [_mapping_view(row, remote_item, actor_user_id=user_id, is_admin=is_admin) for row in rows if (remote_item := remote.get(str(row["weknora_id"]))) is not None]}
stage_started = time.perf_counter()
user_id, is_admin = await _actor(request)
agent_store = get_agent_store(request)
agent = await (agent_store.get_any(agent_id) if is_admin else agent_store.get_visible(agent_id, user_id))
if agent is None:
raise HTTPException(status_code=404, detail="Agent not found")
trace_stage(trace, "actor_and_agent_access", stage_started)
stage_started = time.perf_counter()
try:
config = load_agent_config(agent_id)
except (FileNotFoundError, ValueError):
config = None
bound_ids = list(config.llmwiki_knowledge_base_ids or []) if config else []
trace_stage(trace, "load_agent_binding_config", stage_started)
stage_started = time.perf_counter()
store = _store(request)
rows: list[dict[str, Any]] = []
for mapping_id in bound_ids:
row = await store.get_authorized(
mapping_id,
user_id,
write=False,
is_admin=is_admin,
)
if row is not None:
rows.append(row)
trace_stage(trace, "local_knowledge_base_mapping_query", stage_started)
stage_started = time.perf_counter()
knowledge_bases = []
for row in rows:
remote_item = {
"id": str(row.get("weknora_id") or ""),
"name": str(row.get("name") or ""),
"description": str(row.get("description") or ""),
"_local_cache_only": True,
}
knowledge_bases.append(_mapping_view(row, remote_item, actor_user_id=user_id, is_admin=is_admin))
trace_stage(trace, "map_agent_knowledge_bases", stage_started)
trace["details"].update({"bound_count": len(bound_ids), "remote_count": None, "result_count": len(knowledge_bases)})
return {"knowledge_bases": knowledge_bases}
finally:
finish_trace(trace)
@router.post("/api/llmwiki/knowledge-bases", status_code=201)
async def create_knowledge_base(request: Request, body: KnowledgeBaseCreate) -> dict[str, Any]:
client = _client_or_503()
trace = trace_request(request, "llmwiki_knowledge_base_create")
stage_started = time.perf_counter()
user_id, is_admin = await _actor(request)
trace_stage(trace, "actor_and_access", stage_started)
if body.name.strip() == CONVERSATION_DEPOSIT_KB_NAME:
raise HTTPException(status_code=409, detail="对话沉淀是 cmzs 系统保留知识库")
wiki_config = body.wiki_config if body.wiki_enabled else None
@@ -3595,16 +3694,24 @@ async def create_knowledge_base(request: Request, body: KnowledgeBaseCreate) ->
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from None
try:
stage_started = time.perf_counter()
upstream_models = await client.list_models()
embedding_model_id = _select_embedding_model(
await _embedding_models(client),
[view for item in upstream_models if (view := _embedding_model_view(item)) is not None],
body.embedding_model_id,
)
knowledge_qa_model_id = (
_select_knowledge_qa_model(upstream_models) if body.wiki_enabled else None
)
trace_stage(trace, "weknora_model_catalog", stage_started)
stage_started = time.perf_counter()
remote = await client.create_knowledge_base(
name=body.name.strip(),
description=body.description,
kb_type=body.type,
wiki_enabled=body.wiki_enabled,
embedding_model_id=embedding_model_id,
knowledge_qa_model_id=knowledge_qa_model_id,
wiki_content_instructions=(
compiled.content_instructions if body.wiki_enabled else ""
),
@@ -3612,11 +3719,14 @@ async def create_knowledge_base(request: Request, body: KnowledgeBaseCreate) ->
compiled.extraction_instructions if body.wiki_enabled else ""
),
)
trace_stage(trace, "weknora_create", stage_started)
except WeKnoraError as exc:
finish_trace(trace)
_raise_upstream(exc)
try:
# Directory settings are references for WeKnora's generator. Creating
# folders here would turn the reference into an accidental template.
stage_started = time.perf_counter()
row = await _store(request).create_mapping(
weknora_id=str(remote["id"]),
owner_user_id=user_id,
@@ -3643,7 +3753,9 @@ async def create_knowledge_base(request: Request, body: KnowledgeBaseCreate) ->
),
wiki_config_apply_error=None,
)
trace_stage(trace, "local_mapping_create", stage_started)
except Exception as exc:
finish_trace(trace)
logger.exception(
"Failed to initialize or persist LLMWiki mapping; compensating remote create"
)
@@ -3655,6 +3767,7 @@ async def create_knowledge_base(request: Request, body: KnowledgeBaseCreate) ->
_raise_upstream(exc)
raise HTTPException(status_code=500, detail="Failed to register the knowledge base") from None
_schedule_local_wiki_sync(request, row)
finish_trace(trace)
return _mapping_view(row, remote, actor_user_id=user_id, is_admin=is_admin)
@@ -4056,6 +4169,513 @@ async def get_knowledge_base_graph(
return {"nodes": nodes, "edges": edges, "meta": meta}
def _knowledge_research_store(request: Request):
store = getattr(request.app.state, "knowledge_research_store", None)
if store is None:
raise HTTPException(status_code=503, detail="知识库研究存储尚未就绪")
return store
async def _knowledge_research_can_write(
request: Request,
mapping: dict[str, Any],
user_id: str,
is_admin: bool,
) -> bool:
if _is_conversation_deposit_mapping(mapping):
return False
writable = await _store(request).get_authorized(
str(mapping["id"]),
user_id,
write=True,
is_admin=is_admin,
)
return writable is not None
def _knowledge_research_publication_view(
row: dict[str, Any],
*,
user_id: str,
can_write: bool,
include_content: bool = False,
) -> dict[str, Any] | None:
if not row.get("saved_hash"):
return None
owned = row.get("user_id") == user_id
result: dict[str, Any] = {
"id": row["publication_id"],
"title": row.get("saved_title") or row.get("title") or "",
"wiki_slug": row.get("wiki_slug") or "",
"saved_hash": row["saved_hash"],
"saved_at": row.get("saved_at"),
"updated_at": row.get("saved_at") or row.get("updated_at"),
"owned_by_current_user": owned,
"can_continue": owned,
"can_write": owned and can_write,
}
if owned:
# Only the creator receives a bridge from the public book to the
# private research session. The thread id remains session-only.
result["session_id"] = row["id"]
if include_content:
result["content_markdown"] = row.get("saved_markdown") or ""
return result
def _knowledge_research_session_view(
row: dict[str, Any],
*,
user_id: str,
can_write: bool,
) -> dict[str, Any]:
return {
"id": row["id"],
"thread_id": row["thread_id"],
"title": row.get("title") or "",
"draft_markdown": row.get("draft_markdown") or "",
"created_at": row.get("created_at"),
"updated_at": row.get("updated_at"),
"can_write": can_write,
"publication": _knowledge_research_publication_view(
row,
user_id=user_id,
can_write=can_write,
),
}
def _knowledge_research_default_title(question: str) -> str:
for line in question.splitlines():
normalized = " ".join(line.split())
if normalized:
return normalized[:255]
raise HTTPException(status_code=422, detail="问题不能为空")
@router.get("/api/llmwiki/knowledge-bases/{mapping_id}/research/sessions")
async def list_knowledge_research_sessions(
request: Request,
mapping_id: str,
) -> dict[str, Any]:
mapping, user_id, is_admin = await _authorized_mapping(
request,
mapping_id,
write=False,
)
can_write = await _knowledge_research_can_write(
request,
mapping,
user_id,
is_admin,
)
store = _knowledge_research_store(request)
sessions, publications = await asyncio.gather(
store.list_owned(mapping_id, user_id),
store.list_published(mapping_id),
)
return {
"sessions": [
_knowledge_research_session_view(
row,
user_id=user_id,
can_write=can_write,
)
for row in sessions
],
"publications": [
view
for row in publications
if (
view := _knowledge_research_publication_view(
row,
user_id=user_id,
can_write=can_write,
)
)
is not None
],
}
@router.post("/api/llmwiki/knowledge-bases/{mapping_id}/research/sessions", status_code=201)
async def create_knowledge_research_session(
request: Request,
mapping_id: str,
body: KnowledgeResearchSessionCreate,
) -> dict[str, Any]:
mapping, user_id, is_admin = await _authorized_mapping(
request,
mapping_id,
write=False,
)
can_write = await _knowledge_research_can_write(
request,
mapping,
user_id,
is_admin,
)
question = body.question.strip()
title = _knowledge_research_default_title(question)
store = _knowledge_research_store(request)
session_id = str(uuid4())
publication_id = str(uuid4())
thread_id = str(uuid4())
session = await store.create(
session_id=session_id,
publication_id=publication_id,
thread_id=thread_id,
knowledge_base_id=mapping_id,
user_id=user_id,
title=title,
)
metadata = {
"system": True,
"thread_type": "knowledge_research",
"knowledge_research_session_id": session_id,
"knowledge_research_knowledge_base_id": mapping_id,
"llmwiki_knowledge_base_ids": [mapping_id],
"assistant_knowledge_base_ids": [],
"knowledge_retrieval_mode": "classic",
"knowledge_space_strict": True,
"rag_mode_enabled": True,
"rag_mode": True,
}
try:
from app.gateway.routers.threads import ThreadCreateRequest, create_thread
await create_thread(
ThreadCreateRequest(
thread_id=thread_id,
assistant_id=None,
metadata=metadata,
initialize_checkpoint=True,
),
request,
)
await get_thread_store(request).update_display_name(
thread_id,
title,
user_id=user_id,
)
except Exception:
# Thread creation writes metadata before its initial checkpoint. Undo
# every side on failure so no half-bound private chat remains.
await store.delete(session_id)
try:
checkpointer = get_checkpointer(request)
if hasattr(checkpointer, "adelete_thread"):
await checkpointer.adelete_thread(thread_id)
except Exception:
logger.debug("Could not remove failed research checkpoint", exc_info=True)
try:
await get_thread_store(request).delete(thread_id, user_id=user_id)
except Exception:
logger.debug("Could not remove failed research thread metadata", exc_info=True)
raise
return _knowledge_research_session_view(
session,
user_id=user_id,
can_write=can_write,
)
async def _owned_knowledge_research_session(
request: Request,
mapping_id: str,
session_id: str,
) -> tuple[dict[str, Any], dict[str, Any], str, bool, bool]:
mapping, user_id, is_admin = await _authorized_mapping(
request,
mapping_id,
write=False,
)
session = await _knowledge_research_store(request).get_owned(
session_id,
user_id,
)
if session is None or session["knowledge_base_id"] != mapping_id:
raise HTTPException(status_code=404, detail="研究会话不存在或无权访问")
can_write = await _knowledge_research_can_write(
request,
mapping,
user_id,
is_admin,
)
return session, mapping, user_id, is_admin, can_write
@router.get(
"/api/llmwiki/knowledge-bases/{mapping_id}/research/sessions/{session_id}"
)
async def get_knowledge_research_session(
request: Request,
mapping_id: str,
session_id: str,
) -> dict[str, Any]:
session, _, user_id, _, can_write = await _owned_knowledge_research_session(
request,
mapping_id,
session_id,
)
return _knowledge_research_session_view(
session,
user_id=user_id,
can_write=can_write,
)
@router.patch(
"/api/llmwiki/knowledge-bases/{mapping_id}/research/sessions/{session_id}"
)
async def update_knowledge_research_session(
request: Request,
mapping_id: str,
session_id: str,
body: KnowledgeResearchSessionUpdate,
) -> dict[str, Any]:
current, _, user_id, _, can_write = await _owned_knowledge_research_session(
request,
mapping_id,
session_id,
)
fields = body.model_fields_set
if not fields:
return _knowledge_research_session_view(
current,
user_id=user_id,
can_write=can_write,
)
title = body.title.strip() if "title" in fields and body.title is not None else None
if "title" in fields and not title:
raise HTTPException(status_code=422, detail="标题不能为空")
updated = await _knowledge_research_store(request).update_owned(
session_id,
user_id,
title=title,
draft_markdown=(
body.draft_markdown if "draft_markdown" in fields else None
),
)
if updated is None:
raise HTTPException(status_code=404, detail="研究会话不存在或无权访问")
if title is not None:
try:
await get_thread_store(request).update_display_name(
updated["thread_id"],
title,
user_id=user_id,
)
except Exception:
logger.debug("Could not update research thread title", exc_info=True)
return _knowledge_research_session_view(
updated,
user_id=user_id,
can_write=can_write,
)
async def _upsert_knowledge_research_wiki_page(
*,
client: WeKnoraClient,
knowledge_base_id: str,
slug: str,
payload: dict[str, Any],
previously_saved: bool,
) -> dict[str, Any]:
if previously_saved:
try:
return await client.update_wiki_page(
knowledge_base_id,
slug,
payload,
)
except WeKnoraError as exc:
if exc.status_code != 404:
raise
return await client.create_wiki_page(
knowledge_base_id,
{"slug": slug, **payload},
)
try:
return await client.create_wiki_page(
knowledge_base_id,
{"slug": slug, **payload},
)
except WeKnoraError as create_error:
# A prior request may have committed upstream before its local DB write
# failed. Probe the stable slug and turn the retry into an update.
if create_error.status_code not in {400, 409}:
raise
try:
await client.get_wiki_page(knowledge_base_id, slug)
except WeKnoraError:
raise create_error
return await client.update_wiki_page(knowledge_base_id, slug, payload)
@router.put(
"/api/llmwiki/knowledge-bases/{mapping_id}/research/sessions/{session_id}/publication"
)
async def save_knowledge_research_publication(
request: Request,
mapping_id: str,
session_id: str,
body: KnowledgeResearchPublicationSave,
) -> dict[str, Any]:
# Write authorization and private-session ownership are independent: even
# an administrator may not publish another user's private conversation.
mapping, user_id, _ = await _authorized_mapping(request, mapping_id, write=True)
if _is_conversation_deposit_mapping(mapping):
raise HTTPException(
status_code=403,
detail="Conversation deposit is managed by cmzs",
)
title = body.title.strip()
if not title:
raise HTTPException(status_code=422, detail="标题不能为空")
store = _knowledge_research_store(request)
client = _client_or_503()
remote_id = str(mapping["weknora_id"])
saved: dict[str, Any] | None = None
async with store.locked_publication(session_id, user_id) as publication:
if publication is None:
raise HTTPException(status_code=404, detail="研究会话不存在或无权访问")
session = publication.snapshot()
if session["knowledge_base_id"] != mapping_id:
raise HTTPException(status_code=404, detail="研究会话不存在或无权访问")
markdown = str(session.get("draft_markdown") or "")
if not markdown.strip():
raise HTTPException(status_code=422, detail="请先生成或填写 Markdown 正文")
digest = hashlib.sha256(
json.dumps(
{"title": title, "markdown": markdown},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
).hexdigest()
current_saved_hash = session.get("saved_hash")
if current_saved_hash == digest:
# A prior identical request may have committed both upstream and
# locally while its HTTP response was lost. Treat that retry as
# a no-op even if it carries the pre-save expected hash.
return _knowledge_research_session_view(
session,
user_id=user_id,
can_write=True,
)
if current_saved_hash and body.expected_saved_hash is None:
raise HTTPException(
status_code=409,
detail={
"code": "KNOWLEDGE_RESEARCH_EXPECTED_HASH_REQUIRED",
"message": "覆盖已保存文档时必须携带当前版本,请刷新后再保存。",
"saved_hash": current_saved_hash,
},
)
if (
body.expected_saved_hash is not None
and body.expected_saved_hash != current_saved_hash
):
raise HTTPException(
status_code=409,
detail={
"code": "KNOWLEDGE_RESEARCH_PUBLICATION_CHANGED",
"message": "文档已被更新,请刷新后再保存。",
"saved_hash": current_saved_hash,
},
)
slug = str(
session.get("wiki_slug")
or f"knowledge-research/{session['publication_id']}"
)
try:
folder_ids = await ensure_directory_paths(
client,
remote_id,
[[KNOWLEDGE_RESEARCH_DIRECTORY]],
)
folder_id = folder_ids.get(KNOWLEDGE_RESEARCH_DIRECTORY)
if not folder_id:
raise WeKnoraError("未能创建深度研究 Wiki 目录", status_code=502)
page = await _upsert_knowledge_research_wiki_page(
client=client,
knowledge_base_id=remote_id,
slug=slug,
previously_saved=bool(current_saved_hash),
payload={
"title": title,
"content": markdown,
"summary": "知识库问答沉淀",
"page_type": "summary",
"status": "published",
"category_path": [KNOWLEDGE_RESEARCH_DIRECTORY],
"folder_id": folder_id,
},
)
except WeKnoraError as exc:
if exc.status_code == 409:
raise HTTPException(status_code=409, detail=str(exc)) from None
_raise_upstream(exc)
saved = publication.save(
title=title,
markdown=markdown,
saved_hash=digest,
wiki_slug=slug,
wiki_page_id=str(page.get("id") or page.get("page_id") or "")
or None,
wiki_folder_id=folder_id,
)
assert saved is not None
_schedule_local_wiki_sync(request, mapping)
return _knowledge_research_session_view(
saved,
user_id=user_id,
can_write=True,
)
@router.get(
"/api/llmwiki/knowledge-bases/{mapping_id}/research/publications/{publication_id}"
)
async def get_knowledge_research_publication(
request: Request,
mapping_id: str,
publication_id: str,
) -> dict[str, Any]:
mapping, user_id, is_admin = await _authorized_mapping(
request,
mapping_id,
write=False,
)
publication = await _knowledge_research_store(request).get_publication(
publication_id
)
if (
publication is None
or publication["knowledge_base_id"] != mapping_id
):
raise HTTPException(status_code=404, detail="研究文档不存在")
can_write = await _knowledge_research_can_write(
request,
mapping,
user_id,
is_admin,
)
view = _knowledge_research_publication_view(
publication,
user_id=user_id,
can_write=can_write,
include_content=True,
)
if view is None:
raise HTTPException(status_code=404, detail="研究文档不存在")
return view
@router.get("/api/llmwiki/knowledge-bases/{mapping_id}/wiki/pages")
async def list_wiki_pages(
request: Request,
@@ -29,6 +29,7 @@ from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.auth.disabled_mode import is_auth_disabled
from app.gateway.catalog_cache import cached_catalog, catalog_key, invalidate_catalog
from app.gateway.deps import get_optional_user_from_request
from deerflow.persistence.menu_overrides import MenuOverrideStore
@@ -116,9 +117,11 @@ def _merge_overrides(default_rows: list[dict], user_rows: list[dict] | None) ->
return merged
async def _read_response(request: Request, store: MenuOverrideStore) -> MenuOverrideListResponse:
async def _read_response(
request: Request, store: MenuOverrideStore, *, actor: tuple[str | None, bool] | None = None,
) -> MenuOverrideListResponse:
user_id, is_admin = actor if actor is not None else await _actor(request)
default_rows = await store.list_overrides()
user_id, is_admin = await _actor(request)
if is_admin or user_id is None:
return MenuOverrideListResponse(
overrides=[MenuOverrideResponse(**row) for row in default_rows],
@@ -191,8 +194,12 @@ def _validate_depth(
@router.get("", response_model=MenuOverrideListResponse)
async def list_menu_overrides(request: Request) -> MenuOverrideListResponse:
actor = await _actor(request)
store = _get_store(request)
return await _read_response(request, store)
return await cached_catalog(
request, "menu_overrides", catalog_key(*actor),
lambda: _read_response(request, store, actor=actor), MenuOverrideListResponse,
)
@router.put("", response_model=MenuOverrideListResponse)
@@ -214,6 +221,7 @@ async def replace_menu_overrides(request: Request, body: MenuOverrideReplaceRequ
await store.replace_all(payload, updated_by=user_id)
else:
await store.replace_user_overrides(user_id, payload, updated_by=user_id)
await invalidate_catalog(request, "menu_overrides")
return await _read_response(request, store)
@@ -226,4 +234,5 @@ async def reset_menu_overrides(request: Request) -> MenuOverrideListResponse:
await store.replace_all([], updated_by=user_id)
else:
await store.replace_user_overrides(user_id, [], updated_by=user_id)
await invalidate_catalog(request, "menu_overrides")
return await _read_response(request, store)
@@ -17,15 +17,17 @@ from __future__ import annotations
import asyncio
import copy
import errno
import logging
import os
import re
import shutil
import sys
import tempfile
from datetime import UTC, datetime
from pathlib import Path
from time import perf_counter
from typing import Any
from time import perf_counter, sleep
from typing import Any, Literal
import yaml
from fastapi import APIRouter, HTTPException, Request
@@ -45,6 +47,56 @@ _MODEL_BLOCK_RE = re.compile(r"(?m)^models:[^\r\n]*(?:\r?\n|$)")
_TOP_LEVEL_KEY_RE = re.compile(r"(?m)^[A-Za-z_][A-Za-z0-9_-]*:[^\r\n]*(?:\r?\n|$)")
_BACKUP_DIR_NAME = ".model-config-backups"
_MUTATION_LOCK = asyncio.Lock()
_ATOMIC_REPLACE_RETRIES = 5
_ATOMIC_REPLACE_RETRY_ERRNOS = {
errno.EBUSY,
errno.EAGAIN,
getattr(errno, "ETXTBSY", 26),
}
def _is_file_mountpoint(path: Path) -> bool:
"""Return whether ``path`` itself is mounted (common with Docker file binds)."""
if not sys.platform.startswith("linux"):
return False
try:
target = str(path.resolve())
with Path("/proc/self/mountinfo").open(encoding="utf-8") as mountinfo:
for line in mountinfo:
fields = line.split(" - ", maxsplit=1)[0].split()
if len(fields) < 5:
continue
mount_path = re.sub(
r"\\([0-7]{3})",
lambda match: chr(int(match.group(1), 8)),
fields[4],
)
if mount_path == target:
return True
except OSError:
logger.debug("Unable to inspect Linux mount table for model config path", exc_info=True)
return False
def _write_mounted_config_in_place(path: Path, raw_before: str, raw_after: str) -> None:
"""Update a single-file bind mount, where Linux refuses atomic rename (EBUSY)."""
try:
with path.open("w", encoding="utf-8", newline="") as handle:
handle.write(raw_after)
handle.flush()
os.fsync(handle.fileno())
except Exception:
# The backup was persisted before reaching this point. Restore the
# previous contents best-effort so a failed write does not leave YAML
# truncated or partially replaced.
try:
with path.open("w", encoding="utf-8", newline="") as handle:
handle.write(raw_before)
handle.flush()
os.fsync(handle.fileno())
except OSError:
logger.exception("Failed to restore model config after an in-place bind-mount write failure")
raise
_EDITOR_FIELDS = frozenset(
{
@@ -70,6 +122,7 @@ _EDITOR_FIELDS = frozenset(
"use_responses_api",
"output_version",
"write_file_transport",
"strict_stream_integrity",
}
)
_PRESERVED_FIELDS = frozenset(ModelConfig.model_fields) | _EDITOR_FIELDS
@@ -92,6 +145,7 @@ class ModelAdminItem(BaseModel):
stream_usage: bool | None = None
supports_thinking: bool = False
supports_reasoning_effort: bool = False
default_reasoning_effort: Literal["low", "medium", "xhigh"] = "xhigh"
supports_vision: bool = False
when_thinking_enabled: dict[str, Any] | None = None
when_thinking_disabled: dict[str, Any] | None = None
@@ -99,6 +153,9 @@ class ModelAdminItem(BaseModel):
use_responses_api: bool | None = None
output_version: str | None = None
write_file_transport: WriteFileTransport = WriteFileTransport.NATIVE
# ``None`` means the runtime default: auto-enabled only for the historically
# affected qwen3.8-flash-next model id (see deerflow.models.chat_stream_state).
strict_stream_integrity: bool | None = None
extra_settings: dict[str, Any] = Field(default_factory=dict)
api_key_configured: bool = False
api_key_source: str | None = None
@@ -146,6 +203,9 @@ class ModelUpsertRequest(BaseModel):
# bridge setting during edits. New models still resolve to ModelConfig's
# native default when the field is omitted.
write_file_transport: WriteFileTransport | None = None
# Optional on the wire so edits preserve an existing toggle; the dedicated
# strict-stream-integrity endpoint is the canonical switch surface.
strict_stream_integrity: bool | None = None
extra_settings: dict[str, Any] = Field(default_factory=dict)
@field_validator("name", "use", "model")
@@ -168,6 +228,20 @@ class ModelTestRequest(BaseModel):
class ReasoningEffortCapabilityRequest(BaseModel):
supports_reasoning_effort: bool
default_reasoning_effort: Literal["low", "medium", "xhigh"] | None = None
class WriteFileTransportToggleRequest(BaseModel):
"""Per-model 文件流式兼容 switch: off keeps native, on selects buffered_gateway."""
write_file_transport: Literal["native", "buffered_gateway"]
class StrictStreamIntegrityToggleRequest(BaseModel):
"""Per-model 流完整性检测 switch: detect gateway streams that end without
a terminal frame and retry instead of committing a half answer."""
strict_stream_integrity: bool
class ModelOrderRequest(BaseModel):
@@ -193,6 +267,7 @@ def _to_admin_item(
raw: dict[str, Any],
*,
supports_reasoning_effort: bool = False,
default_reasoning_effort: str = "xhigh",
) -> ModelAdminItem:
api_key = raw.get("api_key")
extra = {key: copy.deepcopy(value) for key, value in raw.items() if key not in _PRESERVED_FIELDS}
@@ -211,6 +286,7 @@ def _to_admin_item(
stream_usage=raw.get("stream_usage"),
supports_thinking=bool(raw.get("supports_thinking", False)),
supports_reasoning_effort=bool(supports_reasoning_effort),
default_reasoning_effort=default_reasoning_effort if default_reasoning_effort in {"low", "medium", "xhigh"} else "xhigh",
supports_vision=bool(raw.get("supports_vision", False)),
when_thinking_enabled=copy.deepcopy(raw.get("when_thinking_enabled")),
when_thinking_disabled=copy.deepcopy(raw.get("when_thinking_disabled")),
@@ -218,6 +294,7 @@ def _to_admin_item(
use_responses_api=raw.get("use_responses_api"),
output_version=raw.get("output_version"),
write_file_transport=raw.get("write_file_transport", WriteFileTransport.NATIVE),
strict_stream_integrity=raw.get("strict_stream_integrity") if isinstance(raw.get("strict_stream_integrity"), bool) else None,
extra_settings=extra,
api_key_configured=bool(api_key),
api_key_source=_mask_env_source(api_key),
@@ -236,12 +313,12 @@ def _get_capability_store(request: Request, *, required: bool = True) -> ModelCa
return store
async def _capabilities_by_name(request: Request) -> dict[str, bool]:
async def _capabilities_by_name(request: Request) -> dict[str, dict[str, Any]]:
store = _get_capability_store(request, required=False)
if store is None:
return {}
rows = await store.list_capabilities()
return {str(row.get("model_name") or ""): bool(row.get("supports_reasoning_effort", False)) for row in rows if row.get("model_name")}
return {str(row.get("model_name") or ""): row for row in rows if row.get("model_name")}
def _admin_user_id(user: Any) -> str | None:
@@ -257,10 +334,48 @@ def _validate_reasoning_capability(model: dict[str, Any], enabled: bool) -> None
)
# ``write_file_transport=buffered_gateway`` and ``strict_stream_integrity`` are
# implemented only by the patched OpenAI-compatible chat-completions classes
# (see deerflow.models.factory); every other driver is rejected there at
# construction time, so mirror the same constraint here with a clearer message.
_PATCHED_OPENAI_COMPATIBLE_USES = frozenset(
{
"langchain_openai:ChatOpenAI",
"deerflow.models.patched_openai:PatchedChatOpenAI",
}
)
def _validate_patched_openai_use(model: dict[str, Any], feature: str) -> None:
if str(model.get("use") or "") not in _PATCHED_OPENAI_COMPATIBLE_USES:
raise HTTPException(
status_code=422,
detail=(
f"{feature} requires an OpenAI-compatible "
"langchain_openai:ChatOpenAI model"
),
)
if bool(model.get("use_responses_api")):
raise HTTPException(
status_code=422,
detail=(
f"{feature} requires the chat-completions API, "
"not use_responses_api"
),
)
def _validate_write_file_transport(model: dict[str, Any], transport: str) -> None:
if transport != WriteFileTransport.BUFFERED_GATEWAY.value:
return
_validate_patched_openai_use(model, "write_file compatibility")
async def _restore_capability(
store: ModelCapabilityStore,
model_name: str,
previous: dict[str, Any] | None,
request: Request,
) -> None:
"""Best-effort semantic rollback for a failed cross-store model update."""
@@ -271,10 +386,15 @@ async def _restore_capability(
await store.set_reasoning_effort_support(
model_name,
supports_reasoning_effort=bool(previous.get("supports_reasoning_effort", False)),
default_reasoning_effort=str(previous.get("default_reasoning_effort") or "xhigh"),
updated_by=previous.get("updated_by"),
)
except Exception:
logger.exception("Failed to roll back model capability for %s", model_name)
finally:
from app.gateway.catalog_cache import invalidate_catalog
await invalidate_catalog(request, "models")
def _read_config_data(path: Path) -> tuple[str, dict[str, Any], list[dict[str, Any]]]:
@@ -328,6 +448,42 @@ def _replace_models_block(raw_text: str, models: list[dict[str, Any]]) -> str:
return f"{raw_text[: block_match.start()]}{rendered}{raw_text[end:]}"
def _replace_config_file_with_retry(
temp_name: str,
path: Path,
raw_before: str,
raw_after: str,
) -> None:
"""Retry short-lived busy errors from shared/container-mounted filesystems.
Keep the write atomic: never fall back to truncating the live config. The
temporary file is unique per mutation, so a bounded retry is safe when
overlay/NFS scanners briefly hold a rename operation.
"""
if _is_file_mountpoint(path):
logger.info("Model config is a single-file mount; updating mounted file in place")
_write_mounted_config_in_place(path, raw_before, raw_after)
os.unlink(temp_name)
return
for attempt in range(_ATOMIC_REPLACE_RETRIES + 1):
try:
os.replace(temp_name, path)
return
except OSError as exc:
if exc.errno not in _ATOMIC_REPLACE_RETRY_ERRNOS or attempt >= _ATOMIC_REPLACE_RETRIES:
raise
delay = min(0.4, 0.025 * (2**attempt))
logger.warning(
"Model config atomic replace is temporarily busy (errno=%s); retry %d/%d after %.3fs",
exc.errno,
attempt + 1,
_ATOMIC_REPLACE_RETRIES,
delay,
)
sleep(delay)
def _backup_and_write_config(path: Path, raw_before: str, raw_after: str) -> Path:
backup_dir = path.parent / _BACKUP_DIR_NAME
backup_dir.mkdir(parents=True, exist_ok=True)
@@ -345,7 +501,7 @@ def _backup_and_write_config(path: Path, raw_before: str, raw_after: str) -> Pat
handle.write(raw_after)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp_name, path)
_replace_config_file_with_retry(temp_name, path, raw_before, raw_after)
except Exception:
try:
os.unlink(temp_name)
@@ -429,6 +585,8 @@ def _strip_database_owned_fields(models: list[dict[str, Any]]) -> list[dict[str,
async def _save_models(request: Request, raw_before: str, models: list[dict[str, Any]]) -> list[dict[str, Any]]:
from app.gateway.catalog_cache import invalidate_catalog
path = _resolve_config_path()
models = _strip_database_owned_fields(models)
raw_after = _replace_models_block(raw_before, models)
@@ -447,6 +605,7 @@ async def _save_models(request: Request, raw_before: str, models: list[dict[str,
status_code=422,
detail="The model configuration could not be activated; the previous config was restored.",
) from exc
await invalidate_catalog(request, "models")
return models
@@ -463,7 +622,8 @@ async def list_model_configurations(request: Request) -> ModelAdminListResponse:
models=[
_to_admin_item(
item,
supports_reasoning_effort=capabilities.get(str(item.get("name") or ""), False),
supports_reasoning_effort=bool(capabilities.get(str(item.get("name") or ""), {}).get("supports_reasoning_effort", False)),
default_reasoning_effort=str(capabilities.get(str(item.get("name") or ""), {}).get("default_reasoning_effort") or "xhigh"),
)
for item in models
],
@@ -490,17 +650,19 @@ async def create_model_configuration(body: ModelUpsertRequest, request: Request)
await capability_store.set_reasoning_effort_support(
body.name,
supports_reasoning_effort=bool(body.supports_reasoning_effort),
default_reasoning_effort="xhigh",
updated_by=_admin_user_id(admin),
)
try:
await _save_models(request, raw_before, [*models, created])
except Exception:
if capability_changed and capability_store is not None:
await _restore_capability(capability_store, body.name, previous_capability)
await _restore_capability(capability_store, body.name, previous_capability, request)
raise
return _to_admin_item(
created,
supports_reasoning_effort=bool(body.supports_reasoning_effort) if capability_changed else False,
default_reasoning_effort="xhigh",
)
@@ -535,7 +697,8 @@ async def reorder_model_configurations(body: ModelOrderRequest, request: Request
return [
_to_admin_item(
item,
supports_reasoning_effort=capabilities.get(str(item.get("name") or ""), False),
supports_reasoning_effort=bool(capabilities.get(str(item.get("name") or ""), {}).get("supports_reasoning_effort", False)),
default_reasoning_effort=str(capabilities.get(str(item.get("name") or ""), {}).get("default_reasoning_effort") or "xhigh"),
)
for item in ordered_models
]
@@ -552,6 +715,8 @@ async def update_reasoning_effort_capability(
) -> ModelAdminItem:
"""Persist the per-model reasoning-effort selector capability in SQL."""
from app.gateway.catalog_cache import invalidate_catalog
admin = await _require_admin(request)
async with _MUTATION_LOCK:
path = _resolve_config_path()
@@ -565,11 +730,100 @@ async def update_reasoning_effort_capability(
await store.set_reasoning_effort_support(
model_name,
supports_reasoning_effort=body.supports_reasoning_effort,
default_reasoning_effort=body.default_reasoning_effort,
updated_by=_admin_user_id(admin),
)
await invalidate_catalog(request, "models")
return _to_admin_item(
model,
supports_reasoning_effort=body.supports_reasoning_effort,
default_reasoning_effort=body.default_reasoning_effort or "xhigh",
)
@router.put(
"/write-file-transport/{model_name:path}",
response_model=ModelAdminItem,
)
async def update_write_file_transport(
model_name: str,
body: WriteFileTransportToggleRequest,
request: Request,
) -> ModelAdminItem:
"""Toggle one model's buffered-gateway write_file compatibility in config.yaml.
The global system-settings switch keeps overriding every compatible model
while it is enabled; this per-model value becomes effective as soon as the
global switch is turned off.
"""
await _require_admin(request)
async with _MUTATION_LOCK:
path = _resolve_config_path()
raw_before, _, models = await asyncio.to_thread(_read_config_data, path)
index = next((i for i, item in enumerate(models) if str(item.get("name")) == model_name), None)
if index is None:
raise HTTPException(status_code=404, detail=f"Model '{model_name}' was not found")
updated = copy.deepcopy(models[index])
_validate_write_file_transport(updated, body.write_file_transport)
updated["write_file_transport"] = body.write_file_transport
try:
ModelConfig.model_validate(updated)
except Exception as exc:
raise HTTPException(status_code=422, detail=f"Invalid model configuration: {exc}") from exc
next_models = [*models]
next_models[index] = updated
await _save_models(request, raw_before, next_models)
capabilities = await _capabilities_by_name(request)
capability = capabilities.get(model_name, {})
return _to_admin_item(
updated,
supports_reasoning_effort=bool(capability.get("supports_reasoning_effort", False)),
default_reasoning_effort=str(capability.get("default_reasoning_effort") or "xhigh"),
)
@router.put(
"/strict-stream-integrity/{model_name:path}",
response_model=ModelAdminItem,
)
async def update_strict_stream_integrity(
model_name: str,
body: StrictStreamIntegrityToggleRequest,
request: Request,
) -> ModelAdminItem:
"""Toggle one model's strict stream integrity check in config.yaml.
When enabled, a provider stream that ends without a terminal frame raises
a retriable ``IncompleteChatStreamError`` instead of committing a half
answer — the LLM error middleware then retries the call. Explicit ``false``
also overrides the auto-enable default for the affected qwen3.8-flash-next
model id; omitting the key entirely keeps that default.
"""
await _require_admin(request)
async with _MUTATION_LOCK:
path = _resolve_config_path()
raw_before, _, models = await asyncio.to_thread(_read_config_data, path)
index = next((i for i, item in enumerate(models) if str(item.get("name")) == model_name), None)
if index is None:
raise HTTPException(status_code=404, detail=f"Model '{model_name}' was not found")
updated = copy.deepcopy(models[index])
_validate_patched_openai_use(updated, "strict stream integrity")
updated["strict_stream_integrity"] = body.strict_stream_integrity
try:
ModelConfig.model_validate(updated)
except Exception as exc:
raise HTTPException(status_code=422, detail=f"Invalid model configuration: {exc}") from exc
next_models = [*models]
next_models[index] = updated
await _save_models(request, raw_before, next_models)
capabilities = await _capabilities_by_name(request)
capability = capabilities.get(model_name, {})
return _to_admin_item(
updated,
supports_reasoning_effort=bool(capability.get("supports_reasoning_effort", False)),
default_reasoning_effort=str(capability.get("default_reasoning_effort") or "xhigh"),
)
@@ -596,6 +850,7 @@ async def update_model_configuration(model_name: str, body: ModelUpsertRequest,
await capability_store.set_reasoning_effort_support(
model_name,
supports_reasoning_effort=desired_capability,
default_reasoning_effort=str((previous_capability or {}).get("default_reasoning_effort") or "xhigh"),
updated_by=_admin_user_id(admin),
)
next_models = [*models]
@@ -604,7 +859,7 @@ async def update_model_configuration(model_name: str, body: ModelUpsertRequest,
await _save_models(request, raw_before, next_models)
except Exception:
if capability_changed and capability_store is not None:
await _restore_capability(capability_store, model_name, previous_capability)
await _restore_capability(capability_store, model_name, previous_capability, request)
raise
if capability_changed:
supports_reasoning_effort = desired_capability
@@ -616,6 +871,7 @@ async def update_model_configuration(model_name: str, body: ModelUpsertRequest,
return _to_admin_item(
updated,
supports_reasoning_effort=supports_reasoning_effort,
default_reasoning_effort=str((previous_capability or {}).get("default_reasoning_effort") or "xhigh"),
)
@@ -638,7 +894,7 @@ async def delete_model_configuration(model_name: str, request: Request) -> None:
await _save_models(request, raw_before, [*models[:index], *models[index + 1 :]])
except Exception:
if capability_store is not None:
await _restore_capability(capability_store, model_name, previous_capability)
await _restore_capability(capability_store, model_name, previous_capability, request)
raise
@@ -1,8 +1,10 @@
import logging
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.catalog_cache import cached_catalog
from app.gateway.deps import get_config
from deerflow.config.app_config import AppConfig
from deerflow.config.model_config import ModelConfig
@@ -36,6 +38,7 @@ def _build_model_response(
model: ModelConfig,
*,
supports_reasoning_effort: bool = False,
default_reasoning_effort: str = "xhigh",
) -> "ModelResponse":
extra = model.model_extra or {}
provider = str(extra["provider"]) if "provider" in extra else _derive_provider(model.use)
@@ -48,6 +51,7 @@ def _build_model_response(
description=model.description,
supports_thinking=model.supports_thinking,
supports_reasoning_effort=bool(supports_reasoning_effort and model.supports_thinking),
default_reasoning_effort=default_reasoning_effort if default_reasoning_effort in {"low", "medium", "xhigh"} else "xhigh",
supports_vision=model.supports_vision,
supports_tool_calling=supports_tool_calling,
is_local=is_local,
@@ -64,6 +68,7 @@ class ModelResponse(BaseModel):
description: str | None = Field(None, description="Model description")
supports_thinking: bool = Field(default=False, description="Whether model supports thinking mode")
supports_reasoning_effort: bool = Field(default=False, description="Whether model supports reasoning effort")
default_reasoning_effort: Literal["low", "medium", "xhigh"] = Field(default="xhigh", description="Globally configured default reasoning effort")
supports_vision: bool = Field(default=False, description="Whether model supports image inputs")
supports_tool_calling: bool = Field(default=True, description="Whether model supports tool/function calling")
is_local: bool = Field(default=False, description="Whether this is a locally-hosted model")
@@ -83,18 +88,22 @@ class ModelsListResponse(BaseModel):
token_usage: TokenUsageResponse
async def _reasoning_capabilities(request: Request) -> dict[str, bool]:
async def _reasoning_capabilities(request: Request) -> dict[str, dict[str, object]]:
store = getattr(request.app.state, "model_capability_store", None)
if store is None:
return {}
async def load() -> dict[str, dict[str, object]]:
rows = await store.list_capabilities()
return {str(row.get("model_name") or ""): row for row in rows if row.get("model_name")}
try:
rows = await store.list_capabilities()
return await cached_catalog(request, "models", "reasoning-capabilities", load)
except Exception:
# Capability reads fail closed: the selector disappears, while ordinary
# model use remains available.
logger.warning("Failed to read model capabilities", exc_info=True)
return {}
return {str(row.get("model_name") or ""): bool(row.get("supports_reasoning_effort", False)) for row in rows if row.get("model_name")}
@router.get(
@@ -143,7 +152,8 @@ async def list_models(request: Request, config: AppConfig = Depends(get_config))
models = [
_build_model_response(
model,
supports_reasoning_effort=capabilities.get(model.name, False),
supports_reasoning_effort=bool(capabilities.get(model.name, {}).get("supports_reasoning_effort", False)),
default_reasoning_effort=str(capabilities.get(model.name, {}).get("default_reasoning_effort") or "xhigh"),
)
for model in config.models
]
@@ -188,5 +198,6 @@ async def get_model(model_name: str, request: Request, config: AppConfig = Depen
capabilities = await _reasoning_capabilities(request)
return _build_model_response(
model,
supports_reasoning_effort=capabilities.get(model.name, False),
supports_reasoning_effort=bool(capabilities.get(model.name, {}).get("supports_reasoning_effort", False)),
default_reasoning_effort=str(capabilities.get(model.name, {}).get("default_reasoning_effort") or "xhigh"),
)
@@ -61,7 +61,6 @@ from typing import Any
import httpx
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import StreamingResponse
from langgraph.checkpoint.base import empty_checkpoint
from app.gateway.roundtable_diag import record_foreground_diag
from app.gateway.roundtable_model_fallback import fallback_model_chain, is_llm_error_message, reason_text
@@ -82,11 +81,18 @@ from deerflow.agents.roundtable_orchestrator.extraction_spec import build_summar
from deerflow.config import get_app_config
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
from deerflow.utils.time import now_iso
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/multi-agent", tags=["multi-agent"])
_STEP3_AGENT_IDS = frozenset({
ACTION_PLAN_AGENT_ID,
DASHBOARD_AGENT_ID,
REPORT_AGENT_ID,
STRUCTURE_AGENT_ID,
SUMMARY_AGENT_ID,
})
def _stage_for_agent(agent_type: str, agent_name: str) -> str:
"""把一次 /run/stream 的 (agent_type, agent_name) 映射成诊断日志的 stage。
@@ -107,12 +113,62 @@ def _stage_for_agent(agent_type: str, agent_name: str) -> str:
return "step2_leader" if agent_type == "leader" else "step2_seat"
def _is_step2_room_limited(
agent_type: str,
agent_name: str,
*,
finalize_only: bool = False,
) -> bool:
"""Only the actual Step-2 coordinator and seats consume a room.
The Step-3 singleton agents reuse the same ``/run/stream`` endpoint, so
this boundary must live here rather than at the route level.
"""
# 最后总控只把已经完成的席位交付整理成正文,不再占用席位执行资源。允许它在
# 长耗时席位跑过房间 hard deadline 后仍完成收口,避免最后一步被 409 踢回第一步。
if agent_type == "leader" and finalize_only:
return False
return agent_type == "leader" or agent_name not in _STEP3_AGENT_IDS
# 圆桌(intent / recommend / 席位 / 协调)创建的 thread 都是功能型内部线程,
# 不是用户的普通对话。打上这个 metadata,前端「最近的对话」列表(recent-chat-list.tsx)
# 据 `metadata.thread_type` 过滤掉,与 scheduler / bootstrap 系统会话同一套机制。
_ROUNDTABLE_THREAD_METADATA: dict[str, Any] = {"thread_type": "roundtable", "system": True}
def _room_thread_metadata(
*,
draft_id: str | None,
user_id: str | None,
task_id: str | None,
room_session_id: str | None,
) -> dict[str, Any]:
"""Step-2 threads carry a stable room key so later occupancy can see live runs."""
from deerflow.persistence.roundtable_jobs.sql import compute_active_dedupe_key
from deerflow.persistence.roundtable_rooms.sql import compute_room_session_key
meta = dict(_ROUNDTABLE_THREAD_METADATA)
clean_session_id = (room_session_id or "").strip() or None
key = compute_room_session_key(clean_session_id) or compute_active_dedupe_key(
draft_id, user_id, task_id
)
if key:
meta["roundtable_room_key"] = key
if clean_session_id:
meta["roundtable_room_session_id"] = clean_session_id
if draft_id:
meta["draft_id"] = draft_id
if task_id:
meta["task_id"] = task_id
return meta
def _step3_thread_metadata() -> dict[str, Any]:
"""Step-3 threads are internal threads, but never Step-2 room occupants."""
return dict(_ROUNDTABLE_THREAD_METADATA)
# ---------------------------------------------------------------------------
# 共享:异常诊断器(intent/recommend/multi-agent 共用)
# ---------------------------------------------------------------------------
@@ -415,59 +471,17 @@ def _resolve_user_id(request: Request) -> str:
async def _create_thread_direct(request: Request, *, metadata: dict[str, Any] | None = None) -> str:
"""同进程版的 ``POST /api/threads``。
完全镜像 ``routers/threads.create_thread`` 的写盘动作(thread_meta +
空 checkpoint),但跳过 TCP + Pydantic 反序列化。仅供 init 阶段批量建席位
线程用,失败时抛 HTTPException 由 SSE 帧上报。
``metadata`` 写入 thread 元数据;init 传 ``_ROUNDTABLE_THREAD_METADATA`` 把
席位 / 协调线程标记成系统会话,从前端「最近的对话」里隐藏。
只创建业务元数据,首轮图运行负责初始化 checkpoint。
metadata 的任务共享/系统会话规则与原入口保持一致。
"""
from app.gateway.deps import get_checkpointer, get_thread_store
from app.gateway.deps import get_thread_store
checkpointer = get_checkpointer(request)
thread_store = get_thread_store(request)
thread_id = str(uuid.uuid4())
now = now_iso()
try:
await thread_store.create(thread_id, assistant_id=None, metadata=metadata or {})
await get_thread_store(request).create(thread_id, assistant_id=None, metadata=metadata or {})
except Exception:
logger.exception("[init-direct] thread_meta create failed for %s", thread_id)
raise HTTPException(status_code=500, detail="Failed to create thread")
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
ckpt_metadata = {
"step": -1,
"source": "input",
"writes": None,
"parents": {},
"created_at": now,
}
async def _cleanup_partial_thread() -> None:
delete_checkpoint = getattr(checkpointer, "adelete_thread", None)
if callable(delete_checkpoint):
try:
await delete_checkpoint(thread_id)
except Exception:
logger.exception("[init-direct] partial checkpoint cleanup failed for %s", thread_id)
try:
await thread_store.delete(thread_id)
except Exception:
logger.exception("[init-direct] partial thread_meta cleanup failed for %s", thread_id)
try:
await checkpointer.aput(config, empty_checkpoint(), ckpt_metadata, {})
except asyncio.CancelledError:
# A sibling seat may fail while this task is between metadata commit and
# checkpoint creation. Compensate before propagating cancellation, under
# shield so a second cancellation cannot abort the cleanup half-way.
await asyncio.shield(_cleanup_partial_thread())
raise
except Exception:
logger.exception("[init-direct] checkpoint create failed for %s", thread_id)
await _cleanup_partial_thread()
raise HTTPException(status_code=500, detail="Failed to create thread")
return thread_id
@@ -718,18 +732,23 @@ def _run_payload(
new_message: str,
excluded_tools: list[str],
skill_stop_names: list[str],
allowed_tools: list[str] | None = None,
recursion_limit: int = 1000,
thinking_enabled: bool = True,
reasoning_effort: str = "medium",
subagent_enabled: bool = False,
stream_custom: bool = False,
multitask_strategy: str = "reject",
cancel_on_disconnect: bool = False,
extra_context: dict[str, Any] | None = None,
files: list[dict[str, Any]] | None = None,
roundtable_file_stream: bool = False,
) -> dict[str, Any]:
"""构造 ``POST /api/threads/{thread_id}/runs/stream`` 的请求体。
- ``context.agent_name``:本次 run 使用的 agent(内置 id 或 init 创建的 coordinator id)。
- ``excluded_tools``:按路由裁剪工具面(intent 仅澄清、special 禁派活等)。
- ``allowed_tools``:安全敏感角色的真正白名单;``None`` 保持兼容,空列表禁用全部。
- ``skill_stop_names``:leader 侧配合 SkillStopMiddleware 在 ``agent_orchestration``
调用后截断图,以便网关从 AIMessage.tool_calls 解析派活列表。
- ``thinking_enabled`` / ``reasoning_effort``:默认开 CoT + medium,适合 Step 1 澄清
@@ -754,7 +773,7 @@ def _run_payload(
]
},
"metadata": {"run_scope": RUN_SCOPE_ORCHESTRATION_CHILD},
"config": {"recursion_limit": 1000},
"config": {"recursion_limit": recursion_limit},
"context": {
"agent_name": agent_name,
"model_name": model_name,
@@ -778,6 +797,7 @@ def _run_payload(
# 席位 → run/stream 报 400 "agent_name ... missing from d_agent_thread_id"。
# 圆桌是一次性研讨,本就不该吃历史会话记忆,这里统一关注入根治该污染。
"memory_injection_enabled": False,
"allowed_tools": allowed_tools,
},
# ultra 席位会派子智能体(task 工具),其 task_started/running/completed 进度走
# **custom** 流(StreamWriter)。仅这种场景加 "custom",让前端任务卡片能显示子智能体
@@ -788,13 +808,10 @@ def _run_payload(
"stream_subgraphs": True,
"stream_resumable": True,
"assistant_id": "lead_agent",
"on_disconnect": "continue",
# ⚠️ 圆桌 run 必须能被「下一次同 thread 的 run」抢占。圆桌每个 thread 同一时刻
# 只应有一个活跃 run,但 on_disconnect="continue" 让前端 abort SSE 后**后端 run
# 仍在跑**(为可恢复);此时用户「人工干预」立刻发起新 leader run 打到同一总控
# thread,默认 reject 策略就会 409「already has an active run」→ 被 _stream_upstream
# 包成 502。调用方(leader / special)传 multitask_strategy="rollback",让新 run 先
# 取消该 thread 上没结束的旧 run 再继续 —— 这正是「最新一轮/干预指令优先」的语义。
# 第二步实时会商不支持脱离浏览器继续执行。断网、暂停或前端主动 abort 后必须
# 取消后端 run;第三步等既有调用保持 continue,不改变其断线恢复语义。
"on_disconnect": "cancel" if cancel_on_disconnect else "continue",
# rollback 仍保留,负责处理人工干预与新一轮抢占同一 thread 的极短竞态。
"multitask_strategy": multitask_strategy,
"excluded_tools": excluded_tools,
"skill_stop_names": skill_stop_names,
@@ -803,9 +820,20 @@ def _run_payload(
# 供 read_peer_delivery 工具按需读取)。只进 context dict,不进 prompt → 不调工具就不耗 token。
if extra_context:
payload["context"].update(extra_context)
if roundtable_file_stream:
payload["context"].update(_roundtable_file_stream_context())
return payload
def _roundtable_file_stream_context() -> dict[str, bool]:
"""会商专用文件流式兼容。关闭时不写 context,本轮仍只看全局开关。"""
from deerflow.config.system_settings import get_roundtable_room_limit_settings
if get_roundtable_room_limit_settings().write_file_compatibility_enabled:
return {"roundtable_write_file_compatibility": True}
return {}
# ---------------------------------------------------------------------------
# POST /api/multi-agent/init — Step 2 席位 + 总控初始化(SSE)
# ---------------------------------------------------------------------------
@@ -826,7 +854,7 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea
SSE 事件(每条 ``data: {...}\\n\\n``)::
seat_ready — 某一席位 thread 已创建(``asyncio.as_completed`` 顺序,先完先发)
coordinator_ready — 总控 thread 就绪 + 已注入本次席位列表
coordinator_ready — 总控 thread 元数据就绪,席位列表随首次问答注入
init_done — ``thread_ids`` 全量映射,前端存 ``threadIds`` 并开始编排
error — 失败详情(同步校验失败则直接 HTTP 4xx,不进 SSE)
@@ -837,9 +865,9 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea
在首次调用时自动落地。每次 init **不再创建新的 coordinator agent**(避免
``.deer-flow/agents/`` 累积 ``roundtable-coordinator-<ts36>`` 残留)。
本次圆桌的「可调度席位列表」由 init 在创建好 coordinator thread 之后,以一条
human 消息追加到 thread 历史里,leader run 每轮都能从对话历史里读到。这样
SOUL 长期稳定,席位列表随会话动态注入。前端为兼容旧 client 仍可传
初始化只创建业务线程元数据,不访问 checkpoint。每轮 leader run 根据 thread
映射注入真实席位清单,随实际输入一起持久化。SOUL 长期稳定,席位列表随会话
动态注入。前端为兼容旧 client 仍可传
``main_agent_name`` 字段,但后端会忽略并使用 ``COORDINATOR_AGENT_ID``。
"""
# 调用前自检:与 intent/recommend 一样,缺失时就地补建,避免 /run/stream 报 500。
@@ -847,7 +875,6 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea
agent_names_in = payload.get("agent_names") or []
agent_names: list[str] = [str(name).lower() for name in agent_names_in]
model: str | None = payload.get("model")
# main_agent_name 是历史字段,2026-05 改造后忽略,统一用单例 id。保留接收但不再用前端值,
# 避免下游再次创建带时间戳的临时 coordinator。
main_agent_name = COORDINATOR_AGENT_ID
@@ -858,6 +885,26 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea
for name in agent_names:
_validate_agent_id(name, "agent_names entry")
# The browser already acquired the room before entering Step 2. Init only
# renews that lease; it cannot create a second room or bypass admission.
from app.gateway.roundtable_room_gate import enforce_existing_roundtable_room
room_draft_id = str(payload.get("draft_id") or "").strip() or None
room_task_id = str(payload.get("task_id") or "").strip() or None
room_session_id = str(payload.get("room_session_id") or "").strip() or None
await enforce_existing_roundtable_room(
request,
draft_id=room_draft_id,
task_id=room_task_id,
room_session_id=room_session_id,
)
room_thread_meta = _room_thread_metadata(
draft_id=room_draft_id,
user_id=_resolve_user_id(request),
task_id=room_task_id,
room_session_id=room_session_id,
)
def _frame(payload: dict[str, Any]) -> str:
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
@@ -873,10 +920,10 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea
agent_meta = await _get_agents_batch_direct(request, agent_names)
t_meta_done = time.perf_counter()
# 2) 每个席位 thread 并行创建(直调 thread_store + checkpointer),
# 2) 每个席位 thread 元数据并行创建,不预写空 checkpoint,
# 用 asyncio.as_completed 保持"先完先发 seat_ready"的体验。
async def _prepare_sub_agent(sub_id: str) -> tuple[str, str, str, str]:
thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
thread_id = await _create_thread_direct(request, metadata=room_thread_meta)
meta = agent_meta.get(sub_id.lower(), {})
return (
sub_id.lower(),
@@ -908,50 +955,11 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea
t_seats_done = time.perf_counter()
agent_threads: dict[str, str] = {sid: thread_id for sid, thread_id, _, _ in prepared}
seat_lines: list[str] = [
f"- agent_name: {sid}({display_name}):{description}"
for sid, _, display_name, description in prepared
]
# 协调智能体改造为**单例**(id = roundtable-coordinator):
# - SOUL 已经由 ensure_roundtable_functional_agents() 在路由入口落地,
# 通用协调规则常驻磁盘,无需每次重写。
# - 本次"可调度席位清单"以 human 消息形式 append 到 coordinator
# thread 历史里,leader run 每轮都能在对话历史最前面看到。
# 这样席位列表随会话动态变化,但磁盘上不再累积 roundtable-coordinator-<ts36>。
#
# 注意:model 字段在 lead_agent run 时通过 context.model_name 注入,
# 不需要写回到 agent config.yaml(否则会污染单例的配置)。
seat_introduction = (
"【圆桌系统初始化】本次研讨可调度的席位清单如下,请严格按 agent_name "
"通过 agent_orchestration 技能派活,不要派给不在清单内的 agent:\n"
+ "\n".join(seat_lines)
)
coord_thread = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
# SOUL 使用固定总控单例。席位清单由 _leader_run 随实际输入注入,
# 初始化不通过 loopback 追加消息,也无需创建任何空 checkpoint。
coord_thread = await _create_thread_direct(request, metadata=room_thread_meta)
agent_threads[main_agent_name] = coord_thread
# 把席位列表注入 coordinator thread 的对话历史。HTTP loopback 调用
# 走 /api/threads/{tid}/state(读+写),开销 ~10-30ms,在 init 整体
# 几百 ms 的尺度上微不足道,但比起手撕 langgraph checkpoint 内部结构
# 风险低、与 _broadcast / _append_thread_message 一套语义,长期可维护。
try:
base = _loopback_base(request)
headers = _auth_headers(request)
async with _make_loopback_client(request) as seed_client:
await _append_thread_message(
seed_client,
base,
headers,
coord_thread,
seat_introduction,
)
except Exception:
# 注入失败不阻断 init:leader run 仍能跑,只是看不到席位清单,
# 可能派活给错的 id。会被 _strip_display_suffix / _validate_agent_id
# 兜底,前端能看到错误帧;运维通过 backend 日志找到根因再修复。
logger.exception("[init-direct] failed to seed coordinator thread with seat list")
t_coord_done = time.perf_counter()
logger.info(
"[init-direct] meta=%.3fs seats=%.3fs coord=%.3fs total=%.3fs seats_count=%d",
@@ -1001,7 +1009,10 @@ 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]:
async def init_report_thread(
request: Request,
payload: dict | None = Body(default=None),
) -> dict[str, str]:
"""为 Step 3「结果绘制」创建一个供方案可视化总结智能体(``roundtable-report``)
运行的线程,返回 ``{"agent_name", "thread_id"}``。
@@ -1016,7 +1027,10 @@ async def init_report_thread(request: Request) -> dict[str, str]:
"""
try:
ensure_roundtable_functional_agents()
thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
thread_id = await _create_thread_direct(
request,
metadata=_step3_thread_metadata(),
)
except Exception as exc:
await record_foreground_diag(
request, stage="step3_report", level="error", event="thread_init_failed",
@@ -1028,7 +1042,10 @@ async def init_report_thread(request: Request) -> dict[str, str]:
@router.post("/summary/init")
async def init_summary_thread(request: Request) -> dict[str, str]:
async def init_summary_thread(
request: Request,
payload: dict | None = Body(default=None),
) -> dict[str, str]:
"""为 Step 3「总结报告」创建一个供方案总结报告智能体(``roundtable-summary``)
运行的线程,返回 ``{"agent_name", "thread_id"}``。
@@ -1044,7 +1061,10 @@ async def init_summary_thread(request: Request) -> dict[str, str]:
"""
try:
ensure_roundtable_functional_agents()
thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
thread_id = await _create_thread_direct(
request,
metadata=_step3_thread_metadata(),
)
except Exception as exc:
await record_foreground_diag(
request, stage="step3_summary", level="error", event="thread_init_failed",
@@ -1082,7 +1102,10 @@ async def init_position_action_plan_thread(request: Request) -> dict[str, str]:
@router.post("/dashboard/init")
async def init_dashboard_thread(request: Request) -> dict[str, str]:
async def init_dashboard_thread(
request: Request,
payload: dict | None = Body(default=None),
) -> dict[str, str]:
"""为 Step 3「大屏多页」创建一个供大屏多页数据智能体(``roundtable-dashboard``)
运行的线程,返回 ``{"agent_name", "thread_id"}``。
@@ -1100,7 +1123,10 @@ async def init_dashboard_thread(request: Request) -> dict[str, str]:
"""
try:
ensure_roundtable_functional_agents()
thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
thread_id = await _create_thread_direct(
request,
metadata=_step3_thread_metadata(),
)
except Exception as exc:
await record_foreground_diag(
request, stage="step3_dashboard", level="error", event="thread_init_failed",
@@ -1112,7 +1138,10 @@ async def init_dashboard_thread(request: Request) -> dict[str, str]:
@router.post("/structure/init")
async def init_structure_thread(request: Request) -> dict[str, str]:
async def init_structure_thread(
request: Request,
payload: dict | None = Body(default=None),
) -> dict[str, str]:
"""为 Step 3「入库」创建一个供结构化抽取智能体(``roundtable-structure``)运行的线程,
返回 ``{"agent_name", "thread_id"}``。
@@ -1130,7 +1159,10 @@ async def init_structure_thread(request: Request) -> dict[str, str]:
"""
try:
ensure_roundtable_functional_agents()
thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
thread_id = await _create_thread_direct(
request,
metadata=_step3_thread_metadata(),
)
except Exception as exc:
await record_foreground_diag(
request, stage="step3_structure", level="error", event="thread_init_failed",
@@ -1181,7 +1213,6 @@ async def _stream_upstream(
本机往返通常 <10ms),怀疑 HTTP_PROXY 拦了回环——检查 client 是否走 _make_loopback_client
(trust_env=False)。
"""
url = f"{base}/api/threads/{thread_id}/runs/stream"
captured: list[str] = []
agent_for_log = (body.get("context") or {}).get("agent_name") or thread_id
@@ -1189,8 +1220,8 @@ async def _stream_upstream(
# 头阶段非 200 的瞬时重试。
# 背景:网关是**单 worker** + checkpointer 是**单连接 aiosqlite**(全进程一把
# asyncio.Lock 串行 checkpoint 读写)。圆桌第三步进入时,前端把「方案总结/结果绘制/
# 大屏」三个重 run **同一刻并行**打出来,叠加第二步 on_disconnect=continue 仍在后台
# 跑的席位 run,瞬时把单锁/单 worker 打满 —— 此时回环子请求 /runs/stream 的 start_run
# 大屏」三个重 run **同一刻并行**打出来,若再叠加尚未完成取消收尾的第二步席位 run,
# 瞬时把单锁/单 worker 打满 —— 此时回环子请求 /runs/stream 的 start_run
# 里那串 DB / checkpoint 写有概率抛错(或同 thread 抢占竞态),返回非 200,被本函数原样
# 包成 502 漏给前端(用户侧表现为"进第三步概率性 502")。
# 关键安全前提:这个非 200 发生在**响应头阶段**,本函数**尚未 yield 任何 SSE 行**给下游,
@@ -1553,6 +1584,7 @@ async def _leader_run(
model_name: str,
seat_meta: dict[str, dict[str, Any]] | None = None,
synthesis_mode: bool = False,
finalize_only: bool = False,
suppress_pre_broadcast: bool = True,
files: list[dict[str, Any]] | None = None,
) -> AsyncIterator[str | dict[str, Any]]:
@@ -1584,6 +1616,9 @@ async def _leader_run(
# new_message),绝不阻断 leader run。这些只进总控**本轮 run 输入**,不广播给席位。
seat_status_block = ""
full_deliverables_block = ""
# Older clients may omit names/descriptions; the thread mapping still
# supplies the authoritative IDs, including on a checkpoint-free first run.
seat_meta = seat_meta or {sid: {} for sid in agent_threads if sid != agent_name}
if seat_meta:
seat_ids = [name for name in agent_threads if name != agent_name]
if seat_ids:
@@ -1600,10 +1635,9 @@ async def _leader_run(
undelivered = [sid for sid in seat_ids if not delivery.get(sid, (False, ""))[0]]
seats_meta_list = [(sid, seat_meta.get(sid, {})) for sid in seat_ids]
seat_status_block = _build_seat_status_block(seats_meta_list, delivery, undelivered_count=len(undelivered))
# 第 2 阶段(仅「综合轮 且 所有席位都已交付」):再**全文**取各已交付席位的交付,拼综合块。
# 还有 ⬜ 未交付席位时不注入 —— 否则"综合输出最终方案"的指令会怂恿弱模型在席位没集齐时
# 就提前收口(实测:3/5 交付就臆称"三方已发言"草草收口)。全文读只在这一轮才付出。
if synthesis_mode and not undelivered:
# 普通综合轮只有全部席位交付后才注入全文,避免弱模型过早收口;显式强制收口轮
# 即使仍有失败席位,也要注入其余有效交付并在最终结论中如实标明缺口。
if finalize_only or (synthesis_mode and not undelivered):
try:
full_delivery = await _collect_delivery_status(
client, base, headers,
@@ -1621,6 +1655,14 @@ async def _leader_run(
len(undelivered),
bool(full_deliverables_block),
)
if finalize_only:
new_message = (
f"{new_message}\n\n"
"【最终收口·纯正文】本轮所有工具均已关闭。请直接根据上方席位交付输出一份合并后的最终结论正文;"
"不要按席位逐份复述原文,也不要反复改写同一份总结。"
"不要调用或尝试 write_file、present_files、agent_orchestration、skill_view 或任何其它工具,"
"不要再派活,也不要只描述你准备做什么。"
)
leader_input = "\n\n".join(
part for part in (seat_status_block, full_deliverables_block, new_message) if part
)
@@ -1637,12 +1679,14 @@ async def _leader_run(
# 带了上传文件时,给总控解锁「读」类工具(leader policy 默认禁 read_file/ls/view_image),
# 否则文件清单注入了却无工具读取。写/派活类仍按 policy 封锁,不放开。
leader_excluded = policy.excluded_tools
if files:
leader_allowed = [] if finalize_only else list(getattr(policy, "allowed_tools", None) or [])
if files and not finalize_only:
_read_tools = {"read_file", "ls", "grep", "view_image"}
leader_excluded = [t for t in leader_excluded if t not in _read_tools]
leader_allowed.extend(sorted(_read_tools))
# 模型自动容错:按 config.yaml models[] 顺序的候选链。当前模型出问题(兜底错误文案 /
# 既无正文又无派活)时切到下一个模型重跑(见循环内检测)。``_mk_body`` 用候选模型重建 body。
# 模型自动容错:按 config.yaml models[] 顺序的候选链。当前模型返回明确的错误标记/
# 兜底错误文案时才切到下一个模型重跑;没有正文或派活不触发换模。
model_chain = fallback_model_chain(model_name) or [model_name]
model_idx = 0
current_model = model_chain[0]
@@ -1655,13 +1699,17 @@ async def _leader_run(
# 注入了席位清单的输入(leader_input);seat_roster 为空时等同 new_message。
new_message=leader_input,
excluded_tools=leader_excluded,
allowed_tools=list(dict.fromkeys(leader_allowed)),
skill_stop_names=effective_stop_names,
recursion_limit=int(getattr(policy, "recursion_limit", 1000)),
thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort,
# 人工干预/恢复会重发 leader run 到同一总控 thread;抢占掉没结束的旧 run,避免 409。
multitask_strategy="rollback",
cancel_on_disconnect=True,
# 用户随干预上传的参考文件 → 总控可用 read_file 等工具读取后再派活。
files=files,
roundtable_file_stream=True,
)
body = _mk_body(current_model)
@@ -1729,11 +1777,11 @@ async def _leader_run(
leader_text = _flatten_content(dispatch_msg.get("content", ""))
tool_calls = dispatch_msg.get("tool_calls") or []
# 模型自动容错:本轮总控产出是「中间件兜底错误文案」或「既无正文又无派活」(模型没干活)
# → 若还有备选模型,切到下一个重跑(不消耗派活纠正预算)。先发 model_switch 帧让前端把
# 这条失败气泡重置,再用新模型重新流式。最后一个模型仍失败则照常往下走(错误文案当共识展示)。
# 模型自动容错通常只识别真正的模型错误。总控暂时没有正文/派活不代表模型故障,不能据此
# 擅自换掉用户选择的模型(部分模型会长时间 reasoning 或连续调用工具)。唯一例外是:
# 无工具的最终收口请求已经完整结束却仍无正文,此时该次结果客观不可交付,才换下一个模型。
fail_reason = is_llm_error_message(dispatch_msg)
if fail_reason is None and not (leader_text or "").strip() and not tool_calls:
if fail_reason is None and finalize_only and not (leader_text or "").strip():
fail_reason = "empty"
if fail_reason and model_idx + 1 < len(model_chain):
model_idx += 1
@@ -1750,6 +1798,16 @@ async def _leader_run(
current_model = next_model
body = _mk_body(current_model)
continue
if fail_reason:
yield {
"status": "error",
"role": "leader",
"agent_name": agent_name,
"model_used": current_model,
"reason": reason_text(fail_reason),
"content": leader_text,
}
return
# 澄清分支:与 intent.py 相同字段;status 为字符串 "clarification"(非数组),
# 前端据此暂停 runOrchestration 循环,展示 Step 2 澄清条。
@@ -1903,11 +1961,9 @@ async def _special_run(
directive = build_summary_directive(business_code)
if directive:
seat_message = f"{new_message}\n\n---\n\n{directive}"
# 模型自动容错:按 config.yaml models[] 顺序的候选链。某模型出问题(兜底错误文案;
# 真正的研讨席位还含「交付为空」)→ 发 model_switch 帧让前端重置该席位气泡,再用下一个
# 模型重跑。Step3 单例(report/summary/dashboard/ingest)的产物是落盘文件,正文可能很短,
# 故**不**把「正文为空」当失败(与后台 run_report/summary 的 validate=None 一致),只在真正
# 的模型错误时换模型。
# 模型自动容错:只在真正的模型错误(中间件错误标记/兜底错误文案)时换下一个模型。
# 「仅有思考 / 暂无可见正文 / 交付为空」不是模型故障,不能触发换模;这些状态仍在本轮
# 结束后按 invalid_delivery 交给既有交付校验处理。
model_chain = fallback_model_chain(model_name) or [model_name]
empty_is_failure = role in ("seat", "seat_parallel")
@@ -1918,7 +1974,9 @@ async def _special_run(
thread_id=thread_id,
new_message=seat_message,
excluded_tools=seat_excluded,
allowed_tools=getattr(policy, "allowed_tools", None),
skill_stop_names=merge_skill_stop_names(policy.skill_stop_names, skill_stop_names),
recursion_limit=int(getattr(policy, "recursion_limit", 1000)),
# 席位执行模式覆盖 policy 的 thinking/reasoning(None → 回退 policy 默认);
# subagent 仅 ultra 档置真。工具裁剪(excluded_tools)仍完全由 policy 决定。
thinking_enabled=(policy.thinking_enabled if thinking_enabled is None else thinking_enabled),
@@ -1928,12 +1986,14 @@ async def _special_run(
stream_custom=subagent_enabled,
# 干预/重新派活会把同一席位 thread 再跑一次;抢占掉没结束的旧 run,避免 409。
multitask_strategy="rollback",
cancel_on_disconnect=role in ("seat", "seat_parallel"),
# 前序席位完整交付 → run context,供 read_peer_delivery 工具按需取全文(§12.9)。
extra_context=(
{"roundtable_peer_deliveries": peer_deliveries} if peer_deliveries else None
),
# 用户随干预上传的参考文件 → 席位可用 read_file 等工具读取。
files=files,
roundtable_file_stream=True,
)
t_payload_built = time.perf_counter()
@@ -2004,10 +2064,8 @@ async def _special_run(
visible = _visible_delivery_from_messages(messages)
delivery_kind = classify_seat_delivery(content) if content.strip() else ("valid" if visible else "empty")
# 模型失败检测:兜底错误文案;研讨席位再加「无可见正文」(思考不算)。还有备选模型则换。
# 模型失败检测:只认中间件标记或兜底错误文案。思考较长/暂无正文绝不触发换模。
fail_reason = is_llm_error_message(last_msg)
if fail_reason is None and empty_is_failure and delivery_kind != "valid":
fail_reason = "thinking_only" if delivery_kind == "thinking_only" else "empty"
if fail_reason and idx + 1 < len(model_chain):
next_model = model_chain[idx + 1]
logger.warning("[special-fallback] %s 模型 %s 失败(%s),切换到 %s", agent_name, candidate, fail_reason, next_model)
@@ -2021,8 +2079,19 @@ async def _special_run(
}
continue
if fail_reason:
yield {
"status": "error",
"role": role,
"agent_name": agent_name,
"model_used": candidate,
"reason": reason_text(fail_reason),
"content": visible or content,
}
return
if empty_is_failure and delivery_kind != "valid":
logger.warning("[special-fallback] %s 所有模型均未产出可见正文(%s)", agent_name, delivery_kind)
logger.warning("[special-delivery] %s 本轮未产出可见正文(%s),不触发换模", agent_name, delivery_kind)
yield {
"status": "invalid_delivery",
"reason": delivery_kind,
@@ -2087,6 +2156,7 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
# 额外把各已交付席位的完整交付正文注入总控输入,供其写最终方案。缺省 False(派活决策轮
# 只给摘要,保持上下文精简)。旧前端不传 → False,行为安全退化。
synthesis_mode: bool = bool(payload.get("synthesis_mode") or False)
finalize_only: bool = bool(payload.get("finalize_only") or False)
# 并行禁写文件标记(仅 special 用):前端 runDagOrchestration 在真并行 stage(≥2 席位)
# 置真,网关据此用 seat_parallel policy 额外禁 write_file/bash/str_replace(§4)。
# 兼容 no_file 别名;缺省 False → 普通 seat,旧前端不传也不破坏。
@@ -2133,6 +2203,27 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
for key in agent_threads:
_validate_agent_id(key, "d_agent_thread_id key")
# Browser-side acquire admits the room exactly once when Step 2 begins.
# Every model-producing Step-2 entry only renews that existing room, so a
# pause, clarification answer or user intervention never competes for a
# second slot. Step 3 is excluded because consensus releases the room.
step2_room_limited = _is_step2_room_limited(
agent_type,
agent_name,
# 只有显式最终收口轮绕过房间时限;普通综合轮仍可继续派活。
finalize_only=finalize_only,
)
if step2_room_limited:
from app.gateway.roundtable_room_gate import enforce_roundtable_run
await enforce_roundtable_run(
request,
draft_id=str(payload.get("draft_id") or "").strip() or None,
task_id=str(payload.get("task_id") or "").strip() or None,
room_session_id=str(payload.get("room_session_id") or "").strip() or None,
thread_ids=list(agent_threads.values()),
)
base = _loopback_base(request)
headers = _auth_headers(request)
@@ -2160,6 +2251,7 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
client, base, headers, agent_threads, agent_name, new_message, skill_stop_names, model_name,
seat_meta=seat_meta,
synthesis_mode=synthesis_mode,
finalize_only=finalize_only,
suppress_pre_broadcast=suppress_pre_broadcast,
files=files,
)
@@ -2211,6 +2303,13 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
"agent_name": agent_name,
}
yield f"data: {json.dumps(err_frame, ensure_ascii=False)}\n\n"
except asyncio.CancelledError:
# Intentional pause and a real network disconnect are both an
# SSE cancellation at this layer. Releasing here made a normal
# pause lose its admitted room. Browser heartbeats now own
# liveness: pauses keep heartbeating; a disconnected browser
# stops heartbeating and the 60-second lease expires.
raise
return StreamingResponse(
stream_generator(),
@@ -6,7 +6,8 @@
``agent_id``,与前端任务深链 ``goPath=3qfx`` 取的是同一个);
2. 按 taskId 拉取任务详情(consumer 的 ``cop-task-detail``,地址 / 鉴权头 / mock 都在
``config.yaml`` 的 ``task_deeplink`` 段),拼出与前端深链一致的开场白
``任务名称:…\n任务内容:…\n任务id(task_id)为…``;
``任务名称:…\n任务内容:…\n任务类型:…\n任务id(task_id)为…``
(``taskType`` 字典码 1/2/3 映射为 打击/塑势/防御,无法识别则省略该行);
3. **新建一条真实会话**(thread 打任务标签并登记到 ``taskId + goPath`` 专用聊天记录),
以开场白作为第一条用户消息
**后台启动一次持久化运行**(写 checkpointer,不阻塞);
@@ -26,7 +27,7 @@
返回::
{"thread_id": "…", "run_id": "…", "agent_id": "…", "status": "running",
"opening_text": "任务名称:…\n任务内容:…\n任务id(task_id)为2055"}
"opening_text": "任务名称:…\n任务内容:…\n任务类型:打击\n任务id(task_id)为2055"}
"""
from __future__ import annotations
@@ -92,6 +93,41 @@ class CopTaskDetail(BaseModel):
task_direction: str = ""
task_name: str = ""
task_content: str = ""
task_type: str = ""
_TASK_TYPE_LABELS = {"1": "打击", "2": "塑势", "3": "防御"}
def map_task_type_label(value: Any) -> str:
"""把 cop-task-detail 的 taskType 字典码映射成发送文本。无法识别时返回空串。"""
if isinstance(value, bool) or value is None:
return ""
if isinstance(value, int):
key = str(value)
elif isinstance(value, float):
if not value.is_integer():
return ""
key = str(int(value))
elif isinstance(value, str):
key = value.strip()
else:
return ""
if key in _TASK_TYPE_LABELS.values():
return key
return _TASK_TYPE_LABELS.get(key, "")
def _task_type_code(value: Any) -> str:
if isinstance(value, bool) or value is None:
return ""
if isinstance(value, int):
return str(value)
if isinstance(value, float):
return str(int(value)) if value.is_integer() else ""
if isinstance(value, str):
return value.strip()
return ""
async def _resolve_threeq_agent_id(request: Request) -> str:
@@ -117,6 +153,7 @@ def _parse_cop_task_detail(body: Any) -> CopTaskDetail:
task_direction=data.get("taskDirection") if isinstance(data.get("taskDirection"), str) else "",
task_name=data.get("taskName") if isinstance(data.get("taskName"), str) else "",
task_content=data.get("taskContent") if isinstance(data.get("taskContent"), str) else "",
task_type=_task_type_code(data.get("taskType")),
)
@@ -146,9 +183,11 @@ async def _fetch_cop_task_detail(task_id: str) -> CopTaskDetail:
def build_opening_text(detail: CopTaskDetail, task_id: str) -> str:
"""拼 3qfx 开场白,与前端 ``buildOpeningText``(3qfx 分支)逐行对齐。"""
task_type_label = map_task_type_label(detail.task_type)
parts = [
f"任务名称:{detail.task_name}" if detail.task_name else "",
f"任务内容:{detail.task_content}" if detail.task_content else "",
f"任务类型:{task_type_label}" if task_type_label else "",
f"任务id(task_id)为{task_id}" if task_id else "",
]
return "\n".join(p for p in parts if p)
@@ -51,6 +51,7 @@ from pydantic import BaseModel, Field
from deerflow.agents.lead_agent.agent import make_lead_agent
from deerflow.config.agents_config import list_custom_agents, validate_agent_id
from deerflow.runtime.runs.worker import build_agent_with_timing
logger = logging.getLogger(__name__)
@@ -81,7 +82,7 @@ class OpenChatRequest(BaseModel):
thinking_enabled: bool = Field(default=False, description="是否开启思考;默认 False = 关闭思考(内网模型也会被强制关闭)")
strip_think: bool = Field(default=True, description="关闭思考时,从答案里剥离残留的 <think>…</think> 块")
thread_id: str | None = Field(default=None, description="可选;仅用于文件隔离,留空自动生成")
recursion_limit: int = Field(default=100, ge=1, le=300, description="智能体单次问答的最大步数")
recursion_limit: int = Field(default=100, ge=1, le=300, description="兼容旧客户端保留;实际步数上限统一使用管理员的问答步骤设置", deprecated=True)
include_files: bool = Field(default=True, description="是否随结果回传智能体在 outputs 目录生成的文件(名称+内容)")
@@ -279,7 +280,13 @@ def _build_run_context(body: OpenChatRequest) -> tuple[dict[str, Any], str | Non
}
if agent_id:
configurable["agent_id"] = agent_id
config: dict[str, Any] = {"configurable": configurable, "recursion_limit": body.recursion_limit}
from deerflow.config.system_settings import get_conversation_context_settings
# Match browser chat: external callers cannot override the server budget.
config: dict[str, Any] = {
"configurable": configurable,
"recursion_limit": get_conversation_context_settings().max_run_steps,
}
return config, agent_id, thread_id
@@ -301,7 +308,7 @@ async def open_chat(body: OpenChatRequest) -> OpenChatResponse:
config, agent_id, thread_id = _build_run_context(body)
try:
agent = make_lead_agent(config)
agent = await build_agent_with_timing(make_lead_agent, config, None, thread_id)
result = await agent.ainvoke({"messages": lc_messages}, config=config)
except Exception as exc: # noqa: BLE001 — 开放接口需把内部错误归一成 502
logger.exception("open_chat 调用智能体失败 (model=%s, agent=%s)", body.model_name, agent_id)
@@ -345,7 +352,7 @@ async def _open_chat_event_stream(body: OpenChatRequest) -> AsyncIterator[str]:
async def produce() -> None:
nonlocal final_state
try:
agent = make_lead_agent(config)
agent = await build_agent_with_timing(make_lead_agent, config, None, thread_id)
async for mode, chunk in agent.astream({"messages": lc_messages}, config=config, stream_mode=["values", "messages"]):
if mode == "values" and isinstance(chunk, dict):
final_state = chunk
@@ -20,7 +20,7 @@ from datetime import datetime
from typing import Any
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from deerflow.runtime.user_context import get_effective_user_id
@@ -103,10 +103,13 @@ def _get_store(request: Request):
@router.get("", response_model=TaskListResponse)
async def list_tasks(request: Request) -> TaskListResponse:
async def list_tasks(
request: Request,
id_prefix: str | None = Query(default=None, max_length=64),
) -> TaskListResponse:
store = _get_store(request)
user_id = _current_user_id(request)
rows = await store.list_tasks(user_id)
rows = await store.list_tasks(user_id, id_prefix=id_prefix)
return TaskListResponse(tasks=[TaskResponse(**r) for r in rows])
@@ -9,8 +9,8 @@ Each fan-out agent runs as a pure **executing seat**: ``excluded_tools`` =
``_EXCLUDED_TOOLS`` (``write_file`` / ``str_replace`` / ``agent_orchestration`` /
``bash``) so a seat can neither write files (parallel writes would clash) nor
dispatch / run scripts (a weak model fed "总控 / 业务链条" framing would otherwise
mimic the controller and "派活"). Read-only tools + ``ask_clarification`` stay on,
so the step bar and clarification cards still work.
mimic the controller and "派活"). Read-only tools stay on. ``ask_clarification``
is available only to strategy intent, the sole stage with a reply surface.
Unified event protocol (one JSON object per ``data:`` frame)::
@@ -42,7 +42,10 @@ from pydantic import BaseModel, Field
from app.gateway.roundtable_model_fallback import is_llm_error_message, reason_text
from app.gateway.routers import agents as agents_router
from app.gateway.routers._strategy_selection_seed import STRATEGY_AGENT_ID_PREFIX
from app.gateway.routers._strategy_selection_seed import (
STRATEGY_AGENT_ID_PREFIX,
STRATEGY_INTENT_AGENT_ID,
)
from app.gateway.routers.multi_agent import (
_ROUNDTABLE_THREAD_METADATA,
_auth_headers,
@@ -128,7 +131,7 @@ def _has_substantive_content(content: str) -> bool:
# 链条」语气的任务后,会概率性地用 agent_orchestration 真去派活,或绕道 bash 跑脚本
# 调内部接口模仿总控调度(bug:单步单智能体链条里子智能体输出「总控调度指令…」并用
# glob / bash 翻找派活脚本)。物理禁掉这两类即可根治。其余只读工具(ls / read_file /
# 检索)与 ask_clarification(协作卡片)保留:既不互相打架,又能正常出步骤条 / 要澄清。
# 检索)保留;ask_clarification 仅意图阶段保留,因为只有它有可续答的协作卡片。
_EXCLUDED_TOOLS: list[str] = [
"write_file", # 并行不互相写文件打架
"str_replace",
@@ -137,6 +140,26 @@ _EXCLUDED_TOOLS: list[str] = [
]
def _strategy_stage_allows_clarification(agent_id: str) -> bool:
"""Return whether a strategy-selection stage has a user reply surface.
The workbench renders and resumes clarification cards only for the intent
stage. Angle generation, judging and final synthesis are one-shot stages;
letting those agents call ``ask_clarification`` produces an interrupt that
the UI cannot answer and therefore turns a recoverable run into a failure.
"""
return agent_id.strip().casefold() == STRATEGY_INTENT_AGENT_ID.casefold()
def _excluded_tools_for_agent(agent_id: str) -> list[str]:
excluded = list(_EXCLUDED_TOOLS)
normalized = agent_id.strip().casefold()
if normalized.startswith(STRATEGY_AGENT_ID_PREFIX.casefold()) and not _strategy_stage_allows_clarification(agent_id):
excluded.append("ask_clarification")
return excluded
# ── 并行面板兼容登录:复用单点开关、密码、限速与封禁校验 ────────────────────
@@ -150,12 +173,44 @@ class _TokenResponse(BaseModel):
user_id: str
def _force_situation_detail_compat_token(username: str) -> _TokenResponse:
"""Return a non-JWT value accepted only by open force-situation detail APIs.
Existing task-detail skills call this legacy endpoint before every detail
GET/PUT and require a non-empty value even though those endpoints are now
credential-free. Keeping the compatibility behavior generic avoids
coupling backend routing to a particular skill name or service account.
"""
normalized = username.casefold()
return _TokenResponse(
# Deliberately not a signed JWT: AuthMiddleware rejects it everywhere
# except the explicitly credential-free force-situation detail routes.
access_token=f"force-situation-detail-compat-{normalized}",
token_type="bearer",
user_id=f"external:{normalized}",
)
@router.post("/auth/token", response_model=_TokenResponse)
async def issue_token(body: _TokenRequest, request: Request, response: Response, password: str | None = Query(default=None)) -> _TokenResponse:
"""Keep the panel response contract while enforcing standard login policy."""
from app.gateway.routers.auth import UsernameLoginRequest, login_by_username
"""Issue a normal login token, or detail-route compatibility value on policy rejection."""
username = body.username.strip()
from app.gateway.routers.auth import login_by_username
result = await login_by_username(request, response, UsernameLoginRequest(username=body.username.strip()), password=password)
try:
result = await login_by_username(
request,
response,
password=password,
)
except HTTPException as exc:
# The caller needs this endpoint only as a preflight before the open
# force-situation detail routes. Do not make individual skill names or
# current password/SSO policy a prerequisite for that workflow.
if exc.status_code in {401, 403}:
return _force_situation_detail_compat_token(username)
raise
return _TokenResponse(access_token=result.access_token, token_type="bearer", user_id=result.user_id)
@@ -368,7 +423,7 @@ async def _run_one_agent(
model_name=model_name,
thread_id=thread_id,
new_message=new_message,
excluded_tools=list(_EXCLUDED_TOOLS),
excluded_tools=_excluded_tools_for_agent(agent_id),
skill_stop_names=[],
thinking_enabled=True,
reasoning_effort="medium",
@@ -425,7 +480,7 @@ async def _run_one_agent(
}
def _build_task_message(task_name: str, brief: str) -> str:
def _build_task_message(task_name: str, brief: str, *, agent_id: str = "") -> str:
parts: list[str] = []
if task_name.strip():
parts.append(f"任务名称:{task_name.strip()}")
@@ -440,13 +495,23 @@ def _build_task_message(task_name: str, brief: str) -> str:
"调度对象 / 分配给『…』的任务 / agent_name」之类调度内容,严禁罗列或转派其他席位的任务,严禁调用"
"任何派活 / 调度类工具,也不要用脚本(bash / python)去调用内部接口或翻找派活脚本。"
)
# 澄清护栏:要向用户确认时,必须用 ask_clarification 工具发起(前端据此渲染可点击的协作
# 卡片),不能只把问题写在正文里 —— 否则用户看不到卡片、无法回应。能合理假设就先假设并标注。
parts.append(
"【需要向用户确认时】如果确实缺少完成分析所必需的关键信息,请**调用 `ask_clarification` 工具**"
"发起确认(这样用户会看到一张可点击的协作卡片来回答);**不要只把问题写在正文里**,否则用户无法回应。"
"能基于已有信息合理假设的,就先假设并在正文中标注你的假设;只有当不同假设会显著改变结论时,才发起确认。"
)
# 只有意图阶段有可交互、可恢复的澄清卡片。其余策略阶段均为一次性交付,必须基于已确认
# 的任务继续完成;否则 ask_clarification 会产生前端无法回答的中断并被误报为生成失败。
if agent_id and agent_id.casefold().startswith(STRATEGY_AGENT_ID_PREFIX.casefold()) and not _strategy_stage_allows_clarification(agent_id):
parts.append(
"【本阶段禁止反问】当前是无需用户中途介入的一次性交付阶段。不得调用 `ask_clarification`,"
"不得在正文里向用户提问、要求补充材料或要求点击重试。信息不足时请采用保守的合理假设,"
"明确降低相应结论的置信度,并把缺口列入“数据限制与待核验”,然后直接完成本阶段要求的全部交付;"
"不要重复输出同一正文或多个版本。"
)
else:
# 澄清护栏:要向用户确认时,必须用 ask_clarification 工具发起(前端据此渲染可点击的
# 协作卡片),不能只把问题写在正文里。能合理假设就先假设并标注。
parts.append(
"【需要向用户确认时】如果确实缺少完成分析所必需的关键信息,请**调用 `ask_clarification` 工具**"
"发起确认(这样用户会看到一张可点击的协作卡片来回答);**不要只把问题写在正文里**,否则用户无法回应。"
"能基于已有信息合理假设的,就先假设并在正文中标注你的假设;只有当不同假设会显著改变结论时,才发起确认。"
)
return "\n\n".join(parts)
@@ -514,13 +579,13 @@ async def run_parallel_agents_stream(request: Request, payload: dict = Body(...)
base = _loopback_base(request)
headers = _auth_headers(request)
new_message = _build_task_message(task_name, brief)
async def _consume_agent(
client: Any,
agent_id: str,
queue: asyncio.Queue[dict[str, Any]],
) -> None:
new_message = _build_task_message(task_name, brief, agent_id=agent_id)
await queue.put({"type": "agent_start", "agent_id": agent_id})
last_event = ""
emitted_tools: set[str] = set() # 已发过 step 的 tool_call id(去重)
@@ -142,14 +142,7 @@ async def get_my_position(request: Request, store: PositionStore = Depends(get_p
user = await get_optional_user_from_request(request)
if user is None:
return MyPositionResponse(position=None)
record = None
pid = await store.get_user_position_id(str(user.id))
if pid:
record = await store.get_position(pid)
if record is None:
default_id = await store.get_default_position_id()
if default_id:
record = await store.get_position(default_id)
record = await store.get_effective_position(str(user.id))
return MyPositionResponse(position=PositionResponse(**record) if record else None)
@@ -114,7 +114,8 @@ async def public_knowledge_vector_search(
# second read ensures an unpublished or newly reserved result is never
# emitted by this anonymous endpoint.
still_published = {str(row["id"]) for row in await _published_mappings(request)}
frontend_base_url = request.app.state.config.llmwiki.local_wiki_index.frontend_base_url
wiki_index = request.app.state.config.llmwiki.local_wiki_index
frontend_base_url = wiki_index.resolve_frontend_base_url(request.headers.get("origin"))
results: list[dict[str, Any]] = []
for raw_item in payload.get("results") or []:
if str(raw_item.get("knowledge_base_id") or "") not in still_published:
@@ -16,10 +16,10 @@ import logging
from fastapi import APIRouter, HTTPException, Query
from app.gateway.admin_query_window import ADMIN_QUERY_WINDOW_DAYS, resolve_admin_query_window
from app.gateway.routers.admin_users import (
AdminLeaderboardResponse,
_load_or_schedule_daily_leaderboard,
_resolve_range,
)
from deerflow.config.system_settings import load_system_settings
from deerflow.persistence.engine import get_session_factory
@@ -68,7 +68,7 @@ def _anonymize(resp: AdminLeaderboardResponse) -> AdminLeaderboardResponse:
@router.get("", response_model=AdminLeaderboardResponse)
@router.get("/", response_model=AdminLeaderboardResponse)
async def get_public_leaderboard(
days: int = Query(default=30, ge=1, le=365),
days: int = Query(default=30, ge=1, le=ADMIN_QUERY_WINDOW_DAYS),
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
limit: int = Query(default=20, ge=5, le=100),
@@ -79,7 +79,11 @@ async def get_public_leaderboard(
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
settings = load_system_settings().leaderboard
since_dt, until_dt, _range_days = _resolve_range(days, since, until)
since_dt, until_dt, _range_days = resolve_admin_query_window(
days=days,
since=since,
until=until,
)
resp = await _load_or_schedule_daily_leaderboard(
session_factory,
settings,
@@ -9,6 +9,7 @@ from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.api_read_cache import get_cache_version, get_cached_json, set_cached_json
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
@@ -65,6 +66,16 @@ async def list_recommended_questions(
all: bool = Query(default=False, description="Admin management view: return every question, unfiltered by 岗位."),
tag_ids: list[str] | None = Query(default=None, description="Keep questions carrying any of these tags (OR)."),
) -> RecommendedQuestionsListResponse:
user_id = _current_user_id(request)
config = getattr(request.app.state, "config", None)
cache_key = f"user:{user_id}"
normalized_tag_ids = tag_ids if isinstance(tag_ids, list) else None
cacheable = not all and not normalized_tag_ids
version = await get_cache_version(config, "recommended_questions") if cacheable else None
if cacheable:
cached = await get_cached_json(config, "recommended_questions", cache_key, version=version)
if cached is not None:
return RecommendedQuestionsListResponse.model_validate(cached)
store = _get_store(request)
rows = await store.list_all()
responses = [RecommendedQuestionResponse(**r) for r in rows]
@@ -91,7 +102,7 @@ async def list_recommended_questions(
sync = getattr(request.app.state, "position_sync", None)
if sync is not None:
try:
visible = await sync.resolve_visible(_current_user_id(request), "recommended_question")
visible = await sync.resolve_visible(user_id, "recommended_question")
except Exception:
logger.debug("recommended-question visibility resolve failed", exc_info=True)
visible = None
@@ -100,11 +111,20 @@ async def list_recommended_questions(
else:
responses = [r for r in responses if not r.tags]
if tag_ids:
wanted = set(tag_ids)
if normalized_tag_ids:
wanted = set(normalized_tag_ids)
responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
return RecommendedQuestionsListResponse(questions=responses)
result = RecommendedQuestionsListResponse(questions=responses)
if cacheable:
await set_cached_json(
config,
"recommended_questions",
cache_key,
result,
expected_version=version,
)
return result
@router.post("/recommended-questions", response_model=RecommendedQuestionResponse, status_code=201)
@@ -41,6 +41,25 @@ router = APIRouter(prefix="/api/roundtable-chains", tags=["roundtable-chains"])
MIN_SEATS = 2
MAX_SEATS = 20
# 功能型内置智能体不可作业务链席位(Step 1 意图理解 / 推荐器 / 总控 / 报告)。
# 与前端 `api/recommend.ts` 的 `filterRecommendCandidates` 排除集逐字对齐:
# 席位富化(_enrich_chain_seats)把这些 id 一律标为 available=False,保证
# 「前端不再实时查 /api/agents」后「功能型智能体不可入席」的语义不变。
_FUNCTIONAL_AGENT_IDS = frozenset(
{
"roundtable-intent",
"roundtable-recommender",
"roundtable-coordinator",
"roundtable-report",
}
)
_FUNCTIONAL_AGENT_PREFIX = "roundtable-coordinator-"
def _is_functional_agent(agent_id: str) -> bool:
lowered = agent_id.lower()
return lowered in _FUNCTIONAL_AGENT_IDS or lowered.startswith(_FUNCTIONAL_AGENT_PREFIX)
# ── schemas ────────────────────────────────────────────────────────────────
@@ -80,11 +99,30 @@ class ChainSeatSchema(BaseModel):
job_responsibilities: str | None = Field(default=None, max_length=4000)
class ChainSeatResponseSchema(ChainSeatSchema):
"""席位响应模型:在落库字段之外附带两个**读时计算**的富化字段。
前端选链(弹窗 / 左栏点选 / 深链自动选链)从此不再实时拉 `/api/agents`,
所需的「席位智能体是否仍可用」与「最新名称」由后端在返回链条时用一次批量
查询(`agent_store.list_by_ids`,与 `/api/agents` 同一套可见性过滤)算好:
- ``available``:该席位智能体当前对请求用户可见(本人 / 内置 / 已发布、
非私享、非管理员隐藏)且不是功能型内置 → True;否则 False。
- ``live_name``:智能体当前名称;查不到时为 None(前端回退 seat.name 快照)。
仅用于响应序列化,**绝不持久化**:create/update 请求沿用 ``ChainSeatSchema``,
富化字段不会进 seats JSON(存量数据零迁移)。
"""
available: bool | None = None
live_name: str | None = None
class ChainResponse(BaseModel):
id: str
title: str = ""
description: str | None = None
seats: list[ChainSeatSchema] = []
seats: list[ChainSeatResponseSchema] = []
# Optional layered orchestration: list of stages, each a list of agent_ids
# run in parallel (stages run serially). None/absent = linear chain.
stages: list[list[str]] | None = None
@@ -103,6 +141,10 @@ class ChainResponse(BaseModel):
# 「待审核」标记:True = owner 已提交审核(等待管理员在「待审核」筛选里发布);
# 管理员发布时随 is_public=True 一起清回 False。缺省 False。
pending_review: bool = False
# 管理员目录开关:True = 从所有普通列表(我的/公共/待审核/选链弹窗)隐藏,仅管理员
# 管理视图(admin_view=true)可见。纯目录开关:get_chain 单条读取不受影响,已配置的
# 业务映射 / 进行中的研讨继续可用。缺省 False。
hidden: bool = False
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
is_mine: bool = True
# 配置者(owner)的 user_id 及解析后的可读用户名(email)。owner_name 由路由层
@@ -199,6 +241,60 @@ async def _enrich_owner_names(rows: list[dict]) -> None:
row["owner_name"] = cache[uid]
def _apply_seat_enrichment(chains: list[dict], live_records: list[dict]) -> None:
"""把批量查到的实时智能体信息写进每条链每个 seat(就地修改响应 dict)。
纯函数(不触网),便于单测:``live_records`` 是 ``agent_store.list_by_ids``
的返回(dict 列表,含 id/name)。规则:
- 功能型内置智能体(_is_functional_agent)与查不到的 id → available=False、
live_name=None(对齐前端 ``filterRecommendCandidates`` 的排除语义);
- 其余 → available=True、live_name=实时 name(回退 None 由前端用快照名)。
"""
live: dict[str, dict] = {}
for record in live_records:
rid = str(record.get("id") or "").lower()
if rid:
live[rid] = record
for chain in chains:
for seat in chain.get("seats") or []:
aid = str(seat.get("agent_id") or "").lower()
record = live.get(aid)
if record is None or _is_functional_agent(aid):
seat["available"] = False
seat["live_name"] = None
else:
seat["available"] = True
seat["live_name"] = str(record.get("name") or "") or None
async def _enrich_chain_seats(request: Request, rows: list[dict]) -> None:
"""席位富化入口:一次批量 SQL 算好所有 seat 的 available / live_name。
best-effort:agent store 不可用 / 查询异常时不写任何字段(响应缺省 → 前端
按存活处理),与旧前端「agents 列表拉取失败不判删」的降级语义一致。
仅修改响应 dict,落库的 seats JSON 不受影响。
"""
agent_ids = {
str(seat.get("agent_id") or "").lower()
for row in rows
for seat in row.get("seats") or []
if seat.get("agent_id")
}
if not agent_ids:
return
try:
store = get_agent_store(request)
except Exception:
logger.warning("Seat enrichment skipped: agent store unavailable")
return
try:
records = await store.list_by_ids(sorted(agent_ids), _current_user_id(request))
except Exception:
logger.warning("Seat enrichment skipped: batch agent lookup failed", exc_info=True)
return
_apply_seat_enrichment(rows, records)
def _get_store(request: Request):
store = getattr(request.app.state, "roundtable_chain_store", None)
if store is None:
@@ -364,7 +460,12 @@ def _validate_seat_mode(seat_mode: str | None) -> None:
@router.get("", response_model=ChainListResponse)
async def list_chains(request: Request, scope: str = "mine") -> ChainListResponse:
async def list_chains(
request: Request,
scope: str = "mine",
admin_view: bool = False,
hidden: bool | None = None,
) -> ChainListResponse:
"""列出业务链条。
``scope`` 查询参数(筛选):
@@ -374,6 +475,11 @@ async def list_chains(request: Request, scope: str = "mine") -> ChainListRespons
非管理员请求 ``pending`` 退化为 ``mine``);
- ``all``:所有账号的全部链条(**仅管理员**;非管理员请求 ``all`` 退化为 ``mine``)。
其余值按 ``mine`` 处理。
隐藏开关(管理员目录):默认(含管理员的普通视图,如选链弹窗)隐藏链条
(``hidden=True``)从所有 scope 排除。管理员传 ``admin_view=true`` 切到管理视图:
不排除隐藏链条,并可用 ``hidden=true/false`` 筛选只看隐藏 / 只看显示中(配置页专用)。
非管理员传 ``admin_view`` 一律忽略,绝不越权。
"""
is_admin = _is_admin(request)
normalized_scope = scope if scope in ("mine", "public", "pending", "all") else "mine"
@@ -382,8 +488,15 @@ async def list_chains(request: Request, scope: str = "mine") -> ChainListRespons
normalized_scope = "mine"
store = _get_store(request)
user_id = _current_user_id(request)
rows = await store.list_chains(user_id, scope=normalized_scope, is_admin=is_admin)
rows = await store.list_chains(
user_id,
scope=normalized_scope,
is_admin=is_admin,
include_hidden=admin_view and is_admin,
hidden=hidden if admin_view and is_admin else None,
)
await _enrich_owner_names(rows)
await _enrich_chain_seats(request, rows)
return ChainListResponse(chains=[ChainResponse(**r) for r in rows])
@@ -411,6 +524,7 @@ async def create_chain(request: Request, body: ChainCreateRequest) -> ChainRespo
},
)
await _enrich_owner_names([row])
await _enrich_chain_seats(request, [row])
return ChainResponse(**row)
@@ -422,6 +536,7 @@ async def get_chain(request: Request, chain_id: str) -> ChainResponse:
if row is None:
raise HTTPException(status_code=404, detail="Chain not found")
await _enrich_owner_names([row])
await _enrich_chain_seats(request, [row])
return ChainResponse(**row)
@@ -451,6 +566,7 @@ async def update_chain(request: Request, chain_id: str, body: ChainUpdateRequest
if row.get("is_public"):
await _publish_seat_agents(request, row, user_id, is_admin=is_admin)
await _enrich_owner_names([row])
await _enrich_chain_seats(request, [row])
return ChainResponse(**row)
@@ -463,6 +579,35 @@ async def delete_chain(request: Request, chain_id: str) -> None:
raise HTTPException(status_code=404, detail="Chain not found")
class ChainHiddenUpdateRequest(BaseModel):
"""Admin-only request body for hiding a chain from every non-admin listing."""
hidden: bool = Field(description="Set true to hide the chain from all non-admin listings (我的/公共/待审核/选链弹窗); false restores it. Purely a catalog switch — get_chain / 业务映射 / 进行中的研讨不受影响。")
@router.put("/{chain_id}/hidden", response_model=ChainResponse)
async def set_chain_hidden(
request: Request, chain_id: str, body: ChainHiddenUpdateRequest
) -> ChainResponse:
"""隐藏 / 取消隐藏一条业务链条(**仅管理员**)。
隐藏后链条从所有普通列表消失(连 owner 的「我的链条」也不显示),但单条读取不过滤:
已配置的业务映射、进行中的研讨、编辑器直接打开(chainId 深链)都继续可用。管理员
管理视图(``admin_view=true``)始终可见,可随时取消隐藏。
"""
if not _is_admin(request):
raise HTTPException(status_code=403, detail="Only system admins can hide or restore chains")
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.update_chain(chain_id, user_id, hidden=body.hidden, is_admin=True)
if row is None:
raise HTTPException(status_code=404, detail="Chain not found")
logger.info("Set chain '%s' hidden=%s by admin", chain_id, body.hidden)
await _enrich_owner_names([row])
await _enrich_chain_seats(request, [row])
return ChainResponse(**row)
# ── 提示词优化(总控提示词 / 阶段目标)──────────────────────────────────────
# 链条编辑器的「AI 优化」按钮:把用户当前写的提示词 + 链条结构发来,单次直连
# LLM 重写后返回全文(同 suggestions 路由的直调模式,不走 agent / loopback)。
@@ -63,6 +63,9 @@ class StartJobRequest(BaseModel):
# 外部任务 id(taskId 深链会商)。非空 = task 作业:按 task_id 共享、不按 user 分权,
# 其 draft_id 指向独立的 roundtable_task_drafts 表(最终报告写回那里)。
taskId: str | None = Field(default=None, max_length=128)
# 当前页面这一次实时会商的隐藏标识。后台接管前用于续占同一个前台名额;
# job 创建后由持久 job 行继续占位。
roomSessionId: str | None = Field(default=None, max_length=128)
intent: str = ""
agents: list[JobAgent] = Field(default_factory=list)
model: str | None = None
@@ -236,6 +239,24 @@ async def start_job(
task_id = (body.taskId or "").strip() or None
draft_id = (body.draftId or "").strip() or None
# Handoff must own the already-admitted foreground room. It only renews
# that lease and never creates capacity during the transition.
from app.gateway.roundtable_room_gate import enforce_existing_roundtable_room
await enforce_existing_roundtable_room(
request,
draft_id=draft_id,
task_id=task_id,
room_session_id=(body.roomSessionId or "").strip() or None,
)
room_store = getattr(request.app.state, "roundtable_room_store", None)
room_started_at = None
read_started_at = getattr(room_store, "started_at", None)
if read_started_at is not None:
room_started_at = await read_started_at(
room_session_id=(body.roomSessionId or "").strip() or None,
holder_user_id=user_id,
)
agents = [SeatRef(a.agentId, a.name or a.agentId) for a in body.agents]
seed_message = (body.seedMessage or body.intent or "").strip()
@@ -280,7 +301,20 @@ async def start_job(
request_id=request_id,
request_hash=_request_hash(body),
input_snapshot=snapshot,
room_started_at=room_started_at,
)
# The durable job row now owns occupancy. Drop the foreground lease so a
# completed background job does not leave a one-minute phantom room.
if room_store is not None:
try:
await room_store.release(
draft_id=draft_id,
user_id=user_id,
task_id=task_id,
room_session_id=(body.roomSessionId or "").strip() or None,
)
except Exception:
logger.warning("failed to release foreground room after job handoff", exc_info=True)
if created:
# 新建(queued):交给 dispatcher 领取执行。
response.status_code = 201
@@ -0,0 +1,123 @@
"""Cross-user roundtable room admission.
Routes (prefix ``/api/roundtable-rooms``)::
GET /summary occupied room count across every account
POST /acquire atomically reserve a room, or 409 when full
POST /heartbeat renew the same durable lease
POST /release release the foreground lease
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.roundtable_room_gate import (
admit_roundtable_room,
backend_occupancy_keys,
release_roundtable_room,
renew_roundtable_room,
)
from deerflow.config.system_settings import get_roundtable_room_limit_settings
router = APIRouter(prefix="/api/roundtable-rooms", tags=["roundtable-rooms"])
class RoomScopeRequest(BaseModel):
draftId: str | None = Field(default=None, max_length=64)
taskId: str | None = Field(default=None, max_length=128)
roomSessionId: str | None = Field(default=None, max_length=128)
class RoomAdmissionResponse(BaseModel):
ok: bool = True
enabled: bool = True
max_rooms: int = 3
active_count: int = 0
renewed: bool = False
code: str = ""
message: str = ""
class RoomSummaryResponse(BaseModel):
enabled: bool = True
max_rooms: int = 3
active_count: int = 0
def _response(decision: dict) -> RoomAdmissionResponse:
return RoomAdmissionResponse(
ok=bool(decision.get("ok")),
enabled=bool(decision.get("enabled")),
max_rooms=int(decision.get("max_rooms") or 0),
active_count=int(decision.get("active_count") or 0),
renewed=bool(decision.get("renewed")),
code=str(decision.get("code") or ""),
message=str(decision.get("message") or ""),
)
def _require_store(request: Request):
store = getattr(request.app.state, "roundtable_room_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Roundtable room store not available")
return store
@router.get("/summary", response_model=RoomSummaryResponse)
async def room_summary(request: Request) -> RoomSummaryResponse:
settings = get_roundtable_room_limit_settings()
store = getattr(request.app.state, "roundtable_room_store", None)
extra = await backend_occupancy_keys(request)
active = (
0
if store is None
else await store.summary(
extra_keys=extra,
max_hold_minutes=getattr(settings, "max_hold_minutes", 30),
)
)
return RoomSummaryResponse(
enabled=settings.enabled,
max_rooms=settings.max_rooms,
active_count=active,
)
@router.post("/acquire", response_model=RoomAdmissionResponse)
async def acquire_room(request: Request, body: RoomScopeRequest) -> RoomAdmissionResponse:
decision = await admit_roundtable_room(
request,
draft_id=body.draftId,
task_id=body.taskId,
room_session_id=body.roomSessionId,
)
if not decision.get("ok", False):
raise HTTPException(status_code=409, detail=decision)
return _response(decision)
@router.post("/heartbeat", response_model=RoomAdmissionResponse)
async def heartbeat_room(request: Request, body: RoomScopeRequest) -> RoomAdmissionResponse:
decision = await renew_roundtable_room(
request,
draft_id=body.draftId,
task_id=body.taskId,
room_session_id=body.roomSessionId,
)
if not decision.get("ok", False):
raise HTTPException(status_code=409, detail=decision)
return _response(decision)
@router.post("/release")
async def release_room(request: Request, body: RoomScopeRequest) -> dict[str, bool]:
_require_store(request)
released = await release_roundtable_room(
request,
draft_id=body.draftId,
task_id=body.taskId,
room_session_id=body.roomSessionId,
)
return {"released": released}
@@ -2,10 +2,11 @@
from __future__ import annotations
import asyncio
import mimetypes
import re
import zipfile
from io import BytesIO
import mimetypes
from pathlib import Path
from typing import Any
from urllib.parse import quote
@@ -210,15 +211,19 @@ async def get_scheduler_thread(request: Request) -> dict:
@router.get("/html-templates")
async def list_html_templates() -> dict:
async def list_html_templates(request: Request) -> dict:
"""可选的 HTML 模板清单(供「模板数据转换器」内置 agent 填充用)。
返回 ``{"templates": [{id, name, description}]}``,前端定时任务表单在选中
「模板数据转换器」后据此渲染「HTML 模板」下拉。开放给任意登录用户。
"""
from app.gateway.catalog_cache import cached_catalog
from deerflow.runtime.scheduler import template_fill
return {"templates": template_fill.list_templates()}
async def load() -> dict:
return {"templates": await asyncio.to_thread(template_fill.list_templates)}
return await cached_catalog(request, "html_templates", "scheduler-list", load)
class TemplateRenderRequest(BaseModel):
@@ -20,7 +20,15 @@ from datetime import datetime
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from starlette.responses import Response
from app.gateway.api_read_cache import (
get_cache_version,
get_cached_json_payload,
release_cache_fill,
set_cached_json,
wait_for_cached_json_payload_or_claim_fill,
)
from app.gateway.deps import get_current_user, get_optional_user_from_request
from deerflow.persistence.sensitive_words import SensitiveWordStore
@@ -89,9 +97,33 @@ async def _require_manager(request: Request) -> None:
@router.get("", response_model=SensitiveWordListResponse)
async def list_sensitive_words(request: Request) -> SensitiveWordListResponse:
store = _get_store(request)
rows = await store.list_words()
return SensitiveWordListResponse(words=[SensitiveWordResponse(**r) for r in rows])
config = getattr(request.app.state, "config", None)
cache_key = "global"
version = await get_cache_version(config, "sensitive_words")
cached = await get_cached_json_payload(config, "sensitive_words", cache_key, version=version)
fill_token = None
if cached is None:
cached, fill_token = await wait_for_cached_json_payload_or_claim_fill(
config, "sensitive_words", cache_key, version=version, wait_ms=1000
)
if cached is not None:
return Response(content=cached, media_type="application/json") # type: ignore[return-value]
try:
store = _get_store(request)
rows = await store.list_words()
result = SensitiveWordListResponse(words=[SensitiveWordResponse(**r) for r in rows])
await set_cached_json(
config,
"sensitive_words",
cache_key,
result.model_dump(mode="json"),
expected_version=version,
ttl_seconds=3600,
)
return result
finally:
if fill_token is not None:
await release_cache_fill(config, "sensitive_words", cache_key, fill_token)
@router.put("", response_model=SensitiveWordListResponse)
@@ -0,0 +1,61 @@
"""Exchange a public service request for a short-lived scoped user token."""
from __future__ import annotations
import hmac
import logging
from datetime import timedelta
from fastapi import APIRouter, HTTPException, Request, status
from pydantic import BaseModel, Field
from app.gateway.auth import create_access_token
from app.gateway.deps import get_local_provider
from app.gateway.routers.auth import enforce_user_approved
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/auth/service", tags=["auth"])
_TOKEN_TTL_SECONDS = 300
_ALLOWED_SCOPES = {"llmwiki:sync", "recommended-questions:read"}
_SERVICE_TOKEN_SECRET = "b4217f191838d12a"
class ServiceExchangeRequest(BaseModel):
username: str = Field(min_length=1, max_length=128)
scope: list[str] = Field(default_factory=lambda: ["llmwiki:sync"], min_length=1, max_length=1)
class ServiceExchangeResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int = _TOKEN_TTL_SECONDS
scope: list[str]
@router.post("/exchange", response_model=ServiceExchangeResponse)
async def exchange_service_token(payload: ServiceExchangeRequest, request: Request) -> ServiceExchangeResponse:
supplied = request.headers.get("X-Service-Token", "")
if not supplied or not hmac.compare_digest(supplied, _SERVICE_TOKEN_SECRET):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid service token")
if len(payload.scope) != 1 or payload.scope[0] not in _ALLOWED_SCOPES:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Unsupported service scope")
provider = get_local_provider()
user = await provider.find_user_by_email_local_part(payload.username.strip().lower())
if user is None:
raise HTTPException(status_code=404, detail="Target user not found")
enforce_user_approved(user)
token = create_access_token(
str(user.id),
expires_delta=timedelta(seconds=_TOKEN_TTL_SECONDS),
token_version=user.token_version,
auth_method="service",
scope=payload.scope,
)
logger.info(
"Issued scoped service token target_user_id=%s scope=%s",
user.id,
payload.scope,
)
return ServiceExchangeResponse(access_token=token, scope=payload.scope)
@@ -8,18 +8,34 @@ import shutil
import subprocess
import sys
import tempfile
import time
import zipfile
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, Request, UploadFile
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, Response, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from sqlalchemy import select
from app.gateway.api_read_cache import (
get_cache_version,
get_cached_json_payload,
release_cache_fill,
set_cached_json,
wait_for_cached_json_payload_or_claim_fill,
)
from app.gateway.deps import get_agent_store, get_config, get_skill_store, get_tag_store
from app.gateway.path_utils import aresolve_thread_virtual_path
from app.gateway.perf_logging import finish_trace, trace_request, trace_stage
from app.gateway.skill_catalog_cache import (
_build_catalog_payload,
_load_usage_entries,
load_or_warm_skill_catalog_snapshot,
load_skill_catalog_projection,
refresh_skill_catalog_projection,
)
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
from deerflow.config.app_config import AppConfig
from deerflow.config.extensions_config import ExtensionsConfig, SkillDisplayConfig, get_extensions_config, patch_extensions_config
@@ -39,6 +55,9 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["skills"])
_UNSET = object()
_OWNER_USERNAME_CACHE_SECONDS = 60.0
_MAX_OWNER_USERNAME_CACHE_ENTRIES = 4096
_owner_username_cache: dict[str, tuple[float, str | None]] = {}
# ---------------------------------------------------------------------------
@@ -81,21 +100,59 @@ async def _attach_owner_usernames(responses: list["SkillResponse"]) -> list["Ski
provider = get_local_provider()
except Exception:
return responses
now = time.monotonic()
names: dict[str, str] = {}
for uid in owner_ids:
missing_ids: set[str] = set()
for user_id in owner_ids:
cached = _owner_username_cache.get(user_id)
if cached is not None and cached[0] > now:
if cached[1]:
names[user_id] = cached[1]
else:
missing_ids.add(user_id)
if missing_ids:
try:
user = await provider.get_user(uid)
users = await provider.get_users_by_ids(sorted(missing_ids))
except Exception:
user = None
local = _email_local_part(getattr(user, "email", None)) if user is not None else None
if local:
names[uid] = local
logger.debug("skill owner username batch lookup failed", exc_info=True)
users = None
if users is not None:
loaded = {
str(user.id): _email_local_part(getattr(user, "email", None))
for user in users
}
expires_at = now + _OWNER_USERNAME_CACHE_SECONDS
for user_id in missing_ids:
local = loaded.get(user_id)
_owner_username_cache[user_id] = (expires_at, local)
if local:
names[user_id] = local
if len(_owner_username_cache) > _MAX_OWNER_USERNAME_CACHE_ENTRIES:
expired = [key for key, value in _owner_username_cache.items() if value[0] <= now]
for key in expired:
_owner_username_cache.pop(key, None)
if len(_owner_username_cache) > _MAX_OWNER_USERNAME_CACHE_ENTRIES:
oldest = sorted(_owner_username_cache, key=lambda key: _owner_username_cache[key][0])
excess = len(_owner_username_cache) - _MAX_OWNER_USERNAME_CACHE_ENTRIES
for key in oldest[:excess]:
_owner_username_cache.pop(key, None)
for resp in responses:
if resp.owner_user_id:
resp.owner_username = names.get(resp.owner_user_id)
return responses
async def _refresh_catalog_snapshot(config: AppConfig) -> None:
"""Keep normalized MySQL skill rows current after a skill edit."""
try:
await refresh_skill_catalog_projection(config)
except Exception:
# A failed projection refresh must not turn a successful skill write
# into an API failure. Startup or the next edit can recover it.
logger.exception("Skill catalog projection refresh failed after a mutation")
def _can_view_skill_source(
category: SkillCategory,
owner_user_id: str | None,
@@ -316,6 +373,7 @@ def _skill_to_response(
skill: Skill,
ownership: dict[str, Any] | None = None,
*,
usage_entry: dict[str, Any] | None = None,
display_override: dict[str, Any] | None | object = _UNSET,
display_sample: dict[str, Any] | None = None,
favorited: bool = False,
@@ -327,11 +385,12 @@ def _skill_to_response(
``owner_user_id`` / ``published`` / ``builtin`` fields used for per-user
visibility and the frontend's owner badges.
"""
from deerflow.skills.usage import get_entry, latest_activity_at
from deerflow.skills.usage import get_entry
entry = get_entry(skill.name)
entry = usage_entry if usage_entry is not None else get_entry(skill.name)
created_at_str = entry.get("created_at")
last_at_str = latest_activity_at(skill.name)
activity_candidates = [entry.get("last_used_at"), entry.get("last_viewed_at"), entry.get("last_patched_at")]
last_at_str = max((value for value in activity_candidates if value), default=created_at_str)
created_at: datetime | None = None
last_at: datetime | None = None
if created_at_str:
@@ -405,6 +464,48 @@ def _skill_to_response(
)
def _catalog_item_to_response(
item: dict[str, Any],
ownership: dict[str, Any] | None,
*,
adapter: dict[str, Any] | None,
favorited: bool,
) -> SkillResponse:
"""Build a list response from the MySQL snapshot and MySQL metadata rows."""
try:
category = SkillCategory(str(item.get("category") or SkillCategory.PUBLIC.value))
except ValueError:
category = SkillCategory.PUBLIC
owner_user_id = ownership.get("owner_user_id") if ownership else None
return SkillResponse(
name=str(item.get("name") or ""),
name_zh=ownership.get("name_zh") if ownership else None,
description=str(item.get("description") or ""),
license=item.get("license"),
category=category,
enabled=bool(item.get("enabled", True)),
always_on=bool(ownership.get("always_on", item.get("always_on", False)) if ownership else item.get("always_on", False)),
use_count=int(item.get("use_count") or 0),
view_count=int(item.get("view_count") or 0),
patch_count=int(item.get("patch_count") or 0),
last_activity_at=item.get("last_activity_at"),
created_at=item.get("created_at"),
state=str(item.get("state") or "active"),
pinned=bool(item.get("pinned")),
featured=bool(item.get("featured")),
source=str(item.get("source") or "upload"),
owner_user_id=owner_user_id,
published=bool(ownership.get("published", False)) if ownership else False,
square_id=str(ownership.get("square_id") or "") if ownership else "",
builtin=bool(ownership.get("builtin", owner_user_id is None)) if ownership else category != SkillCategory.CUSTOM,
favorited=favorited,
detail=(ownership.get("detail_preview") or ownership.get("detail")) if ownership else None,
display=adapter.get("display") if adapter else None,
displaySample=adapter.get("sample") if adapter else None,
)
# ``checkfirst=True`` is a check-then-create operation. The in-process lock
# serializes concurrent requests in one worker; the duplicate-table fallback
# covers two Gateway workers racing during first access.
@@ -498,14 +599,18 @@ async def _list_skill_display_adapters(skill_names: list[str]) -> dict[str, dict
return {}
async with sf() as session:
result = await session.execute(
select(SkillDisplayAdapterRow).where(SkillDisplayAdapterRow.skill_name.in_(skill_names))
select(SkillDisplayAdapterRow.skill_name, SkillDisplayAdapterRow.display_config).where(
SkillDisplayAdapterRow.skill_name.in_(skill_names)
)
)
return {
row.skill_name: {
str(row.skill_name): {
"display": _json_text_loads(row.display_config),
"sample": _json_text_loads(row.sample_payload),
# The editor reads LONGTEXT sample_payload on demand; the list
# API only needs the lightweight display mapping.
"sample": None,
}
for row in result.scalars()
for row in result
}
@@ -716,7 +821,7 @@ async def _generate_skill_display_sample(skill: Skill, app_config: AppConfig) ->
if parsed:
return parsed
logger.warning("Skill display sample model returned non-JSON for %s; falling back to static mock", skill.name)
except asyncio.TimeoutError:
except TimeoutError:
logger.warning("Skill display sample LLM call timed out for %s; falling back to static mock", skill.name)
except Exception:
logger.warning("Skill display sample LLM call failed for %s; falling back to static mock", skill.name, exc_info=True)
@@ -814,20 +919,51 @@ async def _build_ownership_index(skill_store: SkillStore, user_id: str) -> dict[
return {record["name"]: record for record in records}
def _skill_search_tokens(search: str | None) -> list[str]:
"""Split a search query into lowercase AND-tokens (whitespace separated).
``网页 报告`` therefore requires both words to appear, but each may live in
a different field (name vs description) — unlike the old contiguous
substring match which demanded the whole phrase verbatim.
"""
if not search:
return []
return [token for token in search.strip().lower().split() if token]
def _skill_matches_tokens(skill: SkillResponse, tokens: list[str]) -> bool:
"""Every token must appear in at least one searchable skill field.
Fields: English name, Chinese display name, the SKILL.md ``description``,
the long-form ``detail`` annotation, and assigned tag names — so users can
find a skill by what it does, not only by its name.
"""
if not tokens:
return True
haystacks = [
(skill.name or "").lower(),
(skill.name_zh or "").lower(),
(skill.description or "").lower(),
(skill.detail or "").lower(),
" ".join(str(t.get("name") or "").lower() for t in (skill.tags or [])),
]
return all(any(token in field for field in haystacks) for token in tokens)
async def _attach_and_filter_tags(
tag_store: TagStore,
responses: list[SkillResponse],
search: str | None,
tag_ids: list[str] | None,
) -> list[SkillResponse]:
"""Bulk-attach tags to skill responses, then apply name/tag filters."""
"""Bulk-attach tags to skill responses, then apply search/tag filters."""
assignments = await tag_store.list_assignments("skill", [r.name for r in responses])
for resp in responses:
resp.tags = assignments.get(resp.name, [])
if search:
needle = search.strip().lower()
# Match the English name OR the Chinese display name so 中文 search works.
responses = [r for r in responses if needle in r.name.lower() or (r.name_zh or "").lower().find(needle) >= 0]
# Tags are attached first so tag names participate in the match.
tokens = _skill_search_tokens(search)
responses = [r for r in responses if _skill_matches_tokens(r, tokens)]
if tag_ids:
wanted = set(tag_ids)
responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
@@ -856,7 +992,7 @@ async def list_skills(
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
tag_store: TagStore = Depends(get_tag_store),
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on skill name or Chinese name (name_zh)"),
search: str | None = Query(default=None, description="Whitespace-separated keywords (AND); case-insensitive substring match against skill name, Chinese name (name_zh), description, detail, and tag names"),
tag_ids: list[str] | None = Query(default=None, description="Keep skills carrying any of these tags (OR)"),
square: str | None = Query(default=None, description="Keep only skills filed under this publish square (发布广场). Omit for all squares."),
scope: str | None = Query(default=None, description="``mine`` filters to the skills the caller's 岗位 (position) makes visible."),
@@ -874,68 +1010,138 @@ async def list_skills(
),
),
) -> SkillsListResponse:
trace = trace_request(request, "skills_list")
cache_fill_token: str | None = None
cache_config = None
cache_key = ""
try:
user_id = _current_user_id(request)
if admin and not _is_admin_user(request):
raise HTTPException(status_code=403, detail="Only system admins can list all skills")
all_skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
if admin:
cacheable = (
not admin
and not (search or "").strip()
and not tag_ids
and not (square or "").strip()
and sort == "time_desc"
)
query_fingerprint = json.dumps(
{
"search": search,
"tag_ids": sorted(tag_ids or []),
"square": square,
"scope": scope,
"sort": sort,
},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
cache_key = f"user:{user_id}:query:{query_fingerprint}" if cacheable else ""
cache_version = None
request_app_state = getattr(getattr(request, "app", None), "state", None)
cache_config = getattr(request_app_state, "config", config)
if cacheable:
stage_started = time.perf_counter()
cache_version = await get_cache_version(cache_config, "skills")
cached_payload = await get_cached_json_payload(
cache_config,
"skills",
cache_key,
version=cache_version,
)
if cached_payload is None:
cached_payload, cache_fill_token = await wait_for_cached_json_payload_or_claim_fill(
cache_config,
"skills",
cache_key,
version=cache_version,
)
trace_stage(trace, "skills_redis_cache", stage_started)
if cached_payload is not None:
trace.setdefault("details", {})["cache"] = "redis_hit"
return Response(content=cached_payload, media_type="application/json")
stage_started = time.perf_counter()
catalog = await load_skill_catalog_projection(user_id=user_id, admin=admin)
if catalog is None:
# Preserve no-database operation. A MySQL-backed request always
# uses the normalized rows and does not scan SKILL.md files.
legacy_catalog = await load_or_warm_skill_catalog_snapshot(config)
if legacy_catalog is not None:
catalog = legacy_catalog
trace_stage(trace, "skills_legacy_projection_fallback", stage_started)
else:
# This request's dependency and the singleton share the active
# app config. Reuse the process instance to retain parsed skill
# metadata between catalogue reads.
storage = get_or_new_skill_storage(app_config=config)
skills_from_disk = await asyncio.to_thread(storage.load_skills, enabled_only=False)
usage_entries = await asyncio.to_thread(_load_usage_entries)
catalog = await asyncio.to_thread(_build_catalog_payload, skills_from_disk, usage_entries)
trace_stage(trace, "skills_memory_mode_catalog_load", stage_started)
else:
trace_stage(trace, "skills_mysql_projection", stage_started)
trace.setdefault("details", {})["skills_discovered"] = len(catalog)
if get_session_factory() is not None:
# Projection rows include the compact ownership fields; never load
# the SkillRow.detail LONGTEXT column on catalog requests.
ownership = {str(record["name"]): record for record in catalog}
trace_stage(trace, "skills_ownership_in_projection", stage_started)
elif admin:
stage_started = time.perf_counter()
ownership_records = await skill_store.list_all()
ownership = {record["name"]: record for record in ownership_records}
trace_stage(trace, "skills_ownership_db", stage_started)
else:
stage_started = time.perf_counter()
ownership = await _build_ownership_index(skill_store, user_id)
trace_stage(trace, "skills_ownership_db", stage_started)
# Skills the caller's 岗位 (position) granted into their "我的技能"
# collection. Admins browse everything, so the badge is moot for them.
favorite_names: set[str] = set()
if not admin:
try:
stage_started = time.perf_counter()
favorite_names = await skill_store.list_favorite_skill_names(user_id)
trace_stage(trace, "skills_favorites_db", stage_started)
except Exception:
logger.debug("skill favorites lookup failed", exc_info=True)
favorite_names = set()
display_adapters = await _list_skill_display_adapters([skill.name for skill in all_skills])
stage_started = time.perf_counter()
skill_names = [str(item["name"]) for item in catalog if item.get("name")]
display_adapters = await _list_skill_display_adapters(skill_names)
trace_stage(trace, "skills_display_adapters_db", stage_started)
stage_started = time.perf_counter()
responses: list[SkillResponse] = []
for skill in all_skills:
adapter = display_adapters.get(skill.name)
for item in catalog:
skill_name = str(item.get("name") or "")
if not skill_name:
continue
try:
category = SkillCategory(str(item.get("category") or SkillCategory.PUBLIC.value))
except ValueError:
category = SkillCategory.PUBLIC
adapter = display_adapters.get(skill_name)
record = ownership.get(skill_name)
# Public/built-in skills (no DB row) are visible to everyone.
# Custom skills must have an ownership record in the visible set.
if skill.category != SkillCategory.CUSTOM:
# Built-in skills usually have no DB row, but an admin-authored
# ``detail`` annotation materializes one — surface it when present.
responses.append(
_skill_to_response(
skill,
ownership.get(skill.name),
display_override=_display_override_from_adapter(adapter),
display_sample=adapter.get("sample") if adapter else None,
favorited=skill.name in favorite_names,
)
)
if category == SkillCategory.CUSTOM and record is None and not admin and skill_name not in favorite_names:
continue
record = ownership.get(skill.name)
if record is None:
if admin:
# Admin sees every custom skill on disk even without an
# ownership row (legacy / orphaned), so they can clean it up.
record = None
elif skill.name in favorite_names:
# A position granted this skill even though it isn't
# otherwise visible to the user (e.g. an unpublished custom
# skill). Surface it so it lands in their 我的技能.
record = await skill_store.get_any(skill.name)
else:
continue
responses.append(
_skill_to_response(
skill,
_catalog_item_to_response(
item,
record,
display_override=_display_override_from_adapter(adapter),
display_sample=adapter.get("sample") if adapter else None,
favorited=skill.name in favorite_names,
adapter=adapter,
favorited=skill_name in favorite_names,
)
)
trace_stage(trace, "skills_response_assembly", stage_started)
stage_started = time.perf_counter()
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids)
trace_stage(trace, "skills_tags_db_and_filter", stage_started)
stage_started = time.perf_counter()
responses = await _attach_owner_usernames(responses)
trace_stage(trace, "skills_owner_usernames_db", stage_started)
square_id = square.strip() if square and square.strip() else None
if square_id:
from deerflow.config.system_settings import load_system_settings, resolve_default_square_id
@@ -952,13 +1158,30 @@ async def list_skills(
# mirroring how agents are added to 我的智能体. The browse-all 广场 therefore
# always shows every visible skill — the ``scope`` param is kept for API
# compatibility but intentionally does not filter here.
stage_started = time.perf_counter()
responses = _sort_skill_responses(responses, sort)
return SkillsListResponse(skills=responses)
trace_stage(trace, "skills_sort", stage_started)
trace.setdefault("details", {})["skills_returned"] = len(responses)
result = SkillsListResponse(skills=responses)
if cacheable:
await set_cached_json(
cache_config,
"skills",
cache_key,
result,
expected_version=cache_version,
)
trace.setdefault("details", {})["cache"] = "redis_miss"
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to load skills: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to load skills: {str(e)}")
finally:
if cache_fill_token is not None and cache_config is not None and cache_key:
await release_cache_fill(cache_config, "skills", cache_key, cache_fill_token)
finish_trace(trace)
_MAX_SKILL_UPLOAD_BYTES = 512 * 1024 * 1024
@@ -1137,6 +1360,7 @@ async def install_skill_upload(
await skill_store.ensure_legacy(
{"name": result["skill_name"], "owner_user_id": user_id, "published": False}
)
await _refresh_catalog_snapshot(config)
await refresh_skills_system_prompt_cache_async()
return SkillInstallResponse(**result)
except FileNotFoundError as e:
@@ -1392,6 +1616,7 @@ async def install_skill(
await skill_store.ensure_legacy(
{"name": result.skill_name, "owner_user_id": user_id, "published": False}
)
await _refresh_catalog_snapshot(config)
await refresh_skills_system_prompt_cache_async()
return result
except FileNotFoundError as e:
@@ -1505,6 +1730,7 @@ async def duplicate_skill(
mark_uploaded(new_name)
except Exception:
pass
await _refresh_catalog_snapshot(config)
await refresh_skills_system_prompt_cache_async()
return SkillInstallResponse(
success=True,
@@ -1578,9 +1804,16 @@ async def list_custom_skills(
) -> SkillsListResponse:
try:
user_id = _current_user_id(request)
custom = [s for s in get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) if s.category == SkillCategory.CUSTOM]
ownership = await _build_ownership_index(skill_store, user_id)
display_adapters = await _list_skill_display_adapters([skill.name for skill in custom])
storage = get_or_new_skill_storage()
all_skills, usage_entries = await asyncio.gather(
asyncio.to_thread(storage.load_skills, enabled_only=False),
asyncio.to_thread(_load_usage_entries),
)
custom = [skill for skill in all_skills if skill.category == SkillCategory.CUSTOM]
ownership, display_adapters = await asyncio.gather(
_build_ownership_index(skill_store, user_id),
_list_skill_display_adapters([skill.name for skill in custom]),
)
responses = []
for skill in custom:
if skill.name not in ownership:
@@ -1590,6 +1823,7 @@ async def list_custom_skills(
_skill_to_response(
skill,
ownership[skill.name],
usage_entry=usage_entries.get(skill.name, {}),
display_override=_display_override_from_adapter(adapter),
display_sample=adapter.get("sample") if adapter else None,
)
@@ -1637,6 +1871,7 @@ async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest
raise HTTPException(status_code=400, detail=f"Security scan blocked the edit: {scan.reason}")
prev_content = storage.read_custom_skill(skill_name)
storage.write_custom_skill(skill_name, SKILL_MD_FILE, request.content)
await _refresh_catalog_snapshot(config)
storage.append_history(
skill_name,
{
@@ -1706,6 +1941,7 @@ async def delete_custom_skill(
await skill_store.delete_admin(skill_name)
else:
await skill_store.delete(skill_name, user_id)
await _refresh_catalog_snapshot(config)
try:
await get_tag_store(request).unassign_all("skill", skill_name)
@@ -1808,6 +2044,7 @@ async def delete_builtin_skill(
skill_owner_user_id=None,
agent_store=agent_store,
)
await _refresh_catalog_snapshot(config)
await refresh_skills_system_prompt_cache_async()
await _sync_skill_view(skill.name)
logger.info("Administrator deleted built-in skill %s", skill.name)
@@ -1950,6 +2187,7 @@ async def update_custom_skill_state(
from deerflow.skills.usage import set_state as _usage_set_state
_usage_set_state(skill_name, body.state)
await _refresh_catalog_snapshot(config)
await refresh_skills_system_prompt_cache_async()
return _reload_skill_response(skill_name, record, config)
except HTTPException:
@@ -1981,6 +2219,7 @@ async def update_custom_skill_pinned(
from deerflow.skills.usage import set_pinned as _usage_set_pinned
_usage_set_pinned(skill_name, body.pinned)
await _refresh_catalog_snapshot(config)
return _reload_skill_response(skill_name, record, config)
except HTTPException:
raise
@@ -2071,6 +2310,7 @@ async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest,
storage.append_history(skill_name, history_entry)
raise HTTPException(status_code=400, detail=f"Rollback blocked by security scanner: {scan.reason}")
storage.write_custom_skill(skill_name, SKILL_MD_FILE, target_content)
await _refresh_catalog_snapshot(config)
storage.append_history(skill_name, history_entry)
await refresh_skills_system_prompt_cache_async()
return await get_custom_skill(skill_name, config)
@@ -3127,7 +3367,7 @@ async def _generate_skill_name_zh(skill: Skill, app_config: AppConfig, model: An
),
timeout=60,
)
except asyncio.TimeoutError as e:
except TimeoutError as e:
logger.warning("Skill name_zh LLM call timed out for %s", skill.name)
raise SkillNameZhModelError("大模型响应超时(60 秒)") from e
except Exception as e:
@@ -272,6 +272,7 @@ class StrategyAgentItem(BaseModel):
name: str
description: str = ""
model: str | None = None
model_prompt_suffix_enabled: bool = True
soul: str | None = None
skills: list[str] | None = None
created_at: datetime | str | None = None
@@ -287,6 +288,7 @@ class StrategyAngleCreateRequest(BaseModel):
description: str = Field(default="", max_length=2000)
soul: str = Field(default="", max_length=200_000)
model: str | None = Field(default=None, max_length=200)
model_prompt_suffix_enabled: bool = True
skills: list[str] = Field(default_factory=list, max_length=100)
sort_order: int | None = Field(default=None, ge=-10_000, le=10_000)
@@ -296,6 +298,7 @@ class StrategyAgentUpdateRequest(BaseModel):
description: str | None = Field(default=None, max_length=2000)
soul: str | None = Field(default=None, max_length=200_000)
model: str | None = Field(default=None, max_length=200)
model_prompt_suffix_enabled: bool | None = None
skills: list[str] | None = Field(default=None, max_length=100)
enabled: bool | None = None
sort_order: int | None = Field(default=None, ge=-10_000, le=10_000)
@@ -394,6 +397,7 @@ async def _to_item(
name=agent.name,
description=agent.description,
model=agent.model,
model_prompt_suffix_enabled=agent.model_prompt_suffix_enabled,
soul=agent.soul,
skills=agent.skills,
created_at=agent.created_at,
@@ -513,6 +517,7 @@ async def create_strategy_angle(
payload=payload,
name=name,
model=model,
model_prompt_suffix_enabled=payload.model_prompt_suffix_enabled,
skills=skills,
agent_store=agent_store,
role_store=role_store,
@@ -546,6 +551,7 @@ async def _create_strategy_angle_locked(
payload: StrategyAngleCreateRequest,
name: str,
model: str | None,
model_prompt_suffix_enabled: bool,
skills: list[str] | None,
agent_store: AgentStore,
role_store: StrategyAgentRoleStore,
@@ -579,6 +585,7 @@ async def _create_strategy_angle_locked(
description=payload.description,
skills=skills,
model=model,
model_prompt_suffix_enabled=model_prompt_suffix_enabled,
tool_groups=_DYNAMIC_ANGLE_TOOL_GROUPS,
)
await asyncio.to_thread(agents_router._write_agent_soul, agent_id, soul)
@@ -663,6 +670,11 @@ async def _update_strategy_agent_locked(
next_description = payload.description or "" if "description" in fields else str(record.get("description") or "")
extras = await asyncio.to_thread(agents_router._load_config_extras, agent_id)
next_model = _normalize_model(request, payload.model) if "model" in fields else extras["model"]
next_model_prompt_suffix_enabled = (
bool(payload.model_prompt_suffix_enabled)
if "model_prompt_suffix_enabled" in fields
else extras["model_prompt_suffix_enabled"]
)
next_skills = (
await agents_router._validate_strategy_agent_skills(
payload.skills,
@@ -686,7 +698,7 @@ async def _update_strategy_agent_locked(
original_role = dict(role)
role_store = get_strategy_agent_role_store(request)
try:
config_fields = {"name", "description", "model", "skills"}
config_fields = {"name", "description", "model", "model_prompt_suffix_enabled", "skills"}
if fields & config_fields:
await asyncio.to_thread(
agents_router._write_agent_config,
@@ -695,11 +707,13 @@ async def _update_strategy_agent_locked(
description=next_description,
skills=next_skills,
model=next_model,
model_prompt_suffix_enabled=next_model_prompt_suffix_enabled,
tool_groups=extras["tool_groups"],
common_questions=extras["common_questions"],
template_name=extras["template_name"],
seat_skill_directive=extras["seat_skill_directive"],
llmwiki_knowledge_base_ids=extras["llmwiki_knowledge_base_ids"],
chat_link=extras["chat_link"],
)
await agent_store.replace_skills(agent_id, next_skills)
await agent_store.set_extras(
@@ -0,0 +1,181 @@
"""Admin-only model-stream truncation event log (模型截断记录).
Companion to the per-model ``strict_stream_integrity`` switch on the model
management page: the patched OpenAI-compatible streaming layer writes one row
per abnormally ended provider stream (silently committed half answers, strict
integrity detections, output-length caps, mid-stream transport errors). This
router serves the admin viewing page; rows are retained for at most one month
by the SQL repository.
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_optional_user_from_request, get_stream_truncation_store
from deerflow.persistence.stream_truncation.base import StreamTruncationQuery, StreamTruncationStore
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin/stream-truncations", tags=["stream-truncations"])
_EVENT_TYPES = (
"silent_no_terminal",
"incomplete_stream_error",
"length_truncated",
"content_filter",
"midstream_error",
)
class StreamTruncationEventResponse(BaseModel):
id: str
created_at: str | None = None
model_name: str | None = None
provider_completion_id: str | None = None
run_id: str | None = None
event_type: str
strict_mode: bool = False
finish_reason: str = "none"
status: str = "success"
error_type: str | None = None
chunk_count: int = 0
duration_ms: int = 0
first_chunk_ms: int = -1
detail: str | None = None
class StreamTruncationListResponse(BaseModel):
items: list[StreamTruncationEventResponse]
total: int = Field(..., description="Total rows matching the filter, ignoring pagination")
limit: int
offset: int
class StreamTruncationBreakdownItem(BaseModel):
model_name: str
total: int = 0
last_at: str | None = None
silent_no_terminal: int = 0
incomplete_stream_error: int = 0
length_truncated: int = 0
content_filter: int = 0
midstream_error: int = 0
class StreamTruncationBreakdownResponse(BaseModel):
items: list[StreamTruncationBreakdownItem]
async def _require_admin(request: Request) -> None:
user = await get_optional_user_from_request(request)
if user is None:
raise HTTPException(status_code=401, detail="Authentication is required")
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="Stream truncation log is admin-only")
def _require_store(request: Request) -> StreamTruncationStore:
store = get_stream_truncation_store(request)
if store is None:
raise HTTPException(status_code=503, detail="Stream truncation store is not configured")
return store
def _parse_dt(value: str | datetime | None) -> datetime | None:
if not value:
return None
if isinstance(value, datetime):
return value
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise HTTPException(status_code=422, detail=f"Invalid datetime: {value!r} ({exc})") from exc
def _build_query(
*,
model_name: str | None,
event_type: str | None,
since: str | datetime | None,
until: str | datetime | None,
limit: int,
offset: int,
) -> StreamTruncationQuery:
if event_type and event_type not in _EVENT_TYPES:
raise HTTPException(status_code=422, detail=f"Unknown event type: {event_type}")
return StreamTruncationQuery(
model_name=model_name or None,
event_type=event_type or None,
since=_parse_dt(since),
until=_parse_dt(until),
limit=limit,
offset=offset,
)
def _fill_breakdown_zeros(item: dict[str, Any]) -> dict[str, Any]:
for event_type in _EVENT_TYPES:
item.setdefault(event_type, 0)
return item
@router.get("", response_model=StreamTruncationListResponse)
async def list_stream_truncations(
request: Request,
model_name: str | None = Query(default=None, max_length=128),
event_type: str | None = Query(default=None, max_length=32),
since: str | None = Query(default=None),
until: str | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> StreamTruncationListResponse:
"""List truncation events, newest first."""
await _require_admin(request)
store = _require_store(request)
query = _build_query(
model_name=model_name,
event_type=event_type,
since=since,
until=until,
limit=limit,
offset=offset,
)
items = await store.list(query)
total = await store.count(query)
return StreamTruncationListResponse(
items=[StreamTruncationEventResponse(**item) for item in items],
total=total,
limit=limit,
offset=offset,
)
@router.get("/breakdown", response_model=StreamTruncationBreakdownResponse)
async def stream_truncation_breakdown(
request: Request,
since: str | None = Query(default=None),
until: str | None = Query(default=None),
) -> StreamTruncationBreakdownResponse:
"""Per-model aggregate of truncation events over the query window."""
await _require_admin(request)
store = _require_store(request)
query = _build_query(
model_name=None,
event_type=None,
since=since,
until=until,
limit=1,
offset=0,
)
rows = await store.model_breakdown(query)
return StreamTruncationBreakdownResponse(
items=[
StreamTruncationBreakdownItem(**_fill_breakdown_zeros(item))
for item in rows
]
)
@@ -24,11 +24,14 @@ from deerflow.config.system_settings import (
HtmlMapSettings,
LeaderboardSettings,
ModelConcurrencySettings,
ModelPromptSuffix,
PluginDomainAgentMapping,
PluginManagementSettings,
PromptPrefixSettings,
PublishSquare,
PublishSquaresSettings,
RetryKeepPartialSettings,
RoundtableRoomLimitSettings,
SkillCompressionSettings,
StrategyProjectSettings,
WordHeadingNumberingStyle,
@@ -40,12 +43,138 @@ from deerflow.config.system_settings import (
save_system_settings,
set_module_tag_ids,
)
from deerflow.html_runtime.addresses import (
html_map_tile_url,
html_runtime_base_url,
normalize_html_map_tile_url,
normalize_html_runtime_base_url,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/system-settings", tags=["system-settings"])
@router.get("/model-prompt-suffixes", response_model=dict[str, ModelPromptSuffix])
async def get_model_prompt_suffixes(request: Request) -> dict[str, ModelPromptSuffix]:
await _require_model_prompt_admin(request)
return load_system_settings().model_prompt_suffixes
@router.put("/model-prompt-suffixes/{model_name:path}", response_model=ModelPromptSuffix)
async def update_model_prompt_suffix(model_name: str, body: ModelPromptSuffix, request: Request) -> ModelPromptSuffix:
await _require_model_prompt_admin(request)
current = load_system_settings()
# Retain editable entries for temporarily removed models so an administrator
# can still disable them. New entries must target a real configured model.
if get_app_config().get_model_config(model_name) is None and model_name not in current.model_prompt_suffixes:
raise HTTPException(status_code=404, detail="模型不存在")
if body.enabled and not body.prompt.strip():
raise HTTPException(status_code=422, detail="启用时请填写追加提示词")
current.model_prompt_suffixes[model_name] = body
save_system_settings(current)
return body
async def _require_model_prompt_admin(request: Request) -> None:
user = await get_optional_user_from_request(request)
if user is None:
raise HTTPException(status_code=401, detail="请先登录")
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="仅管理员可管理模型追加提示词")
class ApiReadCacheResponse(BaseModel):
enabled: bool = False
redis_configured: bool = False
ttl_seconds: int = 300
max_value_bytes: int = 524288
browser_status_enabled: bool = True
collections: list[dict] = Field(default_factory=list)
class ApiReadCacheUpdateRequest(BaseModel):
enabled: bool | None = None
ttl_seconds: int | None = Field(default=None, ge=1, le=3600)
browser_status_enabled: bool | None = None
collection_overrides: dict[str, bool] | None = None
async def _require_performance_admin(request: Request) -> None:
# Preserve no-auth deployments, but never swallow an invalid/missing login.
user = await get_current_user_from_request(request)
if user is not None and getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="仅管理员可管理接口性能设置")
@router.get("/api-read-cache", response_model=ApiReadCacheResponse)
async def get_api_read_cache_setting(request: Request) -> ApiReadCacheResponse:
"""Read the persisted administrator-controlled Redis cache switch."""
await _require_performance_admin(request)
from app.gateway.api_read_cache import api_read_cache_redis_configured, get_api_cache_controls, get_api_read_cache_settings
config = getattr(request.app.state, "config", None)
cache_config = getattr(config, "api_read_cache", None)
enabled, ttl_seconds = await get_api_read_cache_settings(config, force_refresh=True)
return ApiReadCacheResponse(
enabled=enabled,
redis_configured=api_read_cache_redis_configured(config),
ttl_seconds=ttl_seconds,
max_value_bytes=int(getattr(cache_config, "max_value_bytes", 524288) or 524288),
**await get_api_cache_controls(config),
)
@router.put("/api-read-cache", response_model=ApiReadCacheResponse)
async def update_api_read_cache_setting(
request: Request,
body: ApiReadCacheUpdateRequest,
) -> ApiReadCacheResponse:
"""Save the Redis cache switch to MySQL and apply it immediately."""
await _require_performance_admin(request)
from app.gateway.api_read_cache import (
API_READ_CACHE_COLLECTIONS,
api_read_cache_redis_configured,
get_api_cache_controls,
get_api_read_cache_settings,
invalidate_cached_collection,
save_api_read_cache_settings,
)
config = getattr(request.app.state, "config", None)
unknown = set(body.collection_overrides or {}) - set(API_READ_CACHE_COLLECTIONS)
if unknown:
raise HTTPException(status_code=422, detail=f"未知缓存接口:{', '.join(sorted(unknown))}")
try:
await save_api_read_cache_settings(
body.enabled, body.ttl_seconds, config=config,
collection_overrides=body.collection_overrides,
browser_status_enabled=body.browser_status_enabled,
)
await asyncio.gather(
*(
invalidate_cached_collection(config, collection, force=True)
for collection in API_READ_CACHE_COLLECTIONS
)
)
except Exception as exc:
logger.exception("Could not persist API Redis cache setting")
raise HTTPException(status_code=503, detail="无法将缓存设置保存到 MySQL") from exc
cache_config = getattr(config, "api_read_cache", None)
enabled, ttl_seconds = await get_api_read_cache_settings(config, force_refresh=True)
return ApiReadCacheResponse(
enabled=enabled,
redis_configured=api_read_cache_redis_configured(config),
ttl_seconds=ttl_seconds,
max_value_bytes=int(getattr(cache_config, "max_value_bytes", 524288) or 524288),
**await get_api_cache_controls(config),
)
class PromptPrefixResponse(BaseModel):
image_generation_unavailable_hint_enabled: bool = False
python_dependency_delete_protection_enabled: bool = False
enabled: bool = Field(..., description="Whether the toggle is exposed to users")
switch_label: str = Field(..., description="Toggle label shown in the chat input")
prompt_prefix: str = Field(..., description="Prefix text prepended to the first user message")
@@ -121,6 +250,8 @@ async def update_strategy_project_settings(
class PromptPrefixUpdateRequest(BaseModel):
image_generation_unavailable_hint_enabled: bool | None = None
python_dependency_delete_protection_enabled: bool | None = None
enabled: bool | None = None
switch_label: str | None = None
prompt_prefix: str | None = None
@@ -179,6 +310,16 @@ class HtmlMapSettingsUpdateRequest(BaseModel):
engine: Literal["echarts", "leaflet"] | None = None
class HtmlRuntimeSettingsResponse(BaseModel):
base_url: str
map_tile_url: str
class HtmlRuntimeSettingsUpdateRequest(BaseModel):
base_url: str
map_tile_url: str
class LeaderboardSettingsResponse(BaseModel):
exclude_user_ids: list[str] = Field(default_factory=list)
include_scheduled: bool = True
@@ -197,6 +338,8 @@ class LeaderboardSettingsUpdateRequest(BaseModel):
def _to_response(settings: PromptPrefixSettings) -> PromptPrefixResponse:
return PromptPrefixResponse(
image_generation_unavailable_hint_enabled=settings.image_generation_unavailable_hint_enabled,
python_dependency_delete_protection_enabled=settings.python_dependency_delete_protection_enabled,
enabled=settings.enabled,
switch_label=settings.switch_label,
prompt_prefix=settings.prompt_prefix,
@@ -389,7 +532,22 @@ class WriteFileCompatibilityUpdateRequest(BaseModel):
enabled: bool
class RetryKeepPartialResponse(BaseModel):
enabled: bool = Field(
default=True,
description=(
"Keep the already-streamed partial answer visible when a model "
"call is silently retried"
),
)
class RetryKeepPartialUpdateRequest(BaseModel):
enabled: bool
class ConversationContextResponse(BaseModel):
max_run_steps: int = 1000
recent_turn_window_enabled: bool = Field(
default=False,
description="Whether all lead-agent model requests keep only the latest three dialogue turns",
@@ -398,10 +556,39 @@ class ConversationContextResponse(BaseModel):
default=3,
description="Fixed number of real user turns retained by the sliding window",
)
max_conversation_rounds: int = Field(
default=20,
ge=1,
le=50,
description="Maximum real user turns allowed in one ordinary chat thread",
)
use_question_as_title: bool = Field(
default=False,
description="Whether the first user question is the final title, without post-answer title generation",
)
class ConversationContextUpdateRequest(BaseModel):
recent_turn_window_enabled: bool
max_run_steps: int | None = Field(default=None, ge=20, le=10000, strict=True)
recent_turn_window_enabled: bool | None = None
max_conversation_rounds: int | None = Field(default=None, ge=1, le=50)
use_question_as_title: bool | None = None
class RoundtableRoomLimitResponse(BaseModel):
enabled: bool = True
max_rooms: int = 3
max_hold_minutes: int = 30
max_cycles: int = 8
write_file_compatibility_enabled: bool = False
class RoundtableRoomLimitUpdateRequest(BaseModel):
enabled: bool | None = None
max_rooms: int | None = Field(default=None, ge=1, le=20)
max_hold_minutes: int | None = Field(default=None, ge=1, le=1440)
max_cycles: int | None = Field(default=None, ge=2, le=100)
write_file_compatibility_enabled: bool | None = None
class OrdinaryKnowledgeRetrievalResponse(BaseModel):
@@ -480,10 +667,36 @@ async def update_write_file_compatibility_setting(
)
@router.get("/retry-keep-partial", response_model=RetryKeepPartialResponse)
async def get_retry_keep_partial_setting() -> RetryKeepPartialResponse:
"""Return the retry-keeps-partial display switch (readable by chat clients)."""
settings = load_system_settings().retry_keep_partial
return RetryKeepPartialResponse(enabled=settings.enabled)
@router.put("/retry-keep-partial", response_model=RetryKeepPartialResponse)
async def update_retry_keep_partial_setting(
body: RetryKeepPartialUpdateRequest,
request: Request,
) -> RetryKeepPartialResponse:
await _require_admin(request)
current = load_system_settings()
updated = RetryKeepPartialSettings(enabled=body.enabled)
saved = save_system_settings(current.model_copy(update={"retry_keep_partial": updated}))
logger.info(
"Retry-keeps-partial display switch changed: enabled=%s",
saved.retry_keep_partial.enabled,
)
return RetryKeepPartialResponse(enabled=saved.retry_keep_partial.enabled)
def _conversation_context_response(settings: ConversationContextSettings) -> ConversationContextResponse:
return ConversationContextResponse(
max_run_steps=settings.max_run_steps,
recent_turn_window_enabled=settings.recent_turn_window_enabled,
recent_turns=3,
max_conversation_rounds=settings.max_conversation_rounds,
use_question_as_title=settings.use_question_as_title,
)
@@ -498,20 +711,72 @@ async def update_conversation_context_setting(
body: ConversationContextUpdateRequest,
request: Request,
) -> ConversationContextResponse:
"""Enable or disable the fixed recent-three-turn window. Admin only."""
"""Update the recent-turn window or ordinary-chat round cap. Admin only."""
await _require_admin(request)
current = load_system_settings()
updated = current.conversation_context.model_copy(
update={"recent_turn_window_enabled": body.recent_turn_window_enabled}
)
updated_fields = body.model_dump(exclude_unset=True, exclude_none=True)
if not updated_fields:
raise HTTPException(status_code=422, detail="至少需要提供一个上下文设置项")
updated = current.conversation_context.model_copy(update=updated_fields)
saved = save_system_settings(current.model_copy(update={"conversation_context": updated}))
logger.info(
"Conversation recent-turn window changed: enabled=%s retained_turns=3",
"Conversation runtime settings changed: recent_turn_window=%s use_question_as_title=%s max_conversation_rounds=%s max_run_steps=%s",
saved.conversation_context.recent_turn_window_enabled,
saved.conversation_context.use_question_as_title,
saved.conversation_context.max_conversation_rounds,
saved.conversation_context.max_run_steps,
)
return _conversation_context_response(saved.conversation_context)
def _roundtable_room_limit_response(settings: RoundtableRoomLimitSettings) -> RoundtableRoomLimitResponse:
return RoundtableRoomLimitResponse(
enabled=settings.enabled,
max_rooms=settings.max_rooms,
max_hold_minutes=settings.max_hold_minutes,
max_cycles=settings.max_cycles,
write_file_compatibility_enabled=settings.write_file_compatibility_enabled,
)
@router.get("/roundtable-room-limit", response_model=RoundtableRoomLimitResponse)
async def get_roundtable_room_limit_setting() -> RoundtableRoomLimitResponse:
"""Return the global step-2 room and closeout settings."""
return _roundtable_room_limit_response(load_system_settings().roundtable_room_limit)
@router.put("/roundtable-room-limit", response_model=RoundtableRoomLimitResponse)
async def update_roundtable_room_limit_setting(
body: RoundtableRoomLimitUpdateRequest,
request: Request,
) -> RoundtableRoomLimitResponse:
"""Update global room limits and the coordinator closeout round. Admin only."""
await _require_admin(request)
current = load_system_settings()
updates: dict[str, object] = {}
if body.enabled is not None:
updates["enabled"] = body.enabled
if body.max_rooms is not None:
updates["max_rooms"] = body.max_rooms
if body.max_hold_minutes is not None:
updates["max_hold_minutes"] = body.max_hold_minutes
if body.max_cycles is not None:
updates["max_cycles"] = body.max_cycles
if body.write_file_compatibility_enabled is not None:
updates["write_file_compatibility_enabled"] = body.write_file_compatibility_enabled
updated = current.roundtable_room_limit.model_copy(update=updates)
saved = save_system_settings(current.model_copy(update={"roundtable_room_limit": updated}))
logger.info(
"Roundtable runtime limits changed: enabled=%s max_rooms=%s max_hold_minutes=%s max_cycles=%s write_file_compatibility=%s",
saved.roundtable_room_limit.enabled,
saved.roundtable_room_limit.max_rooms,
saved.roundtable_room_limit.max_hold_minutes,
saved.roundtable_room_limit.max_cycles,
saved.roundtable_room_limit.write_file_compatibility_enabled,
)
return _roundtable_room_limit_response(saved.roundtable_room_limit)
@router.get(
"/ordinary-knowledge-retrieval",
response_model=OrdinaryKnowledgeRetrievalResponse,
@@ -806,6 +1071,38 @@ async def update_html_map_settings(
return HtmlMapSettingsResponse(engine=saved.html_map.engine)
@router.get("/html-runtime", response_model=HtmlRuntimeSettingsResponse)
async def get_html_runtime_settings() -> HtmlRuntimeSettingsResponse:
"""Return the addresses newly generated HTML pages will use."""
return HtmlRuntimeSettingsResponse(
base_url=html_runtime_base_url(),
map_tile_url=html_map_tile_url(),
)
@router.put("/html-runtime", response_model=HtmlRuntimeSettingsResponse)
async def update_html_runtime_settings(
body: HtmlRuntimeSettingsUpdateRequest,
request: Request,
) -> HtmlRuntimeSettingsResponse:
"""Save the browser-reachable runtime and map-tile addresses. Admin only."""
await _require_admin(request)
try:
base_url = normalize_html_runtime_base_url(body.base_url)
map_tile_url = normalize_html_map_tile_url(body.map_tile_url)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
current = load_system_settings()
updated = current.html_runtime.model_copy(
update={"base_url": base_url, "map_tile_url": map_tile_url}
)
saved = save_system_settings(current.model_copy(update={"html_runtime": updated}))
return HtmlRuntimeSettingsResponse(
base_url=saved.html_runtime.base_url,
map_tile_url=saved.html_runtime.map_tile_url,
)
@router.get("/private-square", response_model=PrivateSquareStatusResponse)
async def get_private_square_status() -> PrivateSquareStatusResponse:
"""Return whether the private square is enabled and a password is set."""
@@ -939,6 +1236,12 @@ class AppearanceResponse(BaseModel):
default_skill_view: Literal["card", "list"] = Field(..., description="System default skill list display mode — card or list")
default_agent_view: Literal["card", "list"] = Field(..., description="System default agent list display mode — card or list")
default_layout_mode: Literal["full", "compact"] = Field(..., description="System default interface layout — full (sidebar + header) or compact (hidden)")
default_composer_width_full: int = Field(..., description="System default composer width percent for full layout. 100 keeps the built-in width.")
default_composer_width_compact: int = Field(..., description="System default composer width percent for compact layout. 100 keeps the built-in width.")
default_composer_height_full: int = Field(..., description="System default composer height percent for full layout. 100 keeps the built-in height.")
default_composer_height_compact: int = Field(..., description="System default composer height percent for compact layout. 100 keeps the built-in height.")
default_agent_composer_width: int = Field(..., description="System default agent composer width percent. 100 keeps the built-in width.")
default_agent_composer_height: int = Field(..., description="System default agent composer height percent. 100 keeps the built-in height.")
compact_shortcut_buttons: list[CompactShortcutButton] = Field(default_factory=list, description="Shortcut buttons rendered in top bars")
custom_themes: list[CustomThemePreset] = Field(default_factory=list, description="Administrator-managed runtime theme presets")
@@ -948,6 +1251,12 @@ class AppearanceUpdateRequest(BaseModel):
default_skill_view: Literal["card", "list"] | None = Field(default=None)
default_agent_view: Literal["card", "list"] | None = Field(default=None)
default_layout_mode: Literal["full", "compact"] | None = Field(default=None)
default_composer_width_full: int | None = Field(default=None)
default_composer_width_compact: int | None = Field(default=None)
default_composer_height_full: int | None = Field(default=None)
default_composer_height_compact: int | None = Field(default=None)
default_agent_composer_width: int | None = Field(default=None)
default_agent_composer_height: int | None = Field(default=None)
compact_shortcut_buttons: list[CompactShortcutButton] | None = Field(default=None)
custom_themes: list[CustomThemePreset] | None = Field(default=None)
@@ -1021,6 +1330,12 @@ def _appearance_response(appearance) -> AppearanceResponse:
default_skill_view=appearance.default_skill_view,
default_agent_view=appearance.default_agent_view,
default_layout_mode=appearance.default_layout_mode,
default_composer_width_full=appearance.default_composer_width_full,
default_composer_width_compact=appearance.default_composer_width_compact,
default_composer_height_full=appearance.default_composer_height_full,
default_composer_height_compact=appearance.default_composer_height_compact,
default_agent_composer_width=appearance.default_agent_composer_width,
default_agent_composer_height=appearance.default_agent_composer_height,
compact_shortcut_buttons=sorted(
appearance.compact_shortcut_buttons,
key=lambda item: item.sort_order,
@@ -1079,7 +1394,17 @@ async def update_appearance_settings(body: AppearanceUpdateRequest, request: Req
current = load_system_settings()
fields = body.model_dump(exclude_unset=True)
update: dict[str, object] = {}
for key in ("default_skill_view", "default_agent_view", "default_layout_mode"):
for key in (
"default_skill_view",
"default_agent_view",
"default_layout_mode",
"default_composer_width_full",
"default_composer_width_compact",
"default_composer_height_full",
"default_composer_height_compact",
"default_agent_composer_width",
"default_agent_composer_height",
):
if key in fields and fields[key] is not None:
update[key] = fields[key]
if "compact_shortcut_buttons" in fields and body.compact_shortcut_buttons is not None:
@@ -8,12 +8,14 @@ without asking an administrator to tag every item.
from __future__ import annotations
import asyncio
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.catalog_cache import cached_catalog, catalog_key, invalidate_catalog
from app.gateway.deps import get_tag_store
from deerflow.persistence.tags import TAG_TYPES, TagStore
@@ -22,6 +24,13 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/tags", tags=["tags"])
async def _invalidate_tag_catalogues(request: Request) -> None:
await asyncio.gather(*(
invalidate_catalog(request, collection)
for collection in ("tags", "light_apps", "agents", "skills", "recommended_questions")
))
def _validate_tag_type(tag_type: str) -> str:
if tag_type not in TAG_TYPES:
raise HTTPException(status_code=422, detail=f"Invalid tag type '{tag_type}'. Allowed: {', '.join(TAG_TYPES)}")
@@ -68,14 +77,19 @@ class TagAssignmentRequest(BaseModel):
@router.get("", response_model=TagListResponse, summary="List Tags")
async def list_tags(
request: Request,
tag_type: str | None = Query(default=None, description="Filter by tag type"),
store: TagStore = Depends(get_tag_store),
) -> TagListResponse:
if tag_type is not None:
_validate_tag_type(tag_type)
try:
async def load() -> TagListResponse:
records = await store.list_tags(tag_type)
return TagListResponse(tags=[TagResponse(**r) for r in records])
try:
return await cached_catalog(request, "tags", catalog_key("list", tag_type), load, TagListResponse)
except Exception as e:
logger.error("Failed to list tags: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to list tags: {e}")
@@ -84,6 +98,7 @@ async def list_tags(
@router.post("", response_model=TagResponse, status_code=201, summary="Create Tag")
async def create_tag(
body: TagCreateRequest,
request: Request,
store: TagStore = Depends(get_tag_store),
) -> TagResponse:
_validate_tag_type(body.tag_type)
@@ -97,6 +112,7 @@ async def create_tag(
"description": body.description,
}
)
await _invalidate_tag_catalogues(request)
return TagResponse(**record)
except ValueError as e:
raise HTTPException(status_code=409, detail=str(e))
@@ -111,6 +127,7 @@ async def create_tag(
async def update_tag(
tag_id: str,
body: TagUpdateRequest,
request: Request,
store: TagStore = Depends(get_tag_store),
) -> TagResponse:
update_data = body.model_dump(exclude_unset=True)
@@ -120,6 +137,7 @@ async def update_tag(
record = await store.update_tag(tag_id, update_data)
if record is None:
raise HTTPException(status_code=404, detail=f"Tag '{tag_id}' not found")
await _invalidate_tag_catalogues(request)
return TagResponse(**record)
except ValueError as e:
raise HTTPException(status_code=409, detail=str(e))
@@ -133,12 +151,14 @@ async def update_tag(
@router.delete("/{tag_id}", status_code=204, summary="Delete Tag")
async def delete_tag(
tag_id: str,
request: Request,
store: TagStore = Depends(get_tag_store),
) -> None:
try:
deleted = await store.delete_tag(tag_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"Tag '{tag_id}' not found")
await _invalidate_tag_catalogues(request)
except HTTPException:
raise
except Exception as e:
@@ -157,12 +177,19 @@ async def delete_tag(
async def get_resource_tags(
target_type: str,
target_id: str,
request: Request,
store: TagStore = Depends(get_tag_store),
) -> TagListResponse:
_validate_tag_type(target_type)
try:
async def load() -> TagListResponse:
records = await store.get_target_tags(target_type, target_id)
return TagListResponse(tags=[TagResponse(**r) for r in records])
try:
return await cached_catalog(
request, "tags", catalog_key("assignment", target_type, target_id), load, TagListResponse,
)
except Exception as e:
logger.error("Failed to get resource tags: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to get resource tags: {e}")
@@ -177,11 +204,13 @@ async def set_resource_tags(
target_type: str,
target_id: str,
body: TagAssignmentRequest,
request: Request,
store: TagStore = Depends(get_tag_store),
) -> TagListResponse:
_validate_tag_type(target_type)
try:
records = await store.set_target_tags(target_type, target_id, body.tag_ids)
await _invalidate_tag_catalogues(request)
return TagListResponse(tags=[TagResponse(**r) for r in records])
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
@@ -45,6 +45,7 @@ from app.gateway.routers.multi_agent import (
)
from app.gateway.routers.task_analysis import _store, _task_or_404
from deerflow.persistence.task_analysis.base import TaskAnalysisStore
from deerflow.runtime.task_analysis.thread_metadata import task_analysis_thread_metadata
logger = logging.getLogger(__name__)
@@ -132,9 +133,14 @@ async def qa_init(request: Request, task_id: str) -> QaInitResponse:
agent_meta = await _get_agents_batch_direct(request, agent_ids)
# 并行建席位 thread + 总控 thread。
seat_threads = await _gather_thread_ids(request, agent_ids)
seat_threads = await _gather_thread_ids(request, task, agent_ids)
coord_thread = await _create_thread_direct(
request, metadata={"thread_type": "task-analysis-qa", "system": True}
request,
metadata=task_analysis_thread_metadata(
task,
thread_type="task-analysis-qa",
role="coordinator",
),
)
thread_ids: dict[str, str] = {**seat_threads, COORDINATOR_AGENT_ID: coord_thread}
@@ -179,12 +185,22 @@ async def qa_init(request: Request, task_id: str) -> QaInitResponse:
)
async def _gather_thread_ids(request: Request, agent_ids: list[str]) -> dict[str, str]:
async def _gather_thread_ids(
request: Request,
task: dict[str, Any],
agent_ids: list[str],
) -> dict[str, str]:
import asyncio
async def _one(agent_id: str) -> tuple[str, str]:
thread_id = await _create_thread_direct(
request, metadata={"thread_type": "task-analysis-qa", "system": True}
request,
metadata=task_analysis_thread_metadata(
task,
thread_type="task-analysis-qa",
role="seat",
seat_id=agent_id,
),
)
return agent_id, thread_id
@@ -26,6 +26,7 @@ from datetime import datetime
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.catalog_cache import cached_catalog, invalidate_catalog
from app.gateway.deps import get_current_user, get_optional_user_from_request
from deerflow.persistence.task_buttons import TaskButtonStore
@@ -110,8 +111,12 @@ async def _require_admin(request: Request) -> None:
@router.get("", response_model=TaskButtonListResponse)
async def list_task_buttons(request: Request) -> TaskButtonListResponse:
store = _get_store(request)
rows = await store.list_buttons()
return TaskButtonListResponse(buttons=[TaskButtonResponse(**r) for r in rows])
async def load() -> TaskButtonListResponse:
rows = await store.list_buttons()
return TaskButtonListResponse(buttons=[TaskButtonResponse(**r) for r in rows])
return await cached_catalog(request, "task_buttons", "all", load, TaskButtonListResponse)
@router.put("", response_model=TaskButtonListResponse)
@@ -123,4 +128,5 @@ async def replace_task_buttons(request: Request, body: TaskButtonReplaceRequest)
[b.model_dump() for b in body.buttons],
updated_by=user_id,
)
await invalidate_catalog(request, "task_buttons")
return TaskButtonListResponse(buttons=[TaskButtonResponse(**r) for r in rows])
@@ -0,0 +1,190 @@
"""Task-detail API for externally initiated single-agent deep links.
The detail snapshot is deliberately owned by DeerFlow. The embedding system
writes it once, then the browser only reads it after login; no frontend call is
made back to the embedding system for task content.
"""
from __future__ import annotations
import json
from typing import Any, Literal
from fastapi import APIRouter, HTTPException, Path, Query, Request
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator
from app.gateway.deps import get_task_deeplink_detail_store
router = APIRouter(prefix="/api/task-deeplink-details", tags=["task-deeplink-details"])
_B5_DETAIL_TYPE = "b5-1"
_MAX_EXTRA_BYTES = 64 * 1024
_MAX_SUB_TYPE = 64
class TaskDeeplinkDetailUpsertRequest(BaseModel):
"""Complete task snapshot supplied by the embedding system.
``extra`` is intentionally an object, not a bag of top-level fields, so
the stable contract stays small while integrators can attach their own
structured fields. ``type`` is the business type (the deep-link
``?type=``): the same task id may hold one snapshot per type; omitting it
addresses the legacy no-type partition.
"""
model_config = ConfigDict(populate_by_name=True)
task_name: str = Field(
min_length=1,
max_length=512,
validation_alias=AliasChoices("taskName", "task_name"),
)
task_content: str = Field(
min_length=1,
max_length=20_000,
validation_alias=AliasChoices("taskContent", "task_content"),
)
overview: str = Field(default="", max_length=4_000)
detail_sub_type: str = Field(
default="",
max_length=_MAX_SUB_TYPE,
validation_alias=AliasChoices("type", "taskType", "sub_type"),
)
extra: dict[str, Any] = Field(default_factory=dict)
@field_validator("task_name", "task_content", "overview", "detail_sub_type")
@classmethod
def strip_text(cls, value: str) -> str:
return value.strip()
@field_validator("task_name", "task_content")
@classmethod
def require_nonblank_text(cls, value: str) -> str:
if not value:
raise ValueError("text must not be blank")
return value
@field_validator("extra")
@classmethod
def require_bounded_json_object(cls, value: dict[str, Any]) -> dict[str, Any]:
try:
encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
except (TypeError, ValueError) as exc:
raise ValueError("extra must be JSON serializable") from exc
if len(encoded.encode("utf-8")) > _MAX_EXTRA_BYTES:
raise ValueError(f"extra must not exceed {_MAX_EXTRA_BYTES} bytes")
return value
class TaskDeeplinkDetailResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True)
detail_type: Literal["b5-1"] = Field(serialization_alias="type")
detail_id: str = Field(serialization_alias="id")
detail_sub_type: str = Field(default="", serialization_alias="taskType")
task_name: str = Field(serialization_alias="taskName")
task_content: str = Field(serialization_alias="taskContent")
overview: str = ""
extra: dict[str, Any] = Field(default_factory=dict)
version: int
updated_at: str | None = Field(default=None, serialization_alias="updatedAt")
class TaskDeeplinkDetailEnvelope(BaseModel):
data: TaskDeeplinkDetailResponse
class TaskDeeplinkDetailUpsertEnvelope(TaskDeeplinkDetailEnvelope):
created: bool
changed: bool
def _detail_response(row: dict[str, Any]) -> TaskDeeplinkDetailResponse:
return TaskDeeplinkDetailResponse(
detail_type=row["detail_type"],
detail_id=row["detail_id"],
detail_sub_type=str(row.get("sub_type") or ""),
task_name=row["task_name"],
task_content=row["task_content"],
overview=row.get("overview") or "",
extra=row.get("extra") or {},
version=int(row.get("version") or 1),
updated_at=str(row["updated_at"]) if row.get("updated_at") else None,
)
def _normalized_detail_id(detail_id: str) -> str:
normalized = detail_id.strip()
if not normalized:
raise HTTPException(status_code=422, detail="detail id must not be blank")
return normalized
def _writer_id(request: Request) -> str:
"""Audit identity for a snapshot write.
The PUT endpoint is credential-free (integration systems write before the
user's browser is involved), so an unauthenticated caller is recorded as
the external integrator; a logged-in browser write keeps the user id.
"""
user = getattr(request.state, "user", None)
if user is not None:
return str(getattr(user, "id", "system"))
return "external:task-deeplink"
@router.get("/{detail_type}/{detail_id}", response_model=TaskDeeplinkDetailEnvelope)
async def get_task_deeplink_detail(
request: Request,
detail_type: Literal["b5-1"],
detail_id: str = Path(min_length=1, max_length=128),
type: str = Query(default="", max_length=_MAX_SUB_TYPE),
) -> TaskDeeplinkDetailEnvelope:
"""Read one persisted snapshot for a post-login deep-link workspace.
``?type=`` selects the business-type partition written via the PUT body's
``type`` field; omitted means the legacy no-type partition.
"""
store = get_task_deeplink_detail_store(request)
row = await store.get_detail(
detail_type,
_normalized_detail_id(detail_id),
sub_type=type.strip(),
)
if row is None:
raise HTTPException(status_code=404, detail="Task deep-link detail not found")
return TaskDeeplinkDetailEnvelope(data=_detail_response(row))
@router.put("/{detail_type}/{detail_id}", response_model=TaskDeeplinkDetailUpsertEnvelope)
async def upsert_task_deeplink_detail(
request: Request,
body: TaskDeeplinkDetailUpsertRequest,
detail_type: Literal["b5-1"],
detail_id: str = Path(min_length=1, max_length=128),
) -> TaskDeeplinkDetailUpsertEnvelope:
"""Idempotently create or update a snapshot before ``goPath=b5-1``.
Open write endpoint: no login session or integration key is required
(AuthMiddleware/CSRF let the PUT through); the writer is only recorded
for audit. Reads still require a normal authenticated session.
"""
store = get_task_deeplink_detail_store(request)
row, created, changed = await store.upsert_detail(
detail_type,
_normalized_detail_id(detail_id),
sub_type=body.detail_sub_type,
task_name=body.task_name,
task_content=body.task_content,
overview=body.overview,
extra=body.extra,
updated_by=_writer_id(request),
)
return TaskDeeplinkDetailUpsertEnvelope(
data=_detail_response(row),
created=created,
changed=changed,
)
@@ -1,19 +1,18 @@
"""Fast taskId + goPath history lookup for single-agent deep-link workspaces.
Messages and execution state remain canonical LangGraph threads. This API reads
the dedicated ``task_deeplink_threads`` index and joins each hit to its thread
metadata by primary key, avoiding a global metadata JSON scan/filter.
Messages and execution state remain canonical LangGraph threads. This API
reads only the dedicated ``task_deeplink_threads`` cards and never joins or
scans the generic ``threads_meta`` catalogue.
"""
from __future__ import annotations
from typing import Literal
from fastapi import APIRouter, Query, Request
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel
from app.gateway.deps import get_task_deeplink_thread_store
from app.gateway.routers.threads import ThreadResponse
from deerflow.persistence.task_deeplink_threads.base import is_valid_task_deeplink_go_path
from deerflow.utils.time import coerce_iso
router = APIRouter(
@@ -30,13 +29,24 @@ class TaskDeeplinkThreadListResponse(BaseModel):
async def list_task_deeplink_threads(
request: Request,
task_id: str = Query(min_length=1, max_length=128),
go_path: Literal["3qfx", "rwfx", "xdfx"] = Query(),
go_path: str = Query(min_length=1, max_length=32),
limit: int = Query(default=100, ge=1, le=500),
# b5-1 ?type= partition. Provided (even empty) → only that sub-type;
# omitted → every sub-type of the task/goPath partition (legacy callers).
sub_type: str | None = Query(default=None, max_length=64),
) -> TaskDeeplinkThreadListResponse:
"""Return one shared deep-link history partition, newest first."""
normalized_go_path = go_path.strip().lower()
if not is_valid_task_deeplink_go_path(normalized_go_path):
raise HTTPException(status_code=422, detail="Invalid task deep-link goPath")
store = get_task_deeplink_thread_store(request)
rows = await store.list_threads(task_id.strip(), go_path, limit=limit)
rows = await store.list_threads(
task_id.strip(),
normalized_go_path,
limit=limit,
sub_type=sub_type.strip() if sub_type is not None else None,
)
return TaskDeeplinkThreadListResponse(
threads=[
ThreadResponse(
@@ -177,7 +177,7 @@ async def get_run(thread_id: str, run_id: str, request: Request) -> RunResponse:
if record is None or record.thread_id != thread_id:
# In-memory miss: fall back to the persistent store so a run still
# resolves after a Gateway restart instead of 404ing.
record = await run_mgr.get_persisted(run_id)
record = await run_mgr.get_persisted(run_id, authorized_thread_id=thread_id)
if record is None or record.thread_id != thread_id:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
return _record_to_response(record)
@@ -242,7 +242,7 @@ async def join_run(thread_id: str, run_id: str, request: Request) -> StreamingRe
# means the Gateway restarted (or the finished run was cleaned up). There
# is nothing to reconnect to, so stream a clean terminal end instead of a
# 404 — the SDK then clears its reconnect key and renders history.
persisted = await run_mgr.get_persisted(run_id)
persisted = await run_mgr.get_persisted(run_id, authorized_thread_id=thread_id)
if persisted is None or persisted.thread_id != thread_id:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
@@ -280,7 +280,7 @@ async def stream_existing_run(
# from before a restart (or already finished) — nothing to join or
# cancel, so emit a clean terminal end so the SDK stops reconnecting
# instead of 404ing. A truly unknown run still 404s.
persisted = await run_mgr.get_persisted(run_id)
persisted = await run_mgr.get_persisted(run_id, authorized_thread_id=thread_id)
if persisted is None or persisted.thread_id != thread_id:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
return StreamingResponse(
@@ -17,6 +17,7 @@ import json
import logging
import os
import uuid
from contextlib import aclosing
from pathlib import Path
from typing import Any
@@ -26,7 +27,7 @@ from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from app.gateway.authz import require_permission
from app.gateway.deps import get_checkpointer
from app.gateway.deps import get_checkpointer, get_thread_store
from app.gateway.llmwiki_deposit import delete_conversation_deposit_documents
from app.gateway.operation_timing import OperationTiming
from app.gateway.utils import sanitize_log_param
@@ -61,7 +62,9 @@ _RAG_DEBUG_LOGGER_NAME = "rag_citation_debug"
# owner identity through the API surface. Defense-in-depth — the
# row-level invariant is still ``threads_meta.user_id`` populated from
# the auth contextvar; this list closes the metadata-blob echo gap.
_SERVER_RESERVED_METADATA_KEYS: frozenset[str] = frozenset({"owner_id", "user_id"})
_SERVER_RESERVED_METADATA_KEYS: frozenset[str] = frozenset(
{"owner_id", "user_id", "consumer_values_save_called_at"}
)
def _strip_reserved_metadata(metadata: dict[str, Any] | None) -> dict[str, Any]:
@@ -177,6 +180,7 @@ class ThreadCreateRequest(BaseModel):
thread_id: str | None = Field(default=None, description="Optional thread ID (auto-generated if omitted)")
assistant_id: str | None = Field(default=None, description="Associate thread with an assistant")
metadata: dict[str, Any] = Field(default_factory=dict, description="Initial metadata")
initialize_checkpoint: bool = Field(default=False, description="Compatibility option: immediately write an empty checkpoint instead of initializing on first run")
_strip_reserved = field_validator("metadata")(classmethod(lambda cls, v: _strip_reserved_metadata(v)))
@@ -204,6 +208,19 @@ class ThreadStateResponse(BaseModel):
tasks: list[dict[str, Any]] = Field(default_factory=list, description="Interrupted task details")
class ThreadTitleStateResponse(BaseModel):
"""Lightweight title refresh status backed only by thread metadata."""
title: str = ""
title_provisional: bool | None = None
class ConsumerValuesSaveClaimResponse(BaseModel):
"""Whether the caller must backfill Consumer values for this thread."""
should_save: bool
class ThreadMessagesResponse(BaseModel):
"""A page of the latest thread message state.
@@ -860,13 +877,13 @@ async def delete_thread_data(thread_id: str, request: Request) -> ThreadDeleteRe
async def create_thread(body: ThreadCreateRequest, request: Request, response: Response = None) -> ThreadResponse: # type: ignore[assignment]
"""Create a new thread.
Writes a thread_meta record (so the thread appears in /threads/search)
and an empty checkpoint (so state endpoints work immediately).
Writes thread metadata immediately; the first graph run initializes state.
Legacy callers can explicitly request an immediate empty checkpoint.
Idempotent: returns the existing record when ``thread_id`` already exists.
"""
from app.gateway.deps import get_thread_store
checkpointer = get_checkpointer(request)
checkpointer = get_checkpointer(request) if body.initialize_checkpoint else None
thread_store = get_thread_store(request)
thread_id = body.thread_id or str(uuid.uuid4())
now = now_iso()
@@ -879,12 +896,13 @@ async def create_thread(body: ThreadCreateRequest, request: Request, response: R
"thread_create",
thread_id=sanitize_log_param(thread_id),
metadata_store=type(thread_store).__name__,
checkpointer=type(checkpointer).__name__,
checkpointer=type(checkpointer).__name__ if checkpointer is not None else "deferred",
) as timing:
# Idempotency: return existing record when already present
with timing.phase("metadata_lookup"):
existing_record = await thread_store.get(thread_id)
if existing_record is not None:
_mark_primary_only_thread(request, thread_id)
return ThreadResponse(
thread_id=thread_id,
status=existing_record.get("status", "idle"),
@@ -905,8 +923,12 @@ async def create_thread(body: ThreadCreateRequest, request: Request, response: R
logger.exception("Failed to write thread_meta for %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to create thread")
# Keep the initial checkpoint: state endpoints must work immediately.
# Its timing includes saver lock/pool waits as well as the DB write.
if not body.initialize_checkpoint:
_mark_primary_only_thread(request, thread_id)
return ThreadResponse(thread_id=thread_id, created_at=now, updated_at=now, metadata=body.metadata)
# Explicit compatibility path only. Never schedule this write in the
# background: a late empty checkpoint could replace the first run's head.
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
try:
ckpt_metadata = {
@@ -1068,23 +1090,67 @@ async def patch_thread(thread_id: str, body: ThreadPatchRequest, request: Reques
)
@router.post("/{thread_id}/consumer-values-save-claim", response_model=ConsumerValuesSaveClaimResponse)
@require_permission("threads", "write", owner_check=True, require_existing=True)
async def claim_consumer_values_save(
thread_id: str,
request: Request,
) -> ConsumerValuesSaveClaimResponse:
"""Claim the one-time Consumer backfill for a pre-existing conversation.
The timestamp records that the external save request was *attempted*, not
that Consumer accepted it. This matches the non-blocking UI contract: a
Consumer outage must never be surfaced to or block the chat user.
"""
thread_store = get_thread_store(request)
record = await thread_store.get(thread_id)
if record is None:
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
metadata = record.get("metadata") or {}
if metadata.get("consumer_values_save_called_at"):
return ConsumerValuesSaveClaimResponse(should_save=False)
try:
await thread_store.update_metadata(
thread_id,
{"consumer_values_save_called_at": now_iso()},
)
except Exception:
logger.exception("Failed to claim Consumer values save for thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to record Consumer values save")
return ConsumerValuesSaveClaimResponse(should_save=True)
@router.get("/{thread_id}", response_model=ThreadResponse)
@require_permission("threads", "read", owner_check=True)
async def get_thread(thread_id: str, request: Request) -> ThreadResponse:
async def get_thread(thread_id: str, request: Request, include_state: bool = False) -> ThreadResponse:
"""Get thread info.
Reads metadata from the ThreadMetaStore and derives the accurate
execution status from the checkpointer. Falls back to the checkpointer
alone for threads that pre-date ThreadMetaStore adoption (backward compat).
Metadata reads use only the business database. Clients that need channel
values can opt in with include_state, or use /state. Legacy threads without
a metadata row retain the checkpoint fallback after authorization.
"""
from app.gateway.deps import get_thread_store
thread_store = get_thread_store(request)
checkpointer = get_checkpointer(request)
record: dict | None = await thread_store.get(thread_id)
# The owner-check decorator already authorized this read, including shared
# task threads. A metadata-only shared thread has no checkpoint fallback.
record: dict | None = await thread_store.get(thread_id, user_id=None)
if record is not None and not include_state:
return ThreadResponse(
thread_id=thread_id,
status="busy" if record.get("status") == "running" else record.get("status", "idle"),
created_at=coerce_iso(record.get("created_at", "")),
updated_at=coerce_iso(record.get("updated_at", "")),
metadata=record.get("metadata", {}),
)
# Derive accurate status from the checkpointer
checkpointer = get_checkpointer(request)
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
try:
checkpoint_tuple = await checkpointer.aget_tuple(config)
@@ -1121,61 +1187,91 @@ async def get_thread(thread_id: str, request: Request) -> ThreadResponse:
created_at=coerce_iso(record.get("created_at", "")),
updated_at=coerce_iso(record.get("updated_at", "")),
metadata=record.get("metadata", {}),
values=serialize_channel_values(channel_values),
values=serialize_channel_values(channel_values, slim_display=True),
)
# ---------------------------------------------------------------------------
@router.get("/{thread_id}/title-state", response_model=ThreadTitleStateResponse)
@require_permission("threads", "read", owner_check=True)
async def get_thread_title_state(thread_id: str, request: Request) -> ThreadTitleStateResponse:
"""Read only title metadata; do not load/serialize a checkpoint or messages."""
record = await get_thread_store(request).get(thread_id)
if record is None:
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
metadata = record.get("metadata") or {}
provisional = metadata.get("_deerflow_title_provisional")
return ThreadTitleStateResponse(
title=str(record.get("display_name") or ""),
title_provisional=provisional if isinstance(provisional, bool) else None,
)
@router.get("/{thread_id}/state", response_model=ThreadStateResponse)
@require_permission("threads", "read", owner_check=True)
async def get_thread_state(thread_id: str, request: Request) -> ThreadStateResponse:
async def get_thread_state(thread_id: str, request: Request, response: Response = None) -> ThreadStateResponse: # type: ignore[assignment]
"""Get the latest state snapshot for a thread.
Channel values are serialized to ensure LangChain message objects
are converted to JSON-safe dicts.
are converted to JSON-safe dicts. Oversized tool/file bodies are
truncated for this hydrate snapshot; the checkpoint itself is unchanged.
"""
checkpointer = get_checkpointer(request)
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
try:
checkpoint_tuple = await checkpointer.aget_tuple(config)
except Exception:
logger.exception("Failed to get state for thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to get thread state")
with OperationTiming(
response,
logger,
"thread_state",
thread_id=sanitize_log_param(thread_id),
checkpointer=type(checkpointer).__name__,
) as timing:
try:
with timing.phase("checkpoint_load"):
checkpoint_tuple = await checkpointer.aget_tuple(config)
except Exception:
logger.exception("Failed to get state for thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to get thread state")
if checkpoint_tuple is None:
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
if checkpoint_tuple is None:
from app.gateway.deps import get_thread_store
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
metadata = getattr(checkpoint_tuple, "metadata", {}) or {}
checkpoint_id = None
ckpt_config = getattr(checkpoint_tuple, "config", {})
if ckpt_config:
checkpoint_id = ckpt_config.get("configurable", {}).get("checkpoint_id")
record = await get_thread_store(request).get(thread_id, user_id=None)
if record is not None:
return ThreadStateResponse(metadata=record.get("metadata", {}))
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
channel_values = checkpoint.get("channel_values", {})
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
metadata = getattr(checkpoint_tuple, "metadata", {}) or {}
checkpoint_id = None
ckpt_config = getattr(checkpoint_tuple, "config", {})
if ckpt_config:
checkpoint_id = ckpt_config.get("configurable", {}).get("checkpoint_id")
parent_config = getattr(checkpoint_tuple, "parent_config", None)
parent_checkpoint_id = None
if parent_config:
parent_checkpoint_id = parent_config.get("configurable", {}).get("checkpoint_id")
channel_values = checkpoint.get("channel_values", {})
tasks_raw = getattr(checkpoint_tuple, "tasks", []) or []
next_tasks = [t.name for t in tasks_raw if hasattr(t, "name")]
tasks = [{"id": getattr(t, "id", ""), "name": getattr(t, "name", "")} for t in tasks_raw]
parent_config = getattr(checkpoint_tuple, "parent_config", None)
parent_checkpoint_id = None
if parent_config:
parent_checkpoint_id = parent_config.get("configurable", {}).get("checkpoint_id")
values = serialize_channel_values(channel_values)
tasks_raw = getattr(checkpoint_tuple, "tasks", []) or []
next_tasks = [t.name for t in tasks_raw if hasattr(t, "name")]
tasks = [{"id": getattr(t, "id", ""), "name": getattr(t, "name", "")} for t in tasks_raw]
return ThreadStateResponse(
values=values,
next=next_tasks,
metadata=metadata,
checkpoint={"id": checkpoint_id, "ts": coerce_iso(metadata.get("created_at", ""))},
checkpoint_id=checkpoint_id,
parent_checkpoint_id=parent_checkpoint_id,
created_at=coerce_iso(metadata.get("created_at", "")),
tasks=tasks,
)
with timing.phase("serialize"):
values = serialize_channel_values(channel_values, slim_display=True)
return ThreadStateResponse(
values=values,
next=next_tasks,
metadata=metadata,
checkpoint={"id": checkpoint_id, "ts": coerce_iso(metadata.get("created_at", ""))},
checkpoint_id=checkpoint_id,
parent_checkpoint_id=parent_checkpoint_id,
created_at=coerce_iso(metadata.get("created_at", "")),
tasks=tasks,
)
@router.get("/{thread_id}/reference-batches", response_model=ReferenceBatchesResponse)
@@ -1912,9 +2008,50 @@ async def edit_thread_message(
}
def _mark_primary_only_thread(request: Request, thread_id: str) -> None:
"""Tell a dual-read checkpointer not to scan legacy SQLite for this thread."""
saver = getattr(request.app.state, "checkpointer", None)
remember = getattr(saver, "remember_primary_thread", None)
if callable(remember):
remember(thread_id)
def _history_entry_from_tuple(checkpoint_tuple: Any, *, include_values: bool) -> HistoryEntry:
"""Build one history card. Only the latest head carries serialized values."""
ckpt_config = getattr(checkpoint_tuple, "config", {})
parent_config = getattr(checkpoint_tuple, "parent_config", None)
metadata = getattr(checkpoint_tuple, "metadata", {}) or {}
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
checkpoint_id = ckpt_config.get("configurable", {}).get("checkpoint_id", "")
parent_id = parent_config.get("configurable", {}).get("checkpoint_id") if parent_config else None
channel_values = checkpoint.get("channel_values", {})
values: dict[str, Any] = serialize_channel_values(channel_values, slim_display=True) if include_values else {}
if title := channel_values.get("title"):
values["title"] = title
if thread_data := channel_values.get("thread_data"):
values["thread_data"] = thread_data
tasks_raw = getattr(checkpoint_tuple, "tasks", []) or []
user_meta = {k: v for k, v in metadata.items() if k not in ("created_at", "updated_at", "step", "source", "writes", "parents")}
if "step" in metadata:
user_meta["step"] = metadata["step"]
return HistoryEntry(
checkpoint_id=checkpoint_id,
parent_checkpoint_id=parent_id,
metadata=user_meta,
values=values,
created_at=coerce_iso(metadata.get("created_at", "")),
next=[t.name for t in tasks_raw if hasattr(t, "name")],
)
@router.post("/{thread_id}/history", response_model=list[HistoryEntry])
@require_permission("threads", "read", owner_check=True)
async def get_thread_history(thread_id: str, body: ThreadHistoryRequest, request: Request) -> list[HistoryEntry]:
async def get_thread_history(
thread_id: str,
body: ThreadHistoryRequest,
request: Request,
response: Response = None, # type: ignore[assignment]
) -> list[HistoryEntry]:
"""Get checkpoint history for a thread.
Messages are read from the checkpointer's channel values (the
@@ -1922,65 +2059,37 @@ async def get_thread_history(thread_id: str, body: ThreadHistoryRequest, request
:func:`~deerflow.runtime.serialization.serialize_channel_values`.
Only the latest (first) checkpoint carries the ``messages`` key to
avoid duplicating them across every entry.
The chat UI always asks for ``limit=1``. That path uses ``aget_tuple``
instead of walking the checkpoint iterator.
"""
checkpointer = get_checkpointer(request)
config: dict[str, Any] = {"configurable": {"thread_id": thread_id}}
config: dict[str, Any] = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
if body.before:
config["configurable"]["checkpoint_id"] = body.before
entries: list[HistoryEntry] = []
is_latest_checkpoint = True
try:
async for checkpoint_tuple in checkpointer.alist(config, limit=body.limit):
ckpt_config = getattr(checkpoint_tuple, "config", {})
parent_config = getattr(checkpoint_tuple, "parent_config", None)
metadata = getattr(checkpoint_tuple, "metadata", {}) or {}
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
checkpoint_id = ckpt_config.get("configurable", {}).get("checkpoint_id", "")
parent_id = None
if parent_config:
parent_id = parent_config.get("configurable", {}).get("checkpoint_id")
channel_values = checkpoint.get("channel_values", {})
# Build values from checkpoint channel_values
values: dict[str, Any] = {}
if title := channel_values.get("title"):
values["title"] = title
if thread_data := channel_values.get("thread_data"):
values["thread_data"] = thread_data
# Attach messages only to the latest checkpoint entry.
if is_latest_checkpoint:
messages = channel_values.get("messages")
if messages:
values["messages"] = serialize_channel_values({"messages": messages}).get("messages", [])
is_latest_checkpoint = False
# Derive next tasks
tasks_raw = getattr(checkpoint_tuple, "tasks", []) or []
next_tasks = [t.name for t in tasks_raw if hasattr(t, "name")]
# Strip LangGraph internal keys from metadata
user_meta = {k: v for k, v in metadata.items() if k not in ("created_at", "updated_at", "step", "source", "writes", "parents")}
# Keep step for ordering context
if "step" in metadata:
user_meta["step"] = metadata["step"]
entries.append(
HistoryEntry(
checkpoint_id=checkpoint_id,
parent_checkpoint_id=parent_id,
metadata=user_meta,
values=values,
created_at=coerce_iso(metadata.get("created_at", "")),
next=next_tasks,
)
)
except Exception:
logger.exception("Failed to get history for thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to get thread history")
return entries
with OperationTiming(
response,
logger,
"thread_history",
thread_id=sanitize_log_param(thread_id),
checkpointer=type(checkpointer).__name__,
limit=str(body.limit),
) as timing:
try:
with timing.phase("checkpoint_load"):
if body.limit == 1 and not body.before:
latest = await checkpointer.aget_tuple(config)
checkpoints = [latest] if latest is not None else []
else:
async with aclosing(checkpointer.alist(config, limit=body.limit)) as history:
checkpoints = [checkpoint async for checkpoint in history]
with timing.phase("serialize"):
return [
_history_entry_from_tuple(checkpoint_tuple, include_values=index == 0)
for index, checkpoint_tuple in enumerate(checkpoints)
]
except Exception:
logger.exception("Failed to get history for thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to get thread history")
@@ -19,6 +19,7 @@ from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.gateway.admin_query_window import resolve_admin_query_window
from app.gateway.deps import get_local_provider, get_optional_user_from_request, get_tool_metrics_store
from deerflow.persistence.tool_metrics.base import ToolMetricsQuery, ToolMetricsStore
@@ -85,9 +86,11 @@ def _require_store(request: Request) -> ToolMetricsStore:
return store
def _parse_dt(value: str | None) -> datetime | None:
def _parse_dt(value: str | datetime | None) -> datetime | None:
if not value:
return None
if isinstance(value, datetime):
return value
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
@@ -147,8 +150,8 @@ def _build_query(
skill_name: str | None = None,
status: str | None,
error_category: str | None,
since: str | None,
until: str | None,
since: str | datetime | None,
until: str | datetime | None,
limit: int,
offset: int,
) -> ToolMetricsQuery:
@@ -190,6 +193,7 @@ async def list_tool_metrics(
if resolved is None:
return ToolMetricsListResponse(items=[], total=0, limit=limit, offset=offset)
user_id = resolved
since_dt, until_dt, _range_days = resolve_admin_query_window(since=since, until=until)
query = _build_query(
user_id=user_id,
tool_name=tool_name,
@@ -197,8 +201,8 @@ async def list_tool_metrics(
skill_name=skill_name,
status=status,
error_category=error_category,
since=since,
until=until,
since=since_dt,
until=until_dt,
limit=limit,
offset=offset,
)
@@ -250,6 +254,7 @@ async def tool_metrics_summary(
return ToolMetricsSummaryResponse(total=0, error_total=0, by_category={}, by_skill=[])
user_id = resolved
skill_only = _kind_is_skill(kind)
since_dt, until_dt, _range_days = resolve_admin_query_window(since=since, until=until)
def _q(*, status: str | None = None, error_category: str | None = None) -> ToolMetricsQuery:
return _build_query(
@@ -259,8 +264,8 @@ async def tool_metrics_summary(
skill_name=skill_name,
status=status,
error_category=error_category,
since=since,
until=until,
since=since_dt,
until=until_dt,
limit=1,
offset=0,
)
@@ -305,6 +310,7 @@ async def export_tool_metrics(
if not user_id and user_name:
resolved = await _resolve_user_name_to_id(user_name)
user_id = resolved if resolved is not None else "__no_such_user__"
since_dt, until_dt, _range_days = resolve_admin_query_window(since=since, until=until)
try:
from openpyxl import Workbook
@@ -318,8 +324,8 @@ async def export_tool_metrics(
skill_name=skill_name,
status=status,
error_category=error_category,
since=since,
until=until,
since=since_dt,
until=until_dt,
limit=1,
offset=0,
)
@@ -1,11 +1,14 @@
"""Upload router for handling file uploads."""
import asyncio
import logging
import os
import shutil
import stat
import tempfile
import threading
from pathlib import Path
from weakref import WeakKeyDictionary, WeakValueDictionary
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from pydantic import BaseModel, Field
@@ -33,13 +36,46 @@ from deerflow.uploads.manager import (
)
from deerflow.uploads.policy import classify_upload_filename
from deerflow.uploads.validation import UploadContentError, validate_upload_content
from deerflow.utils.file_conversion import CONVERTIBLE_EXTENSIONS, convert_file_to_markdown
from deerflow.utils.file_conversion import CONVERTIBLE_EXTENSIONS, convert_file_to_markdown_sync
from deerflow.utils.file_io import FileIOPool, file_io
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/threads/{thread_id}/uploads", tags=["uploads"])
UPLOAD_CHUNK_SIZE = 8192
UPLOAD_CHUNK_SIZE = 256 * 1024
# Upload work must not fill the default executor used by authentication, or the
# artifact/event pools used by active conversations. Waiting batches use no worker.
_upload_io = FileIOPool(2, 2, "uploads")
_upload_locks: WeakKeyDictionary = WeakKeyDictionary()
def _upload_lock(directory: Path) -> asyncio.Lock:
locks = _upload_locks.setdefault(asyncio.get_running_loop(), WeakValueDictionary())
key = os.path.normcase(str(directory))
lock = locks.get(key)
if lock is None:
lock = asyncio.Lock()
locks[key] = lock
return lock
class _UploadCancelled(Exception):
"""Cooperative stop inside a worker; the request propagates CancelledError."""
def _check_upload_cancelled(cancelled: threading.Event | None) -> None:
if cancelled is not None and cancelled.is_set():
raise _UploadCancelled()
async def _resolve_uploads_path(thread_id: str) -> Path:
owner = await aresolve_path_user_id(thread_id)
try:
return get_paths().sandbox_uploads_dir(thread_id, user_id=owner)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
class UploadResponse(BaseModel):
@@ -119,10 +155,37 @@ async def _write_upload_file_with_limits(
max_total_size: int,
total_size: int,
) -> tuple[os.PathLike[str] | str, int, int]:
cancelled = threading.Event()
return await _upload_io.run_until_complete(
_copy_upload_file_with_limits,
file,
uploads_dir=uploads_dir,
display_filename=display_filename,
max_single_file_size=max_single_file_size,
max_total_size=max_total_size,
total_size=total_size,
cancelled=cancelled,
on_cancel=cancelled.set,
)
def _copy_upload_file_with_limits(
file: UploadFile,
*,
uploads_dir: os.PathLike[str] | str,
display_filename: str,
max_single_file_size: int,
max_total_size: int,
total_size: int,
cancelled: threading.Event | None = None,
) -> tuple[Path, int, int]:
"""Copy the parsed multipart spool entirely in the dedicated upload worker."""
_check_upload_cancelled(cancelled)
file_size = 0
file_path, fh = open_upload_file_no_symlink(uploads_dir, display_filename)
try:
while chunk := await file.read(UPLOAD_CHUNK_SIZE):
while chunk := file.file.read(UPLOAD_CHUNK_SIZE):
_check_upload_cancelled(cancelled)
file_size += len(chunk)
total_size += len(chunk)
if file_size > max_single_file_size:
@@ -130,6 +193,7 @@ async def _write_upload_file_with_limits(
if total_size > max_total_size:
raise HTTPException(status_code=413, detail="Total upload size too large")
fh.write(chunk)
_check_upload_cancelled(cancelled)
except Exception:
fh.close()
try:
@@ -249,27 +313,56 @@ async def upload_files(
category, max_bytes = classification
prepared.append((file, safe_filename, category, max_bytes))
# Resolve the canonical owner asynchronously before any filesystem work.
sandbox_uploads = await _resolve_uploads_path(thread_id)
cancelled = threading.Event()
# Moving work off the event loop introduces real concurrency. Serialize
# batches for the same directory so rollback cannot undo another upload.
async with _upload_lock(sandbox_uploads):
return await _upload_io.run_until_complete(
_process_upload_batch,
thread_id,
sandbox_uploads,
prepared,
claimed_names,
limits,
_auto_convert_documents_enabled(config),
cancelled,
on_cancel=cancelled.set,
)
def _process_upload_batch(
thread_id: str,
sandbox_uploads: Path,
prepared: list[tuple[UploadFile, str, str, int]],
claimed_names: set[str],
limits: UploadLimits,
auto_convert_documents: bool,
cancelled: threading.Event,
) -> UploadResponse:
"""Own the full staging/commit/rollback lifetime in one bounded worker."""
_check_upload_cancelled(cancelled)
try:
uploads_dir = ensure_uploads_dir(thread_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
sandbox_uploads = get_paths().sandbox_uploads_dir(thread_id, user_id=await aresolve_path_user_id(thread_id))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
uploaded_files: list[dict[str, str]] = []
sandbox_sync_targets: list[tuple[Path, str]] = []
total_size = 0
auto_convert_documents = _auto_convert_documents_enabled(config)
staging_dir = Path(tempfile.mkdtemp(prefix=".upload-staging-", dir=uploads_dir))
records: list[dict] = []
committed_names: list[str] | None = None
try:
for file, safe_filename, category, max_bytes in prepared:
staged_path, file_size, total_size = await _write_upload_file_with_limits(
staged_path, file_size, total_size = _copy_upload_file_with_limits(
file,
uploads_dir=staging_dir,
display_filename=safe_filename,
max_single_file_size=max_bytes,
max_total_size=limits.max_total_size,
total_size=total_size,
cancelled=cancelled,
)
file_ext = staged_path.suffix.lower()
try:
@@ -284,7 +377,8 @@ async def upload_files(
"detected_type": file_ext.removeprefix("."),
}
if auto_convert_documents and file_ext in CONVERTIBLE_EXTENSIONS:
md_path = await convert_file_to_markdown(staged_path)
_check_upload_cancelled(cancelled)
md_path = convert_file_to_markdown_sync(staged_path)
if md_path:
markdown_folded = md_path.name.casefold()
if markdown_folded in claimed_names:
@@ -296,6 +390,7 @@ async def upload_files(
record["markdown_file"] = md_path.name
records.append(record)
_check_upload_cancelled(cancelled)
sandbox_provider = get_sandbox_provider()
sync_to_sandbox = not _uses_thread_data_mounts(sandbox_provider)
sandbox = None
@@ -307,6 +402,7 @@ async def upload_files(
commit_names = [str(record["filename"]) for record in records]
commit_names.extend(str(record["markdown_file"]) for record in records if record.get("markdown_file"))
_check_upload_cancelled(cancelled)
_commit_staged_files(staging_dir, uploads_dir, commit_names)
committed_names = commit_names
@@ -345,9 +441,11 @@ async def upload_files(
if sync_to_sandbox and sandbox is not None:
for file_path, virtual_path in sandbox_sync_targets:
_check_upload_cancelled(cancelled)
_make_file_sandbox_writable(file_path)
sandbox.update_file(virtual_path, file_path.read_bytes())
except HTTPException:
_check_upload_cancelled(cancelled)
except (HTTPException, _UploadCancelled):
if committed_names is not None:
_rollback_committed_files(staging_dir, uploads_dir, committed_names)
raise
@@ -390,6 +488,12 @@ async def get_upload_limits(
@require_permission("threads", "read", owner_check=True)
async def list_uploaded_files(thread_id: str, request: Request) -> dict:
"""List all files in a thread's uploads directory."""
sandbox_uploads = await _resolve_uploads_path(thread_id)
async with _upload_lock(sandbox_uploads):
return await file_io.run_until_complete(_list_uploaded_files, thread_id, sandbox_uploads)
def _list_uploaded_files(thread_id: str, sandbox_uploads: Path) -> dict:
try:
uploads_dir = get_uploads_dir(thread_id)
except ValueError as e:
@@ -398,7 +502,6 @@ async def list_uploaded_files(thread_id: str, request: Request) -> dict:
enrich_file_listing(result, thread_id)
# Gateway additionally includes the sandbox-relative path.
sandbox_uploads = get_paths().sandbox_uploads_dir(thread_id, user_id=await aresolve_path_user_id(thread_id))
for f in result["files"]:
f["path"] = str(sandbox_uploads / f["filename"])
@@ -409,6 +512,12 @@ async def list_uploaded_files(thread_id: str, request: Request) -> dict:
@require_permission("threads", "delete", owner_check=True, require_existing=True)
async def delete_uploaded_file(thread_id: str, filename: str, request: Request) -> dict:
"""Delete a file from a thread's uploads directory."""
sandbox_uploads = await _resolve_uploads_path(thread_id)
async with _upload_lock(sandbox_uploads):
return await file_io.run_until_complete(_delete_uploaded_file, thread_id, filename)
def _delete_uploaded_file(thread_id: str, filename: str) -> dict:
try:
uploads_dir = get_uploads_dir(thread_id)
except ValueError as e:
@@ -13,6 +13,7 @@ import logging
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.api_read_cache import get_cache_version, get_cached_json, set_cached_json
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
@@ -52,10 +53,18 @@ def _to_response(prefs: dict) -> UserPreferencesResponse:
@router.get("", response_model=UserPreferencesResponse)
async def get_user_preferences(request: Request) -> UserPreferencesResponse:
store = _get_store(request)
user_id = _current_user_id(request)
config = getattr(request.app.state, "config", None)
cache_key = f"user:{user_id}"
version = await get_cache_version(config, "user_preferences")
cached = await get_cached_json(config, "user_preferences", cache_key, version=version)
if cached is not None:
return UserPreferencesResponse.model_validate(cached)
store = _get_store(request)
prefs = await store.get_for_user(user_id)
return _to_response(prefs)
response = _to_response(prefs)
await set_cached_json(config, "user_preferences", cache_key, response, expected_version=version)
return response
@router.put("", response_model=UserPreferencesResponse)
@@ -13,6 +13,7 @@ from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.api_read_cache import get_cache_version, get_cached_json, set_cached_json
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
@@ -66,10 +67,18 @@ def _get_store(request: Request):
@router.get("", response_model=UserPromptsListResponse)
async def list_user_prompts(request: Request) -> UserPromptsListResponse:
store = _get_store(request)
user_id = _current_user_id(request)
config = getattr(request.app.state, "config", None)
cache_key = f"user:{user_id}"
version = await get_cache_version(config, "user_prompts")
cached = await get_cached_json(config, "user_prompts", cache_key, version=version)
if cached is not None:
return UserPromptsListResponse.model_validate(cached)
store = _get_store(request)
rows = await store.list_for_user(user_id)
return UserPromptsListResponse(prompts=[UserPromptResponse(**r) for r in rows])
response = UserPromptsListResponse(prompts=[UserPromptResponse(**r) for r in rows])
await set_cached_json(config, "user_prompts", cache_key, response, expected_version=version)
return response
@router.post("", response_model=UserPromptResponse, status_code=201)
@@ -30,6 +30,7 @@ from app.gateway.llmwiki_rag import (
llmwiki_selection_requested,
)
from app.gateway.utils import sanitize_log_param
from app.gateway.worker_pool.admission import bounded_worker_run
from deerflow.agents.middlewares.prompt_prefix_middleware import (
SMART_KNOWLEDGE_SCOPE_PROMPT_KEY,
)
@@ -134,7 +135,9 @@ async def _run_agent_with_concurrency_lease(
)
try:
await run_agent(**run_agent_kwargs)
from app.gateway.worker_pool.admission import run_with_worker_admission
await run_with_worker_admission(run_agent, **run_agent_kwargs)
record = run_agent_kwargs.get("record")
if after_success is not None and getattr(record, "status", None) == RunStatus.success:
try:
@@ -555,6 +558,9 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
"writing_setup_quick",
"writing_sample_context",
"excluded_tools",
# Internal role policies may opt into a fail-closed tool allowlist.
# Ordinary clients omit it and retain the existing tool resolution.
"allowed_tools",
"skill_stop_names",
"skill_stop_message",
# 记忆开关:必须在白名单里才会从 body.context 透传到 run config,
@@ -571,6 +577,8 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
"roundtable_peer_deliveries",
# 岗位会商席位 / 方案总结:每轮 outputs 下 write_file 上限(行动规划不传此键)。
"position_artifact_cap",
# 会商专用文件流式兼容。只由会商 run 写入;普通对话不带此键,仍只看全局开关。
"roundtable_write_file_compatibility",
# 上下文压缩开关:由聊天框下方的按钮控制(默认关闭),透传到 run config 后
# 在 _build_middlewares 里决定是否挂载 SummarizationMiddleware。
"summarization_enabled",
@@ -871,6 +879,7 @@ def enforce_reasoning_effort_run_config(
*,
capability_enabled: bool,
model_supports_thinking: bool,
default_reasoning_effort: str = "xhigh",
) -> str | None:
"""Apply the database-backed reasoning policy to both runtime containers.
@@ -889,7 +898,10 @@ def enforce_reasoning_effort_run_config(
thinking_enabled = bool(effective.get("thinking_enabled", True))
if capability_enabled and model_supports_thinking and thinking_enabled:
normalized = _normalize_reasoning_effort(effective.get("reasoning_effort"))
# The selected model's persisted default is authoritative. A per-thread
# value from an older browser must not override the administrator's
# globally shared model policy.
normalized = _normalize_reasoning_effort(default_reasoning_effort)
else:
normalized = None
@@ -903,12 +915,12 @@ def enforce_reasoning_effort_run_config(
return normalized
async def _reasoning_effort_capability(request: Request, model_name: str) -> bool:
"""Read one model capability, failing closed without breaking chat."""
async def _reasoning_effort_policy(request: Request, model_name: str) -> tuple[bool, str]:
"""Read one model capability and its shared default, failing closed."""
store = getattr(request.app.state, "model_capability_store", None)
if store is None:
return False
return False, "xhigh"
try:
row = await store.get_capability(model_name)
except Exception:
@@ -917,8 +929,13 @@ async def _reasoning_effort_capability(request: Request, model_name: str) -> boo
sanitize_log_param(model_name),
exc_info=True,
)
return False
return bool(row and row.get("supports_reasoning_effort", False))
return False, "xhigh"
if not row:
return False, "xhigh"
default = str(row.get("default_reasoning_effort") or "xhigh")
if default not in {"low", "medium", "xhigh"}:
default = "xhigh"
return bool(row.get("supports_reasoning_effort", False)), default
def inject_validated_analysis_project_context(config: dict[str, Any], context: Mapping[str, Any] | None) -> None:
@@ -1062,6 +1079,12 @@ def build_run_config(
target["agent_id"] = normalized
if metadata:
config.setdefault("metadata", {}).update(metadata)
# Chat clients cannot override the administrator's deployment-wide budget.
# Other built-in graphs (writing/canvas) retain their own execution policy.
if assistant_id not in _BUILTIN_GRAPH_IDS or assistant_id == "lead_agent":
from deerflow.config.system_settings import get_conversation_context_settings
config["recursion_limit"] = get_conversation_context_settings().max_run_steps
return config
@@ -1407,6 +1430,7 @@ async def _fail_unstarted_run(
logger.warning("Failed to release concurrency lease for unstarted run %s", record.run_id, exc_info=True)
@bounded_worker_run
async def start_run(
body: Any,
thread_id: str,
@@ -1439,8 +1463,17 @@ async def start_run(
"""
t_entry = time.perf_counter()
from app.gateway.analysis_projects import prepare_project_run
from app.gateway.knowledge_research import (
inject_validated_knowledge_research_context,
prepare_knowledge_research_run,
)
body, analysis_project = await prepare_project_run(body, thread_id, request)
body, knowledge_research = await prepare_knowledge_research_run(
body,
thread_id,
request,
)
bridge = get_stream_bridge(request)
run_mgr = get_run_manager(request)
run_ctx = get_run_context(request)
@@ -1472,7 +1505,7 @@ async def start_run(
)
fixed_answer = (
None
if analysis_project or requested_retrieval_mode == "smart_reasoning"
if analysis_project or knowledge_research is not None or requested_retrieval_mode == "smart_reasoning"
else await _find_fixed_answer(
body=body,
request=request,
@@ -1825,13 +1858,20 @@ async def start_run(
value = getattr(body, top_level_key, None)
if value is not None and top_level_key not in body_context:
body_context[top_level_key] = value
strict_scope_active = apply_knowledge_space_strict_scope(
body_context,
selection_requested=llmwiki_selection_requested(body),
knowledge_base_ids=llmwiki_rag.knowledge_base_ids,
assistant_knowledge_base_ids=llmwiki_rag.assistant_knowledge_base_ids,
app_config=getattr(request.app.state, "config", None),
)
if knowledge_retrieval_mode == "smart_reasoning":
# Smart retrieval is an optional tool surface in the normal agent. It
# retains the validated KB ids but deliberately does not activate the
# classic fail-closed tool/skill/MCP isolation policy.
body_context["knowledge_space_strict"] = False
strict_scope_active = False
else:
strict_scope_active = apply_knowledge_space_strict_scope(
body_context,
selection_requested=llmwiki_selection_requested(body),
knowledge_base_ids=llmwiki_rag.knowledge_base_ids,
assistant_knowledge_base_ids=llmwiki_rag.assistant_knowledge_base_ids,
app_config=getattr(request.app.state, "config", None),
)
if strict_scope_active:
run_metadata["knowledge_space_strict"] = True
elif not llmwiki_rag.knowledge_base_ids:
@@ -1888,7 +1928,7 @@ async def start_run(
effective_model_config
and getattr(effective_model_config, "supports_thinking", False)
)
reasoning_effort_capability = await _reasoning_effort_capability(
reasoning_effort_capability, default_reasoning_effort = await _reasoning_effort_policy(
request,
final_model_name,
)
@@ -1897,6 +1937,7 @@ async def start_run(
config,
capability_enabled=reasoning_effort_capability,
model_supports_thinking=effective_model_supports_thinking,
default_reasoning_effort=default_reasoning_effort,
)
except Exception as exc:
await _fail_unstarted_run(
@@ -1935,6 +1976,8 @@ async def start_run(
# These keys are deliberately absent from the public context whitelist.
# Only the validated project binding/checkpoint can supply them.
inject_validated_analysis_project_context(config, body.context)
if knowledge_research:
inject_validated_knowledge_research_context(config, body.context)
stream_modes = _with_knowledge_retrieval_progress_stream(
normalize_stream_modes(body.stream_mode),
@@ -1995,6 +2038,7 @@ async def start_run(
)
raise
record.task = task
run_mgr.watch_completion(record)
# Title sync is handled by worker.py's finally block which reads the
# title from the checkpoint and calls thread_store.update_display_name
@@ -2049,6 +2093,12 @@ async def sse_consumer(
A truly gone client fails on the next ``yield`` instead.
"""
last_event_id = request.headers.get("Last-Event-ID")
# Completed records outlive the short SSE replay buffer. Rejoining after
# buffer retirement must end cleanly, not create an empty endless stream.
has_stream = getattr(bridge, "has_stream", None)
if record.status not in (RunStatus.pending, RunStatus.running) and callable(has_stream) and not has_stream(record.run_id):
yield format_sse("end", None)
return
try:
async for entry in bridge.subscribe(record.run_id, last_event_id=last_event_id):
if record.on_disconnect == DisconnectMode.cancel and await request.is_disconnected():
@@ -0,0 +1,440 @@
"""MySQL-backed projection for the Gateway skill list endpoint."""
from __future__ import annotations
import asyncio
import json
import logging
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import or_, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.schema import CreateColumn
from deerflow.config.app_config import AppConfig
from deerflow.persistence.engine import get_engine, get_session_factory
from deerflow.persistence.skills.model import SkillCatalogSnapshotRow, SkillRow
from deerflow.skills.storage import get_or_new_skill_storage
logger = logging.getLogger(__name__)
_CATALOG_CACHE_KEY = "global"
_catalog_warm_lock = asyncio.Lock()
_memory_catalog_snapshot: list[dict[str, Any]] | None = None
_memory_catalog_is_fallback = False
_SKILL_LIST_COLUMNS = (
SkillRow.name,
SkillRow.category,
SkillRow.description,
SkillRow.license,
SkillRow.enabled,
SkillRow.always_on,
SkillRow.use_count,
SkillRow.view_count,
SkillRow.patch_count,
SkillRow.last_activity_at,
SkillRow.created_at,
SkillRow.state,
SkillRow.pinned,
SkillRow.featured,
SkillRow.source,
SkillRow.owner_user_id,
SkillRow.published,
SkillRow.name_zh,
SkillRow.square_id,
SkillRow.detail_preview,
)
_SKILL_CATALOG_COLUMNS = {
"category",
"description",
"license",
"enabled",
"available",
"use_count",
"view_count",
"patch_count",
"last_activity_at",
"state",
"pinned",
"featured",
"source",
"source_mtime_ns",
"detail_preview",
}
async def ensure_skill_catalog_schema() -> None:
"""Add compact catalog columns to an existing MySQL/SQLite skills table.
``create_all`` creates new tables but intentionally does not alter existing
ones. These additive columns all have safe defaults (or are nullable), so
this migration preserves existing ownership/detail data and is repeatable.
"""
engine = get_engine()
if engine is None:
return
def _ensure(sync_conn) -> None:
from sqlalchemy import func, inspect, update
table = SkillRow.__table__
inspector = inspect(sync_conn)
if table.name not in inspector.get_table_names():
table.create(sync_conn, checkfirst=True)
return
existing = {column["name"] for column in inspector.get_columns(table.name)}
preparer = sync_conn.dialect.identifier_preparer
quoted_table = preparer.quote(table.name)
for name in sorted(_SKILL_CATALOG_COLUMNS - existing):
column = table.columns[name]
definition = str(CreateColumn(column).compile(dialect=sync_conn.dialect))
sync_conn.exec_driver_sql(f"ALTER TABLE {quoted_table} ADD COLUMN {definition}")
existing.add(name)
# Preserve the short description previously exposed from ``detail``
# without selecting that potentially large field in normal list reads.
sync_conn.execute(
update(table)
.where(table.c.detail_preview.is_(None), table.c.detail.is_not(None))
.values(detail_preview=func.substr(table.c.detail, 1, 1024))
)
existing_indexes = {index["name"] for index in inspector.get_indexes(table.name)}
for index in table.indexes:
if index.name not in existing_indexes:
index.create(sync_conn, checkfirst=True)
async with engine.begin() as conn:
await conn.run_sync(_ensure)
async def load_skill_catalog_projection(
*,
user_id: str,
admin: bool = False,
) -> list[dict[str, Any]] | None:
"""Read lightweight skill catalog columns from MySQL, never SKILL.md or LONGTEXT.
``None`` means persistence is unavailable; an empty list is a valid empty
catalog. Access filtering happens in SQL so private skill metadata never
crosses into the request process for an unrelated user.
"""
session_factory = get_session_factory()
if session_factory is None:
return None
stmt = select(*_SKILL_LIST_COLUMNS).where(SkillRow.available.is_(True))
if not admin:
stmt = stmt.where(
or_(
SkillRow.owner_user_id.is_(None),
SkillRow.owner_user_id == user_id,
SkillRow.published.is_(True),
)
)
stmt = stmt.order_by(SkillRow.created_at.asc())
try:
async with session_factory() as session:
rows = (await session.execute(stmt)).mappings().all()
except Exception:
logger.warning("Skill MySQL catalog projection unavailable; retaining legacy fallback", exc_info=True)
return None
result: list[dict[str, Any]] = []
for row in rows:
item = dict(row)
item["builtin"] = item.get("owner_user_id") is None
result.append(item)
return result
def _parse_catalog_datetime(value: Any) -> datetime | None:
if isinstance(value, datetime):
return value
if not isinstance(value, str) or not value.strip():
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
async def refresh_skill_catalog_projection(
app_config: AppConfig | None = None,
*,
skills: list[Any] | None = None,
) -> int:
"""Synchronize filesystem metadata into one compact MySQL row per skill.
This runs at startup and after explicit skill changes. Request handlers
only read the projection and never walk the skill tree or usage sidecars.
Ownership, publication, and long-form detail columns are preserved.
"""
session_factory = get_session_factory()
if session_factory is None:
return 0
await ensure_skill_catalog_schema()
storage = get_or_new_skill_storage(app_config=app_config)
if skills is None:
skills = await asyncio.to_thread(storage.load_skills, enabled_only=False)
usage_entries = await asyncio.to_thread(_load_usage_entries)
catalog = await asyncio.to_thread(_build_catalog_payload, skills, usage_entries)
metadata_by_name: dict[str, tuple[str, int]] = {}
for skill in skills:
try:
stat = skill.skill_file.stat()
metadata_by_name[skill.name] = (
datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat(),
int(stat.st_mtime_ns),
)
except OSError:
metadata_by_name[skill.name] = ("", 0)
async with session_factory() as session:
existing_rows = (
await session.execute(select(SkillRow.name, SkillRow.owner_user_id, SkillRow.always_on))
).all()
existing = {str(name): (owner_id, bool(always_on)) for name, owner_id, always_on in existing_rows}
catalog_names = {str(item.get("name") or "").strip() for item in catalog}
removed_names = sorted(name for name in existing if name not in catalog_names)
if removed_names:
await session.execute(
update(SkillRow)
.where(SkillRow.name.in_(removed_names))
.values(available=False, updated_at=datetime.now(UTC))
)
for item in catalog:
name = str(item.get("name") or "").strip()
if not name:
continue
category = str(item.get("category") or "custom")[:16]
created_at_text, source_mtime_ns = metadata_by_name.get(name, ("", 0))
created_at = _parse_catalog_datetime(item.get("created_at") or created_at_text) or datetime.now(UTC)
fields: dict[str, Any] = {
"category": category,
"description": str(item.get("description") or "")[:1024],
"license": str(item.get("license") or "")[:255] or None,
"enabled": bool(item.get("enabled", True)),
"available": True,
"use_count": max(0, int(item.get("use_count") or 0)),
"view_count": max(0, int(item.get("view_count") or 0)),
"patch_count": max(0, int(item.get("patch_count") or 0)),
"last_activity_at": _parse_catalog_datetime(item.get("last_activity_at")),
"state": str(item.get("state") or "active")[:16],
"pinned": bool(item.get("pinned")),
"featured": bool(item.get("featured")),
"source": str(item.get("source") or "upload")[:16],
"source_mtime_ns": source_mtime_ns,
"updated_at": datetime.now(UTC),
}
if name in existing:
# The per-skill always_on preference is admin-editable in MySQL
# and must survive filesystem metadata refreshes.
await session.execute(update(SkillRow).where(SkillRow.name == name).values(**fields))
else:
owner_id = None
session.add(
SkillRow(
name=name,
owner_user_id=owner_id,
published=category != "custom",
always_on=bool(item.get("always_on", False)),
created_at=created_at,
**fields,
)
)
await session.commit()
return len(catalog)
def _build_catalog_payload(skills: list[Any], usage_entries: dict[str, Any]) -> list[dict[str, Any]]:
payload: list[dict[str, Any]] = []
defaults = {
"use_count": 0,
"view_count": 0,
"patch_count": 0,
"last_used_at": None,
"last_viewed_at": None,
"last_patched_at": None,
"created_at": None,
"state": "active",
"pinned": False,
"featured": False,
"source": "upload",
}
for skill in skills:
usage = {**defaults, **(usage_entries.get(skill.name) or {})}
created_at = usage.get("created_at")
if not created_at:
try:
created_at = datetime.fromtimestamp(skill.skill_file.stat().st_mtime, tz=UTC).isoformat()
except OSError:
created_at = None
activity = [
value
for value in (
usage.get("last_used_at"),
usage.get("last_viewed_at"),
usage.get("last_patched_at"),
)
if value
]
last_activity_at = max(activity) if activity else created_at
payload.append(
{
"name": skill.name,
"description": skill.description or "",
"license": skill.license,
"category": str(getattr(skill.category, "value", skill.category)),
"enabled": bool(skill.enabled),
"always_on": bool(getattr(skill, "always_on", False)),
"use_count": int(usage.get("use_count") or 0),
"view_count": int(usage.get("view_count") or 0),
"patch_count": int(usage.get("patch_count") or 0),
"last_activity_at": last_activity_at,
"created_at": created_at,
"state": str(usage.get("state") or "active"),
"pinned": bool(usage.get("pinned")),
"featured": bool(usage.get("featured")),
"source": str(usage.get("source") or "upload"),
}
)
return payload
async def refresh_skill_catalog_snapshot(
app_config: AppConfig | None = None,
*,
skills: list[Any] | None = None,
) -> int:
"""Read skill files off-request and persist a single JSON snapshot in MySQL."""
session_factory = get_session_factory()
if session_factory is None:
return 0
storage = get_or_new_skill_storage(app_config=app_config)
if skills is None:
skills = await asyncio.to_thread(storage.load_skills, enabled_only=False)
usage_entries = await asyncio.to_thread(_load_usage_entries)
catalog = await asyncio.to_thread(_build_catalog_payload, skills, usage_entries)
encoded = json.dumps(catalog, ensure_ascii=False, separators=(",", ":"))
await _persist_catalog_snapshot(encoded)
global _memory_catalog_snapshot, _memory_catalog_is_fallback
_memory_catalog_snapshot = catalog
_memory_catalog_is_fallback = False
return len(catalog)
async def _persist_catalog_snapshot(encoded: str) -> None:
session_factory = get_session_factory()
if session_factory is None:
return
row = SkillCatalogSnapshotRow(
cache_key=_CATALOG_CACHE_KEY,
catalog_json=encoded,
updated_at=datetime.now(UTC),
)
for attempt in range(2):
try:
async with session_factory() as session:
await session.merge(row)
await session.commit()
return
except IntegrityError:
if attempt:
raise
# Two workers can warm an empty table at the same time. The loser
# retries as an update after the winner's transaction is visible.
def _load_usage_entries() -> dict[str, Any]:
from deerflow.skills.usage import all_entries
return all_entries()
async def load_skill_catalog_snapshot() -> list[dict[str, Any]] | None:
"""Load the prepared catalogue using MySQL only; None means not warmed."""
session_factory = get_session_factory()
if session_factory is None:
return None
async with session_factory() as session:
row = await session.get(SkillCatalogSnapshotRow, _CATALOG_CACHE_KEY)
if row is None:
return None
try:
payload = json.loads(row.catalog_json)
except (TypeError, ValueError):
logger.error("Invalid skill catalogue snapshot JSON; refresh is required")
return None
if not isinstance(payload, list):
logger.error("Invalid skill catalogue snapshot payload type; refresh is required")
return None
catalog = [item for item in payload if isinstance(item, dict) and item.get("name")]
global _memory_catalog_snapshot, _memory_catalog_is_fallback
_memory_catalog_snapshot = catalog
_memory_catalog_is_fallback = False
return catalog
async def load_or_warm_skill_catalog_snapshot(
app_config: AppConfig | None = None,
) -> list[dict[str, Any]] | None:
"""Load MySQL snapshot, warming it once on a cold/missing snapshot.
Startup warm-up is best-effort. If it missed because of transient startup
timing, the first list request builds the catalogue off the event loop and
stores it before returning. If persistence is temporarily unavailable, a
process-local copy keeps the endpoint usable without rescanning on every
request.
"""
global _memory_catalog_snapshot, _memory_catalog_is_fallback
if get_session_factory() is None:
return None
if _memory_catalog_is_fallback and _memory_catalog_snapshot is not None:
return _memory_catalog_snapshot
try:
catalog = await load_skill_catalog_snapshot()
except Exception:
logger.warning("Skill catalogue snapshot read failed; attempting a one-time warm-up", exc_info=True)
catalog = None
if catalog is not None:
return catalog
async with _catalog_warm_lock:
if _memory_catalog_is_fallback and _memory_catalog_snapshot is not None:
return _memory_catalog_snapshot
try:
catalog = await load_skill_catalog_snapshot()
except Exception:
logger.warning("Skill catalogue snapshot is unavailable during warm-up", exc_info=True)
catalog = None
if catalog is not None:
return catalog
storage = get_or_new_skill_storage(app_config=app_config)
skills = await asyncio.to_thread(storage.load_skills, enabled_only=False)
usage_entries = await asyncio.to_thread(_load_usage_entries)
catalog = await asyncio.to_thread(_build_catalog_payload, skills, usage_entries)
encoded = json.dumps(catalog, ensure_ascii=False, separators=(",", ":"))
try:
await _persist_catalog_snapshot(encoded)
except Exception:
logger.exception("Skill catalogue snapshot persistence failed; serving the warmed process-local copy")
_memory_catalog_snapshot = catalog
_memory_catalog_is_fallback = True
return catalog
_memory_catalog_snapshot = catalog
_memory_catalog_is_fallback = False
logger.info("Skill catalogue snapshot warmed on first request: skills=%d", len(catalog))
return catalog
@@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2015-present, Rebilly, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Binary file not shown.

After

Width:  |  Height:  |  Size: 628 B

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,2 @@
/*! For license information please see swagger-ui-bundle.js.LICENSE.txt */
!function webpackUniversalModuleDefinition(s,o){"object"==typeof exports&&"object"==typeof module?module.exports=o():"function"==typeof define&&define.amd?define([],o):"object"==typeof exports?exports.SwaggerUIBundle=o():s.SwaggerUIBundle=o()}(this,(()=>(()=>{var s={251:(s,o)=>{o.read=function(s,o,i,a,u){var _,w,x=8*u-a-1,C=(1<<x)-1,j=C>>1,L=-7,B=i?u-1:0,$=i?-1:1,U=s[o+B];for(B+=$,_=U&(1<<-L)-1,U>>=-L,L+=x;L>0;_=256*_+s[o+B],B+=$,L-=8);for(w=_&(1<<-L)-1,_>>=-L,L+=a;L>0;w=256*w+s[o+B],B+=$,L-=8);if(0===_)_=1-j;else{if(_===C)return w?NaN:1/0*(U?-1:1);w+=Math.pow(2,a),_-=j}return(U?-1:1)*w*Math.pow(2,_-a)},o.write=function(s,o,i,a,u,_){var w,x,C,j=8*_-u-1,L=(1<<j)-1,B=L>>1,$=23===u?Math.pow(2,-24)-Math.pow(2,-77):0,U=a?0:_-1,V=a?1:-1,z=o<0||0===o&&1/o<0?1:0;for(o=Math.abs(o),isNaN(o)||o===1/0?(x=isNaN(o)?1:0,w=L):(w=Math.floor(Math.log(o)/Math.LN2),o*(C=Math.pow(2,-w))<1&&(w--,C*=2),(o+=w+B>=1?$/C:$*Math.pow(2,1-B))*C>=2&&(w++,C/=2),w+B>=L?(x=0,w=L):w+B>=1?(x=(o*C-1)*Math.pow(2,u),w+=B):(x=o*Math.pow(2,B-1)*Math.pow(2,u),w=0));u>=8;s[i+U]=255&x,U+=V,x/=256,u-=8);for(w=w<<u|x,j+=u;j>0;s[i+U]=255&w,U+=V,w/=256,j-=8);s[i+U-V]|=128*z}},462:(s,o,i)=>{"use strict";var a=i(40975);s.exports=a},659:(s,o,i)=>{var a=i(51873),u=Object.prototype,_=u.hasOwnProperty,w=u.toString,x=a?a.toStringTag:void 0;s.exports=function getRawTag(s){var o=_.call(s,x),i=s[x];try{s[x]=void 0;var a=!0}catch(s){}var u=w.call(s);return a&&(o?s[x]=i:delete s[x]),u}},694:(s,o,i)=>{"use strict";i(91599);var a=i(37257);i(12560),s.exports=a},953:(s,o,i)=>{"use strict";s.exports=i(53375)},1733:s=>{var o=/[^\x00-\x2f\x3a-\x40\x5b-\x60\x7b-\x7f]+/g;s.exports=function asciiWords(s){return s.match(o)||[]}},1882:(s,o,i)=>{var a=i(72552),u=i(23805);s.exports=function isFunction(s){if(!u(s))return!1;var o=a(s);return"[object Function]"==o||"[object GeneratorFunction]"==o||"[object AsyncFunction]"==o||"[object Proxy]"==o}},1907:(s,o,i)=>{"use strict";var a=i(41505),u=Function.prototype,_=u.call,w=a&&u.bind.bind(_,_);s.exports=a?w:function(s){return function(){return _.apply(s,arguments)}}},2205:function(s,o,i){var a;a=void 0!==i.g?i.g:this,s.exports=function(s){if(s.CSS&&s.CSS.escape)return s.CSS.escape;var cssEscape=function(s){if(0==arguments.length)throw new TypeError("`CSS.escape` requires an argument.");for(var o,i=String(s),a=i.length,u=-1,_="",w=i.charCodeAt(0);++u<a;)0!=(o=i.charCodeAt(u))?_+=o>=1&&o<=31||127==o||0==u&&o>=48&&o<=57||1==u&&o>=48&&o<=57&&45==w?"\\"+o.toString(16)+" ":0==u&&1==a&&45==o||!(o>=128||45==o||95==o||o>=48&&o<=57||o>=65&&o<=90||o>=97&&o<=122)?"\\"+i.charAt(u):i.charAt(u):_+="�";return _};return s.CSS||(s.CSS={}),s.CSS.escape=cssEscape,cssEscape}(a)},2209:(s,o,i)=>{"use strict";var a,u=i(9404),_=function productionTypeChecker(){invariant(!1,"ImmutablePropTypes type checking code is stripped in production.")};_.isRequired=_;var w=function getProductionTypeChecker(){return _};function getPropType(s){var o=typeof s;return Array.isArray(s)?"array":s instanceof RegExp?"object":s instanceof u.Iterable?"Immutable."+s.toSource().split(" ")[0]:o}function createChainableTypeChecker(s){function checkType(o,i,a,u,_,w){for(var x=arguments.length,C=Array(x>6?x-6:0),j=6;j<x;j++)C[j-6]=arguments[j];return w=w||a,u=u||"<<anonymous>>",null!=i[a]?s.apply(void 0,[i,a,u,_,w].concat(C)):o?new Error("Required "+_+" `"+w+"` was not specified in `"+u+"`."):void 0}var o=checkType.bind(null,!1);return o.isRequired=checkType.bind(null,!0),o}function createIterableSubclassTypeChecker(s,o){return function createImmutableTypeChecker(s,o){return createChainableTypeChecker((function validate(i,a,u,_,w){var x=i[a];if(!o(x)){var C=getPropType(x);return new Error("Invalid "+_+" `"+w+"` of type `"+C+"` supplied to `"+u+"`, expected `"+s+"`.")}return null}))}("Iterable."+s,(function(s){return u.Iterable.isIterable(s)&&o(s)}))}(a={listOf:w,mapOf:w,orderedMapOf:w,setOf:w,orderedSetOf:w,stackOf:w,iterableOf:w,recordOf:w,shape:w,contains:w,mapContains:w,orderedMapContains:w,list:_,map:_,orderedMap:_,set:_,orderedSet:_,stack:_,seq:_,record:_,iterable:_}).iterable.indexed=createIterableSubclassTypeChecker("Indexed",u.Iterable.isIndexed),a.iterable.keyed=createIterableSubclassTypeChecker("Keyed",u.Iterable.isKeyed),s.exports=a},2404:(s,o,i)=>{var a=i(60270);s.exports=function isEqual(s,o){return a(s,o)}},2523:s=>{s.exports=function baseFindIndex(s,o,i,a){for(var u=s.length,_=i+(a?1:-1);a?_--:++_<u;)if(o(s[_],_,s))return _;return-1}},2532:(s,o,i)=>{"use strict";var a=i(45951),u=Object.defineProperty;s.exports=function(s,o){try{u(a,s,{value:o,configurable:!0,writable:!0})}catch(i){a[s]=o}return o}},2694:(s,o,i)=>{"use strict";var a=i(6925);function emptyFunction(){}function emptyFunctionWithReset(){}emptyFunctionWithReset.resetWarningCache=emptyFunction,s.exports=function(){function shim(s,o,i,u,_,w){if(w!==a){var x=new Error("Calling PropTypes validators directly is not supported by the `prop-types` package. Use PropTypes.checkPropTypes() to call them. Read more at httpLine truncated
@@ -0,0 +1,3 @@
.swagger-ui{color:#3b4151;font-family:sans-serif}.swagger-ui html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}.swagger-ui body{margin:0}.swagger-ui article,.swagger-ui aside,.swagger-ui footer,.swagger-ui header,.swagger-ui nav,.swagger-ui section{display:block}.swagger-ui h1{font-size:2em;margin:.67em 0}.swagger-ui figcaption,.swagger-ui figure,.swagger-ui main{display:block}.swagger-ui figure{margin:1em 40px}.swagger-ui hr{box-sizing:content-box;height:0;overflow:visible}.swagger-ui pre{font-family:monospace,monospace;font-size:1em}.swagger-ui a{background-color:transparent;-webkit-text-decoration-skip:objects}.swagger-ui abbr[title]{border-bottom:none;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.swagger-ui b,.swagger-ui strong{font-weight:inherit;font-weight:bolder}.swagger-ui code,.swagger-ui kbd,.swagger-ui samp{font-family:monospace,monospace;font-size:1em}.swagger-ui dfn{font-style:italic}.swagger-ui mark{background-color:#ff0;color:#000}.swagger-ui small{font-size:80%}.swagger-ui sub,.swagger-ui sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}.swagger-ui sub{bottom:-.25em}.swagger-ui sup{top:-.5em}.swagger-ui audio,.swagger-ui video{display:inline-block}.swagger-ui audio:not([controls]){display:none;height:0}.swagger-ui img{border-style:none}.swagger-ui svg:not(:root){overflow:hidden}.swagger-ui button,.swagger-ui input,.swagger-ui optgroup,.swagger-ui select,.swagger-ui textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}.swagger-ui button,.swagger-ui input{overflow:visible}.swagger-ui button,.swagger-ui select{text-transform:none}.swagger-ui [type=reset],.swagger-ui [type=submit],.swagger-ui button,.swagger-ui html [type=button]{-webkit-appearance:button}.swagger-ui [type=button]::-moz-focus-inner,.swagger-ui [type=reset]::-moz-focus-inner,.swagger-ui [type=submit]::-moz-focus-inner,.swagger-ui button::-moz-focus-inner{border-style:none;padding:0}.swagger-ui [type=button]:-moz-focusring,.swagger-ui [type=reset]:-moz-focusring,.swagger-ui [type=submit]:-moz-focusring,.swagger-ui button:-moz-focusring{outline:1px dotted ButtonText}.swagger-ui fieldset{padding:.35em .75em .625em}.swagger-ui legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}.swagger-ui progress{display:inline-block;vertical-align:baseline}.swagger-ui textarea{overflow:auto}.swagger-ui [type=checkbox],.swagger-ui [type=radio]{box-sizing:border-box;padding:0}.swagger-ui [type=number]::-webkit-inner-spin-button,.swagger-ui [type=number]::-webkit-outer-spin-button{height:auto}.swagger-ui [type=search]{-webkit-appearance:textfield;outline-offset:-2px}.swagger-ui [type=search]::-webkit-search-cancel-button,.swagger-ui [type=search]::-webkit-search-decoration{-webkit-appearance:none}.swagger-ui ::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}.swagger-ui details,.swagger-ui menu{display:block}.swagger-ui summary{display:list-item}.swagger-ui canvas{display:inline-block}.swagger-ui [hidden],.swagger-ui template{display:none}.swagger-ui .debug *{outline:1px solid gold}.swagger-ui .debug-white *{outline:1px solid #fff}.swagger-ui .debug-black *{outline:1px solid #000}.swagger-ui .debug-grid{background:transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MTRDOTY4N0U2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MTRDOTY4N0Q2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3NjY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3NzY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PsBS+GMAAAAjSURBVHjaYvz//z8DLsD4gcGXiYEAGBIKGBne//fFpwAgwAB98AaF2pjlUQAAAABJRU5ErkJggg==) repeat 0 0}.swagger-ui .debug-grid-16{background:transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDbLine truncated
/*# sourceMappingURL=swagger-ui.css.map*/
@@ -0,0 +1 @@
"""Single-host managed online workers; background scheduling stays on the gateway."""
@@ -0,0 +1,206 @@
"""Bound executing runs, including detached/background runs, not HTTP streams."""
from __future__ import annotations
import asyncio
from contextvars import ContextVar
from functools import wraps
from fastapi import HTTPException
_worker_ticket = ContextVar("worker_admission_ticket", default=None)
class AdmissionTicket:
def __init__(self, gate):
if gate.active + gate.waiting >= gate.capacity + gate.queue_limit:
raise HTTPException(429, "执行节点繁忙,请稍后重试", headers={"Retry-After": "3"})
self.gate = gate
self.deadline = asyncio.get_running_loop().time() + gate.timeout
self.waiting = True
self.acquired = False
self.released = False
self.started = False
gate.waiting += 1
async def acquire(self):
try:
remaining = max(0.001, self.deadline - asyncio.get_running_loop().time())
await asyncio.wait_for(self.gate.semaphore.acquire(), remaining)
self.acquired = True
self.gate.active += 1
except TimeoutError:
raise HTTPException(429, "等待执行超时,请稍后重试") from None
finally:
if self.waiting:
self.gate.waiting -= 1
self.waiting = False
def release(self, _task=None):
if self.released:
return
self.released = True
if self.waiting:
self.gate.waiting -= 1
self.waiting = False
if self.acquired:
self.gate.active -= 1
self.gate.semaphore.release()
async def _finish_queued_run(kwargs, *, cancelled=False, message="等待执行超时,请稍后重试"):
from deerflow.runtime.runs.schemas import RunStatus
manager, record, bridge = kwargs["run_manager"], kwargs["record"], kwargs["bridge"]
try:
async with asyncio.timeout(5):
await manager.set_status(record.run_id, RunStatus.interrupted if cancelled else RunStatus.error, error=None if cancelled else message)
ctx = kwargs.get("ctx")
store = getattr(ctx, "thread_store", None)
if store is not None and not getattr(record, "superseded", False):
await store.update_status(record.thread_id, "interrupted" if cancelled else "error")
finally:
try:
if not cancelled:
await bridge.publish(record.run_id, "error", {"name": "WorkerQueueTimeout", "message": message})
finally:
await bridge.publish_end(record.run_id)
asyncio.create_task(bridge.cleanup(record.run_id, delay=60))
async def run_with_worker_admission(runner, **kwargs):
ticket = _worker_ticket.get()
if ticket is None:
return await runner(**kwargs)
ticket.started = True
record, bridge = kwargs["record"], kwargs["bridge"]
# Publish a real, persisted run id before waiting for an execution slot.
# The router keeps its canonical Location/reconnect headers unchanged.
try:
await bridge.publish(record.run_id, "metadata", {"run_id": record.run_id, "thread_id": record.thread_id,
"worker_queue": {"waiting": ticket.gate.waiting, "capacity": ticket.gate.capacity}})
await ticket.acquire()
except asyncio.CancelledError:
await _finish_queued_run(kwargs, cancelled=True)
raise
except HTTPException as exc:
await _finish_queued_run(kwargs, message=str(exc.detail))
return
except Exception:
await _finish_queued_run(kwargs, message="执行节点排队初始化失败,请查看节点日志")
return
return await runner(**kwargs)
class Admission:
def __init__(self, capacity: int, queue_limit: int = 128, timeout: float = 60):
self.capacity = capacity
self.active = 0
self.waiting = 0
self.queue_limit = queue_limit
self.timeout = timeout
self.semaphore = asyncio.Semaphore(capacity)
self.lock = asyncio.Lock()
async def acquire(self):
async with self.lock:
if self.waiting >= self.queue_limit:
raise HTTPException(429, "执行节点繁忙,请稍后重试", headers={"Retry-After": "3"})
self.waiting += 1
try:
await asyncio.wait_for(self.semaphore.acquire(), self.timeout)
except TimeoutError:
raise HTTPException(429, "等待执行超时,请稍后重试", headers={"Retry-After": "3"}) from None
finally:
self.waiting -= 1
self.active += 1
def release(self, _task=None):
self.active -= 1
self.semaphore.release()
def bounded_worker_run(function):
@wraps(function)
async def wrapped(body, thread_id, request, *args, **kwargs):
pool = getattr(request.app.state, "worker_pool", None)
fallback_to_main = bool(getattr(getattr(request, "state", None), "worker_pool_fallback", False))
if pool is not None and pool.db is not None and pool.db.execute("SELECT 1 FROM affinity WHERE thread=?", (thread_id,)).fetchone() and not fallback_to_main:
raise HTTPException(409, "该会话由独立 Worker 管理,请通过会话接口提交,不能在主服务重复执行")
gate = getattr(request.app.state, "worker_admission", None)
if gate is None:
if pool is None:
return await function(body, thread_id, request, *args, **kwargs)
if pool.local_threads.get(thread_id, 0):
raise HTTPException(409, "该会话正在主服务执行,请等待当前回答完成")
# Reserve before the first await, including start_run's preparation
# phase before RunRecord.task exists. Otherwise a concurrent routed
# request could assign this same thread to another process.
pool.local_threads[thread_id] = pool.local_threads.get(thread_id, 0) + 1
def release_local(_task=None):
remaining = pool.local_threads.get(thread_id, 1) - 1
if remaining:
pool.local_threads[thread_id] = remaining
else:
pool.local_threads.pop(thread_id, None)
try:
record = await function(body, thread_id, request, *args, **kwargs)
except BaseException:
release_local()
raise
if record.task is None:
release_local()
else:
record.task.add_done_callback(release_local)
return record
if getattr(request.app.state, "worker_deferred_admission", False) and getattr(request.app.state, "concurrency_gate", None) is None:
ticket = AdmissionTicket(gate)
token = _worker_ticket.set(ticket)
try:
from deerflow.runtime.thread_paths import aresolve_path_user_id
await aresolve_path_user_id(thread_id)
refresh = getattr(request.app.state, "worker_skill_refresh", None)
if refresh is not None:
await refresh.check()
record = await function(body, thread_id, request, *args, **kwargs)
except BaseException:
ticket.release()
raise
finally:
_worker_ticket.reset(token)
if record.task is None:
ticket.release()
else:
def finish_ticket(task):
ticket.release()
if task.cancelled() and not ticket.started:
manager = getattr(request.app.state, "run_manager", None)
bridge = getattr(request.app.state, "stream_bridge", None)
if manager is not None and bridge is not None:
asyncio.create_task(_finish_queued_run({"run_manager": manager, "record": record, "bridge": bridge}, cancelled=True))
record.task.add_done_callback(finish_ticket)
return record
await gate.acquire()
try:
# File/path middleware is synchronous. Resolve the persisted owner
# on the database's home loop before it touches those helpers.
from deerflow.runtime.thread_paths import aresolve_path_user_id
await aresolve_path_user_id(thread_id)
refresh = getattr(request.app.state, "worker_skill_refresh", None)
if refresh is not None:
await refresh.check()
record = await function(body, thread_id, request, *args, **kwargs)
except BaseException:
gate.release()
raise
if record.task is None:
gate.release()
else:
record.task.add_done_callback(gate.release)
return record
return wrapped
@@ -0,0 +1,359 @@
"""One gateway owns reconciliation and durable conversation affinity.
Never move conversations from a live worker. A draining worker is stopped only
after its run registry AND outstanding HTTP requests are empty. Failure does
not replay a request, because a model/tool may already have produced effects.
"""
from __future__ import annotations
import asyncio
import json
import os
import sqlite3
import time
from urllib.parse import quote
import httpx
from .docker import DockerEngine, DockerError, worker_spec
from .settings import SettingsStore
class PoolUnavailable(RuntimeError):
pass
class Controller:
def __init__(self, store: SettingsStore):
self.store = store
self.lock = asyncio.Lock()
self.route_lock = asyncio.Lock()
self.nodes: dict[str, dict] = {}
self.error: str | None = None
self.task: asyncio.Task | None = None
self._lock_handle = None
self.http = httpx.AsyncClient(timeout=httpx.Timeout(15, read=None), trust_env=False, limits=httpx.Limits(max_connections=256, max_keepalive_connections=64))
self.db: sqlite3.Connection | None = None
self.pending = 0
self.inflight: dict[str, int] = {}
self.local_threads: dict[str, int] = {}
self._python_probe_key: tuple | None = None
self._python_probe_result: str | None = None
self._worker_image_key: tuple | None = None
self._worker_image_result: str | None = None
self.discovery_complete = not bool(store.load().source_container)
def _ensure_db(self):
if self.db is None:
self.db = sqlite3.connect(self.store.directory / "affinity.sqlite3")
self.db.execute("PRAGMA journal_mode=WAL")
self.db.execute("CREATE TABLE IF NOT EXISTS affinity (thread TEXT PRIMARY KEY, node TEXT NOT NULL)")
self.db.commit()
return self.db
def _acquire_controller_lock(self):
if self._lock_handle is not None:
return
self.store.directory.mkdir(mode=0o700, parents=True, exist_ok=True)
handle = (self.store.directory / "controller.lock").open("a+b")
try:
if os.name == "nt":
import msvcrt
handle.seek(0)
if not handle.read(1):
handle.write(b"0")
handle.flush()
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
handle.close()
raise RuntimeError("Worker 池只能由一个主服务进程管理;请关闭重复的 Worker 池控制器") from None
self._lock_handle = handle
def start(self):
settings = self.store.load()
if settings.enabled:
self._acquire_controller_lock()
self._ensure_db()
if self.task is None or self.task.done():
self.task = asyncio.create_task(self.loop())
if hasattr(self, "diagnostics") and self.diagnostics.enabled:
self.diagnostics.start()
async def close(self):
if self.task:
self.task.cancel()
await asyncio.gather(self.task, return_exceptions=True)
if hasattr(self, "diagnostics"):
await self.diagnostics.close()
await self.http.aclose()
if self.db is not None:
self.db.close()
self.db = None
self._release_controller_lock()
async def loop(self):
try:
while True:
try:
await self.reconcile()
except Exception as exc:
self.error = str(exc) if isinstance(exc, (DockerError, PoolUnavailable)) else f"节点检查失败({type(exc).__name__})"
settings = self.store.load()
if not settings.enabled and not self.nodes:
return
await asyncio.sleep(5)
finally:
if not self.store.load().enabled:
self._release_controller_lock()
def _release_controller_lock(self):
if self._lock_handle is None:
return
if os.name == "nt":
import msvcrt
self._lock_handle.seek(0)
msvcrt.locking(self._lock_handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(self._lock_handle, fcntl.LOCK_UN)
self._lock_handle.close()
self._lock_handle = None
async def inspect_template(self, settings):
config = settings
if config.desired * config.cpu > config.total_cpu or config.desired * config.memory_mb > config.total_memory_mb:
raise PoolUnavailable("Worker 总资源超过设置的预算,请减少数量或调整预算")
if config.desired * (config.database_pool_size + 2 * config.postgres_pool_size) > config.database_connection_budget:
raise PoolUnavailable("Worker 数据库连接总额度超过预算,请减少数量或单节点连接数")
if not config.source_container or not config.network:
raise PoolUnavailable("请填写源容器名称和 Docker 网络")
engine = DockerEngine(config)
source = await asyncio.to_thread(engine.request, "GET", f"/containers/{quote(config.source_container, safe='')}/json?size=true")
if source.get("Config", {}).get("Labels", {}).get("deerflow.worker-pool"):
raise PoolUnavailable("源容器必须是主服务,不能选择已有 Worker")
backend = await asyncio.to_thread(
engine.configured_storage_backend, source, config.python_executable, config.working_dir,
)
probe_key = (
config.transport, config.host, config.port, config.username, config.source_container,
source.get("Id"), source.get("Image"), config.python_executable,
config.working_dir, source.get("Config", {}).get("User", ""), backend,
)
if probe_key == self._python_probe_key and self._python_probe_result:
python_executable = self._python_probe_result
else:
python_executable = await asyncio.to_thread(
engine.discover_worker_python, source, config.python_executable, config.working_dir,
)
self._python_probe_key = probe_key
self._python_probe_result = python_executable
if python_executable != config.python_executable:
config = config.model_copy(update={"python_executable": python_executable})
self.store.save(config)
self._python_probe_key = (
config.transport, config.host, config.port, config.username, config.source_container,
source.get("Id"), source.get("Image"), config.python_executable,
config.working_dir, source.get("Config", {}).get("User", ""), backend,
)
runtime_image = None
if config.enabled and config.desired > 0:
image_key = (source.get("Id"), source.get("Image"), python_executable, backend)
if image_key != self._worker_image_key or not self._worker_image_result:
self._worker_image_result = await asyncio.to_thread(
engine.snapshot_worker_runtime, source, self.store.raw()["instance"],
)
self._worker_image_key = image_key
runtime_image = self._worker_image_result
spec = worker_spec(
source, config, self.store.raw()["instance"], self.store.raw()["token"],
str(self.store.directory), runtime_image=runtime_image,
require_shared_archive=backend == "postgres" and config.enabled and config.desired > 0,
)
return engine, spec
async def reconcile(self):
async with self.lock:
settings = self.store.load()
if not settings.enabled and not settings.source_container:
return
db = self._ensure_db()
engine, spec = await self.inspect_template(settings)
instance = self.store.raw()["instance"]
filters = quote(json.dumps({"label": [f"deerflow.worker-pool={instance}"]}))
containers = await asyncio.to_thread(engine.request, "GET", f"/containers/json?all=1&filters={filters}")
self.discovery_complete = True
desired = settings.desired if settings.enabled else 0
wanted = {f"dfw-{instance}-{i}" for i in range(desired)}
existing = {row["Names"][0].lstrip("/"): row for row in containers}
stale_templates = set()
for name, row in list(existing.items()):
self.nodes.setdefault(name, {"name": name, "id": row["Id"], "ready": False, "active": 0, "draining": name not in wanted, "url": f"http://{name}:8001"})
if name in wanted and row.get("State") == "running" and row.get("ImageID") and row["ImageID"] != spec["Image"]:
stale_templates.add(name)
self.nodes[name]["draining"] = True
# A stopped container cannot be executing a request. If its saved
# startup template has an old Python path, replace it with the
# resolved template instead of restarting the same broken command.
for name, row in list(existing.items()):
if name not in wanted or row["State"] == "running":
continue
detail = await asyncio.to_thread(engine.request, "GET", f"/containers/{quote(row['Id'], safe='')}/json")
worker_config = detail.get("Config", {})
stale_template = (
detail.get("Image") != spec["Image"]
or worker_config.get("Entrypoint") != spec["Entrypoint"]
or worker_config.get("WorkingDir") != spec["WorkingDir"]
)
if not stale_template:
continue
if hasattr(self, "diagnostics"):
await self.diagnostics.archive_logs(engine, name)
await asyncio.to_thread(engine.request, "DELETE", f"/containers/{quote(row['Id'], safe='')}")
db.execute("DELETE FROM affinity WHERE node=?", (name,))
db.commit()
existing.pop(name, None)
self.nodes.pop(name, None)
for name, row in list(existing.items()):
previous = self.nodes.get(name, {})
node = {
"name": name,
"id": row["Id"],
"ready": previous.get("ready", False),
"ever_ready": previous.get("ever_ready", False),
"active": previous.get("active", 0),
"capacity": previous.get("capacity"),
"draining": name not in wanted or name in stale_templates,
"url": f"http://{name}:8001",
"checked_at": time.time(),
}
for key in ("running", "waiting", "process_started_at", "runtime_revision", "event_loop_lag_ms", "http"):
if key in previous:
node[key] = previous[key]
self.nodes[name] = node
if row["State"] != "running":
node["ready"] = False
node["ever_ready"] = False
# A confirmed stopped process cannot keep executing. Old
# conversations can move only after this confirmation.
db.execute("DELETE FROM affinity WHERE node=?", (name,))
db.commit()
if name in wanted:
await asyncio.to_thread(engine.request, "POST", f"/containers/{row['Id']}/start")
else:
if hasattr(self, "diagnostics"):
await self.diagnostics.archive_logs(engine, name)
await asyncio.to_thread(engine.request, "DELETE", f"/containers/{row['Id']}")
self.nodes.pop(name, None)
continue
try:
diagnostics_enabled = bool(getattr(getattr(self, "diagnostics", None), "enabled", False))
response = await self.http.get(
node["url"] + "/internal/worker-status",
headers={
"x-deerflow-worker-token": self.store.raw()["token"],
"x-deerflow-diagnostics-enabled": "true" if diagnostics_enabled else "false",
},
timeout=3,
)
response.raise_for_status()
status = response.json()
node.update(ready=status["ready"], active=status["active"], capacity=status["capacity"], ever_ready=True)
for key in ("running", "waiting", "process_started_at", "runtime_revision", "event_loop_lag_ms", "http"):
if key in status:
node[key] = status[key]
except (httpx.HTTPError, ValueError, KeyError):
node["ready"] = False
node["error"] = "等待就绪或连接失败;请检查主容器是否位于同一网络、镜像代码和聊天存储配置"
continue
if node["draining"] and not node["active"] and not self.inflight.get(name, 0):
# Refuse all new reservations while stop is in flight.
node["ready"] = False
node["ever_ready"] = False
await asyncio.to_thread(engine.request, "POST", f"/containers/{row['Id']}/stop?t=30")
if hasattr(self, "diagnostics"):
await self.diagnostics.archive_logs(engine, name)
await asyncio.to_thread(engine.request, "DELETE", f"/containers/{row['Id']}")
db.execute("DELETE FROM affinity WHERE node=?", (name,))
db.commit()
self.nodes.pop(name, None)
existing.pop(name, None)
# Replacements are created only after the previous container has
# drained and been removed. This also refreshes Workers after the
# main service's runtime image or configured storage backend changes.
for name in sorted(wanted - existing.keys()):
row = await asyncio.to_thread(engine.request, "POST", f"/containers/create?name={name}", spec)
self.nodes[name] = {
"name": name,
"id": row["Id"],
"ready": False,
"active": 0,
"draining": False,
"url": f"http://{name}:8001",
}
await asyncio.to_thread(engine.request, "POST", f"/containers/{row['Id']}/start")
existing[name] = {"Id": row["Id"], "State": "running", "ImageID": spec["Image"]}
# Missing nodes are not silently reassigned: an operator must
# reconcile the actual container state before changing affinity.
self.error = None
async def reserve(self, thread: str, *, create: bool) -> dict | None:
settings = self.store.load()
if not settings.enabled and not self.nodes:
return None
db = self._ensure_db()
# Container creation/SSH can be slow; routing must never wait on that lock.
async with self.route_lock:
row = db.execute("SELECT node FROM affinity WHERE thread=?", (thread,)).fetchone()
if row is None and create and self.local_threads.get(thread, 0):
raise PoolUnavailable("该会话正在主服务执行,请等待当前轮完成")
if row:
node = self.nodes.get(row[0])
if node and node.get("draining") and create:
return {"_fallback_to_main": True, "_fallback_reason": "worker_draining", "_worker": dict(node)}
# A delayed health probe stops new work, but must not hide an
# existing run's result. Keep reads/cancellation on its owner;
# stopped containers clear ever_ready before affinity changes.
if not node or (not node["ready"] and (create or not node.get("ever_ready"))):
raise PoolUnavailable("该会话的执行节点暂不可用,请稍后重试;系统不会重复提交任务")
if node["draining"] and create:
raise PoolUnavailable("该会话节点正在停止接单,请等待当前任务完成后重试")
if create:
capacity = max(1, int(node.get("capacity") or settings.concurrency))
load = max(int(node.get("active") or 0), self.inflight.get(node["name"], 0))
if load >= capacity:
return {"_fallback_to_main": True, "_fallback_reason": "worker_saturated", "_worker": dict(node)}
elif not settings.enabled or not create:
return None
else:
choices = [n for n in self.nodes.values() if n["ready"] and not n["draining"]]
if not choices:
return {"_fallback_to_main": True, "_fallback_reason": "workers_unavailable"}
available = []
for candidate in choices:
capacity = max(1, int(candidate.get("capacity") or settings.concurrency))
load = max(int(candidate.get("active") or 0), self.inflight.get(candidate["name"], 0))
if load < capacity:
available.append((load, candidate))
if not available:
return {"_fallback_to_main": True, "_fallback_reason": "worker_pool_saturated"}
_, node = min(available, key=lambda item: (item[0], item[1]["name"]))
db.execute("INSERT INTO affinity VALUES (?, ?)", (thread, node["name"]))
db.commit()
self.inflight[node["name"]] = self.inflight.get(node["name"], 0) + 1
return dict(node)
def release(self, name):
self.inflight[name] = max(0, self.inflight.get(name, 0) - 1)
def status(self):
return {"settings": self.store.public(), "nodes": [{k: v for k, v in n.items() if k != "url"} for n in self.nodes.values()], "error": self.error}
@@ -0,0 +1,348 @@
"""Admin-enabled, bounded diagnostics with independent, short DB probes."""
import asyncio
import json
import os
import re
import sqlite3
import time
from collections import deque
from urllib.parse import quote, urlencode
from .docker import DockerEngine, DockerError, decode_docker_output
from .host_metrics import rates
from .telemetry import RequestRecorder
def redact_logs(value):
value = re.sub(r"(?i)([a-z][a-z0-9+.-]*://)[^\s/@]+:[^\s/@]+@", r"\1[REDACTED]@", value)
value = re.sub(r"(?i)(Bearer\s+)[^\s\"']+", r"\1[REDACTED]", value)
value = re.sub(r"(?i)((?:password|passwd|api[_-]?key|access[_-]?token|secret|authorization|token)[\"']?\s*[:=]\s*[\"']?)[^\s,\"'}]+", r"\1[REDACTED]", value)
value = re.sub(r"\beyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+", "[REDACTED_JWT]", value)
value = re.sub(r"\bsk-[a-zA-Z0-9_-]{12,}", "[REDACTED_KEY]", value)
return value
def container_metrics(detail, stats, previous_stats=None):
cpu = stats.get("cpu_stats", {})
previous = previous_stats.get("cpu_stats", {}) if previous_stats else stats.get("precpu_stats", {})
delta = cpu.get("cpu_usage", {}).get("total_usage", 0) - previous.get("cpu_usage", {}).get("total_usage", 0)
system_delta = cpu.get("system_cpu_usage", 0) - previous.get("system_cpu_usage", 0)
cores = cpu.get("online_cpus") or len(cpu.get("cpu_usage", {}).get("percpu_usage", [])) or 1
memory = stats.get("memory_stats", {})
cache = memory.get("stats", {}).get("inactive_file", memory.get("stats", {}).get("total_inactive_file", 0))
state = detail.get("State", {})
return {
"name": detail.get("Name", "").lstrip("/"),
"image": detail.get("Config", {}).get("Image", ""),
"image_id": detail.get("Image", "")[:22],
"created_at": detail.get("Created"),
"started_at": state.get("StartedAt"),
"status": state.get("Status"),
"health": state.get("Health", {}).get("Status"),
"restarts": detail.get("RestartCount", 0),
"oom_killed": state.get("OOMKilled", False),
"cpu_percent": round(delta / system_delta * cores * 100, 2) if previous.get("system_cpu_usage") and system_delta > 0 and delta >= 0 else None,
"cpu_limit": detail.get("HostConfig", {}).get("NanoCpus", 0) / 1e9 or None,
"memory_bytes": max(0, memory.get("usage", 0) - cache),
"memory_limit": memory.get("limit"),
"pids": stats.get("pids_stats", {}).get("current"),
"log_policy": detail.get("HostConfig", {}).get("LogConfig", {}),
"host_proc_mounted": any(m.get("Destination") == "/host/proc" for m in detail.get("Mounts", [])),
"docker_socket_mounted": any(m.get("Destination") == "/var/run/docker.sock" for m in detail.get("Mounts", [])),
}
class Diagnostics:
def __init__(self, pool, app):
self.pool = pool
self.app = app
self.path = pool.store.directory / "diagnostics-settings.json"
self.archive_directory = pool.store.directory / "log-archive"
self.enabled = json.loads(self.path.read_text()).get("enabled", False) if self.path.exists() else False
self.recorder = RequestRecorder()
self.recorder.enabled = self.enabled
app.state.request_recorder = self.recorder
self.history = deque(maxlen=720)
self.latest = None
self.previous = None
self.container_previous = {}
from .revision import code_revision
self.runtime_revision = code_revision()
self.task = None
self.lag_task = None
self.loop_lag_ms = 0
self.db_engines = {}
self.db = None
if self.enabled:
self._ensure_db()
def _ensure_db(self):
if self.db is None:
self.db = sqlite3.connect(self.pool.store.directory / "diagnostics.sqlite3")
self.db.execute("PRAGMA journal_mode=WAL")
self.db.execute("CREATE TABLE IF NOT EXISTS snapshots (id INTEGER PRIMARY KEY, at REAL, body TEXT)")
self.db.execute("CREATE TABLE IF NOT EXISTS requests (id INTEGER PRIMARY KEY, at REAL, body TEXT)")
for (value,) in reversed(self.db.execute("SELECT body FROM snapshots ORDER BY id DESC LIMIT 720").fetchall()):
self.history.append(json.loads(value))
for (value,) in reversed(self.db.execute("SELECT body FROM requests ORDER BY id DESC LIMIT 2000").fetchall()):
self.recorder.completed.append(json.loads(value))
return self.db
def start(self):
if not self.enabled:
return
self._ensure_db()
if self.task is not None and not self.task.done():
return
self.task = asyncio.create_task(self.loop())
self.lag_task = asyncio.create_task(self.measure_lag())
async def close(self):
for task in (self.task, self.lag_task):
if task:
task.cancel()
await asyncio.gather(*(t for t in (self.task, self.lag_task) if t), return_exceptions=True)
self.persist_requests()
if self.db is not None:
self.db.close()
self.db = None
for engine in self.db_engines.values():
await engine.dispose()
async def set_enabled(self, enabled):
self.enabled = enabled
self.recorder.enabled = enabled
temporary = self.path.with_suffix(".tmp")
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as stream:
json.dump({"enabled": enabled}, stream)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, self.path)
if enabled:
self._ensure_db()
self.start()
else:
tasks = [task for task in (self.task, self.lag_task) if task]
for task in tasks:
if task:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
self.task = None
self.lag_task = None
self.previous = None
self.persist_requests()
async def measure_lag(self):
while True:
before = asyncio.get_running_loop().time()
await asyncio.sleep(1)
self.loop_lag_ms = round(max(0, asyncio.get_running_loop().time() - before - 1) * 1000, 2) if self.enabled else 0
async def loop(self):
while True:
if self.enabled:
try:
await self.sample()
except Exception as exc:
self.latest = {"at": time.time(), "error": f"采集失败({type(exc).__name__})"}
await asyncio.sleep(5)
def persist_requests(self):
batch = []
while self.recorder.pending:
row = self.recorder.pending.popleft()
batch.append((row["started"], json.dumps(row)))
if batch and self.db is not None:
self.db.executemany("INSERT INTO requests(at, body) VALUES (?, ?)", batch)
self.db.execute("DELETE FROM requests WHERE id <= (SELECT COALESCE(MAX(id),0)-10000 FROM requests)")
self.db.commit()
async def sample(self):
self._ensure_db()
settings = self.pool.store.load()
engine = DockerEngine(settings)
names = [settings.source_container, *list(self.pool.nodes)]
async def read_container(name):
try:
encoded = quote(name, safe="")
detail = await asyncio.to_thread(engine.request, "GET", f"/containers/{encoded}/json")
stats = await asyncio.to_thread(engine.request, "GET", f"/containers/{encoded}/stats?stream=false&one-shot=true")
key = detail.get("Id", name)
result = container_metrics(detail, stats, self.container_previous.get(key))
self.container_previous[key] = stats
return result
except Exception as exc:
return {"name": name, "error": f"节点指标不可用({type(exc).__name__})"}
async def host():
try:
raw = await asyncio.to_thread(engine.host_snapshot, settings.source_container)
result = rates(raw, self.previous)
self.previous = raw
return result
except Exception as exc:
detail = str(exc) if isinstance(exc, DockerError) else type(exc).__name__
return {"available": False, "message": f"宿主机采集不可用:{detail}。请检查只读挂载和 Docker 连接"}
host_data, containers, databases = await asyncio.gather(host(), asyncio.gather(*(read_container(n) for n in names if n)), self.database_metrics())
result = {"at": time.time(), "host": host_data, "containers": containers, "databases": databases, "event_loop_lag_ms": self.loop_lag_ms, "nodes": self.pool.status()["nodes"], "runtime_revision": self.runtime_revision}
self.latest = result
summary = {
"at": result["at"],
"cpu_percent": host_data.get("cpu_percent"),
"iowait_percent": host_data.get("iowait_percent"),
"memory_used_percent": round(100 * (1 - host_data["memory_available"] / host_data["memory_total"]), 2) if host_data.get("memory_total") else None,
"rx_bps": sum(v.get("rx_bps") or 0 for v in host_data.get("interfaces", {}).values()),
"tx_bps": sum(v.get("tx_bps") or 0 for v in host_data.get("interfaces", {}).values()),
"loop_lag_ms": self.loop_lag_ms,
"mysql_connections": databases.get("mysql", {}).get("connected"),
"postgres_connections": databases.get("postgres", {}).get("connected"),
"p95_headers_ms": self.recorder.summary()["p95_headers_ms"],
}
self.history.append(summary)
self.db.execute("INSERT INTO snapshots(at,body) VALUES (?,?)", (result["at"], json.dumps(summary)))
self.db.execute("DELETE FROM snapshots WHERE id <= (SELECT COALESCE(MAX(id),0)-720 FROM snapshots)")
self.db.commit()
self.persist_requests()
def status(self):
return {"enabled": self.enabled, "sample_interval_seconds": 5, "retained_samples": 720, "latest": self.latest, "history": list(self.history), "http": self.recorder.summary()}
async def database_metrics(self):
config = getattr(self.app.state, "config", None)
db = getattr(config, "database", None)
checkpoint = getattr(config, "checkpointer", None)
result = {}
if getattr(db, "backend", None) == "mysql":
result["mysql"] = await self.mysql_metrics(db.app_sqlalchemy_url)
else:
result["mysql"] = {"available": False, "reason": "业务库未使用 MySQL"}
from .preflight import postgres_connection_string
pg_url = postgres_connection_string(config)
if pg_url:
result["postgres"] = await self.postgres_metrics(pg_url)
else:
result["postgres"] = {"available": False, "configured": False, "reason": "当前聊天存储为 SQLite,未启用 PostgreSQL,不能显示为 0 个连接"}
result["chat_backend"] = "postgres" if pg_url else ("sqlite" if checkpoint and checkpoint.type in {"sqlite", "postgres"} else "unknown")
return result
async def mysql_metrics(self, url):
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
key = "mysql"
try:
if key not in self.db_engines:
self.db_engines[key] = create_async_engine(url, pool_size=1, max_overflow=0, pool_timeout=1, pool_pre_ping=False, connect_args={"connect_timeout": 2})
async with asyncio.timeout(4):
async with self.db_engines[key].connect() as connection:
statuses = dict((await connection.execute(text("SHOW GLOBAL STATUS WHERE Variable_name IN ('Threads_connected','Threads_running','Max_used_connections','Slow_queries','Aborted_connects')"))).all())
variables = dict((await connection.execute(text("SHOW GLOBAL VARIABLES WHERE Variable_name IN ('max_connections','long_query_time')"))).all())
rows = (await connection.execute(text("SHOW PROCESSLIST"))).mappings().all()
running = [r for r in rows if str(r.get("Command", "")).lower() not in {"sleep", "daemon"}]
return {
"available": True,
"connected": int(statuses.get("Threads_connected", 0)),
"running": int(statuses.get("Threads_running", 0)),
"max_connections": int(variables.get("max_connections", 0)),
"peak_connections": int(statuses.get("Max_used_connections", 0)),
"slow_queries": int(statuses.get("Slow_queries", 0)),
"slow_query_threshold_seconds": float(variables.get("long_query_time", 0)),
"longest_active_seconds": max((int(r.get("Time", 0)) for r in running), default=0),
"processes_visible": len(rows),
"note": "连接总数为 MySQL 全局值(包含采集连接);最长执行时间只覆盖当前账号可见进程,不读取 SQL 文本。",
}
except Exception as exc:
return {"available": False, "error": f"MySQL 指标不可用({type(exc).__name__}),请检查连接与只读监控权限"}
async def postgres_metrics(self, url):
try:
import psycopg
async with asyncio.timeout(4):
async with await psycopg.AsyncConnection.connect(url, connect_timeout=2, autocommit=True) as connection:
async with connection.cursor() as cursor:
query = (
"SELECT count(*), count(*) FILTER (WHERE state='active'), "
"count(*) FILTER (WHERE wait_event_type='Lock'), "
"COALESCE(max(EXTRACT(EPOCH FROM now()-query_start)) "
"FILTER (WHERE state='active' AND pid<>pg_backend_pid()), 0) "
"FROM pg_stat_activity"
)
await cursor.execute(query)
connected, active, locks, longest = await cursor.fetchone()
await cursor.execute("SHOW max_connections")
maximum = (await cursor.fetchone())[0]
return {
"available": True,
"configured": True,
"connected": connected,
"active": active,
"lock_waits": locks,
"max_connections": int(maximum),
"longest_active_seconds": float(longest),
"note": "按 pg_stat_activity 采集,普通账号可能看不到其他账号的状态和耗时;包含采集连接,不读取 SQL 文本。",
}
except Exception as exc:
return {"available": False, "configured": True, "error": f"PostgreSQL 指标不可用({type(exc).__name__}),请检查连接与监控权限"}
async def logs(self, name, tail=200, minutes=60, query=""):
if name.startswith("archive:"):
return self.read_archive(name[len("archive:") :], query)
settings = self.pool.store.load()
if name != settings.source_container and name not in self.pool.nodes:
raise DockerError("只能查看当前主服务和本 Worker 池的日志")
engine = DockerEngine(settings)
encoded = quote(name, safe="")
detail = await asyncio.to_thread(engine.request, "GET", f"/containers/{encoded}/json")
if name != settings.source_container and detail.get("Config", {}).get("Labels", {}).get("deerflow.worker-pool") != self.pool.store.raw()["instance"]:
raise DockerError("节点不属于当前 Worker 池")
params = urlencode({"stdout": 1, "stderr": 1, "timestamps": 1, "tail": min(1000, max(1, tail)), "since": int(time.time() - min(1440, max(1, minutes)) * 60)})
data = await asyncio.to_thread(engine.request, "GET", f"/containers/{encoded}/logs?{params}", raw=True, max_bytes=1024 * 1024)
lines = redact_logs(decode_docker_output(data)).splitlines()
if query:
lines = [line for line in lines if query.casefold() in line.casefold()]
return {"node": name, "lines": lines[-1000:], "log_policy": detail.get("HostConfig", {}).get("LogConfig", {}), "limited_bytes": 1024 * 1024}
async def archive_logs(self, engine, name):
"""Keep a bounded redacted tail BEFORE removing an owned container."""
import gzip
encoded = quote(name, safe="")
params = urlencode({"stdout": 1, "stderr": 1, "timestamps": 1, "tail": 10000})
data = await asyncio.to_thread(engine.request, "GET", f"/containers/{encoded}/logs?{params}", raw=True, max_bytes=2 * 1024 * 1024)
archive_id = f"{name}-{time.time_ns()}"
payload = {"node": name, "created_at": time.time(), "lines": redact_logs(decode_docker_output(data)).splitlines(), "limited_bytes": 2 * 1024 * 1024, "note": "节点删除前的最近 10000 行 / 最多 2 MB 归档;保存 7 天,最多 50 份。"}
self.archive_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
target = self.archive_directory / f"{archive_id}.json.gz"
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "wb") as out:
out.write(gzip.compress(json.dumps(payload).encode()))
files = sorted(self.archive_directory.glob("*.json.gz"), key=lambda p: p.stat().st_mtime, reverse=True)
for index, path in enumerate(files):
if index >= 50 or path.stat().st_mtime < time.time() - 7 * 86400:
path.unlink()
def archives(self):
return [
{"id": p.name.removesuffix(".json.gz"), "name": p.name.removesuffix(".json.gz"), "created_at": p.stat().st_mtime}
for p in sorted(self.archive_directory.glob("*.json.gz"), key=lambda p: p.stat().st_mtime, reverse=True)[:50]
if p.stat().st_mtime >= time.time() - 7 * 86400
]
def read_archive(self, archive_id, query=""):
import gzip
if not re.fullmatch(r"[A-Za-z0-9_.-]{1,180}", archive_id):
raise DockerError("无效日志归档")
path = self.archive_directory / f"{archive_id}.json.gz"
if not path.is_file() or path.stat().st_mtime < time.time() - 7 * 86400:
raise DockerError("日志归档不存在或已过期")
payload = json.loads(gzip.decompress(path.read_bytes()))
payload["lines"] = [line for line in payload["lines"] if query.casefold() in line.casefold()][-1000:]
return payload
@@ -0,0 +1,60 @@
"""Read-only server discovery and conservative starter recommendations."""
import base64
import hashlib
import math
import socket
from .docker import DockerEngine, DockerError
def host_fingerprint(host: str, port: int) -> str:
import paramiko
transport = None
try:
with socket.create_connection((host, port), timeout=10) as connection:
transport = paramiko.Transport(connection)
transport.start_client(timeout=10)
key = transport.get_remote_server_key()
return "SHA256:" + base64.b64encode(hashlib.sha256(key.asbytes()).digest()).decode().rstrip("=")
except Exception as exc:
raise DockerError(f"读取服务器指纹失败({type(exc).__name__})") from None
finally:
if transport is not None:
transport.close()
def recommend(cpus, memory_mb, reserved_cpu=0, reserved_memory_mb=0, connection_budget=64):
# Reserve at least half the host for the main app, DB and other services.
cpu_budget = max(0, min(cpus / 2, cpus - reserved_cpu - 1))
memory_budget = max(0, min(memory_mb // 2, memory_mb - reserved_memory_mb - 2048))
cpu = 2 if cpu_budget >= 2 else 1
memory = 2048
count = max(0, min(4, math.floor(cpu_budget / cpu), memory_budget // memory, connection_budget // 12))
return {"desired": int(count), "cpu": cpu, "memory_mb": memory,
"total_cpu": max(cpu, count * cpu), "total_memory_mb": max(memory, count * memory),
"concurrency": 4, "database_pool_size": 4, "postgres_pool_size": 4}
def discover(settings, instance):
engine = DockerEngine(settings)
info = engine.request("GET", "/info")
containers = engine.request("GET", "/containers/json")
cpu_reserved = 0
memory_reserved = 0
templates = []
for item in containers:
if item.get("Labels", {}).get("deerflow.worker-pool") == instance:
continue
detail = engine.request("GET", f"/containers/{item['Id']}/json")
limits = detail.get("HostConfig", {})
cpu_reserved += limits.get("NanoCpus", 0) / 1_000_000_000
memory_reserved += limits.get("Memory", 0) // (1024 ** 2)
if item.get("Labels", {}).get("deerflow.worker-role") == "online":
continue
networks = [name for name in detail.get("NetworkSettings", {}).get("Networks", {}) if name not in {"host", "none", "bridge"}]
templates.append({"name": detail.get("Name", "").lstrip("/"), "networks": networks})
cpus = int(info.get("NCPU", 0))
memory_mb = int(info.get("MemTotal", 0)) // (1024 ** 2)
return {"cpus": cpus, "memory_mb": memory_mb, "containers": templates,
"recommendation": recommend(cpus, memory_mb, cpu_reserved, memory_reserved, settings.database_connection_budget),
"note": "按硬件总量和其他容器已配置的资源上限估算,至少为主服务、数据库及系统保留一半资源;不是实时空闲内存。建议先用最多 4 个节点,再按实际负载调整。"}
@@ -0,0 +1,505 @@
"""Docker Engine API over a Unix socket or a pinned SSH connection.
Only the constant `docker system dial-stdio` command runs on the server.
All variable data travels as JSON, never interpolated into a shell command.
"""
from __future__ import annotations
import base64
import hashlib
import http.client
import json
import posixpath
import socket
from urllib.parse import quote
from .settings import PoolSettings
class DockerError(RuntimeError):
pass
_WORKER_STORAGE_SELECTION = """\
import os
from pathlib import Path
import yaml
config_path = Path(os.getenv('DEER_FLOW_CONFIG_PATH') or 'config.yaml')
config = yaml.safe_load(config_path.read_text(encoding='utf-8')) or {}
checkpoint = config.get('checkpointer')
if checkpoint is not None:
checkpoint_type = checkpoint.get('type')
if checkpoint_type == 'postgres':
raw = os.getenv('DEER_FLOW_USE_PG_CHECKPOINTER')
if raw:
postgres_enabled = raw.strip().lower() not in {'0', 'false', 'no', 'off'}
else:
postgres_enabled = bool(checkpoint.get('use_postgres', True))
backend = 'postgres' if postgres_enabled else 'sqlite'
elif checkpoint_type == 'sqlite':
backend = 'sqlite'
else:
backend = 'memory'
else:
backend = (config.get('database') or {}).get('backend', 'memory')
if backend not in {'postgres', 'sqlite'}:
backend = 'memory'
"""
_WORKER_STORAGE_PROBE = _WORKER_STORAGE_SELECTION + """\
print('__DEERFLOW_STORAGE_BACKEND__=' + backend)
"""
_WORKER_PYTHON_PROBE = _WORKER_STORAGE_SELECTION + """\
import importlib.util
import os
import sys
if sys.version_info < (3, 12):
raise SystemExit(78)
required = ['uvicorn']
if backend == 'postgres':
required.extend((
'langgraph.checkpoint.postgres.aio',
'langgraph.store.postgres.aio',
'psycopg.rows',
'psycopg_pool',
))
elif backend == 'sqlite':
required.extend((
'langgraph.checkpoint.sqlite.aio',
'langgraph.store.sqlite.aio',
'aiosqlite',
))
missing = []
for module in required:
try:
if importlib.util.find_spec(module) is None:
missing.append(module)
except (ImportError, ModuleNotFoundError, ValueError):
missing.append(module)
if not Path('app/gateway/worker_pool/worker.py').is_file():
missing.append('app.gateway.worker_pool.worker')
if missing:
packages = (
'langgraph-checkpoint-postgres psycopg[binary] psycopg-pool'
if backend == 'postgres'
else 'langgraph-checkpoint-sqlite aiosqlite'
)
print('__DEERFLOW_STORAGE_ERROR__=Worker Python is missing configured runtime modules: '
+ ', '.join(missing) + '; install ' + packages, flush=True)
raise SystemExit(78)
print('__DEERFLOW_WORKER_PYTHON__=' + os.path.abspath(sys.executable))
"""
_WORKER_PYTHON_PROBE_SHELL = r'''
probe() {
candidate="$1"
[ -n "$candidate" ] || return 1
output=$("$candidate" -c "$2" 2>/dev/null) || {
case "$output" in
*__DEERFLOW_STORAGE_ERROR__=*)
error=${output##*__DEERFLOW_STORAGE_ERROR__=}
printf '__DEERFLOW_STORAGE_ERROR__=%s\n' "$error"
;;
esac
return 1
}
case "$output" in
*__DEERFLOW_WORKER_PYTHON__=*)
resolved=${output##*__DEERFLOW_WORKER_PYTHON__=}
case "$resolved" in /*) printf '%s\n' "$resolved"; return 0 ;; esac
;;
esac
return 1
}
code="$1"
shift
for candidate in "$@"; do
if probe "$candidate" "$code"; then exit 0; fi
done
# The main API may use a Python executable outside the image's default PATH.
# Probe the interpreter actually running in this source container before PATH
# fallbacks, using the same configured persistence-adapter check above.
for process_exe in /proc/[0-9]*/exe; do
candidate=$(readlink "$process_exe" 2>/dev/null) || continue
case "$candidate" in
*/python|*/python[0-9]|*/python[0-9].* )
case "$candidate" in *" (deleted)"*) continue ;; esac
if probe "$candidate" "$code"; then exit 0; fi
;;
esac
done
for candidate in python3 python3.14 python3.13 python3.12 python3.11 python; do
if probe "$candidate" "$code"; then exit 0; fi
done
exit 1
'''
class DockerEngine:
def __init__(self, settings: PoolSettings):
self.settings = settings
def request(self, method: str, path: str, body: dict | None = None, *, raw=False, max_bytes=None, json_stream=False):
client = None
channel = None
# Committing a large production backend layer can take longer than
# the ordinary metadata/exec requests. Do not turn a slow but valid
# runtime snapshot into a permanent Worker reconciliation failure.
request_timeout = 300 if method == "POST" and path.startswith("/commit?") else 20
try:
if self.settings.transport == "socket":
channel = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
channel.settimeout(request_timeout)
channel.connect("/var/run/docker.sock")
else:
import paramiko
expected = self.settings.host_key_sha256
if not expected.startswith("SHA256:"):
raise DockerError("请先填写服务器 SSH 主机密钥指纹(SHA256:…)")
class PinnedKey(paramiko.MissingHostKeyPolicy):
def missing_host_key(self, ssh, hostname, key):
actual = "SHA256:" + base64.b64encode(hashlib.sha256(key.asbytes()).digest()).decode().rstrip("=")
if actual != expected:
raise DockerError("SSH 主机密钥指纹不匹配,连接已拒绝")
client = paramiko.SSHClient()
client.set_missing_host_key_policy(PinnedKey())
client.connect(
self.settings.host,
port=self.settings.port,
username=self.settings.username,
password=self.settings.password,
timeout=10,
auth_timeout=10,
banner_timeout=10,
look_for_keys=False,
allow_agent=False,
)
channel = client.get_transport().open_session(timeout=20)
channel.settimeout(request_timeout)
channel.exec_command("docker system dial-stdio")
data = json.dumps(body).encode() if body is not None else b""
request = f"{method} {path} HTTP/1.1\r\nHost: docker\r\nContent-Type: application/json\r\nContent-Length: {len(data)}\r\nConnection: close\r\n\r\n".encode() + data
channel.sendall(request)
response = http.client.HTTPResponse(channel)
response.begin()
if json_stream and response.status < 400:
# Exec uses a hijacked connection that may outlive the process.
# Stop on a complete bounded JSON result instead of waiting for EOF.
result = b""
while len(result) < (max_bytes or 1024 * 1024):
chunk = response.read1(min(65536, (max_bytes or 1024 * 1024) - len(result)))
if not chunk:
break
result += chunk
try:
json.loads(decode_docker_output(result))
break
except ValueError:
pass
else:
result = response.read(max_bytes) if max_bytes else response.read()
if response.status >= 400:
# Docker errors can contain environment values / host paths.
raise DockerError(f"Docker 操作失败(HTTP {response.status}),请检查容器、网络和账号权限")
return result if raw else (json.loads(result) if result else {})
except DockerError:
raise
except Exception as exc:
raise DockerError(f"服务器连接失败({type(exc).__name__}),请检查地址、账号、密码和 Docker 权限") from None
finally:
if channel is not None:
channel.close()
if client is not None:
client.close()
def _exec_capture(self, container_id: str, command: list[str], working_dir: str, user: str) -> tuple[int, str]:
created = self.request("POST", f"/containers/{quote(container_id, safe='')}/exec", {
"AttachStdout": True,
"AttachStderr": True,
"Cmd": command,
"WorkingDir": working_dir,
"User": user,
})
exec_id = quote(created["Id"], safe="")
output = self.request(
"POST", f"/exec/{exec_id}/start", {"Detach": False, "Tty": False},
raw=True, max_bytes=1024 * 1024,
)
inspected = self.request("GET", f"/exec/{exec_id}/json")
exit_code = inspected.get("ExitCode")
return (int(exit_code) if exit_code is not None else 1), decode_docker_output(output)
@staticmethod
def _python_candidates(source: dict, preferred: str) -> list[str]:
candidates = [
preferred,
"/opt/venv/bin/python", "/opt/venv/bin/python3",
"/app/.venv/bin/python", "/app/.venv/bin/python3",
"/app/backend/.venv/bin/python", "/venv/bin/python", "/venv/bin/python3",
"/usr/local/bin/python", "/usr/local/bin/python3",
"/usr/local/bin/python3.14", "/usr/local/bin/python3.13",
"/usr/local/bin/python3.12", "/usr/local/bin/python3.11",
"/usr/bin/python", "/usr/bin/python3", "/usr/bin/python3.12",
]
env = source.get("Config", {}).get("Env", [])
path_value = next((item[5:] for item in env if item.startswith("PATH=")), "")
for directory in path_value.split(":"):
if directory.startswith("/"):
candidates.extend(f"{directory.rstrip('/')}/{name}" for name in ("python3", "python"))
return list(dict.fromkeys(path for path in candidates if path and path.startswith("/")))
def discover_worker_python(self, source: dict, preferred: str, working_dir: str) -> str:
"""Find an interpreter inside the source image that can actually start a Worker."""
container_id = source.get("Id")
if not container_id:
raise DockerError("无法读取主后端容器 ID,不能检测镜像内 Python")
config = source.get("Config", {})
user = config.get("User", "")
candidates = self._python_candidates(source, preferred)
storage_error = None
try:
code, output = self._exec_capture(
container_id,
["/bin/sh", "-c", _WORKER_PYTHON_PROBE_SHELL, "worker-python-probe", _WORKER_PYTHON_PROBE, *candidates],
working_dir,
user,
)
if code == 0:
for line in reversed(output.splitlines()):
if line.startswith("/") and "\x00" not in line:
return line.strip()
for line in output.splitlines():
prefix = "__DEERFLOW_STORAGE_ERROR__="
if line.startswith(prefix):
storage_error = line[len(prefix):].strip()
except DockerError:
# Minimal images may not include a shell; try known Python paths directly.
pass
for candidate in candidates:
try:
code, output = self._exec_capture(
container_id,
[candidate, "-c", _WORKER_PYTHON_PROBE],
working_dir,
user,
)
except DockerError:
continue
if code == 0:
for line in reversed(output.splitlines()):
prefix = "__DEERFLOW_WORKER_PYTHON__="
if line.startswith(prefix):
resolved = line[len(prefix):].strip()
if resolved.startswith("/") and "\x00" not in resolved:
return resolved
for line in output.splitlines():
prefix = "__DEERFLOW_STORAGE_ERROR__="
if line.startswith(prefix):
storage_error = line[len(prefix):].strip()
if storage_error:
raise DockerError(storage_error)
raise DockerError(
"未找到与主服务聊天存储配置兼容的 Worker Python(要求 Python 3.12+,"
"且能导入 uvicorn、Worker 入口及当前 SQLite/PostgreSQL 适配器);"
"已检查主服务常见 Python 路径和运行进程解释器,请确认 Worker 复用了主服务的镜像、"
"环境变量、挂载目录和 Python 环境"
)
def configured_storage_backend(self, source: dict, preferred: str, working_dir: str) -> str:
"""Read the live config so SQLite→PostgreSQL changes invalidate probes."""
container_id = source.get("Id")
if not container_id:
raise DockerError("无法读取主后端容器 ID,不能检查聊天存储配置")
user = source.get("Config", {}).get("User", "")
for candidate in self._python_candidates(source, preferred):
try:
code, output = self._exec_capture(
container_id,
[candidate, "-c", _WORKER_STORAGE_PROBE],
working_dir,
user,
)
except DockerError:
continue
if code == 0:
for line in reversed(output.splitlines()):
prefix = "__DEERFLOW_STORAGE_BACKEND__="
if line.startswith(prefix):
backend = line[len(prefix):].strip()
if backend in {"postgres", "sqlite", "memory"}:
return backend
raise DockerError(
"无法读取主服务当前聊天存储配置;请确认 Python 路径、工作目录和主服务配置文件正确"
)
def snapshot_worker_runtime(self, source: dict, instance: str) -> str:
"""Snapshot the running main container's writable layer for new workers.
Docker create from Config.Image alone loses packages installed into the
running container after it was created. A local commit carries those
offline-installed packages; bind/volume mounts are still cloned as-is.
"""
container_id = source.get("Id")
if not container_id:
raise DockerError("无法读取主后端容器 ID,不能复用其 Python 运行环境")
# A freshly started backend often has only a tiny writable overlay
# (logs/runtime markers). Committing it can take minutes on an offline
# Docker host even though the immutable image already has the runtime.
# Keep committing larger overlays that may include offline-installed
# Python packages.
base_image = source.get("Image")
writable_layer_size = source.get("SizeRw")
if (
isinstance(base_image, str)
and base_image
and isinstance(writable_layer_size, int)
and writable_layer_size <= 128 * 1024
):
return base_image
repo = f"deerflow-worker-runtime-{instance}"
tag = container_id[:12]
result = self.request(
"POST",
f"/commit?container={quote(container_id, safe='')}&repo={quote(repo, safe='')}&tag={quote(tag, safe='')}&pause=false",
)
image_id = result.get("Id") if isinstance(result, dict) else None
if not isinstance(image_id, str) or not image_id.startswith("sha256:"):
raise DockerError("无法从主容器生成 Worker 运行镜像;请确认 Docker 账号具有容器提交权限")
return image_id
def host_snapshot(self, container: str):
"""Only our constant read-only collector may be executed by diagnostics."""
if self.settings.transport == "socket":
from .host_metrics import read_snapshot
return read_snapshot()
command = [self.settings.python_executable, "-m", "app.gateway.worker_pool.host_metrics"]
created = self.request("POST", f"/containers/{quote(container, safe='')}/exec", {
"AttachStdout": True, "AttachStderr": True, "Cmd": command,
"WorkingDir": self.settings.working_dir,
})
data = self.request("POST", f"/exec/{created['Id']}/start", {"Detach": False, "Tty": False}, raw=True, max_bytes=1024 * 1024, json_stream=True)
out = decode_docker_output(data)
try:
return json.loads(out)
except ValueError:
raise DockerError("宿主机采集器未运行成功,请检查主容器代码和只读 /proc、/sys 挂载") from None
def decode_docker_output(data: bytes) -> str:
"""Docker attaches stdout/stderr in 8-byte length-prefixed frames."""
import struct
if len(data) < 8 or data[0] not in (0, 1, 2) or data[1:4] != b"\x00\x00\x00":
return data.decode("utf-8", errors="replace")
parts = []
offset = 0
while offset + 8 <= len(data):
size = struct.unpack(">I", data[offset + 4:offset + 8])[0]
parts.append(data[offset + 8:offset + 8 + size])
offset += 8 + size
return b"".join(parts).decode("utf-8", errors="replace")
def worker_spec(
source: dict,
settings: PoolSettings,
instance: str,
token: str,
control_home: str,
*,
runtime_image: str | None = None,
require_shared_archive: bool = False,
) -> dict:
"""Clone an explicit runtime template, without ports, privileges or Docker access."""
if settings.network not in source["NetworkSettings"]["Networks"]:
raise DockerError("源容器必须连接所选 Docker 网络")
if settings.network in {"host", "none", "bridge"}:
raise DockerError("请选择用户自定义 Docker 网络")
mounts = []
for mount in source.get("Mounts", []):
target = mount["Destination"].rstrip("/")
if control_home == target:
continue # dedicated controller volume is never passed to workers
if "docker.sock" in target or target in {"/", "/var/run", "/run"}:
continue
if target in {"/host/proc", "/host/sys"}:
continue # host diagnostics belong to the controller only
if control_home.startswith(target + "/"):
raise DockerError("控制器密钥目录不能与 Worker 共享,请为 DEER_FLOW_WORKER_CONTROL_HOME 配置独立持久卷")
if mount["Type"] not in {"bind", "volume"}:
continue
mounts.append({"Type": mount["Type"], "Source": mount.get("Name") if mount["Type"] == "volume" else mount["Source"], "Target": mount["Destination"], "ReadOnly": not mount.get("RW", True)})
env = dict(item.split("=", 1) for item in source["Config"].get("Env", []) if "=" in item)
if not env.get("AUTH_JWT_SECRET"):
raise DockerError("请在源容器环境变量中设置固定 AUTH_JWT_SECRET,使 Worker 能验证现有登录会话")
if not mounts:
raise DockerError("源容器没有共享持久化挂载,无法保证 Worker 访问技能、配置和上传文件")
if require_shared_archive:
if env.get("DEER_FLOW_CHAT_ARCHIVE_DIR"):
archive_path = posixpath.normpath(env["DEER_FLOW_CHAT_ARCHIVE_DIR"])
elif env.get("DEER_FLOW_HOME"):
archive_path = posixpath.join(env["DEER_FLOW_HOME"], "data", "chat-archives")
else:
project_root = env.get("DEER_FLOW_PROJECT_ROOT") or source["Config"].get("WorkingDir") or settings.working_dir
archive_path = posixpath.join(project_root, ".deer-flow", "data", "chat-archives")
if not posixpath.isabs(archive_path):
workdir = source["Config"].get("WorkingDir") or settings.working_dir
archive_path = posixpath.join(workdir, archive_path)
archive_path = posixpath.normpath(archive_path)
shared = any(
mount.get("ReadOnly") is False
and (archive_path == mount["Target"].rstrip("/")
or archive_path.startswith(mount["Target"].rstrip("/") + "/"))
for mount in mounts
)
if not shared:
raise DockerError(
f"PostgreSQL 聊天归档目录 {archive_path} 必须位于主容器与 Worker 共享的可写持久卷中;"
"请将 DEER_FLOW_CHAT_ARCHIVE_DIR 指向共享卷目录或挂载 DEER_FLOW_HOME/data"
)
for key in list(env):
if key.startswith("DEER_FLOW_WORKER_") or key in {"DEER_FLOW_REBUILD_WORKFLOW_SCHEMA", "WEB_CONCURRENCY", "UVICORN_WORKERS"}:
env.pop(key)
env.update(
DEER_FLOW_WORKER_DB_POOL_SIZE=str(settings.database_pool_size),
DEER_FLOW_PG_POOL_MIN="1",
DEER_FLOW_PG_POOL_MAX=str(settings.postgres_pool_size),
DEER_FLOW_WORKER_ROLE="online",
DEER_FLOW_WORKER_TOKEN=token,
DEER_FLOW_WORKER_CONCURRENCY=str(settings.concurrency),
DEER_FLOW_WORKER_QUEUE_LIMIT=str(settings.queue_limit),
DEER_FLOW_WORKER_QUEUE_TIMEOUT=str(settings.queue_timeout),
SCHEDULED_TASKS_ENABLED="false",
TASK_ANALYSIS_ENABLED="false",
)
return {
"Image": runtime_image or source["Image"],
"WorkingDir": settings.working_dir,
"User": source["Config"].get("User", ""),
"Entrypoint": [settings.python_executable, "-m", "uvicorn"],
"Cmd": ["app.gateway.worker_pool.worker:app", "--host", "0.0.0.0", "--port", "8001", "--workers", "1"],
"Env": [f"{key}={value}" for key, value in env.items()],
"Labels": {"deerflow.worker-pool": instance, "deerflow.worker-role": "online"},
"NetworkingConfig": {"EndpointsConfig": {name: {} for name in source["NetworkSettings"]["Networks"] if name not in {"host", "none", "bridge"}}},
"Healthcheck": {
"Test": ["CMD", settings.python_executable, "-c", "import os,urllib.request; urllib.request.urlopen(urllib.request.Request('http://127.0.0.1:8001/internal/worker-status',headers={'x-deerflow-worker-token':os.environ['DEER_FLOW_WORKER_TOKEN']}),timeout=4).read()"],
"Interval": 20_000_000_000, "Timeout": 5_000_000_000, "Retries": 6, "StartPeriod": 90_000_000_000,
},
"HostConfig": {
"Mounts": mounts,
"NetworkMode": settings.network,
"Memory": settings.memory_mb * 1024 * 1024,
"NanoCpus": int(settings.cpu * 1_000_000_000),
"PidsLimit": 512,
"RestartPolicy": {"Name": "unless-stopped"},
"SecurityOpt": ["no-new-privileges:true"],
"ExtraHosts": source.get("HostConfig", {}).get("ExtraHosts") or [],
"Dns": source.get("HostConfig", {}).get("Dns") or [],
"Init": True,
"LogConfig": {"Type": "json-file", "Config": {"max-size": "10m", "max-file": "3"}},
},
}
@@ -0,0 +1,110 @@
"""Small read-only host snapshot; requires /proc and /sys mounted by the admin."""
import json
import os
import re
import time
from pathlib import Path
def read_snapshot(proc=Path("/host/proc"), sys=Path("/host/sys")):
if not (proc / "stat").is_file():
return {"available": False, "message": "请为主容器挂载 /proc:/host/proc:ro 和 /sys:/host/sys:ro;Worker 不需要挂载。"}
cpu = next(line for line in (proc / "stat").read_text().splitlines() if line.startswith("cpu "))
fields = [int(v) for v in cpu.split()[1:9]] # guest times are already included in user/nice
mem = {}
for line in (proc / "meminfo").read_text().splitlines():
k, value = line.split(":", 1)
mem[k] = int(value.split()[0]) * 1024
disks = {}
for line in (proc / "diskstats").read_text().splitlines():
v = line.split()
if len(v) < 14 or re.match(r"^(loop|ram|fd|sr)", v[2]):
continue
# Whole devices only: including partitions would double-count disk IO.
if (sys / "class/block" / v[2] / "partition").exists():
continue
disks[v[2]] = {"read_bytes": int(v[5]) * 512, "write_bytes": int(v[9]) * 512,
"io_ms": int(v[12]), "inflight": int(v[11])}
interfaces = {}
network_error = None
try:
# /proc/1 is the host PID namespace; /proc/net would resolve to this container.
for line in (proc / "1/net/dev").read_text().splitlines()[2:]:
name, values = line.split(":", 1)
name = name.strip()
if name == "lo" or name.startswith(("veth", "docker", "br-", "virbr", "tun", "tap")):
continue
v = values.split()
speed = None
try:
speed = int((sys / "class/net" / name / "speed").read_text())
except (OSError, ValueError):
pass
interfaces[name] = {"rx_bytes": int(v[0]), "tx_bytes": int(v[8]),
"rx_errors": int(v[2]), "tx_errors": int(v[10]), "link_mbps": speed if speed and speed > 0 else None}
except OSError:
network_error = "无法读取宿主机网络命名空间,检查 /host/proc 挂载及读取权限"
pressure = {}
for name in ("cpu", "memory", "io"):
try:
pressure[name] = (proc / "pressure" / name).read_text().strip()[:400]
except OSError:
pressure[name] = None
processes = {}
page_size = getattr(os, "sysconf", lambda _: 4096)("SC_PAGE_SIZE")
for item in proc.iterdir():
if not item.name.isdigit():
continue
if len(processes) >= 5000:
break
try:
stat = (item / "stat").read_text()
end = stat.rfind(")")
process_fields = stat[end + 2:].split()
processes[item.name] = {"name": stat[stat.find("(") + 1:end][:80], "ticks": int(process_fields[11]) + int(process_fields[12]),
"started": int(process_fields[19]), "rss_bytes": max(0, int(process_fields[21])) * page_size}
except (OSError, ValueError, IndexError):
continue
return {"available": True, "at": time.time(), "cpu_total": sum(fields), "cpu_idle": fields[3] + fields[4],
"cpu_iowait": fields[4], "cpu_count": sum(line.startswith("cpu") and line[3:4].isdigit() for line in (proc / "stat").read_text().splitlines()),
"memory_total": mem.get("MemTotal", 0), "memory_available": mem.get("MemAvailable", mem.get("MemFree", 0)),
"swap_total": mem.get("SwapTotal", 0), "swap_free": mem.get("SwapFree", 0),
"load": [float(v) for v in (proc / "loadavg").read_text().split()[:3]],
"disks": disks, "interfaces": interfaces, "network_error": network_error, "pressure": pressure,
"process_counters": processes, "clock_ticks": getattr(os, "sysconf", lambda _: 100)("SC_CLK_TCK")}
def rates(current, previous):
result = dict(current)
if not current.get("available"):
return result
result.update(cpu_percent=None, iowait_percent=None)
elapsed = current["at"] - previous["at"] if previous and previous.get("available") else 0
if elapsed > 0:
delta = current["cpu_total"] - previous["cpu_total"]
if delta > 0:
result["cpu_percent"] = round(100 * (1 - (current["cpu_idle"] - previous["cpu_idle"]) / delta), 2)
result["iowait_percent"] = round(100 * (current["cpu_iowait"] - previous["cpu_iowait"]) / delta, 2)
top = []
for pid, item in current.get("process_counters", {}).items():
old = previous.get("process_counters", {}).get(pid) if elapsed > 0 else None
if old and old["started"] == item["started"]:
percent = max(0, (item["ticks"] - old["ticks"]) / (current.get("clock_ticks", 100) * elapsed) * 100)
top.append({"pid": int(pid), "name": item["name"], "cpu_percent": round(percent, 2), "rss_bytes": item["rss_bytes"]})
result.pop("process_counters", None)
result["top_processes"] = sorted(top, key=lambda p: p["cpu_percent"], reverse=True)[:20]
for name, item in result["disks"].items():
old = previous.get("disks", {}).get(name) if elapsed > 0 else None
item.update(read_bps=max(0, (item["read_bytes"] - old["read_bytes"]) / elapsed) if old else None,
write_bps=max(0, (item["write_bytes"] - old["write_bytes"]) / elapsed) if old else None,
busy_percent=min(100, max(0, (item["io_ms"] - old["io_ms"]) / (elapsed * 10))) if old else None)
for name, item in result["interfaces"].items():
old = previous.get("interfaces", {}).get(name) if elapsed > 0 else None
item.update(rx_bps=max(0, (item["rx_bytes"] - old["rx_bytes"]) / elapsed) if old else None,
tx_bps=max(0, (item["tx_bytes"] - old["tx_bytes"]) / elapsed) if old else None)
return result
if __name__ == "__main__":
print(json.dumps(read_snapshot()))
@@ -0,0 +1,51 @@
"""Create the opt-in Worker controller without restricting normal startup."""
import json
import os
from contextlib import asynccontextmanager
from pathlib import Path
def _control_directory() -> Path:
return Path(os.getenv("DEER_FLOW_WORKER_CONTROL_HOME", ".worker-pool")).resolve()
def _persisted_feature_enabled(directory: Path) -> bool:
def read(path):
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
state = read(directory / "settings.json")
diagnostics = read(directory / "diagnostics-settings.json")
return bool(state.get("settings", {}).get("enabled") or diagnostics.get("enabled"))
def ensure_controller(app):
pool = getattr(app.state, "worker_pool", None)
if pool is not None:
return pool
from .controller import Controller
from .diagnostics import Diagnostics
from .settings import SettingsStore
directory = getattr(app.state, "worker_pool_directory", _control_directory())
pool = Controller(SettingsStore(directory))
pool.diagnostics = Diagnostics(pool, app)
app.state.worker_pool = pool
return pool
@asynccontextmanager
async def managed_controller(app):
directory = _control_directory()
app.state.worker_pool_directory = directory
app.state.worker_pool = None
pool = ensure_controller(app) if _persisted_feature_enabled(directory) else None
try:
yield pool
finally:
pool = getattr(app.state, "worker_pool", None)
if pool is not None:
await pool.close()
Loaded 100 of 873 files, more files were not shown because too many files have changed in this diff. Show more