44 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
This is a customized offline deployment of DeerFlow — a LangGraph-based AI super agent platform. The repo contains two sub-projects:
offline-backend-20260512/backend/— Python backend (FastAPI Gateway + LangGraph agent runtime)frontend-web/— Vite + React 19 frontend (TypeScript, pnpm)
Managed together via shell scripts in scripts/.
Commands
Full Application (from repo root)
./scripts/start-all.sh # Start backend + frontend as background daemons
./scripts/stop-all.sh # Stop both
./scripts/restart-all.sh # Restart both
./scripts/status.sh # Check running status
Logs and PID files are written to .runtime/logs/ and .runtime/pids/.
Backend Only (from offline-backend-20260512/backend/)
make install # Install Python dependencies (uv)
make dev # Run Gateway API with hot-reload on port 8001
make gateway # Run Gateway API without reload
make test # Run all tests: PYTHONPATH=. uv run pytest tests/ -v
make lint # Lint with ruff
make format # Format with ruff
Run a single test file:
PYTHONPATH=. uv run pytest tests/test_<feature>.py -v
Frontend Only (from frontend-web/)
pnpm install # Install dependencies
pnpm dev # Start Vite dev server on port 5174
pnpm build # Production build
pnpm typecheck # TypeScript type checking (tsc --noEmit)
Service Ports
| Service | Port | Notes |
|---|---|---|
| Backend (Gateway API) | 8001 | FastAPI + LangGraph runtime |
| Frontend (Vite) | 5174 | React SPA |
Frontend connects to backend via env vars VITE_BACKEND_BASE_URL and VITE_LANGGRAPH_BASE_URL (injected by scripts/start-frontend.sh).
Architecture
Backend
The backend (offline-backend-20260512/backend/) is split into two layers:
packages/harness/deerflow/— publishable agent framework (deerflow.*imports): LangGraph lead agent, middleware chain, sandbox, tools, MCP, memory, models, skills. Never imports fromapp.*.app/— application layer (app.*imports): FastAPI Gateway routers, IM channel integrations (Feishu, Slack, Telegram, DingTalk).
The Lead Agent (deerflow/agents/lead_agent/) is the single LangGraph graph entry point. It wraps a middleware chain (~18 middlewares assembled in strict order) around LLM calls, handling: thread isolation, sandbox lifecycle, uploads, memory, plan mode, vision, subagent delegation, and loop detection.
Middleware order matters — middlewares are appended in a fixed sequence; ClarificationMiddleware must always be last. See offline-backend-20260512/backend/CLAUDE.md for the full ordered list.
Harness → App import firewall is enforced by tests/test_harness_boundary.py (runs in CI).
Workflow Studio (deerflow/workflows/ + /api/workflows*) is a second, independent execution path beside the lead agent: a persisted DAG scheduler that runs published workflow graphs, streams a durable event log over SSE, and supports human-input pauses, cancellation, and crash recovery via lease reclaim. Coze Studio supplies the canvas UI only; LangGraph is used only inside an agent/skill node. Master switch and all limits live in config.yaml → workflows. See the backend CLAUDE.md「Workflow Studio」and docs/WORKFLOW_STUDIO_BACKEND_DEV_ZH.md.
Frontend
The frontend (frontend-web/) is a React 19 + Vite SPA:
- Routing: React Router v7; top-level routes in
src/pages/PageRoutes.tsx, workspace sub-routes insrc/pages/WorkspaceRoutes.tsx - Pages:
ChatPage(thread chat),AgentChatPage(custom agent chat),AgentsPage/NewAgentPage(agent management),ScheduledTasksPage/ScheduledTaskRunDetailPage(scheduled tasks),LightAppManagementPage/LightAppListPage/LightAppIframeView(轻应用中心 — register/list/embed mini-apps) - 定时任务「JSON → HTML 模板填充」(模板数据转换器): a third scheduled-task execution mode. In the create/edit dialog (
ScheduledTasksPage.tsx), selecting the built-in agent 「模板数据转换器」 (execution_agent_name === "template-json-builder",TEMPLATE_BUILDER_AGENT_ID) reveals a 「HTML模板」 select (useHtmlTemplates→GET /api/scheduled-tasks/html-templates) + a .json 上传 button (reads the file's text into the prompt box) and hides the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists asexecution_context.template_config.template_id(TemplateFillConfigincore/scheduled-tasks/types.ts). At run time the backend converts the source JSON (the task prompt) into that template'sDATAstructure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be pure JSON, plain text, or text-with-embedded-JSON — the backend pre-extracts any embedded JSON and the agent'sSOUL.mdmaps it (or the plain text's semantics) to the template's schema skeleton. Chat-page debug entry (only this agent'sAgentChatPage): whenagent_id === "template-json-builder", the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the latest AI reply, POSTs it toPOST /api/scheduled-tasks/template-fill/render(renderTemplateFill), and pops a dialog rendering the filled HTML in asandbox="allow-scripts"iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (deerflow/runtime/scheduler/template_fill.py), agent seed, dispatch, and the render API. - AI 写作·对话版(对话驱动写作台): a parallel new entry beside the classic AI-writing page (classic page/routes untouched) — route
/page/canvas/ai-writing-chat(OpenCanvasRoutes.tsx), sidebar 「问答管理 → 课题研究(对话版)」. Pageopen-canvas/pages/AIWritingChatPage.tsxfully reuses the engine (AIWritingContextstate machine + LangGraphuseStream,AIWritingHistoryProvider, right-sideAIWritingDraftPanel) but replaces the presentation: left =components/ai-writing-chat/WritingChatPanel.tsx(timeline + button-only intervention cardsChatInterventionCards.tsx— the five pause cards copied fromInterventionCard.tsxwith all inline textareas removed, confirm/action buttons kept — plus a bottom unified smart composer). Free text goes through two-level intent resolution (open-canvas/api/ai-writing-intent.ts): deterministic short-phrase fast path (确认/继续/重新搜索/重写大纲…; high-cost actions never fast-pathed) thenPOST /api/ai-writing/sessions/{id}/intent— the backend reads the graph checkpoint (authoritative pause point from the pending interrupt) and routes viadeerflow/agents/ai_writing/intent_router.py(LLM + strict per-pause action whitelist aligned to the graph's real resume actions, confidence gates: finalize/force_finalize/覆盖式 re_search need ≥0.85 else clarify, payload key-whitelist cleaning). Results map tosubmitIntervention/control (暂停/继续/后台挂起)/QA bypass (answerrendered as a chat bubble, never touches graph state)/clarify (amber follow-up bubble with multi-turn context). Two-state layout: idle (未开始写作) renders the classic setup experience unchanged —WritingSetupChat+ collapsibleAIWritingPanelform underWritingFormProvider(same asAIWritingPage's idle branch); once writing starts (or history view opens) it switches to the chat workbench. Unified chronological feed (ChatFlowTimeline.tsx): intent-layer chat bubbles are interleaved INTO the progress-event timeline by anchor (=progressEvents.lengthat message creation), so events triggered by a user message render below it (Google-AI-Studio-style single stream, not timeline-above/chat-below); composer-driven interventions suppress the duplicate 「我的要求」card (suppressedRequirementIndices). 素材不足自动续跑 only atmaterial_confirm(the only pause the graph supports re-search from): needResearch caches the payload as a pending intent, submitsre_search + userQuery, and auto-submits the pending intent when the new material-confirm pause arrives (cancellable pill). Stage chips + per-stage placeholder + 「当前交互对象」status badge. Backend teststests/test_ai_writing_intent.py. Plan doc:frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md. - 轻应用中心 (Light App Center): a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers
window_openoriframemini-apps with a route-param builder: login params resolved fromlocalStorage.userInfo, theme params literal) and 应用列表 (card grid ofwindow_openapps,window.openlaunch).iframeapps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and dynamically injected into the sidebar bycore/page-layout/use-sidebar-menu.ts(useSidebarMenuItems), opening in theLightAppIframeViewhost. Client + React Query hooks instrategy-components/api/light-apps.ts; URL builder/constants instrategy-components/lib/light-app.ts. Backend:/api/light-apps(global/shared, admin-gated writes). - 菜单管理 (Menu Management, admin-only): an overlay over the static sidebar menu —
MenuManagementPage(pages/MenuManagementPage.tsx, route/page/strategy/admin/menu-management, opened from the admin-only 「菜单管理」 item in the bottom-left settings dropdown ofcomponents/page-sidebar.tsx). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag or the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, including injected iframe light-app nodes (light-app-<appId>). The static tree (core/page-layout/sidebar-menu.ts) stays the source of icons/routes; only per-node deltas (label/disabled/parentId/sortOrder) persist to the backend. Merge logic incore/page-layout/menu-overrides.ts(applyMenuOverrides, applied inuse-sidebar-menu.tsafter light-app injection, before role filter; disabling a node hides its whole subtree) — strict 3-level cap (wouldExceedDepth). Clientstrategy-components/api/menu-overrides.ts. Backend:/api/menu-overrides(global/shared; open read, admin atomic replace-all). 简介模式 left-nav is a separate overlay:/api/compact-menu-overridesstores two independent sets (typeA/B); each website picks one viaruntime-config.jsVITE_COMPACT_MENU_TYPE. - 白名单管理 (Login Whitelist, admin-only): gates who may log in, run as a blocklist (黑名单模式). Two pieces: a global master switch (
system_settings.json → login_whitelist.enabled, surfaced by admin-onlyGET|PUT /api/system-settings/login-whitelist) + a per-user 放行 flag (users.approvedcolumn, migration20260625_02). Users self-register as usual and every new account defaults toapproved=True— so a user who has never logged into this system before is allowed straight through; the switch only blocks users an admin has explicitly 取消放行 (approved=False). (create_user/User/UserRowall defaultapproved=True; admins are always approved; existing rows are backfilledTrue.) Login interception is the single helperenforce_user_approved(user)inapp/gateway/routers/auth.py, called in all three login endpoints (/login/{local,username,token}) after the account is resolved/created and before the JWT is issued: switch off → everyone passes;system_role=="admin"→ always passes (anti-lockout); elseapproved=False(admin-revoked) → 403{code:"PENDING_APPROVAL"}(the frontend'sextractErrorMessagesurfaces the message and login throws before writing any auth state, so no half-login). Admin approves onpages/LoginWhitelistPage.tsx(route/page/strategy/admin/login-whitelist, entry「白名单管理」in the bottom-left settings dropdown ofcomponents/page-sidebar.tsx, both sidebar modes): a top master-switch + a list of all registered users (GET /api/v1/auth/users, now carryingapproved) with a per-row 放行 Switch (PUT /api/v1/auth/users/{id}/approval, admin-only; revoking only blocks the next login —token_versionis left untouched, existing sessions are not force-killed). Client/hooks: switch instrategy-components/api/login-whitelist.ts, user list + approval incore/auth/{api,hooks}.ts(useSetUserApproval). Backend tests:tests/test_login_approval.py. 账号级「登录口令」(per-account password — 临时开通): the same page also lets an admin assign one specific account an individual plaintext password (users.login_passwordcolumn, migration20260625_04, nullable; auto-added on startup by_ensure_orm_columns_sync). Once set, that account's username login (POST /api/v1/auth/login/username) requires?password=to exactly match it and that personal password takes precedence over the global shared-password gate (username_login.require_password); accounts without one keep today's shared-gate / passwordless behavior. The user then logs in via the existing frontend URL#/login/<用户名>?password=xxxx(LoginPage already forwards?password=;<用户名>= the account's email@-prefix). Plaintext is intentional — the password rides in the URL query anyway, so storing it readably lets the admin re-copy the full login link from the page; it is exposed only on the admin-onlyGET /api/v1/auth/users(UserListItem.login_password) and the new admin-onlyPUT /api/v1/auth/users/{id}/login-password(set non-empty / clear with null·empty; doesn't touchtoken_version). The whitelist page renders a per-user 「登录口令」 column with a set/修改 editor + a live 「复制登录链接」 button (useSetUserLoginPasswordincore/auth/hooks.ts,setUserLoginPasswordincore/auth/api.ts). Backend gate helper_lookup_user_by_username(lookup-only, no auto-register) inrouters/auth.py; teststests/test_login_personal_password.py. - 敏感词管理 (Sensitive Word Management): configures the 展示层脱敏映射 (原始词 → 替换词). Gated on the username
lqq(not the admin role) —getAccountDisplayName().toLowerCase() === "lqq"(username = email@prefix, elseuser_id, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check.src/lib/desensitize.tsships a frozen seed mapSEED_DESENSITIZE_MAP(the deployment seed) plus a live mutableDESENSITIZE_MAP(initialized from the seed sodesensitize()works before any fetch).DesensitizeWordsLoader(mounted inApp.tsxAppProviders) fetches/api/sensitive-wordson startup and, if non-empty, callsreplaceDesensitizeMap(...)to make the backend config the source of truth (empty/failed → keep seed). PageSensitiveWordsPage.tsx(route/page/strategy/admin/sensitive-words, opened from the 「敏感词管理」 item in the bottom-left settings dropdown ofcomponents/page-sidebar.tsx, present in both full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the whole set atomically (replace-all); a 「导入种子词」 button merges any missingSEED_DESENSITIZE_MAPentries into the editor. Client + hooks instrategy-components/api/sensitive-words.ts(wordsToMapfolds enabled rows into the runtime map). Backend:/api/sensitive-words(global/shared; open read, write allowed for admin or thelqqaccount), mirroring 菜单管理's store shape. 侧边栏 + 导航栏 menu labels are desensitized at the data source:useSidebarMenuItems/useActiveThirdLevel(core/page-layout/use-sidebar-menu.ts) map every node'slabelthrough a desensitizer built directly from the/api/sensitive-wordsquery (makeDesensitizer(wordsToMap(words)), falling back to the global seeddesensitizewhile empty) — this sidesteps thereplaceDesensitizeMapeffect-timing race. Onlylabelis rewritten;path/id/iconare untouched so routing/highlight are unaffected. All nav surfaces (page-sidebar.tsx,page-sidebar-dark.tsx,page-sidebar-v1.tsx,strategy-components/Header.tsx,PageRoutes.tsx) consume this hook and are covered;MenuManagementPageuses the raw tree so admins still edit real labels. - 技能地址管理 (Skill Address Management, admin-only): 扫描运行中的技能(
skills/{public,custom}下所有.md/.py)里的 http(s) URL 与 IP/IP:端口,按唯一地址聚合全部出现位置(跨 .md/.py),支持批量替换(如把 UAT 地址一次性改成生产)。主体抽成可复用组件SkillAddressesPanel(pages/SkillAddressesPage.tsx导出,默认导出SkillAddressesPage仍保留旧路由/page/strategy/admin/skill-addresses作直链回退),作为「技能地址管理」标签页嵌入管理员「技能管理」页pages/AdminSkillsPage.tsx(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」workspace-nav-menu.tsx→/page/workspace/admin/skills,不再单列于components/page-sidebar.tsx左下角设置下拉):打开自动扫描并带进度条(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有uat.4.cn→prod.4.cn)、每地址「替换为」输入框(可展开看file:line片段)、内联预览、应用、多关键词检索(空格分隔全部命中),以及修改记录面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可针对单次记录回退(二次确认)。client + NDJSON 流式解析在strategy-components/api/skill-addresses.ts。后端/api/skill-addresses(仅管理员):所有文件 IO 走asyncio.to_thread不阻塞事件循环;GET /scan(+/scan/stream)、POST /preview、POST /apply(+/apply/stream)、GET /backups、POST /rollback/{id};子串安全替换(基于 token span,不会误伤子串)、expected_total乐观锁(409)、写前自动磁盘备份到{skills_root}/.address-edits/(同时写一份{id}.manifest.json记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎deerflow/skills/address_scan.py,routerapp/gateway/routers/skill_addresses.py,teststests/test_skill_addresses.py。范围仅运行中技能,不动deploy/minimal/skills/。 - 按钮管理 (Task Button Management, admin-only): configures the 可配置跳转按钮 rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see
TaskFxWorkspace). Each button ={business, label, linkType, target, appendTaskId, idParam, idKind, openMode, loginParams, appendAuth, authTokenParam, authNameParam, enabled, sortOrder};linkTypeisurl(外链window.open, optional append of?{idParam}={id}—idParamis the editable query-param name, e.g.id/task_id),business(内部切换到另一业务, reusesresolveTaskDeeplinkTarget, always task id), orpurpose(弹出当前任务的「任务目的」表格 via the sharedTaskPurposeDialog, no target — configurable for any page incl. xdfx).host(宿主路由) asks the embedding parent to navigate:runTaskButtonsendswindow.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)—targetis the host route (e.g./task/item-aiceh/step-analysis-audience),appendTaskId/idParambecome numericqueryParams(task id only), origin fromruntime-config.jsVITE_HOST_NAVIGATE_ORIGIN(else ancestorOrigins/referrer; never*). Independent-tab clicks toast and do nothing.urlbuttons support two independent, coexisting login-append mechanisms: (1)loginParams(追加登录参数, like 应用管理/Light App Center) — a list of{key, source, customValue}resolved at click time:runTaskButtonreadsuserInfo(viareadUserInfofromstrategy-components/lib/light-app, embed-isolated throughauthStorage) and appends?{key}={value}for each (source= a userInfo field such asaccessToken/yUserId, orother→ literalcustomValue); empty list = nothing appended. ReusesLOGIN_SOURCE_OPTIONSfrom light-app; stored as JSON columntask_buttons.login_params_json(PortableJSON, migration20260625_01). (2)appendAuth(追加登录态) — appends?{authTokenParam}={token1}&{authNameParam}={username}(default param namestoken/username) so the跳转目标外部系统 can identify the current user — e.g.…/action/sentiment?token=<JWT>&username=lizhaoxing. Gated on token login:token1= the?authToken=used by token-exchange login;username= the raw login name the backend resolved fromgetTokenInfo. Both are stashed at login bypersistLoginCredentialsintologin.token1/login.username(core/auth/login-credentials.ts, scoped via scoped-storage so嵌入/独立会话隔离 + 随独立会话迁移). Only token login writes them → a non-token-login session has notoken1, sorunTaskButtonsilently skips the auth append (the jump still works, just without those two params). The frontend readsusernamestraight from the newUsernameLoginResponse.usernamefield (the stored email local-part is lossy — lowercased/normalized — so not used), not by decoding the JWT. Stored as columnstask_buttons.{append_auth, auth_token_param, auth_name_param}(migration20260625_02). The admin page renders both editors forurlbuttons (loginParams 虚线框 + 追加登录态 switch).idKind(task/action) only matters for xdfx: its deep-link?taskId=is an 行动id and the 任务id is resolved at login (fetchTaskIdFromAction) —TaskContextnow also stashes the rawactionId, so a url button can append either (defaulttask). PageTaskButtonsPage.tsx(route/page/strategy/admin/task-buttons, admin-gated, entry「按钮管理」in the bottom-left settings dropdown ofcomponents/page-sidebar.tsx, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the whole set atomically (replace-all), plus 「导入默认」 to merge code-seed defaults. Seed/fallback (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back todefaultTaskButtons(cfg)— rwfx's url buttons (编辑/上一步/下一步/3q详情) built from the live/api/public/config/task-deeplinkplus cross-business switch buttons for all three businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. Bottom 快捷跳转 bar groups by prev/next:TaskJumpBarrenders only「上一步/下一步」-type buttons in the bottom bar — aprevgroup (id 含prev或文案「上一步」, 描边次要样式 + ←图标) rendered 左侧, then anextgroup (id 含next或文案「下一步」, 实心主色) on the 右侧 (containerjustify-end); everything else goes to the right-edge floating menu. The default 上一步 button (rwfx-prev,url, target =DASHBOARD_AGENT_ROUTE/page/strategy/dashboard-agent,appendTaskId+idParam=taskId→?taskId=任务id,openMode=self) 默认指向应用内「大屏绘制」页,在 both 单智能体 (TaskFxWorkspace) 与 多智能体会商 (RoundtablePlanningPage深链 rwfx-A) 都显示(共用TaskJumpBar)。runTaskButton的url类型支持应用内路由:target 以单个/开头时按内部路由处理——self走 react-router 同窗导航(不刷新),blank拼成当前源的 hash 路由新标签打开;外链行为不变。 The old hardcoded jump buttons were migrated out:TaskFxWorkspaceno longer hardcodes 切换到三情分析,TaskActionsPanelkeeps only the non-jump 清除记录, andTaskPurposePaneldropped its 3q详情 button (now configurable) and defaults the 目的树 table to collapsed (chevron toggle). Helperscore/auth/task-buttons.ts(TaskButton,defaultTaskButtons,resolveTaskButtons,buttonsForBusiness,runTaskButton), client+hooksstrategy-components/api/task-buttons.ts. Backend:/api/task-buttons(global/shared; open read, admin replace-all), storedeerflow.persistence.task_buttons(tabletask_buttons, auto-created bycreate_all), wiredapp.state.task_button_store. Tests:tests/test_task_buttons.py. - 实时并发监控 (Concurrency / System-Pressure Monitor, admin-only): a live panel mounted at the top of the admin 排行榜 page (
components/workspace/admin/concurrency-monitor.tsxConcurrencyMonitorPanel, rendered inpages/AdminUserLeaderboardPage.tsx) showing 当前并发的大模型调用数 + 列表(用户/智能体/类型/已运行时长), each row with a 「停止」button (and a 「全部停止」) that truly aborts that run's in-flight model stream viaRunManager.cancel(POST /api/admin/active-runs/{run_id}/cancel) — to relieve pressure when 使用人数过多. Two line charts (minute/hour toggle): 并发量折线图 (peak/avg,GET /api/admin/active-runs/concurrency, fed by a lightweight background sampler intoconcurrency_samples) and 大模型出字速度折线图 (tokens/sec,GET /api/admin/active-runs/token-speed, aggregated fromllm_call_metrics) so a dip pinpoints where the bottleneck is. A 「后台采样」Switch (system_settings.json → concurrency_monitor.enabled, default off / opt-in,GET|PUT /api/admin/active-runs/settings) turns the background sampling on/off at runtime, no restart (the live concurrency count + 「停止」 work even while sampling is off — only the 并发量 history chart needs it on); everything is designed to never impact production (one tiny INSERT/15s, retention-purged, best-effort). Client/hooks:core/admin/active-runs.ts+core/admin/hooks.ts(useActiveRuns/useConcurrencySeries/useTokenSpeedSeries/useCancel*/useMonitorSettings). See the backend CLAUDE.md「Admin Concurrency Monitor」. - 会话冲突 409 中文化: when a user submits a new question while the previous turn is still streaming, the backend's
RunManager.create_or_rejectrejects with HTTP 409 (services.pynow returns a Chinese detail「当前对话还在生成回答…」).core/threads/hooks.tsonErrordetects the conflict (isActiveRunConflictError: 409 / "already has an active run" / the Chinese text). A premature SSE close (SDK treats it as success and restores the send button while the backend run is still occupying the thread) is recovered byconfirmOrRejoin: hold the stop button,joinStreamthe live run, and on 409 auto-reconnect instead of a fake AI bubble.sse_consumerno longer ends a resumable (on_disconnect=continue) stream on a false-positiveis_disconnected(). - State: TanStack Query for server state, context providers (
SubtasksProvider,ArtifactsProvider,PromptInputProvider) scoped toWorkspaceRoutes - LLM streaming:
@langchain/langgraph-sdkstreams messages from the backend via SSE - UI: Radix UI primitives + TailwindCSS v4 + shadcn-style components in
src/components/ui/ - Shims:
src/shims/providesnext/linkandnext/navigationpolyfills (original code was Next.js) - Login URL params (
LoginPage.tsx, frontend-only — the backend never sees these): besides the auth params (ticket/userId/env/theme/yUserId/token/access_token/authToken/password), the page supports a task deep-link via?taskId=&goPath=. After login it calls the consumer APIGET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=(headerAuthorization: Bearer admin, auto-injected byutils/request.ts), stashes the fulldata(id/overview/taskDirection/taskName/taskContent) intosessionStorage["login.copTaskDetail"], then routes bygoPath: base codes3qfx/rwfx/xdfx→ that business's configured single agent Q&A inside a 任务工作区 (resolved via/api/business-mapping;resolveTaskDeeplinkTargetcallspersistTaskContext({taskId, agentId, openingText})and navigates to the…/chatsindex, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOTpendingAgentMessage); the-Asuffix (3qfx-A/…) → multi-agent roundtable withroundtablePrefill.businessCodeso the page auto-selects the configured 业务链条. xdfx's deep-link?taskId=is actually an 行动id, not a 任务id —resolveDeeplinkTaskId(goPath, rawId)(called inLoginPagebefore everything) detectsxdfx/xdfx-Aand resolves it viaGET {action_detail_url}/{actionId}→data.taskId(fetchTaskIdFromAction;action_test+mock.action_detailfor offline testing); the resolved 任务id then drivescop-task-detail, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id:resolveTaskDeeplinkTargetsetspartitionByAction:true+ keeps the raw action_id asTaskContext.actionId, so new threads are taggedmetadata.{taskId,actionId}(taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys offmetadata.taskId) and the conversation list filters byactionId(+agent_id) — different actions of the same task get separate lists. xdfx's first-question opening text also appends行动id(action_id)为…(seebuildOpeningText/taskThreadMetadata). goPath→businessCode:3qfx→3Q,rwfx→6BF,xdfx→7BF. The roundtable target also threads the URLtaskIdintoroundtablePrefill.taskId(resolveTaskDeeplinkTarget(goPath, detail, taskId)), which turns on two taskId-scoped behaviors: (1) separate, unpartitioned per-task storage — taskId 会商聊天记录 live in their own store/table (roundtable_task_drafts), keyed only bytask_id(one taskId = many records, shared across users, no user 分权 at all;created_byis audit-only), kept entirely out of the per-user personal store (roundtable_drafts, now strictly per-user) so the two never mix. Backend router/api/roundtable-task-drafts(GET /by-task/{id}+/by-task/{id}/latest,POST,GET/PUT/DELETE /{id}— all user-agnostic); frontend clientroundtable-planning/api/task-drafts.ts, anduseDraftPersistence/useRoundtableDraftsroute all CRUD to it when ataskIdis present (else the personaldrafts.ts). The matching background jobs are shared by task too:roundtable_jobs.task_idmarks a task job; reads/dedup/stream/resume/cancel pass?task_id=so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over byscripts/migrate_roundtable_task_drafts.py(migration20260618_01). The history dropdown filters to the task and 新建 keeps the binding; (2) Step-3 auto structured output — after theroundtable-summaryagent writes the Markdown report it automatically runs a second round emitting aflow-jsonflowchart (useStep3SummaryautoFlow+chainLevels), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). Both targets render embedded under the 任务管理 layout — nested routes undertask-management/<business-path>/{roundtable,agent/:agent_id/chats}inStrategyRoutes.tsx(<business-path>=plan-task-sentiment/plan-task/plan-task-fx/plan-task/plan-task-action, matching the COH中任务 menu paths insidebar-menu.ts) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. All three single-agent businesses shareTaskFxWorkspace(task-components/TaskFxWorkspace.tsx): left = a「当前任务」card (readCopTaskDetail()任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (useTaskThreads(taskId, agentId, actionId?)— filtered by bothmetadata.taskIdANDmetadata.agent_idso the three businesses' conversations stay isolated even though they share one taskId; xdfx passes a thirdactionIdso it filters bymetadata.actionIdtoo, partitioning chat records per 行动 —useChatsBasederives it fromTaskContext.partitionByAction) + 新建对话; the…/chatsindex (TaskFxIndexResolver) opens the latest task thread or 新建. The right:thread_idhost differs by business: rwfx →TaskFxChat(AgentChatPage taskGate) = full workspace (non-lite): 目的树闸门 panel + 清除记录 (placed under 新建对话); xdfx → alsoTaskFxChat(same 目的树闸门 query+display, samepurpose_detail_url) but workspace passedlite, plus its own「清除记录」button (ActionClearButton, under 新建对话) that deletes by 行动id: opening its confirm dialog first runs a「行动历史是否存在」pre-check (fetchActionHistoryExists; query endpointtask_deeplink.action_history_urlis address-TBD — empty ⇒ unknown, does not block the delete; once a definite「无历史」comes back it disables 确认清除), then deletes via the shared delete endpointtask_deeplink.delete_url(migrated todropSixOrSevenData, used by both rwfx + xdfx — Six=6BF/rwfx, Seven=7BF/xdfx):deleteTaskPurpose(id, {idKind})sends rwfx?taskId=(omits actionId) / xdfx?actionId=(omits taskId) — the unused param is absent from the URL, not null.deleteTaskPurpose+fetchActionHistoryExistslive incore/auth/task-purpose.ts. 3qfx →TaskBindChat(AgentChatPage taskBind,lite) = 1:N list only, no gate, and 新建对话 auto-sends the task opening text (taskAutoSendText; gate businesses send it via the gate's 开始问答 instead). rwfx auto-starts the first Q&A when the 任务目的 query returns empty (TaskPurposePanel autoStartWhenEmpty, threaded from the rwfxTaskFxChatonly), but gated by the 3q precheck: on entering the rwfx workspaceRwfxSqGateProvider(task-components/rwfx-sq-gate.tsx, wraps the workspace, rwfx only) callsfetchSqReportStatus(taskId)—incomplete(3q 无数据) pops the「3q 未完成,是否前往 3qfx」dialog AND holds the auto-send; the question is only sent when the user picks「暂不」(or dismisses — i.e. does NOT go to 3q analysis), while「前往 3qfx」navigates and discards the send.complete/unknownsend immediately (no dialog). The panel's auto-start callsuseRwfxSqGate().gateStart(onStart)instead ofonStart(); the manual「开始问答」/覆盖确认 path is unchanged (ungated). This replaced the old standaloneRwfxSqReportGate(which popped the same dialog but did not gate the auto-send). The 目的树闸门 (TaskPurposePanel) defaults collapsed and now keeps only 弹框查看 + 开始问答; the former jump buttons (编辑/下一步/3q详情/切换三情/弹框查看) all moved to the configurable bottom 快捷跳转 bar (TaskJumpBar, red 红底白字 buttons, right-aligned) driven by 按钮管理 — see the 按钮管理 bullet.TaskPurposeDialog(exported fromTaskPurposePanel) backs both the gate zoom and the bottom bar'spurpose-type button.chatBasePathkeeps the post-send URL rewrite + new-chat button in the task-management context. Backend configtask_deeplink(config.yaml, surfaced byGET /api/public/config/task-deeplink, mirrored incore/auth/task-deeplink-config.ts) addsaction_test/action_detail_url/detail_3q_url+mock.action_detail. Any failure (no consumer URL / fetch error / unmapped goPath / unresolvable action id / no configured agent) silently falls back to the default home. Helpers:core/auth/task-deeplink.ts,core/auth/task-purpose.ts,core/auth/task-deeplink-config.ts,core/threads/use-task-threads.ts. - iframe 嵌入模式 (外部系统用 iframe 套用深链问答/会商) — 三个按标签页开关,全部由深链 URL 参数开启、存进同一份
sessionStorage会话(embed.session,键名常量在core/embed/embed-session.ts),跨登录跳转/刷新/页内导航存活,绝不跨标签污染同浏览器另开的正常系统页。核心隔离手法:用sessionStorage(按标签页/浏览上下文隔离,iframe 即便同源也有独立一份)而非localStorage(跨标签共享)。开关:(1)?embed=1(可带&theme=) — 去全局 chrome(PageLayout的pageSidebarAllowed && !isEmbedActive()→ 隐藏 Header + PageSidebar)+ 按外部主题色强制明/暗:走 next-themes 的forcedTheme(App.tsxAppProviders调resolveForcedEmbedTheme,不写 localStoragestrategy-theme),离散映射dark-blue/dark→深、其余→浅;嵌入侧绝不调setTheme/persistSidebarEnabled(那俩落 localStorage 跨标签污染,LoginPage据readEmbedParam跳过)。(2)?hideJump=1— 隐藏深链底部「快捷跳转」栏TaskJumpBar的两部分(底部「下一步」等按钮 + 右侧垂直居中悬浮菜单),单智能体问答 + 会商共用该栏,isJumpBarHidden()为真时整条return null。(2.5)?hideChrome=1— 仅去全局 chrome(Header 导航栏 + PageSidebar 侧边栏),不强制主题、不隔离鉴权(区别于embed:只想要一个干净全屏页面,如「大屏绘制」/page/strategy/dashboard-agent?taskId=…&hideChrome=1)。新增EmbedSession.hideChrome字段 +readHideChromeParam()+isChromeHidden()(取embed || hideChrome之并),与hideJump完全对称;PageRoutes.tsx的PageLayout把chromeHidden(isChromeHidden() || readHideChromeParam(location.search)==="on")并入pageSidebarAllowed,且其优先级高于 dashboard-agent 路由原本的强制全局 chrome(forceChromeForDashboardAgent)。hideChrome=0可显式关闭。(3) 登录用户隔离 — 嵌入态下鉴权/身份键(deerflow.authtoken、userInfo、login.*、tools_token、maxkey/userDetail token)全部经core/auth/scoped-storage.ts的authStorage()(嵌入→sessionStorage / 正常→localStorage)读写,使 iframe 里登录的用户与正常登录用户完全隔离、互不覆盖;所有 token 读取都过getStoredAuth()/getAuthorizationHeaderValue()(API 客户端core/api/*全走这俩),改中心函数即让全体调用方自动隔离;跨标签账号变更监听AuthSyncWatcher(core/auth/sync.tsx)在嵌入态禁用。嵌入会话在main.tsx渲染前syncEmbedSessionFromCurrentUrl()同步启用(早于任何鉴权写入,避免首帧 effect 顺序漏写 localStorage);AppProviders的 effect 再按 SPA 导航同步。embed=0/hideJump=0可显式关闭。正常(非嵌入)标签页所有isXxx()恒 false,行为零变化。第三个开关?isolate=1(独立登录会话)把同一套登录用户隔离单独提供给普通(带 chrome)标签页——只切换鉴权存储、不去 chrome / 不强制主题 / 不动跳转栏:authStorage()的条件从「仅嵌入」放宽为isEmbedActive() || isIsolatedSession(),AuthSyncWatcher的跨标签监听对嵌入态或独立态都即时忽略(handler 内实时判,运行时开关也即刻生效)。除 URL 参数外,设置 → 外观 →「登录会话」 有一个 Switch「独立登录会话(本标签页)」运行时切换(setIsolatedSession,appearance-settings-page.tsx):开启时先migrateAuthToSession()(scoped-storage.ts,把 localStorage 的受保护键deerflow.auth/userInfo/userId/tools_token+login.*前缀快照搬进 sessionStorage)再翻标志,故无感不登出;此后本标签页登录态独立——别处登录/换号影响不到它,它换号也影响不到别人,但关掉该标签即结束(需重登)。开启给 toast 提示。 - 操作日志(Y-Log)上报 — 用户的关键操作(登录登出/查询/查看/增删改/启停/导入导出/问答等)成功后,前端直连外部接口
POST {write_url}(默认https://ch1.b.uat.4.cn/api/y-log/write)记一条审计日志。核心模块src/core/log/:y-log.ts(writeYLog(params)+OptType枚举 0/1/3..12;异步即发即忘、永不 await、永不抛错、失败静默,自动把完整 url 转相对路径去 ip/host、对象入参/返回自动JSON.stringify+ 50k 裁剪、optResult恒 0 只报成功),y-log-config.ts(从公开只读GET /api/public/config/y-log拉{enabled, writeUrl},模块级缓存 + 去重 + 默认兜底;App.tsxAppProviders启动预热fetchYLogConfig()),y-log-token.ts(token1 = token 登录的?authToken=,经 scoped-storage 的authStorage*存取,嵌入态自动隔离)。双门控:仅当①存在 token1(即用 token 登录,LoginPage成功后persistYLogToken(authToken),登出account-settings-page.tsx先报后清 token1)且②配置enabled && writeUrl才上报;否则一律不发。optDetail统一「CM助手-全量-…」(禁内部框架名)。埋点广覆盖各 API client 的成功路径——原则是「一个用户操作(查看/查询/新增/编辑/删除/启停/导入导出)= 一条日志」,每个 API client 的单条详情 GET(查看→view 4)/ 列表查询 GET(search 3)/ 新增 POST(create 9)/ 改 PUT·PATCH(edit 5)/ 删 DELETE(delete 6)/ 启停(enabled 11 / disabled 10)/ 导入导出(import 7 / export 8)都各落一条,仅跳过高频 typeahead/状态轮询/纯 bootstrap config 读。覆盖文件:strategy-components/api/{sensitive-words,menu-overrides,task-buttons,skill-addresses,light-apps,business-mapping}.ts、roundtable-planning/api/{drafts,task-drafts,roundtable-jobs,recommend,chains,multi-agent,dashboard-sessions}.ts、core/{agents,scheduled-tasks,knowledge,positions,tags,studio,system-settings,memory,mcp,models,custom-prompts,user-preferences,notifications,recommended-questions,user-qa,curator,admin,thread-shares,roundtable-draft-shares,tool-metrics,llm-metrics,note,artifacts}/api.ts、core/api/markdown-docx-export.ts。问答(Q&A)改为「一次大模型调用 = 一条日志」:在共享的core/threads/hooks.tsuseThreadStream的onLangChainEvent里监听on_chat_model_end,每次模型调用结束就上报一条(optType 12,reqParam取本次 input 的最后一条 user 文本 + 模型名,returnParam取本次输出文本)——一个问答轮次里 lead agent / 子代理的每次 LLM 调用都各记一条,覆盖所有走useThreadStream的页面(ChatPage/AgentChatPage/创建智能体/iframe/会商等);ChatPage/AgentChatPage的onFinish仍额外保留一条轮次汇总。后端仅新增公开只读配置:deerflow/config/y_log_config.py(YLogConfig{enabled,write_url})挂到AppConfig.y_log,app/gateway/routers/public_config.py加GET /api/public/config/y-log,地址写在config.yaml的y_log段(mtime 热重载,前端无需重建);不做后端代理。Tests:tests/test_y_log_config.py。
Configuration
Backend config lives in offline-backend-20260512/backend/config.yaml. Values starting with $ resolve as environment variables (e.g., $OPENAI_API_KEY). MCP servers and skills are configured in extensions_config.json in the same directory.
Detailed Backend Reference
The backend has its own comprehensive documentation at:
offline-backend-20260512/backend/CLAUDE.md— architecture, middleware chain, all API routes, config schema, sandbox/memory/subagent systemsoffline-backend-20260512/backend/README.md— setup guide, technology stack