📖 引言

在上一篇中,我们用七分时间完成了思考:分析了需求、对比了方案、选定了技术栈、画出了架构图。就像盖房子之前,我们已经画好了图纸,选好了材料。

现在,是时候拿起砖块,开始真正的搭建了。

这一篇,我们将从零开始,把上一篇的设计思路变成实实在在的代码。当你完成这一篇的学习,你将拥有一个可以运行的Express服务器,能够通过API动态发现智能体,并能在浏览器中看到第一个版本的智能体列表。

这将是MultiMind的第一块里程碑。

获取项目源代码 Gitee MultiMind 雪豹同志

请添加图片描述


🎯 本章目标

学完本篇,你将能够:

  • 初始化Node.js项目并安装必要依赖
  • 搭建Express服务器基础框架
  • 实现智能体动态扫描的核心逻辑
  • 创建第一个智能体(助手小M)
  • 通过浏览器或curl验证API接口
  • 理解路由、中间件、文件操作的核心概念

📦 前置准备

在开始编码前,请确保你的开发环境已经准备好:

工具 版本要求 验证命令
Node.js 14.x 或更高 node -v
npm 6.x 或更高 npm -v
编辑器 任意 VSCode推荐

如果你还没有安装Node.js,请前往官网下载安装。安装完成后,在终端运行验证命令,看到版本号即表示成功。


🚀 第一步:项目初始化

创建项目目录

打开终端,执行以下命令:

# 创建项目目录
mkdir ai-chat-agents
cd ai-chat-agents

# 初始化package.json
npm init -y

npm init -y会生成一个默认的package.json文件。这个文件是Node.js项目的“身份证”,记录了项目的名称、版本、依赖等信息。

根据我们的需求,修改package.json

{
  "name": "ai-chat-agents",
  "version": "1.0.0",
  "description": "AI智能体对话平台",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js"
  },
  "keywords": ["ai", "chatbot", "agents"],
  "author": "你的名字",
  "license": "MIT"
}

设计思考:为什么把main改成server.js?因为我们的后端入口文件叫server.js更直观。为什么加dev脚本?nodemon可以在代码修改后自动重启服务器,开发时非常方便。为什么用MIT许可证?MIT是最宽松的开源许可证,允许别人自由使用和修改。

安装依赖

接下来安装项目依赖:

# 安装生产依赖
npm install express

# 安装开发依赖
npm install --save-dev nodemon

依赖说明

  • express:Node.js Web框架,用于创建API服务。它简化了路由、中间件、请求处理等操作,是Node.js后端开发的事实标准。
  • nodemon:开发工具,监听文件变化自动重启服务器,让我们在修改代码后不用手动重启,极大提升开发效率。

安装完成后,你会发现项目里多了两个文件:

  • node_modules/:存放所有安装的包(很大,不要动它)
  • package-lock.json:锁定依赖版本,确保团队开发时依赖一致

🏗️ 第二步:创建项目骨架

创建目录结构

按照上一篇的架构设计,我们需要创建以下目录和文件:

# 创建public目录(存放前端静态文件)
mkdir public

# 创建第一个智能体目录
mkdir "助手小M"

# 创建智能体文件(先创建空文件,内容稍后填写)
touch "助手小M/助手小M.txt"
touch "助手小M/助手小M.png"  # 实际使用时,你需要放一张真实的图片

现在项目结构变成:

ai-chat-agents/
├── node_modules/
├── public/                  # 前端静态文件
├── 助手小M/                  # 第一个智能体
│   ├── 助手小M.txt
│   └── 助手小M.png
├── package.json
└── package-lock.json

在这里插入图片描述

编写第一个智能体的prompt

编辑助手小M/助手小M.txt,写入以下内容:

你是助手小M,一个友好热情的AI助手。

性格特点:
- 温和有礼,总是用礼貌的语气说话
- 乐于助人,对每个问题都认真回答
- 略带幽默感,但不失专业

对话规则:
1. 初次见面要自我介绍
2. 回答要简洁明了,不说废话
3. 遇到不懂的问题要坦诚承认

设计思考:这个prompt虽然简单,但已经包含了人格设定的三个核心要素:身份定义、性格描述、行为规则。后续我们会看到,正是这些设定让AI的回复有了“人格”。

关于头像文件的说明:目前我们只是创建了一个空的.png文件。在实际使用中,你需要准备一张真正的图片。如果你暂时没有合适的图片,可以先用一张占位图。注意:文件名必须完全匹配——文件夹叫“助手小M”,文件就必须叫“助手小M.png”。这是我们的约定规则。


⚙️ 第三步:编写服务器基础代码

创建server.js

在项目根目录创建server.js,写入最基础的Express服务器代码:

// 引入依赖
const express = require('express');
const fs = require('fs');
const path = require('path');

// 创建Express应用
const app = express();

// 定义端口
const PORT = 3002;

// 启动服务器
app.listen(PORT, () => {
  console.log(`🚀 服务器运行在 http://localhost:${PORT}`);
});

代码解析

  • require('express'):引入Express模块
  • require('fs'):引入文件系统模块(内置,无需安装)
  • require('path'):引入路径处理模块(内置)
  • express():创建Express应用实例
  • app.listen(PORT, callback):启动服务器,监听指定端口

现在运行试试:

node server.js

你应该看到输出:🚀 服务器运行在 http://localhost:3002

Ctrl+C 可以停止服务器。

添加中间件

Express通过中间件来处理请求。我们需要添加两个基础的中间件:

// 中间件配置
app.use(express.json()); // 解析JSON格式的请求体
app.use(express.static('public')); // 提供静态文件服务

中间件说明

  • express.json():当客户端发送JSON数据时,自动解析并挂在req.body
  • express.static('public'):将public目录暴露为静态资源,如http://localhost:3002/index.html

添加静态路由

为了让前端能访问智能体的头像图片,还需要添加一个静态路由:

// 提供智能体文件夹的静态访问
app.use('/agents', express.static('.'));

这行代码的意思是:当用户访问/agents/xxx时,去项目根目录(.)查找对应的文件。例如,访问http://localhost:3002/agents/助手小M/助手小M.png,就会返回./助手小M/助手小M.png文件。

安全思考:这样会不会暴露整个项目目录?是的,确实会。但我们的项目结构简单,目前只有智能体文件夹需要对外访问。如果担心安全问题,可以只暴露特定的目录。


🔍 第四步:实现智能体发现接口

现在来实现最核心的功能——智能体动态发现。

理解需求

我们需要一个API接口:GET /api/agents,返回所有智能体的列表。返回的数据格式应该是:

{
  "success": true,
  "agents": [
    {
      "name": "助手小M",
      "prompt": "你是助手小M...",
      "image": "/agents/助手小M/助手小M.png"
    }
  ]
}

实现思路

要实现这个接口,我们需要:

  1. 扫描项目根目录,找出所有文件夹
  2. 对每个文件夹,检查是否包含文件夹名.png文件夹名.txt
  3. 如果都包含,读取.txt文件内容作为prompt
  4. 组装成JSON返回

核心代码片段

// 获取所有智能体列表
app.get('/api/agents', (req, res) => {
  try {
    const agents = [];
    const directory = path.join(__dirname);
    
    // 读取当前目录,获取所有文件夹
    const folders = fs.readdirSync(directory, { withFileTypes: true })
      .filter(dirent => dirent.isDirectory())
      .map(dirent => dirent.name);
    
    folders.forEach(folder => {
      const folderPath = path.join(directory, folder);
      const files = fs.readdirSync(folderPath);
      
      // 检查是否存在对应名称的.png和.txt文件
      const hasPng = files.includes(`${folder}.png`);
      const hasTxt = files.includes(`${folder}.txt`);
      
      if (hasPng && hasTxt) {
        // 读取prompt内容
        const promptPath = path.join(folderPath, `${folder}.txt`);
        const prompt = fs.readFileSync(promptPath, 'utf8').trim();
        
        agents.push({
          name: folder,
          prompt: prompt,
          image: `/agents/${folder}/${folder}.png`
        });
      }
    });
    
    res.json({ success: true, agents });
  } catch (error) {
    console.error('获取智能体列表失败:', error);
    res.status(500).json({ success: false, error: '获取智能体列表失败' });
  }
});

关键点思考

1. 为什么用fs.readdirSync而不是异步方法?

在Node.js中,异步是主流,但这里我刻意用了同步方法:

同步 异步
代码简单,线性执行 需要回调或Promise,代码复杂
会阻塞事件循环 不会阻塞
适合启动时执行 适合高并发场景

选择原因:智能体列表只在启动和手动刷新时读取,数据量小(几十个文件夹),代码简洁易懂更重要。如果未来智能体数量达到上千个,或者API被频繁调用,可以优化为异步。

2. { withFileTypes: true }的作用是什么?

不加这个参数,fs.readdirSync返回的是文件名数组。加上后,返回的是包含文件类型信息的对象数组,我们可以直接用.isDirectory()判断是不是文件夹,而不需要再次调用fs.statSync()。这是一个性能优化的小技巧。

3. 为什么用path.join处理路径?

__dirname是Node.js的全局变量,表示当前文件所在的目录绝对路径。path.join会根据操作系统自动使用正确的路径分隔符(Windows用\,Linux/mac用/),避免手动拼接带来的跨平台问题。

4. try-catch 错误处理的重要性

任何一步都可能出错(比如文件夹被占用、文件权限不够),用try-catch捕获所有错误,返回统一的错误格式。这样客户端就能知道是服务器出错,而不是自己的问题。

接口测试

现在我们可以测试这个接口了。确保服务器在运行:

npm run dev

打开浏览器,访问:http://localhost:3002/api/agents

你应该看到类似这样的JSON返回:

{
  "success": true,
  "agents": [
    {
      "name": "助手小M",
      "prompt": "你是助手小M,一个友好热情的AI助手。\n\n性格特点:\n- 温和有礼...",
      "image": "/agents/助手小M/助手小M.png"
    }
  ]
}

🎉 恭喜!你的第一个API接口跑通了!


🖥️ 第五步:创建前端页面(简略版)

有了API接口,我们还需要一个前端页面来展示这些智能体。这里只给出核心思路和代码片段,完整的样式代码可以参考项目源码。

核心思路

  1. 创建public/index.html作为入口
  2. 页面加载时调用/api/agents获取数据
  3. 动态渲染智能体卡片
  4. 处理加载状态和错误情况

核心代码片段

<div id="agentsContainer">
  <div class="loading">加载中...</div>
</div>

<script>
  async function loadAgents() {
    try {
      const response = await fetch('/api/agents');
      const data = await response.json();
      
      const container = document.getElementById('agentsContainer');
      
      if (data.success && data.agents.length > 0) {
        container.innerHTML = data.agents.map(agent => `
          <div class="agent-card">
            <img src="${agent.image}" alt="${agent.name}">
            <h3>${agent.name}</h3>
            <p>${agent.prompt.substring(0, 100)}...</p>
          </div>
        `).join('');
      } else {
        container.innerHTML = '<div>暂无智能体,请先创建</div>';
      }
    } catch (error) {
      container.innerHTML = '<div>加载失败,请刷新重试</div>';
    }
  }
  
  document.addEventListener('DOMContentLoaded', loadAgents);
</script>

设计思考

  • 为什么用substring(0, 100)?因为prompt可能很长,在卡片上只显示前100个字符作为预览,保持界面整洁。
  • 为什么用onerror处理图片?网络图片可能加载失败,提供一个后备方案提升用户体验。

🧪 第六步:测试与验证

创建更多智能体来测试

为了验证动态发现机制,我们再创建几个智能体:

# 创建第二个智能体
mkdir "鲁迅"
touch "鲁迅/鲁迅.txt"
touch "鲁迅/鲁迅.png"

# 创建第三个智能体
mkdir "凌云"
touch "凌云/凌云.txt"
touch "凌云/凌云.png"

给它们写入不同的prompt:

鲁迅/鲁迅.txt

你是鲁迅,中国现代文学的奠基人。
- 冷峻、犀利、幽默
- 常以笔为刀,解剖社会
- 说话带着绍兴口音,爱用比喻

凌云/凌云.txt

你是凌云,隐于市的程序员。
- 儒雅,书卷气,言语间带古风
- 被问技术问题,只谈思路不谈实现
- 若被追问代码,便说“不想写”

刷新页面,你会看到新智能体自动出现在列表中——不需要重启服务器,不需要改任何配置

这就是“文件夹即智能体”的魔力!

用curl测试API

如果你更喜欢命令行,也可以用curl测试:

curl http://localhost:3002/api/agents

会返回格式化的JSON数据。


🔧 第七步:扩展思考

思考1:如何支持更多图片格式?

目前只支持.png。如果要支持.jpg.jpeg.gif,可以这样改:

const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif'];
const hasImage = imageExtensions.some(ext => 
  files.includes(`${folder}${ext}`)
);

找到图片后,需要记录实际的文件名,而不是写死.png。这引出一个问题:如果同时存在.png和.jpg,应该用哪个? 可以定义优先级,比如优先使用png,或者让用户指定。

思考2:如何添加缓存?

每次请求都扫描磁盘,如果有性能问题,可以添加内存缓存:

let agentsCache = null;
let cacheTime = 0;
const CACHE_TTL = 60000; // 1分钟

app.get('/api/agents', (req, res) => {
  // 如果缓存存在且未过期,直接返回缓存
  if (agentsCache && Date.now() - cacheTime < CACHE_TTL) {
    return res.json(agentsCache);
  }
  
  // ...扫描逻辑...
  
  // 更新缓存
  agentsCache = { success: true, agents };
  cacheTime = Date.now();
  res.json(agentsCache);
});

但这也带来了新问题:如果用户在缓存期间新增了智能体,列表不会立即更新。怎么解决?可以提供一个手动刷新的接口,或者在修改文件时主动清理缓存。

思考3:如何处理文件名大小写?

Windows不区分大小写,Linux区分。这是跨平台开发中常见的坑。比如用户在Windows上创建了“鲁迅”文件夹,里面放的是“LuXun.png”,在Linux上就识别不出来。

解决方案:在扫描时做大小写不敏感匹配:

const filesLower = files.map(f => f.toLowerCase());
const hasPng = filesLower.includes(`${folder.toLowerCase()}.png`);

但这样做有个问题:返回的图片路径需要是实际的文件名,而不是小写后的。所以还需要找到原始文件名:

const actualPngFile = files.find(f => 
  f.toLowerCase() === `${folder.toLowerCase()}.png`
);

思考4:如何保证智能体数据的完整性?

如果用户只放了.png文件,忘了放.txt,这个智能体算不算有效?

几种方案

  1. 严格模式:必须两个文件都存在,才算有效
  2. 宽松模式:只要有头像就算,prompt用默认值
  3. 智能模式:缺什么补什么,比如没有txt就自动生成一个

初期我们选择严格模式,因为逻辑简单,符合预期。但可以在UI上提示“不完整智能体”,引导用户补全。

思考5:文件夹名包含特殊字符怎么办?

如果文件夹名包含空格、中文标点等,URL编码会带来问题。比如/agents/助手小M/助手小M.png在浏览器中会自动编码,通常没问题。但如果文件夹名是测试/智能体(包含斜杠),就会破坏路径结构。

最佳实践:建议文件夹名只使用字母、数字、中文和下划线,避免使用特殊字符。


🎯 本篇小结

在这一篇中,我们真正动手写代码,完成了:

任务 成果
项目初始化 创建了package.json,安装了express和nodemon
服务器搭建 创建了基础Express服务器,配置了中间件
智能体创建 创建了“助手小M”等三个智能体
API实现 实现了GET /api/agents动态发现接口
前端页面 创建了简单的智能体卡片列表
测试验证 验证了动态发现机制,测试了异常情况

核心收获

  1. “文件夹即智能体”的设计理念已经通过代码实现
  2. 新增智能体 = 新建文件夹 + 放两张文件,无需改代码
  3. 所有智能体数据集中在各自的文件夹里,易于管理
  4. 前端自动渲染,无需手动配置

🔮 下篇预告

第三篇:对话之心——实现多轮对话上下文管理

在下一篇中,我们将实现真正的对话功能:

  • 聊天界面的设计与实现
  • 对话历史的数据结构设计
  • 消息的发送与接收
  • 对话上下文的维护
  • 第一版AI对话集成

敬请期待!


📝 写在最后

从想法到代码,从架构到实现,MultiMind已经迈出了坚实的第一步。

现在的系统虽然简单,但已经具备了最核心的能力:动态发现智能体。这为后续所有功能打下了基础。

回顾这一篇,你会发现我们并没有写很多代码——核心的API接口只有几十行。但每一行代码背后,都有上一篇的深思熟虑作为支撑。这就是“七分思考,三分编码”的意义。

代码是思维的映射。好的代码,源于好的思考。

下一篇见!


思考题:现在的智能体发现机制要求文件夹名必须和文件名完全一致。你觉得这个设计合理吗?如果遇到文件夹名包含特殊字符(如空格、中文标点),会有什么问题?欢迎在评论区分享你的思考。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐