首页
/ Dify Agent V2 前端架构:从 Payload 判别符到单一数据源草稿状态的边界规范

Dify Agent V2 前端架构:从 Payload 判别符到单一数据源草稿状态的边界规范

2026-09-06 23:40:14作者:廉皓灿Ida

本文以 Dify 仓库中 Agent V2 前端模块的架构约束文档 为核心,讲清 Agent V2 与旧版 workflow Agent 的隔离边界、agent-composer 单一配置数据源的设计,以及 agent-detail/configure 页面如何通过 jotai 原子状态完成服务端同步、构建草稿与预览会话的组合。读完本文,你可以理解 Agent V2 前端为什么采用"判别符 + 单一 Store"的设计,并能沿文中给出的源码路径复现每个架构决策的实现依据。

Agent V2 与旧版 Agent 的隔离边界

web/features/agent-v2/AGENTS.md 的第一条规则要求:Agent V2 必须与遗留的 workflow Agent(agent_strategy_* 系列行为与数据形状)保持分离,禁止把两者桥接起来。具体而言,凡是 Agent V2 的代码必须落在两个固定位置,并携带明确的载荷判别信息:

Payload 判别符的类型定义

判别符不是约定俗成,而是被固化在节点类型定义中。types.ts 定义了 AgentV2NodeType,它在工作流通用节点类型基础上附加了四个关键字段:

export type AgentV2NodeType = CommonNodeType & {
  agent_binding?: AgentBinding
  agent_declared_outputs?: DeclaredOutputConfig[]
  agent_node_kind: 'dify_agent'
  agent_task?: string
  version: '2'
}

判断一个节点数据是否属于 Agent V2 的守卫函数 isAgentV2NodeData 同时校验三个条件:节点 typeBlockEnum.AgentBlockEnum.AgentV2(兼容迁移中的图)、agent_node_kind === 'dify_agent'version === '2'

export function isAgentV2NodeData(data: CommonNodeType): data is AgentV2NodeType {
  const payload = data as { agent_node_kind?: string; version?: string }
  return (
    (data.type === BlockEnum.Agent || data.type === BlockEnum.AgentV2) &&
    payload.agent_node_kind === 'dify_agent' &&
    payload.version === '2'
  )
}

从源码结构看,data.type 同时接受 BlockEnum.AgentBlockEnum.AgentV2,说明迁移期间旧枚举类型的节点也可能携带 V2 判别载荷,判别真正依赖的是 agent_node_kind + version 组合,而非单独的节点类型字段。

绑定类型:roster_agent 与 inline_agent

Agent V2 节点通过 agent_binding 字段关联一个 Agent 实例,types.ts 中定义了两种绑定形态,并各自配备校验函数:

绑定类型 校验函数 有效条件
roster_agent(引用 Agent 花名册中的 Agent) hasValidRosterAgentBinding binding_type === 'roster_agent'agent_id 为非空字符串
inline_agent(内联 Agent) hasValidInlineAgentBinding binding_type === 'inline_agent',且 agent_idcurrent_snapshot_id 均为非空字符串

其中 needsInlineAgentBindingCreation 用于识别"已选择内联绑定但快照尚未创建"的中间态;hasValidAgentBinding 则是两者的合取。节点默认值定义在 default.ts 中,新建 Agent V2 节点即携带判别载荷,且默认采用内联绑定:

const nodeDefault: NodeDefault<AgentV2NodeType> = {
  metaData,
  defaultValue: {
    agent_binding: {
      binding_type: 'inline_agent',
    },
    agent_node_kind: 'dify_agent',
    version: '2',
  },
  checkValid(payload, t) {
    if (!hasValidAgentBinding(payload)) {
      return {
        isValid: false,
        errorMessage: t(($) => $['errorMsg.fieldRequired'], {
          ns: 'workflow',
          field: t(($) => $['nodes.agent.roster.label'], { ns: 'workflow' }),
        }),
      }
    }
    return { isValid: true, errorMessage: '' }
  },
}

checkValidhasValidAgentBinding 的对应关系意味着:工作流画布上未绑定任何有效 Agent 的 Agent V2 节点会被判定为无效配置,这正是判别符体系在运行时校验层面的落地。

功能开关

Agent V2 特性由环境变量门控,实现见 feature-flag.ts

import { env } from '@/env'

export const isAgentV2Enabled = () => env.NEXT_PUBLIC_ENABLE_AGENT_V2

从源码结构看,这是唯一的能力判定入口,前端其他位置通过该函数决定 Agent V2 界面是否可用,而不是各自读取环境配置。

agent-composer:可编辑配置状态的唯一所有者

文档第二条规则是核心架构决策:"agent-composer 拥有可编辑的 Agent 配置状态"。它包含两层含义:状态集中管理,以及任何上层组件不得复制出第二份配置 Store。

基于 jotai 的草稿原子组

配置状态的核心 Store 定义在 store.ts,只有四个原子:

export const agentComposerSavedDraftAtom = atom<AgentSoulConfigFormState | undefined>(
  defaultAgentSoulConfigFormState,
)
export const agentComposerDraftAtom = atom<AgentSoulConfigFormState>(
  defaultAgentSoulConfigFormState,
)

export const rebaseAgentComposerDraftAtom = atom(
  null,
  (_get, set, { draft }: { draft: AgentSoulConfigFormState }) => {
    set(agentComposerDraftAtom, draft)
    set(agentComposerSavedDraftAtom, draft)
  },
)

export const isAgentComposerDirtyAtom = atom((get) => {
  const savedDraft = get(agentComposerSavedDraftAtom)
  const draft = get(agentComposerDraftAtom)
  return !isEqual(draft, savedDraft ?? defaultAgentSoulConfigFormState)
})

这套设计对应三个概念:

  • agentComposerDraftAtom:当前编辑器中的实时草稿,所有表单控件直接写入它;
  • agentComposerSavedDraftAtom:上次成功持久化(或初始化加载)的基线快照;
  • rebaseAgentComposerDraftAtom:写原子,将草稿与基线同时重置为新值。它在版本恢复、构建草稿应用等"外部状态回灌"场景使用,重置后脏标记自然归零,避免脏检查把服务端已有内容误判为未保存修改;
  • isAgentComposerDirtyAtom:派生原子,用 fast-deep-equal 比较草稿与基线得出脏状态,是自定时的触发依据。

表单状态形状与服务端配置的互转

表单状态类型 AgentSoulConfigFormState 定义在 form-state.ts,完整覆盖了 Agent 配置的所有可编辑面:

export type AgentSoulConfigFormState = {
  configNote: string
  prompt: string
  model?: AgentComposerModel
  appFeatures?: AgentSoulAppFeaturesConfig
  skills: AgentSkill[]
  files: AgentFileNode[]
  tools: AgentTool[]
  knowledgeRetrievals: AgentKnowledgeRetrievalItem[]
  envVariables: EnvVariable[]
  toolSettings: Record<string, Record<string, unknown>>
}

其中 tools 是联合类型:AgentProviderToolkind: 'provider',含凭据类型 api-key | oauth2 | unauthorized、凭据变体与动作列表)与 AgentCliToolkind: 'cli',含安装命令和环境变量),后者还支持 scope: 'secret' | 'plain' 的环境变量区分。defaultAgentSoulConfigFormState 给出所有字段的可运行空值,是 Store 的初始值与脏比较的兜底基准。

表单状态与服务端 AgentSoulConfig 之间的双向转换集中在 conversions.ts,两个核心入口函数为 agentSoulConfigToFormState(服务端到表单)与 formStateToAgentSoulConfig(表单到服务端)。转换层处理了大量字段归一化细节,例如:

  • 知识检索集合(knowledge sets)在两种表示之间映射 query.modeuser_query 对应自定义查询、generated_query 对应 Agent 生成查询)、单路/多路检索参数(top_kscore_threshold、reranking 等),未配置时回落到 DATASET_DEFAULT.top_k
  • 工具凭据状态由 toCredentialVariant 归一为 authorized | unauthorized | none 三态,供 UI 直接消费;
  • CLI 工具的环境变量按 secret_refsvariables 拆分为 scope: 'secret'(带 masked: true)与 scope: 'plain' 两类;
  • 发布前的环境变量序列化(toEnvConfig)会过滤掉键名不合法或值为空的条目,保证只有可发布变量进入载荷。

这种"表单状态 ≠ 服务端配置、中间隔一个显式转换层"的结构,正是"单一配置 Store"规则能成立的物理基础:上层组件只需要读写 jotai 原子,序列化细节被隔离在 conversions 模块内。

agent-detail/configure:组合而非复制

文档第二条规则的后半句规定了 agent-detail/configure 的职责边界:它把 agent-composer 的状态与服务端同步、构建草稿命令、预览聊天会话、版本查看和工作区面板"组合"起来,且"不得创建第二个配置 Store"。源码中这一边界体现在三处。

作用域隔离:以 agentId 为键的 ScopeProvider

配置页入口 page.tsx 最外层用 ScopeProvideragentId 隔离了页面级原子:

export function AgentConfigurePage({ agentId }: AgentConfigurePageProps) {
  return (
    <ScopeProvider key={agentId} atoms={agentConfigureScopedAtoms} name="AgentConfigure">
      <AgentConfigurePageContent agentId={agentId} />
    </ScopeProvider>
  )
}

被隔离的原子组定义在 state.ts:选中的版本 ID、composer rebase 版本号、soul 来源覆盖('draft' | 'build-draft' | 'view-version')、右面板模式('build' | 'preview')与各模式的会话 ID 等。key={agentId} 确保在两个 Agent 间切换时页面状态完全重建,而 agent-composer 的全局草稿原子则通过会话 key 在下方被重置(见下文),两者职责不重叠。右面板模式同时受服务端能力门控:deploymentEdition !== 'COMMUNITY' 时才允许 preview,否则强制回落 build

composer-session:会话 key 驱动的状态重建

composer-session.tsx 是"组合"逻辑的中枢。它不持有配置状态,而是计算一个会话 key 并将其作为 AgentComposerProviderkey

const composerSessionKey =
  `${agentId}:${activeVersionId ?? selectedVersionId ?? 'draft'}:${composerHydrationState}:${composerRebaseRevision}`

key 由 agentId、当前查看的版本(或 draft)、composer 数据水合状态、rebase 修订号四段拼成。从源码结构看,key 变化即意味着 React 卸载重建 AgentComposerProvider 子树,从而以全新的 initialDraft 重新初始化 agent-composer 的原子组——切换 Agent、切换版本、版本恢复(触发 onComposerRebase 递增修订号)都会走到这条路径。页面侧的 rebaseAgentConfigureComposerAtom 只是一个递增计数器,真正的状态重置依赖 agent-composer 提供的 rebaseAgentComposerDraftAtom,二者配合完成"外部回灌、脏标记清零"的闭环。

use-agent-configure-sync:服务端同步的唯一实现

保存与发布逻辑集中在 use-agent-configure-sync.ts,它读取 agentComposerDraftAtom、写入 agentComposerSavedDraftAtom,是 configure 目录中唯一直接触碰 composer Store 的文件。关键机制包括:

  • 5 秒防抖自动保存DRAFT_AUTOSAVE_WAIT = 5000。Hook 通过 store.sub(agentComposerDraftAtom, ...) 订阅草稿变化,仅在脏标记为真、且草稿快照与上次已保存快照(JSON.stringify 比较)不同时才触发防抖保存;
  • 串行化保存saveComposer 包装在 useSerialAsyncCallback 中,避免并发保存乱序,并用 saveSequence 序号丢弃过期的保存回执,防止旧的响应覆盖新的基线;
  • 载荷形状:保存请求体固定为 { variant: 'agent_app', save_strategy: 'save_to_current_version', agent_soul: configSnapshot },调用 consoleQuery.agent.byAgentId.composer.put 生成的 mutation options;
  • 静默与显式两种失败语义:自动保存失败时静默返回 false 并保留本地草稿(注释明确写道"Autosave is silent and keeps the local draft intact; explicit commands must stop at this boundary"),而显式保存与发布失败则向上抛出;
  • 页面关闭兜底:监听 visibilitychange(页面隐藏)与 beforeunload,以及组件卸载时的 cleanup,调用 saveDirtyDraftOnPageClose。该函数用 draftKey 去重,跳过已自动保存过或正在显式保存中的相同快照,避免重复请求;
  • 发布前校验链publishDraft 依次检查模型已选(model_providermodel 非空)、工具可发布性(getAgentToolPublishIssue 检查未安装/未授权工具)、知识检索配置合法性(validateKnowledgeRetrievals),任一失败即 toast.error 并中止;成功后执行"保存 + 发布"两步事务并失效版本、详情、API 访问三个查询缓存。

发布入口最终由 composer-session.tsx 中的 AgentOrchestratePanelonPublish 回调承接,左面板(编排/配置)与右面板(build/preview 聊天)在此完成装配。build 模式运行前还会先调用 saveDraftBeforeBuildRun 落盘当前草稿,保证 Agent 侧读到的是最新配置。

Configure 浮层与 ModalContext 迁移边界

文档第三条规则规定:配置相关的浮层(overlay)必须留在拥有该交互的特性组件内部,并使用 Dify UI 原语组合而成;现有的 ModalContext 消费者只作为遗留迁移边界存在,不是 Agent V2 新对话框的 API。

这一约束的实际含义是:Agent V2 中的确认对话框、上传对话框、版本详情等一律就近声明在使用它们的特性组件里。从目录结构可以直接验证这一点,例如 agent-detail/configure/components/confirm-clear-session-dialog.tsx 服务于切换 preview 模式前的清空确认、skills/upload-dialog.tsxfiles/upload-dialog.tsx 分别属于技能与文件两个特性组件、access/agent-api-key-modal.tsx 属于访问页特性。每个对话框的状态与其宿主组件的生命周期绑定,不依赖全局模态上下文,这也是"不创建第二个状态 Store"规则在 UI 层的对应物。

测试视角下的边界验证

上述三条架构规则都有对应的测试文件作为可执行约束,可作为理解各边界的补充证据:

小结:三条规则如何落到目录结构上

规则 承载模块 关键实现证据
与旧版 Agent 隔离 web/features/agent-v2web/app/components/workflow/nodes/agent-v2 agent_node_kind: 'dify_agent' + version: '2' 判别载荷(types.ts)、BlockEnum.AgentV2NEXT_PUBLIC_ENABLE_AGENT_V2 开关(feature-flag.ts
agent-composer 拥有唯一配置状态 agent-composer/store.tsconversions.ts 四个 jotai 原子 + 脏派生原子;表单/服务端配置显式互转层
configure 只做组合 page.tsxcomposer-session.tsxuse-agent-configure-sync.ts ScopeProvider 按 agentId 隔离页面原子、会话 key 重建 composer 子树、唯一同步 Hook
浮层就近、组合 UI 原语 各特性组件内的 dialog/modal 文件 对话框与宿主特性组件同目录、无全局模态上下文

Agent V2 前端的整体设计可以概括为:用载荷判别符在数据层面与旧版 Agent 划清界限,用一组最小化的 jotai 原子把可编辑状态收敛到单一所有者,再由 configure 页面通过作用域隔离、会话 key 重建与单一同步 Hook 完成组合。任何新增功能若绕开这三个边界(桥接旧版数据形状、复制配置 Store、引入全局模态上下文),都会破坏上述测试所固化的架构契约。

登录后查看全文
热门项目推荐
相关项目推荐