Dify兼容低版本Chrome及iOS APP

39 阅读4分钟

Dify兼容低版本Chrome及iOS APP

目前dify使用的版本是1.1.3

1. 存在的问题

  • Dify使用的js语法较新,打包部署时没有向下兼容。 错误表现:Application error: a client-side exception has occurred while loading 10.60.206.9 (see the browser console for more information).
  • 由于iOS APP webview的安全限制,禁止自定义请求头。 错误表现:页面死循环刷新,401,404,应用不可用。

2. 针对Dify的js语法降级

经过测试目前,能够兼容到Chrome浏览器v75版本,safari浏览器v14

2.1 修改web/next.config.js文件

dify默认使用的是pnpm管理三方包,但是经过测试,transpilePackages对pnpm的包存在兼容问题,就是放入到.pnpm目录的三方包, next.js打包时是找不到的,也就不能编译,所以我把pnpm都退回到了使用npm管理,对应的打包docker镜像配置文件web/Dockerfile也修改了

# base image
FROM node:20-alpine3.20 AS base
# LABEL maintainer="takatost@gmail.com"

# if you located in China, you can use aliyun mirror to speed up
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories

RUN apk add --no-cache tzdata
# RUN npm install -g pnpm@9.12.2
# ENV PNPM_HOME="/pnpm"
# ENV PATH="$PNPM_HOME:$PATH"


# install packages
FROM base AS packages

WORKDIR /app/web

COPY package.json .
# COPY pnpm-lock.yaml .

# if you located in China, you can use taobao registry to speed up
# RUN pnpm install --frozen-lockfile --registry https://registry.npmmirror.com/

RUN npm config set registry https://registry.npmmirror.com

# RUN pnpm install --frozen-lockfile
RUN npm i --legacy-peer-deps --ignore-scripts

# build resources
FROM base AS builder
WORKDIR /app/web
COPY --from=packages /app/web/ .
COPY . .

ENV NODE_OPTIONS="--max-old-space-size=8192"
# RUN pnpm build
RUN npm run build


# production stage
FROM base AS production

ENV NODE_ENV=production
ENV EDITION=SELF_HOSTED
ENV DEPLOY_ENV=PRODUCTION
ENV CONSOLE_API_URL=http://127.0.0.1:5001
ENV APP_API_URL=http://127.0.0.1:5001
ENV MARKETPLACE_API_URL=http://127.0.0.1:5001
ENV MARKETPLACE_URL=http://127.0.0.1:5001
ENV PORT=3000
ENV NEXT_TELEMETRY_DISABLED=1
ENV PM2_INSTANCES=2

# set timezone
ENV TZ=UTC
RUN ln -s /usr/share/zoneinfo/${TZ} /etc/localtime \
    && echo ${TZ} > /etc/timezone


WORKDIR /app/web
COPY --from=builder /app/web/public ./public
COPY --from=builder /app/web/.next/standalone ./
COPY --from=builder /app/web/.next/static ./.next/static

COPY docker/entrypoint.sh ./entrypoint.sh


# global runtime packages
# RUN pnpm add -g pm2 \
RUN npm i -g pm2 \
    && mkdir /.pm2 \
    && chown -R 1001:0 /.pm2 /app/web \
    && chmod -R g=u /.pm2 /app/web

ARG COMMIT_SHA
ENV COMMIT_SHA=${COMMIT_SHA}

USER 1001
EXPOSE 3000
ENTRYPOINT ["/bin/sh", "./entrypoint.sh"]

出于性能考虑,next.js框架在配置打包时,默认会排除掉node_modules中的三方包的编译,因此需要找到/chat/XXX页面用到的所有不兼容的三方包

const nextConfig = {
    transpilePackages: ['@tanstack/query-core', 'mermaid', 'marked', 'next/dist/client', 'mdast-util-gfm-autolink-literal', 'ky', 'react-i18next', 'tailwind-merge', 'emoji-mart', '@tanstack/virtual-core', '@reactflow/core', 'hast-util-from-html-isomorphic', 'mime', '@tanstack/react-query', 'hast-util-to-text', 'rehype-katex', 'zundo', '@svgdotjs/svg.js', '@monaco-editor/react', 'micromark-util-decode-numeric-character-reference', 'micromark-util-sanitize-uri'],
}

2.2 为了方便排查,关闭了代码的压缩和混淆

const nextConfig = {
    webpack: (config, { dev, isServer }) => {
    config.plugins.push(codeInspectorPlugin({ bundler: 'webpack' }))
    config.optimization.minimize = false;
    if (!isServer) {
      config.optimization.minimizer = [];
    }

    return config
  },
}

2.3 个别写法手动替换

由于有些三方包的正则表达式使用了零宽断言语法,即便配置了编译,也依旧转换不了,针对这一部分,暂时时在docker容器中替换修改,后期考虑写脚本替换。

源码位置实在 dify/web/node_modules/mdast-util-gfm-autolink-literal/lib/index.js 由于打包后,文件名已经混淆,真实位置需要根据在safari浏览器控制台报错的位置确定,或者在容器中全局搜索

搜索搜索函数名transformGfmAutolinkLiterals或者findEmail

将正则表达式/(?<=^|\s|\p{P}|\p{S})([-.\w+]+)@([-\w]+(?:\.[-\w]+)+)/gu替换为/(?:^|\s|[^\w\s])([-.\w+]+)@([-\w]+(?:\.[-\w]+)+)/gu

function transformGfmAutolinkLiterals(tree) {
  findAndReplace(
    tree,
    [
      [/(https?:\/\/|www(?=\.))([-.\w]+)([^ \t\r\n]*)/gi, findUrl],
      // [/(?<=^|\s|\p{P}|\p{S})([-.\w+]+)@([-\w]+(?:\.[-\w]+)+)/gu, findEmail],源码修改前,编译后的零宽断言依旧没转
      [/(?:^|\s|[^\w\s])([-.\w+]+)@([-\w]+(?:\.[-\w]+)+)/gu, findEmail] //修改后
    ],
    {ignore: ['link', 'linkReference']}
  )
}

3.解决iOS请求头问题

3.1 源码部署

根据项目的源码启动文档docker/README.md,先进行源码启动,前后端及数据库启动完毕后,如果需要对外统一暴露80端口,还需要在本地额外搭建一个nginx服务器,根据不同的api前缀转发不同内部服务。下面是参考官方精简配置

server {
    listen 80;
    server_name Dify;

    location /console/api {
      proxy_pass http://api:5001;
    }

    location /api {
      proxy_pass http://api:5001;
    }

    location /v1 {
      proxy_pass http://api:5001;
    }

    location /files {
      proxy_pass http://api:5001;
    }

    location /explore {
      proxy_pass http://web:3000;
    }

    location /e/ {
      proxy_pass http://plugin_daemon:5002;
      proxy_set_header Dify-Hook-Url $scheme://$host$request_uri;
    }

    location / {
      proxy_pass http://web:3000;
    }
}

3.2 解决X-App-Code缺失问题

这个值存放的是聊天应用的id,思路是从referer中获取,由于在有些跨站等情况下referer的地址不完整,所以添加了nginx配置,保证每次都可以稳定的从referer中获取id

add_header Referrer-Policy "unsafe-url";

添加允许自定义header头

add_header 'Access-Control-Allow-Headers' 'X-App-Code, Authorization';

为开发调试方便,禁用浏览器缓存

expires -1;
add_header Cache-Control "private, no-store, no-cache, must-revalidate, proxy-revalidate";

开发模式下需要兼容socket连接

proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

nginx转发时,从referer中截取id信息,再把id赋值给请求头的X-App-Code中

# x-app-code的agentId
set $app_code "";
# referer中提取的agentId
set $referer_app_code "";

# 从referer中提取app_code(如果存在)
if ($http_referer ~* "/chat/([A-Za-z0-9]+)") {
    set $referer_app_code $1;
}
# 设置最终使用的app_code - 优先使用header中的值
if ($http_x_app_code != "") {
    set $app_code $http_x_app_code;
}
# 如果header中没有值,使用从referer提取的值
if ($http_x_app_code = "") {
    set $app_code $referer_app_code;
}
proxy_set_header X-App-Code $app_code;

next.config.js中增加开发模式下,增加安全域名

const nextConfig = {
    allowedDevOrigins: ['10.60.206.9', '20.60.128.21']
}

3.3 解决Authorization缺失问题

解决了x-app-code问题后,又报了很多/api/site/conversations?limit=100&pinned=false/api/parameters/api/meta接口401问题

经过postman测试是缺少Authorization,这个值是url中传入的token,而是经过dify后台拿到用户信息后生成的dify的token。所以当dify生成完新token后,需要由浏览器存入到浏览器的cookie中,再由nginx将该值提取出来放入Authorization

首先修改前端配置文件web/.env.localweb/.env.example,解决前端调后端接口跨域问题

NEXT_PUBLIC_API_PREFIX=/console/api
NEXT_PUBLIC_PUBLIC_API_PREFIX=/api

修改web/app/components/share/utils.ts文件

if (!accessTokenJson[sharedToken]) {
    const res = await fetchAccessToken(sharedToken, fromToken)
    if (res.access_token) {
      accessTokenJson[sharedToken] = res.access_token
      localStorage.setItem('token', JSON.stringify(accessTokenJson))
      const expires = new Date()
      expires.setFullYear(expires.getFullYear() + 10)
      document.cookie = `token=${accessTokenJson[sharedToken]}; path=/; expires=${expires.toUTCString()}` //新增
    }
    } else {
        const expires = new Date()
        expires.setFullYear(expires.getFullYear() + 10)
        document.cookie = `token=${accessTokenJson[sharedToken]}; path=/; expires=${expires.toUTCString()}` //新增
}

nginx进行数据转换

# 定义一个变量来存储token
set $auth_token "";

# 定义一个变量标记Authorization是否有效
set $auth_is_valid 0;
# 检查Authorization请求头是否存在且格式正确(以Bearer开头)
if ($http_authorization != "") {
    set $auth_is_valid 1;
}
if ($auth_is_valid = 1) {
    set $auth_token $http_authorization;
}
if ($auth_is_valid = 0) {
    set $auth_token "Bearer $cookie_token";
}
proxy_set_header Authorization $auth_token;

完整的开发nginx配置如下:

# Dify
server {
    listen 80;
    server_name localhost:80;

    location /console/api {
        proxy_pass http://localhost:5001;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        # 缓存设置
        expires -1;
        add_header Cache-Control "private, no-store, no-cache, must-revalidate, proxy-revalidate";

        # 跨域支持
        add_header 'Access-Control-Allow-Headers' 'X-App-Code, Authorization';
        add_header Referrer-Policy "unsafe-url";

        # WebSocket支持
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }

    location /api {
        proxy_pass http://localhost:5001;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        # 预先提取所有需要的变量
        # x-app-code的agentId
        set $app_code "";
        # referer中提取的agentId
        set $referer_app_code "";

        # 从referer中提取app_code(如果存在)
        if ($http_referer ~* "/chat/([A-Za-z0-9]+)") {
            set $referer_app_code $1;
        }
        # 设置最终使用的app_code - 优先使用header中的值
        if ($http_x_app_code != "") {
            set $app_code $http_x_app_code;
        }
        # 如果header中没有值,使用从referer提取的值
        if ($http_x_app_code = "") {
            set $app_code $referer_app_code;
        }
        proxy_set_header X-App-Code $app_code;

        # 定义一个变量来存储token
        set $auth_token "";

        # 定义一个变量标记Authorization是否有效
        set $auth_is_valid 0;
        # 检查Authorization请求头是否存在且格式正确(以Bearer开头)
        if ($http_authorization != "") {
            set $auth_is_valid 1;
        }
        if ($auth_is_valid = 1) {
            set $auth_token $http_authorization;
        }
        if ($auth_is_valid = 0) {
            set $auth_token "Bearer $cookie_token";
        }
        proxy_set_header Authorization $auth_token;

        # 缓存设置
        expires -1;
        add_header Cache-Control "private, no-store, no-cache, must-revalidate, proxy-revalidate";
        # 跨域支持
        add_header 'Access-Control-Allow-Headers' 'X-App-Code, Authorization';
        add_header Referrer-Policy "unsafe-url";

        # WebSocket支持
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }

    location /v1 {
        proxy_pass http://localhost:5001;
    }

    location /files {
        proxy_pass http://localhost:5001;
    }

    location /explore {
        proxy_pass http://localhost:3000;
    }

    # location /e/ {
    #     proxy_pass http://plugin_daemon:5002;
    # }

    location / {
        proxy_pass http://localhost:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        expires -1;
        add_header Cache-Control "private, no-store, no-cache, must-revalidate, proxy-revalidate";

        # 跨域支持
        add_header 'Access-Control-Allow-Headers' 'X-App-Code, Authorization';
        add_header Referrer-Policy "unsafe-url";

        # WebSocket支持
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

    }

}

next.config.js完成配置

const { codeInspectorPlugin } = require('code-inspector-plugin')
const withMDX = require('@next/mdx')({
  extension: /\.mdx?$/,
  options: {
    // If you use remark-gfm, you'll need to use next.config.mjs
    // as the package is ESM only
    // https://github.com/remarkjs/remark-gfm#install
    remarkPlugins: [],
    rehypePlugins: [],
    // If you use `MDXProvider`, uncomment the following line.
    // providerImportSource: "@mdx-js/react",
  },
})

/** @type {import('next').NextConfig} */
const nextConfig = {
  webpack: (config, { dev, isServer }) => {
    config.plugins.push(codeInspectorPlugin({ bundler: 'webpack' }))
    config.optimization.minimize = false;
    if (!isServer) {
      config.optimization.minimizer = [];
    }

    return config
  },
  productionBrowserSourceMaps: true, // enable browser source map generation during the production build
  // Configure pageExtensions to include md and mdx
  pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'],
  experimental: {
    externalDir: true,
  },
  allowedDevOrigins: ['10.60.206.9', '20.60.128.21'],
  transpilePackages: ['@tanstack/query-core', 'mermaid', 'marked', 'next/dist/client', 'mdast-util-gfm-autolink-literal', 'ky', 'react-i18next', 'tailwind-merge', 'emoji-mart', '@tanstack/virtual-core', '@reactflow/core', 'hast-util-from-html-isomorphic', 'mime', '@tanstack/react-query', 'hast-util-to-text', 'rehype-katex', 'zundo', '@svgdotjs/svg.js', '@monaco-editor/react', 'micromark-util-decode-numeric-character-reference', 'micromark-util-sanitize-uri'],
  // fix all before production. Now it slow the develop speed.
  eslint: {
    // Warning: This allows production builds to successfully complete even if
    // your project has ESLint errors.
    ignoreDuringBuilds: true,
    dirs: ['app', 'bin', 'config', 'context', 'hooks', 'i18n', 'models', 'service', 'test', 'types', 'utils'],
  },
  typescript: {
    // https://nextjs.org/docs/api-reference/next.config.js/ignoring-typescript-errors
    ignoreBuildErrors: true,
  },
  reactStrictMode: true,
  async redirects() {
    return [
      {
        source: '/',
        destination: '/apps',
        permanent: false,
      },
    ]
  },
  output: 'standalone',
}

module.exports = withMDX(nextConfig)