目录

序言

下载多模型和接口测试

代码

部分代码


序言

这个没啥可说,就是调本地ollama的接口.

下载多模型和接口测试

先下一个能分析图片和文件的模型,这里就是qwen2.5vl:7b 了

下载模型
ollama pull qwen2.5vl:7b

运行模型
ollama pull qwen2.5vl:7b

然后先用postman 测一下吧,直接看图

接口要求传入的图片和文件是base64的,并且开头要把base64的标识给拿掉。

因为我们用转文件的工具得到的编码是这样的:

要把前面那一段base64给拿掉。

代码

代码地址:https://download.csdn.net/download/csdnliuxin123524/92924211

下载后,执行命令安装、打包、启动命令就行了

  • npm install
  • npm run build
  • npm start

访问页面:http://localhost:3000/

效果如下:

总体一般,因为是本地电脑,性能一般,所以处理很慢,处理几K的文件或图片还行,视频都没试,如果是7、8百k的文件就报错了。

部分代码

代码没啥好讲的,后端接口在server/index.js:

/**
 * Ollama Chat - Node.js 原生 HTTP 服务
 *
 * 纯 Node.js http 模块实现,不依赖 Express
 * 统一端口 3000,同时托管 API + 前端静态文件
 *
 * 启动方式:
 *   开发: npm run dev   (vite --watch 编译 + node server)
 *   生产: npm start     (直接运行,需先 npm run build)
 */

// ========== 1. 加载环境变量 ==========
const path = require('path');
const dotenv = require('dotenv');
dotenv.config({ path: path.resolve(__dirname, '..', '.env') });

console.log('[env] OLLAMA_URL =', process.env.OLLAMA_URL);
console.log('[env] TEXT_MODEL =', process.env.TEXT_MODEL);
console.log('[env] VISION_MODEL =', process.env.VISION_MODEL);
console.log('[env] PORT =', process.env.PORT);

// ========== 2. 内置模块 ==========
const http = require('http');
const fs = require('fs');
const fsPromises = fs.promises;
const url = require('url');
const multiparty = require('multiparty');

// ========== 3. 业务模块 ==========
const ollama = require('./services/ollama');
const fileProcessor = require('./services/fileProcessor');

// 常量
const PORT = process.env.PORT || 3000;
const distPath = path.resolve(__dirname, '..', 'dist');
const uploadsDir = path.join(__dirname, 'uploads');

// 确保上传目录存在
if (!fs.existsSync(uploadsDir)) {
  fs.mkdirSync(uploadsDir, { recursive: true });
}

// MIME 类型映射
const MIME_TYPES = {
  '.html': 'text/html',
  '.js': 'application/javascript',
  '.css': 'text/css',
  '.json': 'application/json',
  '.png': 'image/png',
  '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg',
  '.gif': 'image/gif',
  '.svg': 'image/svg+xml',
  '.ico': 'image/x-icon',
  '.woff2': 'font/woff2',
  '.woff': 'font/woff',
  '.ttf': 'font/ttf',
};

/**
 * 发送 JSON 响应
 */
function json(res, statusCode, data) {
  res.writeHead(statusCode, {
    'Content-Type': 'application/json; charset=utf-8',
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type',
  });
  res.end(JSON.stringify(data));
}

/**
 * 解析 JSON 请求体
 */
function parseBody(req) {
  return new Promise((resolve, reject) => {
    const chunks = [];
    req.on('data', chunk => chunks.push(chunk));
    req.on('end', () => {
      try {
        const body = Buffer.concat(chunks).toString('utf-8');
        resolve(body ? JSON.parse(body) : {});
      } catch (e) {
        reject(e);
      }
    });
    req.on('error', reject);
  });
}

/**
 * 解析 multipart/form-data(文件上传)
 * 返回 { fields: {}, files: {} }
 */
function parseMultipart(req) {
  return new Promise((resolve, reject) => {
    const form = new multiparty.Form({
      uploadDir: uploadsDir,
      maxFilesSize: 500 * 1024 * 1024, // 500MB
    });
    form.parse(req, (err, fields, files) => {
      if (err) reject(err);
      else resolve({ fields, files });
    });
  });
}

// ========== 4. 路由处理器 ==========

const routes = {
  // GET /api/health
  'GET /api/health': async (req, res) => {
    const status = await ollama.checkOllama();
    json(res, 200, { status: 'ok', ollama: status, timestamp: new Date().toISOString() });
  },

  // POST /api/chat
  'POST /api/chat': async (req, res) => {
    const body = await parseBody(req);
    const { message, history = [], systemPrompt } = body;

    if (!message) {
      return json(res, 400, { error: '消息不能为空' });
    }

    const response = await ollama.chat(message, history, systemPrompt);
    json(res, 200, { success: true, response, model: ollama.DEFAULT_TEXT_MODEL });
  },

  // POST /api/chat/image
  'POST /api/chat/image': async (req, res) => {
    const { fields, files } = await parseMultipart(req);
    const message = (fields.message?.[0] || '请详细描述这张图片的内容');
    const imageFile = files.image?.[0];

    if (!imageFile) {
      return json(res, 400, { error: '请上传图片' });
    }

    // 读取图片文件转 base64
    const buffer = await fsPromises.readFile(imageFile.path);
    const base64 = buffer.toString('base64');

    // 删除临时文件
    try { await fsPromises.unlink(imageFile.path); } catch (e) {}

    try {
        console.log("调用ollama图片分析接口", imageFile.fileName)
      const response = await ollama.analyzeImage(base64, message);
      json(res, 200, { success: true, response, model: ollama.DEFAULT_VISION_MODEL, method: 'vision' });
    } catch (visionError) {
      // llava 不可用,尝试 OCR
      console.log('llava 不可用,尝试 OCR:', visionError.message);

      const tempDir = path.join(uploadsDir, 'temp');
      await fsPromises.mkdir(tempDir, { recursive: true });
      const tempPath = path.join(tempDir, `${Date.now()}_ocr.jpg`);
      await fsPromises.writeFile(tempPath, buffer);

      try {
        const ocrResult = await fileProcessor.ocrImage(tempPath);
        const prompt = `用户上传了一张图片,OCR 识别出的文字内容如下:\n${ocrResult.content}\n\n请根据这些内容回答用户的问题:${message}`;
        const response = await ollama.chat(prompt);

        json(res, 200, {
          success: true, response,
          model: ollama.DEFAULT_TEXT_MODEL,
          method: 'ocr',
          ocrText: ocrResult.content,
        });
      } catch (ocrError) {
        json(res, 500, {
          error: '图片处理失败',
          message: '多模态模型 llava 未安装,且 OCR 功能不可用',
          details: { visionError: visionError.message, ocrError: ocrError.message },
          solution: '方案1: 运行 "ollama pull llava" 安装多模态模型\n方案2: 安装 OCR 依赖',
        });
      } finally {
        try { await fsPromises.unlink(tempPath); } catch (e) {}
      }
    }
  },

  // POST /api/chat/file
  'POST /api/chat/file': async (req, res) => {
    const { fields, files } = await parseMultipart(req);
    const message = (fields.message?.[0] || '请分析这个文件的内容');
    const uploadedFile = files.file?.[0];

    if (!uploadedFile) {
      return json(res, 400, { error: '请上传文件' });
    }

    const filePath = uploadedFile.path;
    const originalName = uploadedFile.originalFilename || 'unknown';

    console.log(`处理文件: ${originalName}`);

    try {
      const fileData = await fileProcessor.processFile(filePath, originalName);

      let response;
      let extraData = {};

      if (fileData.type === 'image') {
        try {
          response = await ollama.analyzeImage(fileData.base64, message);
          extraData = { model: ollama.DEFAULT_VISION_MODEL, method: 'vision' };
        } catch (e) {
          response = await ollama.chat(
            `用户上传了一张图片(${originalName}),但无法直接查看。请告诉用户如需图片分析,请安装 llava 模型:ollama pull llava`
          );
          extraData = { model: ollama.DEFAULT_TEXT_MODEL, method: 'fallback' };
        }
      } else if (fileData.type === 'video') {
        const videoInfo = fileData.info || {};
        const keyframesInfo = fileData.keyframes?.length > 0
          ? `\n已提取 ${fileData.keyframes.length} 张关键帧图片` : '';
        const transcriptionInfo = fileData.transcription
          ? `\n语音转写内容:\n${fileData.transcription}` : '';

        const prompt = `用户上传了一个视频文件:${originalName}\n视频信息:\n- 时长: ${videoInfo.duration ? (videoInfo.duration / 60).toFixed(2) : '?'} 分钟\n- 分辨率: ${videoInfo.width || '?'} x ${videoInfo.height || '?'}\n- 编码格式: ${videoInfo.codec || '?'}\n- 文件大小: ${(videoInfo.size / 1024 / 1024).toFixed(2)} MB\n${keyframesInfo}\n${transcriptionInfo}\n\n用户问题:${message}\n请根据视频信息进行分析。`;

        response = await ollama.chat(prompt);
        extraData = { model: ollama.DEFAULT_TEXT_MODEL, method: 'video', videoInfo };
      } else {
        const prompt = `用户上传了一个文件:${originalName}\n文件类型:${fileData.type}\n${fileData.pages ? `页数:${fileData.pages}` : ''}\n${fileData.sheets ? `工作表:${fileData.sheets.join(', ')}` : ''}\n\n文件内容:\n${fileData.content}\n\n用户问题:${message}\n请根据文件内容进行分析,如果内容较多,请总结重点。`;

        response = await ollama.chat(prompt);
        extraData = { model: ollama.DEFAULT_TEXT_MODEL, method: 'text', fileType: fileData.type };
      }

      json(res, 200, {
        success: true, response,
        fileName: originalName, fileType: fileData.type,
        ...extraData,
      });
    } finally {
      try { await fsPromises.rm(path.dirname(filePath), { recursive: true, force: true }); } catch (e) {}
    }
  },

  // POST /api/structured
  'POST /api/structured': async (req, res) => {
    const body = await parseBody(req);
    const { content, schema } = body;

    if (!content || !schema) {
      return json(res, 400, { error: '内容和格式 schema 不能为空' });
    }

    const response = await ollama.structuredOutput(content, schema);
    json(res, 200, { success: true, response, model: ollama.DEFAULT_TEXT_MODEL });
  },
};

// ========== 5. 静态文件服务 ==========

/**
 * 托管 dist/ 目录下的静态文件
 */
async function serveStatic(reqPath, res) {
  // 安全处理:防止目录遍历攻击
  const safePath = path.normalize(reqPath).replace(/^(\.\.[\/\\])+/, '');
  const filePath = path.join(distPath, safePath);

  // 确保不超出 dist 目录
  if (!filePath.startsWith(distPath)) {
    res.writeHead(403);
    return res.end('Forbidden');
  }

  let stat;
  try {
    stat = await fsPromises.stat(filePath);
  } catch {
    return false; // 文件不存在,交给 fallback 处理
  }

  if (stat.isDirectory()) {
    // 目录则尝试返回 index.html
    const indexPath = path.join(filePath, 'index.html');
    try {
      const indexStat = await fsPromises.stat(indexPath);
      if (indexStat.isFile()) {
        const content = await fsPromises.readFile(indexPath);
        res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
        return res.end(content);
      }
    } catch {
      return false;
    }
  }

  // 读取文件
  const ext = path.extname(filePath).toLowerCase();
  const contentType = MIME_TYPES[ext] || 'application/octet-stream';

  const content = await fsPromises.readFile(filePath);
  res.writeHead(200, {
    'Content-Type': contentType,
    'Content-Length': stat.size,
  });
  return res.end(content);
}

// ========== 6. 主服务器 ==========

const server = http.createServer(async (req, res) => {
  const parsedUrl = url.parse(req.url, true);
  const pathname = parsedUrl.pathname;
  const method = req.method;

  // CORS 预检
  if (method === 'OPTIONS') {
    res.writeHead(204, {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type',
    });
    return res.end();
  }

  // API 路由匹配
  const routeKey = `${method} ${pathname}`;
  const handler = routes[routeKey];

  if (handler) {
    try {
      await handler(req, res);
    } catch (error) {
      console.error(`[API Error] ${routeKey}:`, error);
      json(res, 500, { error: '服务器内部错误', message: error.message });
    }
    return;
  }

  // 静态文件服务(仅当 dist 存在时)
  if (fs.existsSync(distPath)) {
    const staticPath = pathname === '/' ? 'index.html' : pathname;
    const served = await serveStatic(staticPath, res);
    if (served !== false) return;

    // 未匹配到静态文件 → SPA fallback(返回 index.html,让 React Router 处理)
    try {
      const indexHtml = await fsPromises.readFile(path.join(distPath, 'index.html'));
      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
      return res.end(indexHtml);
    } catch (e) {
      res.writeHead(500);
      return res.end('Server Error');
    }
  }

  // 无任何匹配
  json(res, 404, { error: 'Not Found', path: pathname });
});

server.listen(PORT, () => {
  const hasDist = fs.existsSync(distPath);
  console.log('='.repeat(55));
  console.log('🤖 Ollama Chat 服务已启动 [Node.js 原生 http]');
  console.log(`📦 运行模式: ${hasDist ? '前后端合一' : 'API-only(请先 npm run build)'}`);
  console.log('='.repeat(55));
  console.log(`🌐 访问地址: http://localhost:${PORT}`);
  console.log(`📡 API 地址: http://localhost:${PORT}/api`);
  console.log('');
  console.log('📋 API 接口:');
  console.log('   GET  /api/health         - 健康检查');
  console.log('   POST /api/chat           - 文本对话');
  console.log('   POST /api/chat/image     - 图片分析');
  console.log('   POST /api/chat/file      - 文件分析');
  console.log('   POST /api/structured     - 结构化输出');
  console.log('');
  console.log('⚙️  环境变量:');
  console.log(`   OLLAMA_URL:   ${process.env.OLLAMA_URL || 'http://localhost:11434'}`);
  console.log(`   TEXT_MODEL:   ${process.env.TEXT_MODEL || 'qwen2.5:7b'}`);
  console.log(`   VISION_MODEL: ${process.env.VISION_MODEL || 'llava'}`);
  console.log('');
  console.log('💡 提示: 请确保 Ollama 服务已启动 (ollama serve)');
  console.log('='.repeat(55));
});

// 优雅关闭
process.on('SIGINT', () => {
  console.log('\n👋 正在关闭服务...');
  server.close(() => {
    console.log('✅ 服务已关闭');
    process.exit(0);
  });
});

index中的接口再调用ollama提供的接口:

server/services/ollama.js

/**
 * Ollama API 服务
 * 负责与本地 Ollama 服务通信,支持文本对话、图片分析等
 */

const OLLAMA_BASE_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
const DEFAULT_TEXT_MODEL = process.env.TEXT_MODEL || 'qwen2.5:7b';
const DEFAULT_VISION_MODEL = process.env.VISION_MODEL || 'llava';

/**
 * 检查 Ollama 服务是否运行
 */
async function checkOllama() {
  try {
    const response = await fetch(`${OLLAMA_BASE_URL}/api/tags`);
    if (!response.ok) throw new Error('Ollama 服务未响应');
    const data = await response.json();
    return {
      running: true,
      models: data.models || []
    };
  } catch (error) {
    return {
      running: false,
      error: error.message
    };
  }
}

/**
 * 文本对话(使用 qwen2.5:7b)
 * @param {string} message - 用户消息
 * @param {Array} history - 历史对话记录
 * @param {string} systemPrompt - 系统提示词
 */
async function chat(message, history = [], systemPrompt = null) {
  const messages = [];

  if (systemPrompt) {
    messages.push({ role: 'system', content: systemPrompt });
  }

  // 添加历史记录
  history.forEach(msg => {
    messages.push({ role: msg.role, content: msg.content });
  });

  // 添加当前消息
  messages.push({ role: 'user', content: message });

  const response = await fetch(`${OLLAMA_BASE_URL}/api/chat`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      model: DEFAULT_TEXT_MODEL,
      messages,
      stream: false
    })
  });

  if (!response.ok) {
    const error = await response.text();
    throw new Error(`Ollama API 错误: ${error}`);
  }

  const data = await response.json();
  return data.message?.content || '';
}

/**
 * 图片分析(使用 llava 多模态模型)
 * @param {string} imageBase64 - Base64 编码的图片
 * @param {string} prompt - 分析提示词
 */
async function analyzeImage(imageBase64, prompt = '请详细描述这张图片的内容') {
  console.log(imageBase64)
  const response = await fetch(`${OLLAMA_BASE_URL}/api/chat`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      model: DEFAULT_VISION_MODEL,
      messages: [{
        role: 'user',
        content: prompt,
        images: [imageBase64]
      }],
      stream: false
    })
  });

  if (!response.ok) {
    // 如果 llava 不可用,返回错误信息
    const error = await response.text();
    throw new Error(`VISION_MODEL_ERROR: ${error}`);
  }

  const data = await response.json();
  return data.message?.content || '';
}

/**
 * 结构化输出(JSON 格式)
 * @param {string} content - 需要分析的内容
 * @param {string} schema - JSON 格式描述
 */
async function structuredOutput(content, schema) {
  const prompt = `请分析以下内容,并严格按照以下 JSON 格式输出,只输出 JSON 内容,不要输出其他文字:
${schema}

内容:
${content}`;

  const response = await fetch(`${OLLAMA_BASE_URL}/api/chat`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      model: DEFAULT_TEXT_MODEL,
      messages: [{
        role: 'user',
        content: prompt
      }],
      stream: false,
      format: 'json'
    })
  });

  if (!response.ok) {
    const error = await response.text();
    throw new Error(`Ollama API 错误: ${error}`);
  }

  const data = await response.json();
  return data.message?.content || '';
}

module.exports = {
  checkOllama,
  chat,
  analyzeImage,
  structuredOutput,
  OLLAMA_BASE_URL,
  DEFAULT_TEXT_MODEL,
  DEFAULT_VISION_MODEL
};

Logo

免费领 200 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐