DIFY工作流分享:生成PPT并导出(一)

12,659 阅读3分钟
  • 在AI迅速发展的现在,身为前端工程师也要学习AI,跟上技术潮流,保持自身竞争力。
  • 本文会分享一个DIFY工作流,完成了 市场分析->生成PPT->总结内容和标题->发送邮件 的流程,使用的技术:node写后端接口、dify制作工作流、marp将md转换成PPT。当然,这样的流程也存在缺陷,后续也换了其他的技术,接下来会一一说明。

image.png

1、DIFY部署

使用DIFY官网提供的云端DIFY存在很多的限制,例如只有单工作空间、工作空间最多10个项目、知识库容量限制等。所以我们可以在自己的服务器上部署DIFY,这样私有化部署,数据的安全性也得到了提高。DIFY的部署、为什么会选择DIFY,这些问题在上一篇文章中有详细描述。

image.png

2、引入大模型

dify支持的大模型非常多,我使用的是kimi联网搜索版,kimi每人有15的免费额度,像豆包、文心、智谱等都有免费的额度,测试的时候可以先用免费的。 调用kimi接口有两点注意事项,一是使用网络搜索需要自己加上tools $web_search,二是如果需要kimi输出json格式的数据,需要指定一下response_format

node接口示例如下:

const openai = require('openai'); // 需要安装 openai 库
const { kimiConfig } = require('../config/kimi');

const client = new openai.OpenAI({
  apiKey: kimiConfig.apiKey,
  baseURL: kimiConfig.baseURL,
});

const tools = [
  {
    type: 'builtin_function',
    function: {
      name: '$web_search',
    },
  },
];
class KimiReqService {
  constructor() {
    this.client = new openai.OpenAI({
      apiKey: kimiConfig.apiKey,
      baseURL: kimiConfig.baseURL,
    });
  }

  async kimiReq(content, isJSON = false) {
    let finishReason = null;
    let tool_result = null;
    let finalResponse = '';

    const messages = [
      {
        role: 'system',
        content:
          '你会为用户提供安全,有帮助,准确的回答。同时,你会拒绝一切涉及恐怖主义,种族歧视,黄色暴力等问题的回答。',
      },
      { role: 'user', content: content },
    ];

    while (finishReason === null || finishReason === 'tool_calls') {
      const completion = await client.chat.completions.create({
        model: 'moonshot-v1-auto',
        messages: messages,
        temperature: 0.3,
        tools: tools,
        response_format: isJSON ? { type: 'json_object' } : { type: 'text' },
      });
      const choice = completion.choices[0];
      finishReason = choice.finish_reason;

      if (finishReason === 'tool_calls') {
        messages.push(choice.message);
        for (const toolCall of choice.message.tool_calls) {
          const tool_call_name = toolCall.function.name;
          const tool_call_arguments = JSON.parse(toolCall.function.arguments);
          if (tool_call_name === '$web_search') {
            tool_result = tool_call_arguments;
          } else {
            tool_result = 'no tool found';
          }

          messages.push({
            role: 'tool',
            tool_call_id: toolCall.id,
            name: tool_call_name,
            content: JSON.stringify(tool_result),
          });
        }
      } else {
        finalResponse = choice.message.content;
      }
    }

    return finalResponse;
  }
}

module.exports = new KimiReqService();

3、DIFY HTTP节点调用注意事项

POST请求的form-data使用下来貌似有bug(有成功的朋友可以说一声),所以我选择用JSON传递参数。大模型输出的body也是string类型,我们需要对输出结果进行转换,然后才可以直接调用。

image.png

image.png

4、PPT生成

PPT生成,我使用的是Marp,如果有用过的朋友会知道,Marp是基于 Markdown 的开源幻灯片制作工具,可以轻松将 Markdown 文档转换为精美的幻灯片,输出格式可以是HTML、PPTX、PDF。当然Marp有一个致命的缺点就是,他输出的PPT是不可编辑的,和直接编辑PPT有本质的区别。(为了生成可编辑的PPTX文件,后续换了一个PPTist的方案,开发完成后会输出文档,欢迎一起交流。)

所以,接着之前的工作流,这一步会将得到的分析内容,接下来需要转换成md格式,方便Marp去输出PPT(提示词需要多调试调试)。

MARP使用示例:

const path = require('path');
const fs = require('fs').promises;
const { execSync } = require('child_process');

class generateService {
  async generatePPT(content, type = 'pptx') {
    try {
      const result = await this.marpPPT(content, type);
      return result;
    } catch (error) {
      console.error('生成PPT错误:', error);
      throw error;
    }
  }

  async marpPPT(content, type = 'pptx') {
    const timestamp = Date.now();
    const mdFileName = `slides_${timestamp}.md`;
    const outputFileName = `slides_${timestamp}.${type}`;

    // 设置目录路径
    const baseDir = process.cwd();
    const mdDir = path.join(baseDir, 'public/markdown');
    const outputDir = path.join(baseDir, 'public/ppt');

    let mdPath = null;
    let outputPath = null;

    try {
      // 创建目录
      await fs.mkdir(mdDir, { recursive: true });
      await fs.mkdir(outputDir, { recursive: true });

      mdPath = path.join(mdDir, mdFileName);
      outputPath = path.join(outputDir, outputFileName);

      // 写入markdown内容并等待完成
      await fs.writeFile(mdPath, content, 'utf8');
      console.log('Markdown文件已创建:', mdPath);

      // 确认文件存在
      const stats = await fs.stat(mdPath);
      console.log('Markdown文件大小:', stats.size);

      // 使用 execSync 执行命令
      try {
        const cmd = `npx --no @marp-team/marp-cli "${mdPath}" --pptx -o "${outputPath}" --chrome --no-sandbox --disable-setuid-sandbox`;
        console.log('执行命令:', cmd);

        const output = execSync(cmd, {
          cwd: baseDir,
          timeout: 600000,
          stdio: 'pipe',
        });

        console.log('命令执行输出:', output.toString());
      } catch (execError) {
        console.error('命令执行错误:', execError);
        console.error('错误输出:', execError.stderr?.toString());
        throw new Error(`PPT转换失败: ${execError.message}`);
      }

      // 确认输出文件存在
      await fs.access(outputPath);
      console.log('PPT文件已生成:', outputPath);

      // 读取生成的文件
      const fileContent = await fs.readFile(outputPath);
      console.log('PPT文件大小:', fileContent.length);

      return {
        mdPath,
        filePath: outputPath,
        fileContent,
      };
    } catch (error) {
      console.error('详细错误:', error);
      throw new Error(`生成PPT失败: ${error.message}`);
    }
  }

  async cleanUp(path, mdPath) {
    if (path) {
      fs.unlink(path).catch((e) => console.error('清理PPT文件失败:', e));
    }
    if (mdPath) {
      fs.unlink(mdPath).catch((e) => console.error('清理MD文件失败:', e));
    }
  }
}

module.exports = new generateService();

5、发送邮件

DIFY有发送邮件的工具,但是这个工具不支持邮件名有_ 等特殊符号的,所以只能用nodemailer手搓一个发送邮件的接口。在发送之前,需要大模型总结一下邮件的标题和正文内容,最后标题、正文、附件PPT一同发送至目标邮箱。

const sendEmailService = require("../service/email");
const path = require("path");

const emailController = async (req, res) => {
  if (!Array.isArray(req.body.emails)) {
    return res.json({
      success: false,
      message: "emails 必须是数组",
    });
  }

  const body = {
    emails: req.body.emails.join(","),
    subject: req.body.subject || "主题",
    text: req.body.text || "正文",
    attachments: req.body.attachments || [
      {
        filename: "test.txt", // 附件文件名
        path: path.join(__dirname, "../../public/ppt/test.txt"), // 附件路径
      },
    ],
  };

  // 邮件选项
  const mailOptions = {
    from: "1342980924@qq.com", // 发件人地址
    to: body.emails, // 收件人地址
    subject: body.subject, // 邮件主题
    text: body.text, // 邮件正文
    attachments: body.attachments,
  };

  try {
    await sendEmailService(mailOptions);
    return res.json({
      success: true,
      message: "邮件发送成功",
    });
  } catch (error) {
    return res.json({
      success: false,
      message: error,
    });
  }
};

module.exports = {
  emailController,
};

//services
const nodemailer = require("nodemailer");
const { smtpConfig } = require("../config/email");

class EmailService {
  constructor() {
    this.transporter = nodemailer.createTransport(smtpConfig);
  }

  async sendEmail(mailOptions) {
    return new Promise((resolve, reject) => {
      this.transporter.sendMail(mailOptions, (error, info) => {
        if (error) {
          return reject("发送失败: " + error);
        }
        return resolve("邮件发送成功: " + info.respons);
      });
    });
  }
}

module.exports = new EmailService();

6、总结

这是一个前后端和工作流结合开发的尝试,要到生产环境中跑起来还有许多的问题,例如:

  • 开头分析的结果可以再跑一个工作流输出更详细、更准确的内容;
  • 大模型输出md文档也许会有问题导致PPT不能正确分页;
  • 大模型输出内容过少,PPT留白太多;
  • Marp接口部署在服务器上需要正确配置无头浏览器;
  • dify部署对服务器要求较高;
  • dify社区版还有很多bug等。