两个前端目录是什么关系,技术栈各是什么
clone 下 DeepTutor 之后,第一眼会被目录名骗一次:仓库根下同时有 deeptutor_web/ 和 web/,前者名字更”正式”,看着像主目录。实际上在源码检出里,deeptutor_web/ 是空的——ls -a deeptutor_web 只列得出一个 __init__.py。
这篇就把这两个目录的关系、web/ 到底是什么技术栈、以及前端怎么找到后端这三件事按行号读一遍。
下面所有行号与数字都对应我们采集的仓库快照:HEAD 456f9c2,采集日 2026-08-10,deeptutor/__version__.py 里的版本号是 1.5.11。你自己 clone 之后行号可能已经漂了,文件名和常量名是稳的。
一、deeptutor_web/ 是个壳,web/ 才是本体
deeptutor_web/__init__.py 的 docstring 已经把这件事写清楚了(deeptutor_web/__init__.py:1-6):这个包是用来装「打包好的 DeepTutor Web standalone 资产」的,release 构建会用 next build 的 standalone 产物——server.js、.next/static、public 以及最小 Node 运行时文件——把它填满;源码检出通常留空,launcher 回退到 web/。
所以两个目录不是”新旧两版前端”,而是同一份前端的两种存在形态:web/ 是源码,deeptutor_web/ 是发行包里的构建产物落点。
反直觉的地方在 launcher 的判定条件上。deeptutor/runtime/launcher.py:486-492 的 _packaged_web_dir() 先 import deeptutor_web,导入成功也不算数——它接着检查该目录下有没有 server.js,没有就返回 None。也就是说,判据不是”包在不在”,而是”包里有没有被填过”。源码检出下这个包永远导得进来、永远返回 None,于是走到 _source_web_dir(home)(launcher.py:565-570):依次看 home/web 与 PACKAGE_ROOT/web,取第一个含 package.json 的。
这个设计的直接后果是:如果你把 deeptutor_web/ 手工塞了几个文件进去但没有 server.js,它仍然会被跳过;反过来,web/ 里如果 package.json 丢了,两条路就都断了。排查”前端起不来”时,这两处是判定走哪条支路的直接依据。
二、打包前端为什么还要复制一份
打包路径上还有一步容易被忽略。Next.js 的 NEXT_PUBLIC_* 变量是构建期内联进 bundle 的,发行包里的前端已经把值烧死了。launcher 的处理是:把整包复制到可写的运行缓存 data/user/runtime/web,再在副本里替换两个占位符 __NEXT_PUBLIC_API_BASE_PLACEHOLDER__ 与 __NEXT_PUBLIC_AUTH_ENABLED_PLACEHOLDER__(launcher.py:31、502-553、537-543)。
另外一个常量值得单记:源码下的生产构建输出目录是 SOURCE_PRODUCTION_DIST_DIR = ".next-deeptutor",与开发用的 .next 分开(launcher.py:32、web/next.config.js:94-98)。注释给的理由是否则两条命令会互相作废对方的缓存。
三、源码检出下,它会在你机器上跑 npm
这一点必须说明白:launcher.py:571-590 的 _ensure_web_dependencies() 会在 web/node_modules 不存在时自动执行 npm——有 lockfile 用 npm ci,否则 npm install,注释里引用了 issue #709,具体缘由见该函数的 docstring。
换句话说,你敲的是一条 Python 命令,实际发生的是本机的一次外部程序执行与一轮网络依赖安装。我们采集时本地就不存在 web/node_modules(ls -d web/node_modules 返回不存在),也就是说这条分支在一个干净检出上必然会触发。是否接受这个行为、在什么网络环境下跑,请自行评估。
同一条线上还有一处相关的:web/app/(utility)/settings 下的 agents 分组包含 claude-code、codex、gemini、kimi、mimo、opencode 六个子页。这类对接的具体机制不在本文核对范围(我们另有一篇专门讲它与本机 agent CLI 的关系),但你至少要知道这条线是存在的。
四、web/ 的技术栈:从 package.json 读
包名是 opentutor-web,version 1.0.0,private: true(web/package.json:2-4)。目录叫 web、Python 侧的包叫 deeptutor_web、npm 包名叫 opentutor-web——三个名字各不相同,找东西时按文件路径找,别按名字猜。
| 分类 | 依赖与版本 |
|---|---|
| 框架 | next ^16.2.3、react ^19.0.0、react-dom ^19.0.0、TypeScript ^5 |
| 样式 | tailwindcss ^3.4.17、postcss ^8、autoprefixer、tailwind-merge ^3.4.0、clsx ^2.1.1 |
| 内容渲染 | react-markdown ^10.1.0、remark-gfm ^4.0.0、remark-math ^6.0.0、rehype-katex ^7.0.1、rehype-raw ^7.0.0、react-syntax-highlighter ^16.1.1 |
| 图表图形 | chart.js ^4.5.1 + react-chartjs-2 ^5.3.1、mermaid ^11.14.0、cytoscape ^3.33.1、framer-motion ^12.24.0、lucide-react ^0.562.0 |
| 文档导出 | docx-preview ^0.3.7、exceljs ^4.4.0、jspdf ^4.2.0、html2canvas ^1.4.1 |
| i18n / 测试 | i18next ^25.8.0 + react-i18next ^16.5.3;@playwright/test ^1.53.2 |
这张表怎么读:remark-math + rehype-katex 说明公式渲染在前端做;docx-preview / exceljs / jspdf / html2canvas 四件一起出现,说明导出这件事也压在浏览器端。测试侧除 Playwright(npm run audit 跑 --project=ui-audit)外还有一套自研 node 跑法 test:node → scripts/run-node-tests.mjs。
next.config.js 里有四处值得单看:
output: "standalone"、transpilePackages: ["mermaid"],以及experimental.proxyClientMaxBodySize = 210*1024*1024(web/next.config.js:108-120)。注释解释这个 210MB 是对着后端最大 200MB 上传(对应DocumentValidator.MAX_FILE_SIZE)留的余量,因为 proxy 默认的 10MB 会静默截断。碰到大文件上传失败又没有报错,这是第一个该看的数字。- 版本号是用正则读 Python 文件拿到的:解析
deeptutor/__version__.py后注入NEXT_PUBLIC_APP_VERSION(web/next.config.js:78-106),注释说明这样是为了避免 JS 构建过程去执行 Python。 allowedDevOrigins会动态探测本机非回环 IPv4(web/next.config.js:130-142)。注释写的是:Next 16 不放行的话,跨 host 访问会出现「SSR HTML 渲染出来但 React 事件与 effect 永不挂载」这种半死不活的状态。- webpack 与 turbopack 两边都把
cytoscape别名指向 CJS 构建(web/next.config.js:144-163),注释说是为了修 mermaid 那条 cytoscape 依赖。
规模上有两组口径不同的数字,别混着看。一组是 web/ 下排除构建产物与 node_modules 后的 .ts/.tsx 文件数,共 465 个;另一组是各子目录的文件总数(不限 .ts/.tsx):components 185、lib 96、app 95、tests 61、hooks 15、scripts 8、context 5、features 3、types 1。后一组不是前一组的分解,两者用的 find 条件不一样,相加对不上是正常的。页面侧 find web/app -name 'page.tsx' 是 60 个,layout.tsx 9 个。60 个页面里 settings 一组就占 27 页(/settings 加 26 个子页)、space 占 9 页、memory 占 8 页——这三块加起来 44 页,是页面数的大头。
五、前端怎么找到后端:bundle 是 URL 无关的
这是本文第二处反直觉的地方。前端 bundle 里没有后端地址:浏览器一律请求前端自身 origin 下的 /api/... 与 /api/.../ws,web/lib/api.ts:1-38 里的 apiUrl() 与 wsUrl() 都是原样返回路径的 pass-through,真正的改写发生在 web/proxy.ts 这个 Next.js middleware 里。
- 后端基址取自
DEEPTUTOR_API_BASE_URL,兜底http://127.0.0.1:8001(web/proxy.ts:12-21)。注释特意说明必须写 IPv4 字面量而不是localhost:双栈主机会先解析到::1,而 uvicorn 绑的是0.0.0.0,只覆盖 IPv4。 - 改写判定极简:
pathname.startsWith("/api/") || pathname.startsWith("/ws/")(web/lib/proxy-policy.ts:22-24)。 - Codex OAuth 回调是单独一条改写规则:前端
/auth/callback→ 后端/api/v1/auth/openai-codex/callback(web/lib/proxy-policy.ts:11-16、web/proxy.ts:44-49)。 - middleware 的 matcher 排除
_next/static、_next/image、favicon.ico(web/proxy.ts:74-80)。
鉴权这块有两个细节要分清楚,免得排查时找错层:
其一,middleware 那道 gate 是不验签的。 它对 cookie 的校验只是前线快速判断,真正的验证仍由后端在每次 API 调用时做(web/lib/proxy-policy.ts:48-74)。这道 gate 的四态划分与豁免清单我们另有一篇专讲鉴权的文章展开,本文只取这条结论:在 middleware 这层被放行,不等于后端认了你。
其二,“auth 是否开启”不走构建期 env。 DEEPTUTOR_AUTH_ENABLED 没有 NEXT_PUBLIC_ 前缀,不会被内联,浏览器 bundle 看不到它;实际由 fetchAuthStatus() 在运行时向后端问,拿到后调 setRuntimeAuthEnabled(),默认值是 false(web/lib/api.ts:52-66)。注释给的理由是避免默认无鉴权的部署被偶发 401 弹去登录页。这跟本节开头那件事是同一个取向:后端地址与鉴权开关都不进 bundle,前者交给 middleware 改写,后者交给运行时询问。
调用面的规模:apiUrl("/api... 这种形式的调用点在 web/lib、web/components、web/app、web/features 四处共 184 个;用到 wsUrl( 的文件只有 6 个——web/lib/api.ts、web/lib/unified-ws.ts、web/lib/book-api.ts、web/lib/quiz-judge.ts、web/hooks/useKnowledgeProgress.ts、web/components/partners/PartnerChat.tsx。要摸清 WebSocket 这条线,读这 6 个文件就够,不用在 web/components 的 185 个文件里翻。
六、前端只有两种语言
web/i18n/init.ts:10-17、28-42:export type AppLanguage = "en" | "zh",normalizeLanguage 把 zh/cn/chinese 归一到 zh,其余一律 en,fallback 是 en。这与后端 deeptutor/i18n/metadata_i18n.py 里只有 en 与 zh 两个语言键是对上的。
locale 文件只有 4 个:web/locales/{en,zh}/{app,common}.json。我们用 Python 递归数叶子节点,app.json 两边各 2930 个键、common.json 两边各 6 个键,en 与 zh 数量完全一致。中文包是懒加载的——ensureLanguage("zh") 时才 import("@/locales/zh/app.json") 并 addResourceBundle(web/i18n/init.ts:48-54)。仓库自带 i18n 校验脚本 i18n:parity / i18n:audit / i18n:audit:strict / i18n:check(web/package.json 的 scripts 段)。
要加第三种语言的话,光丢一份 JSON 不够:AppLanguage 这个联合类型与 normalizeLanguage 的归一逻辑都得改,后端那侧的语言键也是另一套。这里只陈述改动会牵动哪几处,具体怎么改超出我们核对范围。
七、一处可核实的不一致
.gitignore:79 写着忽略 web/.next-deeptutor/;而 git ls-files web/.next-deeptutor | wc -l 返回 4322,git ls-files -s web/.next-deeptutor/BUILD_ID 也能返回一条索引项。两者不一致——.gitignore 只对未跟踪文件生效,已进索引的文件不受它约束,所以这个构建产物目录目前是被跟踪的状态。
我们本地是 depth=1 的 shallow clone,只有一个提交,无法通过历史判断该目录是何时、以何种方式进入索引的。按纪律,说完差异就停:不推断原因,也不据此评价什么。
顺带记一处文档侧的覆盖缺口:AGENTS.md 的 Key Files 表(AGENTS.md:101-118)里没有 web/proxy.ts。这个文件是前后端之间唯一的改写层,看那张表入门会漏掉它。
八、你可以照着核的六步
ls -a deeptutor_web,确认源码检出下只有__init__.py;再读它的 docstring 第 1-6 行。- 打开
deeptutor/runtime/launcher.py:486-492,确认打包前端的判据是server.js存在而非包能导入;再看565-570的回退顺序。 - 打开
launcher.py:571-590,确认自动执行npm ci/npm install的那段,以及它的触发条件是node_modules不存在。 - 打开
web/next.config.js:108-120,把 210MB、后端 200MB、proxy 默认 10MB 这三个数字放在一起看。 - 打开
web/lib/proxy-policy.ts:22-24与web/proxy.ts:12-21,确认改写规则与127.0.0.1:8001这个兜底基址。 - 打开
web/lib/api.ts:52-66,确认 auth 开关是运行时向后端问来的、默认值是false,而不是构建期内联进 bundle 的。
需要说明的边界:本文只读了 web/lib/api.ts、web/lib/proxy-policy.ts、web/i18n/init.ts、web/next.config.js、web/package.json 与 launcher.py 中与 web 目录解析、npm 安装相关的约一百二十行;web/components 的 185 个文件、web/lib 96 个文件里除 api.ts 与 proxy-policy.ts 之外的部分、web/tests 的 61 个文件我们都没有读,因此”某个页面具体渲染什么""测试覆盖了什么”本文一律不下结论。web/.next-deeptutor/ 下的 4322 个文件是构建产物,只报存在与数量,内容未读。文中出现的所有默认值都是源码中的默认配置,不是运行结果的保证。
本文依据 DeepTutor 官方仓库(github.com/HKUDS/DeepTutor)的 README、AGENTS.md、
pyproject.toml 与 deeptutor/ 下的源码整理,核对日 2026-08-10,对应仓库快照 456f9c2(版本 1.5.11)。
本文内容为仓库源码与文档口径,我们没有安装、部署或运行过该项目,也没有调用过其中任何一个模型 API,
因此不涉及生成质量、响应速度与教学效果的任何描述。
参数与默认值随版本变动,请以仓库最新代码与 --help 的实际输出为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。