主题
前端技术栈与架构
本章目标:
- 看懂 a-cdm 前端的技术选型(Next.js 16 + React 19 + Tailwind 4 + pnpm)以及它为什么是 App Router 而非传统 SPA
- 建立
src/core(业务逻辑)/src/components(视图)/app(路由)三层心智地图,知道改业务该去哪个目录- 彻底弄清
basePath=/a-cdm是编译期静态替换——为什么改它必须重 build,而不能靠 Caddy 热切
TL;DR
a-cdm 前端是把 deer-flow 原生聊天前端深度二开后的 Next.js 16 应用:app/ 跑 App Router(41 个 page,核心是 /workspace/*),src/core/ 装纯业务逻辑(API client / hooks / 类型),src/components/ 装视图(ui/ 是 Shadcn 生成物,workspace/ 172 个业务组件)。所有 a-cdm 定制的业务客户端集中在 src/core/acdm/(27 个文件,经 index.ts 桶导出),走 /api/acdm 同域 + HttpOnly cookie。最关键的运维约束是 basePath=/a-cdm:它在 pnpm build 时被静态烤进每一个 <Link>、/_next/static 路径,改它只能重 build,Caddy 运行时改不动。
Overview(为什么是这套栈,为什么 App Router)
a-cdm 前端要回答的问题是:一个有状态的 AI 协作工作台,前端怎么组织才不乱?
这不是一个普通表单网站。它要同时承载:① AI 流式对话(SSE 长连、可重连)② 项目/会议/报告等大量 CRUD 业务页 ③ 鉴权守卫(未登录跳 Keycloak)④ 富交互(日历、文件树、Markdown 渲染、Mermaid)。如果用纯 CSR 的 SPA,会撞上三个问题:
- 首屏鉴权时机:登录态校验若全在客户端做,未登录用户会先看到一闪而过的工作台空壳,再被踢走——体验差且有信息泄露风险。
- 路由即资源:
/workspace/projects/[id]/meetings/[mid]这种深层资源路由,手写客户端路由表会爆炸。 - 服务端能力缺失:鉴权 cookie 解析、SSR locale 探测、反向代理后端 API,都需要一个服务端运行时。
答案是 Next.js 16 App Router:服务端组件默认渲染、proxy.ts(Next.js 16 把 middleware.ts 重命名而来)做边缘守卫、文件系统即路由表、next.config.js 的 rewrites 充当 BFF 反代。a-cdm 没有重写这套底座,而是把 deer-flow 原生前端"撑开"——保留它的线程/流式机制,在 src/core/acdm/ 旁挂一整套业务域。React 19 + Tailwind 4 + pnpm 是 deer-flow 上游既定选型,a-cdm 沿用。
Architecture:三层分工 + 一份桶导出
前端代码遵循一条铁律:src/core/ 不含 JSX、src/components/ 不含跨页业务编排、app/ 只做路由装配。这条边界让"改业务逻辑"和"改界面"互不污染。
| 层 | 目录 | 职责 | 关键证据 | Source |
|---|---|---|---|---|
| 路由层 | src/app/ | App Router,文件系统即路由,Server Component 默认 | 41 个 page.tsx,核心 /workspace/* | deer-flow/frontend/src/app/layout.tsx:23-36 |
| 业务逻辑层 | src/core/ | API client / TanStack hooks / 类型 / 流式状态,纯 TS 无视图 | 26 个子模块,acdm/ 27 文件最大 | deer-flow/frontend/src/core/acdm/index.ts:1-13 |
| 视图层 | src/components/ | React 组件;ui/+ai-elements/ 为生成物只读 | workspace/ 172 文件,ui/ 48 | deer-flow/frontend/src/components/workspace/acdm |
| 边缘层 | src/proxy.ts | 鉴权守卫 + dev auth header 注入 | matcher 限 /workspace、/api/langgraph、/api/memory | deer-flow/frontend/src/proxy.ts:92-99 |
| 配置层 | next.config.js / env.js | basePath / rewrites / T3 env 校验 | basePath 来自 env | deer-flow/frontend/next.config.js:35 |
src/core/ 子模块按规模排序后,acdm/(27 文件)、i18n/(11)、api/(10)是三个最重的——acdm/ 是 a-cdm 全部定制业务的家,api/ 是 deer-flow 原生 LangGraph 接入层,i18n/ 是上游国际化。a-cdm 二开策略由此清晰:不动 deer-flow 原生 core/threads、core/api 的流式骨架,所有新业务进 core/acdm/。
Components / Subsystems
App Router 路由结构
职责:文件系统即路由表,layout.tsx 嵌套构成 Provider 树。
根布局 app/layout.tsx(src/app/layout.tsx:23-36)是所有页面的外壳:它做 SSR locale 探测(detectLocaleServer)、挂 ThemeProvider + I18nProvider,并在此处声明 metadata title: "A-CDM"——这是 a-cdm 改的第一处定制痕迹(上游叫 DeerFlow)。
往下一层 app/workspace/layout.tsx(src/app/workspace/layout.tsx:18-39)是工作台外壳:从 sidebar_state cookie 读侧栏初始展开态(避免水合闪烁),挂 QueryClientProvider(TanStack Query)、SidebarProvider、CommandPalette、Toaster。再往下 app/workspace/projects/[id]/(workspace)/layout.tsx 用 Route Group (workspace)(括号目录不映射 URL)统一给所有项目子页套三栏布局:ProjectSubNav | 子页 | ProjectAgentChatPanel(src/app/workspace/projects/[id]/(workspace)/layout.tsx:1-21)。
41 个 page.tsx 的拓扑:/(landing)→ /workspace/*(主战场,含 chats / projects / agents / knowledge / admin / skills)→ /blog、/[lang]/docs(Nextra 文档站)→ /mock/api/*(本地 mock 端点)。
| 路由段 | 用途 | Source |
|---|---|---|
app/workspace/chats/[thread_id] | AI 对话(deer-flow 原生骨架) | deer-flow/frontend/src/app/workspace/chats/[thread_id]/page.tsx |
app/workspace/projects/[id]/(workspace)/* | 项目工作区(概览/文档/会议/报告/日历/WBS/知识库) | deer-flow/frontend/src/app/workspace/projects/[id]/(workspace)/layout.tsx:1-30 |
app/workspace/admin/* | 后台(用户/审计/AI 角色/回收站) | deer-flow/frontend/src/app/workspace/admin/layout.tsx |
app/workspace/agents/* | Agent 管理(新建/编辑/对话) | deer-flow/frontend/src/app/workspace/agents/page.tsx |
app/mock/api/* | 本地开发 mock(无后端时返假数据) | deer-flow/frontend/src/app/mock/api/models/route.ts |
src/core/acdm —— a-cdm 定制业务总线
职责:把 a-cdm 所有自研业务域的 HTTP client + TanStack hooks + 类型集中,经一个桶文件统一对外。
core/acdm/index.ts(src/core/acdm/index.ts:1-13)只做一件事——export * 把 13 个模块再导出。组件只需 import { projectApi, useMeetings } from "@/core/acdm",不关心内部拆成多少文件。这是典型的 barrel(桶)模式:对外一个稳定入口,对内自由重构。
核心是 core/acdm/api.ts(src/core/acdm/api.ts:62-97)的 request<T>() fetch 封装,所有业务 API 都走它:
ts
// 摘自 deer-flow/frontend/src/core/acdm/api.ts:62-97
export async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const resp = await fetch(`${ACDM_BASE}${path}`, {
...init, headers,
credentials: "include", // 带 HttpOnly cookie
});
if (resp.status === 401) { maybeRedirectToLogin(); throw new AcdmUnauthorized(); }
// 非 JSON 5xx 兜底:resp.json() 直接炸会把真错误淹掉
if (!resp.ok && !contentType.includes("application/json")) {
throw new AcdmError(resp.status, `HTTP ${resp.status}: ${text.slice(0, 200)}`);
}
const json = (await resp.json()) as ApiResponse<T> & ...;
if (json.code !== 0) throw new AcdmError(json.code, json.message, json.details);
return json.data as T;
}三个设计要点:① ACDM_BASE = "/api/acdm" 是同域相对路径,靠 next.config.js rewrite 转发,这样 HttpOnly cookie 才能被浏览器自动带回(src/core/acdm/api.ts:58-60);② 统一解 {code, message, data} 信封,code !== 0 一律抛 AcdmError;③ 401 自动 maybeRedirectToLogin() 跳 /api/acdm/auth/login,且用模块级 _redirectingToLogin 标志防重复跳(src/core/acdm/api.ts:110-117)。api.ts 之下按域拆成 ba-report-api.ts / knowledge-api.ts / wiki-api.ts,hooks 侧 hooks.ts / knowledge-hooks.ts / wiki-hooks.ts 用 TanStack Query 包一层(src/core/acdm/hooks.ts:1-23)。
src/components —— 生成物与业务组件分治
职责:视图层,把"自动生成、禁手改"的设计系统组件和"手写业务"组件物理隔离。
components.json(Shadcn 配置)声明 ui 别名指向 @/components/ui,registries 含 ai-elements / magicui / react-bits 三个远程源。这些目录是注册表生成物:eslint.config.js:10-13 明确把 src/components/ui/** 和 src/components/ai-elements/** 加进 ignores——ESLint 不检查,也意味着不该手改(改了下次从 registry 重拉会被覆盖)。
业务组件全在 components/workspace/(172 文件),其中 components/workspace/acdm/ 是 a-cdm 定制视图(acdm-chat.tsx / CreateMeetingDialog.tsx / ba-report/ 等),与 core/acdm/ 一一呼应:core/acdm 出数据,components/workspace/acdm 出界面。
core/api —— deer-flow 原生 LangGraph 接入(只读骨架)
职责:LangGraph SDK 客户端单例 + 拦截器,a-cdm 不动其骨架只复用。
core/api/api-client.ts(src/core/api/api-client.ts:109-120)用 Map 缓存 client 单例(default / mock 两个 key),getAPIClient() 是唯一入口。installLangGraphInterceptor(src/core/api/api-client.ts:24-59)在 SDK 上打补丁:threads.create 默认补 ifExists: "do_nothing" 防 409,runs.stream 前先 ensureThread 兜住 SDK 内部 auto-create。注释明确写 owner_sub 注入已移到服务端 acdm_auth,客户端不再传——这是 Aegra 迁移后的契约变更。这块属于 deer-flow 原生流式机制(详见第 31 章),本章只定位它在分层中的位置:core/api 喂 core/threads,再喂 components/workspace/chats。
Data Flow:一次业务请求怎么走
关键在第 3-5 步:前端绝不直连 127.0.0.1:8002,而是请求同域 /api/acdm/*,由 next.config.js:110-119 的 rewrite 转发到 ACDM_BACKEND_BASE_URL(dev 默认 http://127.0.0.1:8003,prod 由 Caddy 接管)。同域是 HttpOnly cookie 能用的前提——跨域 fetch 浏览器不会自动带 cookie。生产环境 rewrite 部分被跳过(NEXT_PUBLIC_*_BASE_URL 已设),改由 Caddy 处理(详见第 33 章)。
Implementation Details:basePath 是编译期静态替换
这是全章最重要、也最容易在生产踩坑的一点。
next.config.js:35 一行:
js
// 摘自 deer-flow/frontend/next.config.js:33-35
// 本地 dev 不设 → 走 /;prod build 时 NEXT_PUBLIC_BASE_PATH=/acdm
basePath: process.env.NEXT_PUBLIC_BASE_PATH || undefined,dev 不设 → basePath 为空,访问 http://localhost:3000/workspace/projects。prod 在 deploy/prod/acdm-frontend/Dockerfile 的 builder stage ENV NEXT_PUBLIC_BASE_PATH=/a-cdm 后 RUN pnpm build——关键就在这个顺序:basePath 是 Next.js 的 build-time 静态替换,pnpm build 时所有 <Link href>、next/image src、/_next/static/* chunk 路径都被字面量烤成带 /a-cdm 前缀的绝对 URL,写死进 .next 产物。
后果(运维铁律):
- ❌ 不能在运行时靠 Caddy rewrite /
Locationheader / 环境变量热切 basePath——bundle 里已是死字符串 - ✅ 改 basePath 必须
docker compose build(prod Dockerfile 还用ARG BUILD_HASH=$commit_sha防 BuildKit COPY layer cache 误命中,见 Dockerfile 注释) - ✅ 生产 Caddy 只
reverse_proxy acdm-frontend:3000,不感知 basePath——前端 bundle 自己把所有路径带/a-cdm
prod Dockerfile 还配套设了 NEXT_PRODUCTION_STANDALONE=1,触发 next.config.js:27 的 output: "standalone",产出可独立跑的 server.js(deploy/prod/acdm-frontend/Dockerfile)。compose 里特意取消了 healthcheck——因为 basePath 让 / 必返 404,健康检查会永远 unhealthy 误导排错(deploy/prod/acdm-frontend/compose.yml)。
Configuration
| Config | dev 默认 | prod 值 | 含义 | Source |
|---|---|---|---|---|
NEXT_PUBLIC_BASE_PATH | 不设(空) | /a-cdm | 路径前缀,build 时静态烤入 | deer-flow/frontend/next.config.js:35 |
NEXT_PRODUCTION_STANDALONE | 不设 | 1 | 触发 standalone 产物 | deer-flow/frontend/next.config.js:27 |
experimental.proxyTimeout | 180000ms | 同 | rewrite 反代超时(豆包多模态需 40-100s) | deer-flow/frontend/next.config.js:30-32 |
ACDM_BACKEND_BASE_URL | http://127.0.0.1:8003 | Caddy 接管 | /api/acdm/* rewrite 目标 | deer-flow/frontend/next.config.js:14-19 |
NEXT_PUBLIC_BACKEND_BASE_URL | 空(走 rewrite) | /api/deerflow | 设了则跳过 dev rewrite | deer-flow/frontend/src/env.js:28 |
NEXT_PUBLIC_LANGGRAPH_BASE_URL | 空 | /api/langgraph | LangGraph SDK base | deer-flow/frontend/src/core/config/index.ts:21-40 |
BETTER_AUTH_SECRET | 可选 | 必填(32B hex) | prod 下 T3 env 强校验,缺则容器 500 | deer-flow/frontend/src/env.js:10-13 |
SKIP_ENV_VALIDATION | — | 1(Docker build) | 跳过 env.js zod 校验 | deer-flow/frontend/src/env.js:54 |
安全相关:BETTER_AUTH_SECRET 在 NODE_ENV=production 下被 env.js:10-13 标为 z.string() 必填,dev 下 optional。prod compose 注释明确警告:不设则 T3 env 校验失败 → 容器启动 HTTP 500。SKIP_ENV_VALIDATION=1 是 Docker build 阶段的逃生口(runtime 变量由容器注入,build 时还没有)。
Common Pitfalls / 实战 Tips
- 改 basePath 没重 build = 白改:
basePath是 build-time 替换,只改 env 不 rebuild 完全无效(next.config.js:33-35注释 + prod Dockerfile)。这是 lessons-learned L26 的来源。 /acdm/vs/a-cdm/一字之差:历史上/acdm/(无连字符)是已删的老 SSG 基座,当前是/a-cdm/(有连字符)。引用路径时别漏连字符。- 别手改
components/ui/**和ai-elements/**:它们是 registry 生成物,ESLint 已 ignore(eslint.config.js:10-13),手改会在下次从 registry 重拉时丢失。 - fetch 必须
credentials: "include"+ 同域:core/acdm/api.ts:73注释强调跨域 cookie 带不回,所有 a-cdm API 走/api/acdm相对路径而非绝对 URL。 - 带 body 的 PATCH/POST 不加 Content-Type 会 422:
api.ts:519-527有 hotfix 注释——FastAPI 把无 Content-Type 的 body 当 plain text,Pydantic 报 422,request()/fetchNoEnvelope()都已自动补。 - prod 健康检查必 unhealthy:basePath 让
/返 404,compose 已主动取消 healthcheck,别被 unhealthy 状态误导(deploy/prod/acdm-frontend/compose.yml)。
References
deer-flow/frontend/package.json:1-137— 技术栈真源(Next 16 / React 19 / Tailwind 4 / pnpm 10.26.2 + 依赖全集)deer-flow/frontend/next.config.js:1-126— basePath / rewrites / standalone / proxyTimeout 配置deer-flow/frontend/src/env.js:1-60— T3 env + zod 校验(BETTER_AUTH_SECRET prod 必填)deer-flow/frontend/src/app/layout.tsx:1-36— 根布局 + Provider 树 + A-CDM metadatadeer-flow/frontend/src/app/workspace/layout.tsx:1-39— 工作台外壳(QueryClient/Sidebar/CommandPalette)deer-flow/frontend/src/core/acdm/index.ts:1-13— 定制业务桶导出入口deer-flow/frontend/src/core/acdm/api.ts:62-117— request() fetch 封装(信封解包/401 跳登录/cookie)deer-flow/frontend/src/core/api/api-client.ts:24-120— LangGraph SDK 单例 + 拦截器deer-flow/frontend/src/proxy.ts:73-99— 边缘守卫(Next.js 16 中 middleware→proxy)deer-flow/frontend/src/components/workspace/acdm— a-cdm 定制业务组件目录deer-flow/frontend/eslint.config.js:10-13— ui/ai-elements 生成物 ignoredeploy/prod/acdm-frontend/Dockerfile— prod build 烤 basePath=/a-cdm + standalonedeploy/prod/acdm-frontend/compose.yml— prod 容器(取消 healthcheck 原因)
Related Pages
| Page | Relationship |
|---|---|
| proxy 鉴权守卫与 API 反代 | 本章 proxy.ts 边缘守卫与 next.config.js rewrites 在该章深挖 |
| 项目工作台与工作区 | 本章 /workspace/projects/[id] 三栏布局与业务组件在该章展开 |
| AI 消息流与 SSE 基础设施 | 本章 core/api / core/threads 流式骨架由该章详解 |
| Caddy 网关与生产路由拓扑 | 本章 prod 不走 dev rewrite,改由该章 Caddy 反代;basePath 仅前端感知 |
| CICD 与生产部署运维 | 本章 basePath 编译期约束 → 该章解释"改 basePath 必重 build"运维铁律 |
| 鉴权与授权双层体系 | 本章 401 跳 /api/acdm/auth/login 接入该章 Keycloak 链路 |
| 系统整体架构 | 本章前端是该章整体拓扑中的 acdm-frontend 节点 |