工具通过 GitHub URL 快速用 AI 生成陌生代码库的导航地图,让程序员在几秒内理解架构和业务逻辑,大幅提升代码阅读效率。
实时依赖图与本地 LLM 支持
第一次浏览大型代码库是一件痛苦的事。你克隆仓库后,发现里面有 300 个文件,却完全不知道各个内容位于何处。
你可以询问 AI 助手,但它会迅速耗尽上下文,而且你永远无法确定它是否编造了一个根本不存在的文件路径。
Codebase Navigator 解决了这个问题。粘贴一个 URL,用自然语言提出任何问题,然后就能看到根据真实 import 语句构建的实际依赖图实时呈现出来。
它使用 CopilotKit、Zenflow、GitHub API 和 React Flow 构建。你可以在本地使用 Ollama,完全免费地运行它。
在本文中,我们将介绍它的架构、关键模式,以及所有内容如何端到端地协同工作。
Codebase Navigator 允许你粘贴任意公开 GitHub 仓库的 URL,并用自然语言询问与其相关的问题。你得到的不会是一大段文字,而是四个同时更新的面板。
依赖图会动态变化,展示通过真实 import 关系连接起来的所有相关文件;代码查看器会打开对应文件;聊天窗口则会解释每个部分的作用。整个过程无须切换上下文。
你可以在本地使用 Ollama 完全免费地运行它,无须任何 API 密钥。
下面是你提出问题后,完整的请求 → 响应流程:
用户输入“身份认证是如何工作的?”
↓
CopilotChat → POST /api/copilotkit
↓
LLM 接收仓库上下文(文件路径、当前选择、系统规则)
↓
LLM 调用 analyzeRepository 工具
↓
工具通过 /api/github/file 获取相关文件
↓
提取 import/require 语句 → 解析路径 → 构建依赖图
↓
Zustand store 更新
↓
四个面板全部实时重新渲染:依赖图、文件树、代码查看器、聊天响应
从宏观上看,这个项目由以下技术组合构建:
Next.js 16 - 前端和 API 路由
Next.js 16 - 前端和 API 路由
CopilotKit - AI 智能体与 UI 的状态同步及聊天界面。提供 useAgentContext、useFrontendTool、CopilotChat 等内置 Hook 和组件
CopilotKit - AI 智能体与 UI 的状态同步及聊天界面。提供 useAgentContext、useFrontendTool、CopilotChat 等内置 Hook 和组件
Zenflow - 负责规划、测试并编排构建流程的工作流工具
Zenflow - 负责规划、测试并编排构建流程的工作流工具
React Flow 与 dagre - 支持自动布局的交互式依赖图
React Flow 与 dagre - 支持自动布局的交互式依赖图
Octokit - 用于获取仓库树和文件内容的 GitHub API 代理
Octokit - 用于获取仓库树和文件内容的 GitHub API 代理
Zustand - 在全部四个面板之间共享状态
Zustand - 在全部四个面板之间共享状态
Tailwind CSS - 样式设计
Tailwind CSS - 样式设计
Ollama / OpenAI - 可从 UI 切换的本地或云端 LLM 后端
Ollama / OpenAI - 可从 UI 切换的本地或云端 LLM 后端
CopilotKit 运行时自托管在一个 Next.js API 路由中,因此你可以接入任何与 OpenAI 兼容的后端,包括 Ollama,从而完全免费地进行本地推理。
它将 UI、AI 智能体和工具连接到同一个交互循环中。
Zenflow 是一种 AI 开发工具,它将软件构建视为一个结构化的工程流程,而不仅仅是自动补全代码。
这个项目是在一次会话中使用 Zenflow(Zencoder 的工作流引擎)完成规划、构建、测试、审查和部署的。
它从零开始执行了一个包含六个阶段的流程:
架构与规格说明
脚手架与基础结构
数据层(GitHub API 集成)
AI 层(CopilotKit actions 与上下文)
可视化层(React Flow 依赖图)
最终组装与连接
每个阶段在进入下一阶段之前,都会通过 lint、类型检查和测试进行验证。构建完成后,它执行了代码审查,发现 14 个问题,并系统性地修复了其中 11 个:
API 密钥暴露在请求头中 → 移至 httpOnly cookie
伪造的星形依赖图 → 替换为基于真实 import 关系的依赖解析
重复的文件获取逻辑 → 合并为单个带缓存的工具函数
仓库中的 .zenflow/ 文件夹包含它在构建过程中生成的任务计划、规格说明和报告文件。
以上就是我们使用 Zenflow 构建它的过程。下面来看看由此形成的架构。
应用被划分为三层:浏览器负责所有 UI 和 AI 工具逻辑;Next.js API 路由充当访问外部服务的安全代理;GitHub API 和 LLM 后端位于最底层。浏览器不会直接与 GitHub 或 LLM 通信。
下面是所有层及其组织方式的宏观概览。
┌─────────────────────────────────────────────────────────┐
│ 浏览器 │
│ │
│ ┌─────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ │
│ │ 聊天 │ │ 依赖图 │ │ 代码 │ │ 文件 │ │
│ │ 面板 │ │ 画布 │ │ 查看器 │ │ 树 │ │
│ └────┬────┘ └────┬─────┘ └────┬─────┘ └───┬────┘ │
│ └────────────┴─────────────┴────────────┘ │
│ │ │
│ ┌──────▼──────┐ │
│ │ Zustand │ ← 共享状态 │
│ └──────┬──────┘ │
│ │ │
│ ┌───────────▼────────────┐ │
│ │ CopilotKit │ │
│ │ 前端工具 │ │
│ │ analyzeRepository │ │
│ │ fetchFileContent │ │
│ │ highlightCode │ │
│ └───────────┬────────────┘ │
└──────────────────────────┼──────────────────────────────┘
│
┌──────────────────────────▼──────────────────────────────┐
│ NEXT.JS API 路由 │
│ │
│ /api/copilotkit /api/github/* /api/settings │
│ (LLM 运行时) (代理) (httpOnly cookie)│
└──────────┬────────────────┬─────────────────────────────┘
│ │
┌─────▼─────┐ ┌──────▼──────┐
│ Ollama │ │ GitHub API │
│ / OpenAI │ │ (Octokit) │
└───────────┘ └─────────────┘
下面是项目结构的简化视图。
src/
├── app/api/
│ ├── copilotkit/route.ts → CopilotKit 运行时端点
│ ├── github/
│ │ ├── tree/route.ts → 获取仓库树
│ │ ├── file/route.ts → 获取文件并进行 base64 解码
│ │ └── search/route.ts → 代码搜索
│ └── settings/route.ts → LLM 配置(httpOnly cookie)
├── components/
│ ├── panels/ → 5 个 UI 面板(聊天、依赖图、代码、分析、仓库)
│ └── flow/ → 自定义 React Flow 节点类型
├── hooks/ → AI 工具注册、AI 智能体上下文同步、仓库加载
├── lib/ → import 提取、dagre 布局、Octokit 客户端
├── store/ → Zustand(AppState + SettingsState)
└── .zenflow/ → Zenflow 任务计划、规格说明和报告
下面来深入了解所有内容在幕后是如何工作的。这将帮助你理解其中的关键模式。
现在你已经了解了整体情况,接下来让我们逐一详细介绍各个部分。
底层发生了很多事情,因此我们会将其分成十个部分,从首次加载仓库一直讲到状态如何在各个面板之间流转。
当你粘贴 GitHub URL 并单击 Explore 时,会触发 useRepository.loadRepository()(来自 useRepository.ts Hook)。
它会调用服务器上的 /api/github/tree,后者使用 Octokit 完成以下操作:
将仓库 URL 解析为 { owner, repo }
获取默认分支
通过 GitHub Trees API 获取完整的递归文件树
将扁平数组转换为嵌套的 TreeNode 结构
src/api/github/tree/route.ts 路由如下所示:
export async function GET(request: NextRequest) {
const { owner, repo } = parseRepoUrl(repoUrl);
const resolvedBranch = branch || (await getDefaultBranch(owner, repo));
const tree = await getRepoTree(owner, repo, resolvedBranch);
return NextResponse.json({ owner, repo, branch: resolvedBranch, tree });
}
这个嵌套树为侧边栏文件浏览器提供数据,并成为后续所有操作的起点。
所有 GitHub 调用都通过 Next.js API 路由进行代理。浏览器永远不会直接与 GitHub 通信,以确保令牌安全。
这是让应用显得鲜活的关键视觉效果。加载时看到的依赖图(架构视图)与提问后看到的依赖图(依赖关系图)是两种完全不同的内容,其构建方式也完全不同。
两者都位于 src/lib/analyzer.ts 中:buildOverviewGraph 用于构建架构视图,buildDependencyNodes 用于构建依赖关系图。
仓库加载完成后,useRepository.ts 会在目录树响应返回后立即调用 buildOverviewGraph,并将结果直接推送到图中:
const overview = buildOverviewGraph(data.tree);
setVisualization(overview.nodes, overview.edges, "architecture");
buildOverviewGraph 完全不会获取任何文件。它只是遍历目录树结构并构建文件夹映射:根节点位于顶部,顶层目录作为其子节点,再向下展示一层子文件夹:
export function buildOverviewGraph(tree: TreeNode) {
const rootId = `node-${nodeId++}`;
nodes.push({ id: rootId, type: "module", label: tree.path || "root" });
const topDirs = (tree.children || []).filter((c) => c.type === "directory");
for (const dir of topDirs) {
const fileCount = flattenTree(dir).length;
nodes.push({ id, label: `${dir.name} (${fileCount})`, type: categorizeDirType(dir.path) });
edges.push({ source: rootId, target: id, type: "flow" });
}
}
这里的边除了表示“这个文件夹位于那个文件夹内”之外,没有其他含义。
当你提出问题后,useCopilotActions.ts 会获取实际的文件内容,并改为调用 buildDependencyNodes:
const graph = buildDependencyNodes(fileData);
setVisualization(graph.nodes, graph.edges, "dependency");
buildDependencyNodes 会为每个文件创建一个节点,并为每条真实的 import 语句创建一条边:
export function buildDependencyNodes(files: { path: string; imports: string[] }[]) {
const nodes = files.map((file) => ({
id: file.path,
type: "file",
label: file.path.split("/").pop() || file.path,
metadata: { fullPath: file.path },
}));
for (const file of files) {
for (const imp of file.imports) {
const resolved = resolveImportPath(imp, file.path, filePathSet);
if (resolved) {
edges.push({ source: file.path, target: resolved, type: "import" });
}
}
}
return { nodes, edges };
}
调用的仍然是同一个 setVisualization,但传入的数据不同。React Flow 会重新渲染,图也会从文件夹映射转变为真实的依赖关系图,并且精确聚焦于与你的问题相关的文件。
/api/copilotkit 路由是 LLM 请求真正到达的地方。API 密钥和服务提供商配置存储在 httpOnly cookie 中,这意味着它们保存在服务器端,永远不会发送到浏览器。
该路由会读取配置,创建一个兼容 OpenAI 的客户端,然后将请求交给 CopilotKit 运行时:
export const POST = async (req: NextRequest) => {
const { baseURL, apiKey, model } = await getLLMConfig();
const openai = new OpenAI({ baseURL, apiKey });
const serviceAdapter = new OpenAIAdapter({ openai, model });
const runtime = new CopilotRuntime();
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
serviceAdapter,
endpoint: "/api/copilotkit",
});
return handleRequest(req);
};
默认配置指向本地 localhost:11434/v1 上的 Ollama,并使用 qwen2.5。
由于 OpenAI SDK 接受自定义 baseURL,因此切换到 OpenAI、Groq 或其他任何服务提供商时,只需修改配置,无须更改代码。
LLM 要想给出有用的回答,首先需要知道当前加载了哪个仓库,以及其中存在哪些文件。这正是 useCopilotContext.ts hook 的职责。
它会调用 useAgentContext。这是一个 CopilotKit hook,用于将结构化数据附加到你发送的每条消息中。你可以多次调用它,以附加不同部分的上下文。
这里调用了两次:一次用于提供文件列表,另一次用于提供系统指令,告诉 LLM 应当如何行动:
useAgentContext({
description: "File paths in the repository (max 500), one per line",
value: fileList, // flattened from the TreeNode structure
});
useAgentContext({
description: "System instructions",
value: `You are a Codebase Navigator assistant. You MUST use tool calls to answer questions.
CRITICAL RULES:
1. For ANY question about the repository call the "analyzeRepository" tool.
2. To show a file, call "fetchFileContent" with the exact file path.
3. NEVER respond with only text. ALWAYS call a tool first.
4. Use ONLY file paths from the file list above.`
});
文件列表最多包含 500 个路径,以免超出 token 限制。
由于每次 Zustand store 发生变化时,这个 hook 都会重新运行,因此 LLM 始终能够看到当前的仓库、选中的文件和分析状态,而不是页面加载时留下的过期快照。
注意:如果你运行的本地模型拥有较大的上下文窗口,可以在 useCopilotContext.ts 中提高这一限制。
这是整个应用工作方式的核心。LLM 不只是回复文本,它还可以调用工具。每个工具都有名称、可接收的一组参数和一个 handler,而 handler 其实就是 LLM 调用该工具时执行的函数。
CopilotKit 允许你通过 useFrontendTool hook 直接在浏览器中注册这些工具,该 hook 定义在 useCopilotActions.ts 中。
工具的 handler 运行时会直接更新 Zustand 状态。因此,所有四个面板——图、代码查看器、分析面板和聊天窗口——都能同时响应,无须任何额外的连接代码。
analyzeRepository 是主要工具,LLM 几乎会针对每个问题调用它。下面逐步说明它的工作方式:
useFrontendTool({
name: "analyzeRepository",
parameters: z.object({
query: z.string(),
explanation: z.string(),
}),
handler: async ({ query, explanation }) => {
// 1. Find relevant files using keyword matching
const matchedPaths = findFilesByQuery(repo.tree, query);
// 2. Cap at 15 files to keep things fast
const capped = matchedPaths.slice(0, 15);
// 3. Fetch each file content (cached)
const fileData = await Promise.all(
capped.map(async (p) => ({
path: p,
imports: extractImports(await fetchFile(owner, repo, p, branch)),
}))
);
// 4. Build real dependency graph from actual import statements
const graph = buildDependencyNodes(fileData);
// 5. Categorize each node by file type
graph.nodes.forEach(node => {
node.type = categorizeFileType(node.metadata.fullPath);
});
// 6. Push to Zustand - all four panels react
setAnalysisResult({ explanation, relevantFiles, flowDiagram: graph });
setVisualization(graph.nodes, graph.edges, "dependency");
},
});
fetchFileContent 会在代码查看器中打开指定文件。当你要求 LLM 展示某个文件时,它就会调用该工具。这个工具只会获取文件内容,然后调用 setCodeViewer:
useFrontendTool({
name: "fetchFileContent",
parameters: z.object({
filePath: z.string(),
}),
handler: async ({ filePath }) => {
const content = await fetchFile(repo.repoInfo.owner, repo.repoInfo.repo, filePath, repo.repoInfo.branch);
setCodeViewer(filePath, content);
},
});
generateFlowDiagram 与 analyzeRepository 类似,但更加聚焦。你不需要搜索整个仓库,而是向它提供一组特定的文件路径,它会仅为这些文件构建图。比如,当你只想可视化 API 层这类代码库切片时,这个工具会很有用:
useFrontendTool({
name: "generateFlowDiagram",
parameters: z.object({
files: z.array(z.string()),
diagramType: z.enum(["dependency", "flow", "architecture"]),
}),
handler: async ({ files, diagramType }) => {
const fileData = await Promise.all(files.slice(0, 20).map(async (f) => ({
path: f,
imports: extractImports(await fetchFile(repo.repoInfo.owner, repo.repoInfo.repo, f, repo.repoInfo.branch)),
})));
const graph = buildDependencyNodes(fileData);
setVisualization(graph.nodes, graph.edges, diagramType);
},
});
highlightCode 会获取一个文件,并高亮显示指定行号,同时附上解释。当 LLM 想要精确指出某个逻辑在代码中的具体位置时,就会使用这个工具:
useFrontendTool({
name: "highlightCode",
parameters: z.object({
filePath: z.string(),
lines: z.array(z.number()),
explanation: z.string(),
}),
handler: async ({ filePath, lines, explanation }) => {
const content = await fetchFile(repo.repoInfo.owner, repo.repoInfo.repo, filePath, repo.repoInfo.branch);
setCodeViewer(filePath, content, lines, explanation);
},
});
请注意,这些工具都不会向聊天窗口返回文本。它们只会更新状态。LLM 并不是在描述它发现的内容,而是在直接控制你在屏幕上看到的内容。
例如,我询问了 v2 packages 的架构。
当 analyzeRepository 收到类似 "how does authentication work?" 的查询时,它需要判断实际应该查看哪些文件。这部分由 src/lib/analyzer.ts 中的 findFilesByQuery 负责处理。
它会检查查询是否匹配六个预定义类别中的某一个,每个类别都有一组对应的关键词和文件路径模式:
const ANALYSIS_CATEGORIES = [
{
name: "authentication",
keywords: ["auth", "login", "logout", "token", "jwt", "session", "oauth"],
filePatterns: [/auth/i, /login/i, /session/i, /oauth/i, /jwt/i],
},
{
name: "database",
keywords: ["database", "db", "model", "schema", "prisma", "mongoose"],
filePatterns: [/model/i, /schema/i, /migration/i, /prisma/i, /db/i],
},
// ... api, configuration, testing, styling
]
如果查询与某个类别匹配,它就会使用该类别的正则表达式模式过滤所有文件路径。
如果没有匹配任何类别,它会回退到将查询拆分成单独的词,并检查是否有文件路径包含这些词。因此,即便是像“where is the config”这样模糊的查询,仍然能够找到名称中包含“config”的文件。
这种设计是刻意保持简单的。没有嵌入,没有向量搜索,只对文件名使用正则表达式。它之所以效果很好,是因为在结构良好的项目中,文件名通常具有足够的描述性,仅通过名称匹配就能找到正确的文件。
每当工具获取文件时,都会经过 src/lib/fetch-file.ts。该文件维护着一个 TTL 为 5 分钟的内存缓存:
const cache = new Map<string, { content: string; timestamp: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
export async function fetchFile(owner, repo, path, ref) {
const key = `${owner}/${repo}/${ref}/${path}`;
const cached = cache.get(key);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.content; // serve from cache
}
const res = await fetch(`/api/github/file?repo=...&path=...&ref=...`);
const data = await res.json();
cache.set(key, { content: data.content, timestamp: Date.now() });
// LRU eviction: if over 200 items, drop the oldest 50
if (cache.size > 200) {
const oldest = [...cache.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp);
for (let i = 0; i < 50; i++) cache.delete(oldest[i][0]);
}
return data.content;
}
这一点非常重要,因为 analyzeRepository 一次最多会获取 15 个文件。针对相同文件的后续问题会命中缓存,而不是再次发起 15 个 API 请求。
如果没有 GitHub token,每小时只能发起 60 个请求,因此缓存是让重复查询保持快速且免费运行的关键。
一旦缓存中的条目超过 200 个,它还会淘汰最早的条目,因此在长时间会话中,缓存永远不会无限增长。
当 analyzeRepository 获得相关文件路径后,它会获取每个文件并解析其中的 import 语句。处理这项工作的两个函数都位于 src/lib/analyzer.ts 中。
extractImports 会处理原始文件内容并提取所有导入,同时支持 ES6 和 CommonJS 语法:
export function extractImports(content: string): string[] {
const imports: string[] = [];
// ES6: import X from 'y' and import 'y'
const esImports = content.matchAll(
/(?:import\s+.*?\s+from\s+['"](.+?)['"]|import\s+['"](.+?)['"])/g
);
for (const match of esImports) imports.push(match[1] || match[2]);
// CommonJS: require('y')
const requires = content.matchAll(/require\s*\(\s*['"](.+?)['"]\s*\)/g);
for (const match of requires) imports.push(match[1]);
return imports;
}
但 "./utils" 或 "@/lib/github" 这样的原始导入字符串还不是文件路径。
buildDependencyNodes 会针对每一个导入调用 resolveImportPath,将其转换为仓库中实际存在的路径:
function resolveImportPath(importPath, fromFile, filePathSet) {
// Skip npm packages (no ./ or @/)
if (!importPath.startsWith(".") && !importPath.startsWith("@/")) return null;
// Resolve @/ alias to src/
if (importPath.startsWith("@/")) {
resolved = "src/" + importPath.slice(2);
}
// Try with common extensions: .ts, .tsx, .js, /index.ts, etc.
const extensions = ["", ".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.tsx"];
for (const ext of extensions) {
if (filePathSet.has(resolved + ext)) return resolved + ext;
}
return null;
}
像 react 或 zod 这样的 npm 包会被立即跳过,因为它们不是以 ./ 或 @/ 开头的。对于本地导入,它会尝试 .ts、.tsx 和 /index.ts 等常见扩展形式,因此既能处理直接导入文件的情况,也能处理通过索引文件导出的文件夹。
如果解析后的路径在仓库中不存在,它就会返回 null,并且不会创建边。这正是保证图中关系真实可靠的机制。
构建完节点和边后,需要先为它们计算出屏幕上的实际位置,React Flow 才能进行渲染。这就是 src/lib/graph-layout.ts 中 applyDagreLayout 的作用。
它使用 Dagre——一个能够自动为有向图中的每个节点计算 x 和 y 坐标、确保节点互不重叠的 JavaScript 库。整个过程完全在客户端进行,不需要服务器:
export function applyDagreLayout(nodes, edges, options = {}) {
const g = new dagre.graphlib.Graph();
g.setGraph({ rankdir: "TB", ranksep: 80, nodesep: 40 });
// Tell dagre the size of each node
for (const node of nodes) {
g.setNode(node.id, { width: 200, height: 60 });
}
for (const edge of edges) {
g.setEdge(edge.source, edge.target);
}
dagre.layout(g); // dagre calculates x,y for every node
return nodes.map((node) => {
const pos = g.node(node.id);
return { ...node, position: { x: pos.x - 100, y: pos.y - 30 } };
});
}
可以通过 UI 在从上到下(TB)和从左到右(LR)两种方向之间切换。
每种节点类型都会根据之前由 categorizeFileType 分配的文件类型映射到相应的 React Flow 组件:
module / service → ModuleNode(靛蓝色)
function → FunctionNode(蓝绿色)
file → FileNode(灰色)
四个面板之所以能够保持同步,是因为它们都会从 src/store/index.ts 中的同一个 Zustand store 读取数据。这个 store 包含四个 slice,每个 slice 分别负责一项主要关注点:
interface AppState {
repo: {
repoInfo: RepoInfo | null; // owner, repo, branch
tree: TreeNode | null; // full file tree
selectedFile: string | null; // active file path
loading: boolean;
error: string | null;
};
analysis: {
result: AnalysisResult | null; // explanation + relevant files + graph
loading: boolean;
error: string | null;
};
visualizat