diff --git a/.gitattributes b/.gitattributes index efdba8764..ede858916 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ * text=auto *.sh text eol=lf +*.vue linguist-language=python diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml index 0c95c5397..edb2b8acb 100644 --- a/.github/workflows/build_and_push.yml +++ b/.github/workflows/build_and_push.yml @@ -7,7 +7,7 @@ on: inputs: dockerImageTag: description: 'Image Tag' - default: 'v0.9.0' + default: 'dev' required: true dockerImageTagWithLatest: description: '是否发布latest tag(正式发版时选择,测试版本切勿选择)' @@ -42,6 +42,22 @@ jobs: uses: actions/checkout@v4 with: ref: ${{ github.ref_name }} + - name: Free Up GitHub Actions Ubuntu Runner Disk Space + uses: jlumbroso/free-disk-space@main + with: + # This might remove tools that are actually needed, if set to "true" but frees about 6 GB + tool-cache: false + + # All of these default to true, but feel free to set to "false" if necessary for your workflow + android: true + dotnet: true + haskell: true + large-packages: false + swap-storage: true + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - name: Prepare id: prepare run: | @@ -57,8 +73,6 @@ jobs: echo ::set-output name=buildx_args::--platform ${DOCKER_PLATFORMS} --memory-swap -1 \ --build-arg DOCKER_IMAGE_TAG=${{ github.event.inputs.dockerImageTag }} --build-arg BUILD_AT=$(TZ=Asia/Shanghai date +'%Y-%m-%dT%H:%M') --build-arg GITHUB_COMMIT=`git rev-parse --short HEAD` --no-cache \ ${DOCKER_IMAGE_TAGS} . - - name: Set up Docker Buildx - uses: crazy-max/ghaction-docker-buildx@v3 - name: Login to Aliyun Registry uses: docker/login-action@v2 with: @@ -77,6 +91,22 @@ jobs: uses: actions/checkout@v4 with: ref: ${{ github.ref_name }} + - name: Free Up GitHub Actions Ubuntu Runner Disk Space + uses: jlumbroso/free-disk-space@main + with: + # This might remove tools that are actually needed, if set to "true" but frees about 6 GB + tool-cache: false + + # All of these default to true, but feel free to set to "false" if necessary for your workflow + android: true + dotnet: true + haskell: true + large-packages: false + swap-storage: true + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - name: Prepare id: prepare run: | @@ -92,8 +122,6 @@ jobs: echo ::set-output name=buildx_args::--platform ${DOCKER_PLATFORMS} --memory-swap -1 \ --build-arg DOCKER_IMAGE_TAG=${{ github.event.inputs.dockerImageTag }} --build-arg BUILD_AT=$(TZ=Asia/Shanghai date +'%Y-%m-%dT%H:%M') --build-arg GITHUB_COMMIT=`git rev-parse --short HEAD` --no-cache \ ${DOCKER_IMAGE_TAGS} . - - name: Set up Docker Buildx - uses: crazy-max/ghaction-docker-buildx@v3 - name: Login to Docker Hub uses: docker/login-action@v3 with: @@ -101,4 +129,4 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Docker Buildx (build-and-push) run: | - docker buildx build --output "type=image,push=true" ${{ steps.prepare.outputs.buildx_args }} \ No newline at end of file + docker buildx build --output "type=image,push=true" ${{ steps.prepare.outputs.buildx_args }} diff --git a/.github/workflows/build_base_and_push.yml b/.github/workflows/build_base_and_push.yml new file mode 100644 index 000000000..77964dba4 --- /dev/null +++ b/.github/workflows/build_base_and_push.yml @@ -0,0 +1,104 @@ +name: build-base-and-push + +run-name: 构建镜像并推送仓库 ${{ github.event.inputs.dockerImageTag }} (${{ github.event.inputs.registry }}) (${{ github.event.inputs.architecture }}) + +on: + workflow_dispatch: + inputs: + dockerImageTag: + description: 'Image Tag' + default: 'v0.9.0' + required: true + dockerImageTagWithLatest: + description: '是否发布latest tag(正式发版时选择,测试版本切勿选择)' + default: false + required: true + type: boolean + architecture: + description: 'Architecture' + required: true + default: 'linux/amd64,linux/arm64' + type: choice + options: + - linux/amd64 + - linux/arm64 + - linux/amd64,linux/arm64 + registry: + description: 'Push To Registry' + required: true + default: 'aliyun-registry' + type: choice + options: + - aliyun-registry + - dockerhub + - dockerhub, aliyun-registry + +jobs: + build-and-push-to-aliyun-registry: + if: ${{ contains(github.event.inputs.registry, 'aliyun') }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + - name: Prepare + id: prepare + run: | + DOCKER_IMAGE=${{ secrets.ALIYUN_REGISTRY_HOST }}/dataease/sqlbot-python-pg + DOCKER_PLATFORMS=${{ github.event.inputs.architecture }} + TAG_NAME=${{ github.event.inputs.dockerImageTag }} + TAG_NAME_WITH_LATEST=${{ github.event.inputs.dockerImageTagWithLatest }} + if [[ ${TAG_NAME_WITH_LATEST} == 'true' ]]; then + DOCKER_IMAGE_TAGS="--tag ${DOCKER_IMAGE}:${TAG_NAME} --tag ${DOCKER_IMAGE}:${TAG_NAME%%.*} --tag ${DOCKER_IMAGE}:latest" + else + DOCKER_IMAGE_TAGS="--tag ${DOCKER_IMAGE}:${TAG_NAME}" + fi + echo ::set-output name=buildx_args::--platform ${DOCKER_PLATFORMS} --memory-swap -1 \ + --build-arg DOCKER_IMAGE_TAG=${{ github.event.inputs.dockerImageTag }} --build-arg BUILD_AT=$(TZ=Asia/Shanghai date +'%Y-%m-%dT%H:%M') --build-arg GITHUB_COMMIT=`git rev-parse --short HEAD` --no-cache \ + ${DOCKER_IMAGE_TAGS} . + - name: Set up Docker Buildx + uses: crazy-max/ghaction-docker-buildx@v3 + - name: Login to Aliyun Registry + uses: docker/login-action@v2 + with: + registry: ${{ secrets.ALIYUN_REGISTRY_HOST }} + username: ${{ secrets.ALIYUN_REGISTRY_USERNAME }} + password: ${{ secrets.ALIYUN_REGISTRY_PASSWORD }} + - name: Docker Buildx (build-and-push) + run: | + docker buildx build -f Dockerfile-base --output "type=image,push=true" ${{ steps.prepare.outputs.buildx_args }} + + build-and-push-to-dockerhub: + if: ${{ contains(github.event.inputs.registry, 'dockerhub') }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + - name: Prepare + id: prepare + run: | + DOCKER_IMAGE=dataease/sqlbot-python-pg + DOCKER_PLATFORMS=${{ github.event.inputs.architecture }} + TAG_NAME=${{ github.event.inputs.dockerImageTag }} + TAG_NAME_WITH_LATEST=${{ github.event.inputs.dockerImageTagWithLatest }} + if [[ ${TAG_NAME_WITH_LATEST} == 'true' ]]; then + DOCKER_IMAGE_TAGS="--tag ${DOCKER_IMAGE}:${TAG_NAME} --tag ${DOCKER_IMAGE}:${TAG_NAME%%.*} --tag ${DOCKER_IMAGE}:latest" + else + DOCKER_IMAGE_TAGS="--tag ${DOCKER_IMAGE}:${TAG_NAME}" + fi + echo ::set-output name=buildx_args::--platform ${DOCKER_PLATFORMS} --memory-swap -1 \ + --build-arg DOCKER_IMAGE_TAG=${{ github.event.inputs.dockerImageTag }} --build-arg BUILD_AT=$(TZ=Asia/Shanghai date +'%Y-%m-%dT%H:%M') --build-arg GITHUB_COMMIT=`git rev-parse --short HEAD` --no-cache \ + ${DOCKER_IMAGE_TAGS} . + - name: Set up Docker Buildx + uses: crazy-max/ghaction-docker-buildx@v3 + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Docker Buildx (build-and-push) + run: | + docker buildx build -f Dockerfile-base --output "type=image,push=true" ${{ steps.prepare.outputs.buildx_args }} diff --git a/.github/workflows/package_and_push.yml b/.github/workflows/package_and_push.yml index 3ec89e7c5..c430301b8 100644 --- a/.github/workflows/package_and_push.yml +++ b/.github/workflows/package_and_push.yml @@ -5,14 +5,31 @@ run-name: 构建离线安装包 ${{ github.event.inputs.dockerImageTag }} on: workflow_dispatch: inputs: + target_os: + description: 'Select OS to run on' + required: false + default: '["ubuntu-latest","ubuntu-24.04-arm"]' + type: choice + options: + - '["ubuntu-latest"]' + - '["ubuntu-24.04-arm"]' + - '["ubuntu-latest","ubuntu-24.04-arm"]' dockerImageTag: description: 'Image Tag' default: 'v0.9.5' required: true + packageEE: + description: '是否打包企业版' + default: false + required: true + type: boolean jobs: package-and-push-to-aliyun-oss: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: ${{ fromJSON(inputs.target_os) }} steps: - name: Checkout uses: actions/checkout@v4 @@ -20,8 +37,7 @@ jobs: ref: ${{ github.ref_name }} - name: Install ossutil run: | - curl -L https://gosspublic.alicdn.com/ossutil/1.7.0/ossutil64 -o /usr/local/bin/ossutil - chmod +x /usr/local/bin/ossutil + sudo -v ; curl https://gosspublic.alicdn.com/ossutil/install.sh | sudo bash - name: Prepare package files env: TAG_NAME: ${{ github.event.inputs.dockerImageTag }} @@ -31,7 +47,31 @@ jobs: ALIYUN_OSS_BUCKET_ENDPOINT: ${{ secrets.ALIYUN_OSS_BUCKET_ENDPOINT }} ALIYUN_OSS_ACCESS_KEY: ${{ secrets.ALIYUN_OSS_ACCESS_KEY }} ALIYUN_OSS_ACCESS_SECRET: ${{ secrets.ALIYUN_OSS_ACCESS_SECRET }} + PACKAGE_EE: ${{ github.event.inputs.packageEE }} run: | + platform="" + ARCH="x86_64" + OS_type="$(uname -m)" + case "$OS_type" in + x86_64|amd64) + ARCH='x86_64' + platform='' + ;; + aarch64|arm64) + ARCH='aarch64' + platform='-arm64' + ;; + *) + echo 'OS type not supported' + exit 2 + ;; + esac + + if [[ $ARCH == 'aarch64' ]] && [[ ${PACKAGE_EE} != 'true' ]]; then + echo 'No need to pack' + exit 2 + fi + DOCKER_IMAGE=${ALIYUN_REGISTRY_HOST}/dataease/sqlbot cd installer @@ -44,9 +84,7 @@ jobs: docker pull $image_path docker save $image_path | gzip > images/$image_name.tar.gz done - - platform="" - ARCH="x86_64" + DOCKER_VERSION="docker-27.2.0" DOCKER_COMPOSE_VERSION="v2.29.2" @@ -59,29 +97,46 @@ jobs: wget https://resource-fit2cloud-com.oss-cn-hangzhou.aliyuncs.com/docker/docker.service mkdir docker/service && mv docker.service docker/service/ - #打包离线包 - package_offline="sqlbot-offline-installer-${TAG_NAME}${platform}-ce.tar.gz" - touch $package_offline - tar --transform "s/^\./sqlbot-offline-installer-${TAG_NAME}${platform}-ce/" \ - --exclude $package_offline \ - --exclude .git \ - -czvf $package_offline . - - #打包在线包 - package_online="sqlbot-online-installer-${TAG_NAME}${platform}-ce.tar.gz" - touch $package_online - tar --transform "s/^\./sqlbot-online-installer-${TAG_NAME}${platform}-ce/" \ - --exclude $package_online \ - --exclude $package_offline \ - --exclude .git \ - --exclude images \ - --exclude docker \ - -czvf $package_online . + if [[ $ARCH == 'x86_64' ]]; then + #打包离线包 + package_offline="sqlbot-offline-installer-${TAG_NAME}${platform}-ce.tar.gz" + touch $package_offline + tar --transform "s/^\./sqlbot-offline-installer-${TAG_NAME}${platform}-ce/" \ + --exclude $package_offline \ + --exclude .git \ + -czvf $package_offline . + + #打包在线包 + package_online="sqlbot-online-installer-${TAG_NAME}${platform}-ce.tar.gz" + touch $package_online + tar --transform "s/^\./sqlbot-online-installer-${TAG_NAME}${platform}-ce/" \ + --exclude $package_online \ + --exclude $package_offline \ + --exclude .git \ + --exclude images \ + --exclude docker \ + -czvf $package_online . + fi - #Sync files to OSS - ossutil cp -rf ${package_offline} oss://$ALIYUN_OSS_BUCKET/sqlbot/${package_offline} --access-key-id=$ALIYUN_OSS_ACCESS_KEY --access-key-secret=$ALIYUN_OSS_ACCESS_SECRET --endpoint=${ALIYUN_OSS_BUCKET_ENDPOINT} - ossutil cp -rf ${package_online} oss://$ALIYUN_OSS_BUCKET/sqlbot/${package_online} --access-key-id=$ALIYUN_OSS_ACCESS_KEY --access-key-secret=$ALIYUN_OSS_ACCESS_SECRET --endpoint=${ALIYUN_OSS_BUCKET_ENDPOINT} + if [[ ${PACKAGE_EE} == 'true' ]]; then + package_offline_ee="sqlbot-offline-installer-${TAG_NAME}${platform}-ee.tar.gz" + touch $package_offline_ee + tar --transform "s/^\./sqlbot-offline-installer-${TAG_NAME}${platform}-ee/" \ + --exclude $package_online \ + --exclude $package_offline \ + --exclude $package_offline_ee \ + --exclude .git \ + -czvf $package_offline_ee . + + ossutil cp -rf ${package_offline_ee} oss://$ALIYUN_OSS_BUCKET/sqlbot/${package_offline_ee} --access-key-id=$ALIYUN_OSS_ACCESS_KEY --access-key-secret=$ALIYUN_OSS_ACCESS_SECRET --endpoint=${ALIYUN_OSS_BUCKET_ENDPOINT} + fi + + if [[ $ARCH == 'x86_64' ]]; then + #Sync files to OSS + ossutil cp -rf ${package_offline} oss://$ALIYUN_OSS_BUCKET/sqlbot/${package_offline} --access-key-id=$ALIYUN_OSS_ACCESS_KEY --access-key-secret=$ALIYUN_OSS_ACCESS_SECRET --endpoint=${ALIYUN_OSS_BUCKET_ENDPOINT} + ossutil cp -rf ${package_online} oss://$ALIYUN_OSS_BUCKET/sqlbot/${package_online} --access-key-id=$ALIYUN_OSS_ACCESS_KEY --access-key-secret=$ALIYUN_OSS_ACCESS_SECRET --endpoint=${ALIYUN_OSS_BUCKET_ENDPOINT} + fi + + - - diff --git a/.github/workflows/sync2gitee.yml b/.github/workflows/sync2gitee.yml new file mode 100644 index 000000000..78092f290 --- /dev/null +++ b/.github/workflows/sync2gitee.yml @@ -0,0 +1,15 @@ +name: Synchronize to Gitee +on: [push] +jobs: + repo-sync: + runs-on: ubuntu-latest + steps: + - name: Mirror the Github organization repos to Gitee. + uses: Yikun/hub-mirror-action@master + with: + src: 'github/dataease' + dst: 'gitee/fit2cloud-feizhiyun' + dst_key: ${{ secrets.GITEE_PRIVATE_KEY }} + dst_token: ${{ secrets.GITEE_TOKEN }} + static_list: "SQLBot" + force_update: true diff --git a/.github/workflows/sync_to_cnb.yml b/.github/workflows/sync_to_cnb.yml new file mode 100644 index 000000000..7be13f676 --- /dev/null +++ b/.github/workflows/sync_to_cnb.yml @@ -0,0 +1,23 @@ +name: Sync to CNB + +on: + workflow_dispatch: +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Sync to CNB Repository + run: | + docker run --rm \ + -v ${{ github.workspace }}:${{ github.workspace }} \ + -w ${{ github.workspace }} \ + -e PLUGIN_TARGET_URL="https://cnb.cool/dataease/sqlbot.git" \ + -e PLUGIN_AUTH_TYPE="https" \ + -e PLUGIN_USERNAME="cnb" \ + -e PLUGIN_PASSWORD=${{ secrets.CNB_PASSWORD }} \ + -e PLUGIN_FORCE=true \ + tencentcom/git-sync diff --git a/.github/workflows/xpack.yml b/.github/workflows/xpack.yml new file mode 100644 index 000000000..b60d5cf3c --- /dev/null +++ b/.github/workflows/xpack.yml @@ -0,0 +1,99 @@ +# .github/workflows/wheel.yml +name: Build Wheels Universal + +on: + workflow_dispatch: + inputs: + + target_os: + description: 'Select OS to run on' + required: false + default: '["ubuntu-latest","macos-latest","windows-latest","macos-15-intel","ubuntu-24.04-arm"]' + type: choice + options: + - '["macos-latest"]' + - '["macos-15-intel"]' + - '["ubuntu-latest"]' + - '["windows-latest"]' + - '["ubuntu-24.04-arm"]' + - '["ubuntu-latest","windows-latest"]' + - '["ubuntu-latest","macos-latest"]' + - '["macos-latest","windows-latest"]' + - '["ubuntu-latest","macos-latest","windows-latest"]' + - '["ubuntu-latest","macos-latest","windows-latest","macos-15-intel","ubuntu-24.04-arm"]' + publish: + description: 'Publish to repository' + required: false + default: false + type: boolean + repository: + description: 'Select repository to publish (Attention: Disable pypi selection until release)' + required: false + default: 'testpypi' + type: choice + options: + - testpypi + - pypi + xpack_branch: + description: 'SQLBot X-Pack Branch' + required: true + type: choice + default: "main" + options: + - main + +jobs: + build: + runs-on: ${{ matrix.os }} + environment: sqlbot-xpack-env + defaults: + run: + working-directory: ./sqlbot-xpack + strategy: + matrix: + os: ${{ fromJSON(inputs.target_os) }} + steps: + - uses: actions/checkout@v4 + - name: Check out SQLBot xpack repo + uses: actions/checkout@v4 + with: + repository: dataease/sqlbot-xpack + ref: ${{ inputs.xpack_branch }} + path: ./sqlbot-xpack + token: ${{ secrets.GH_TOKEN }} + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - name: Install and build + working-directory: ./sqlbot-xpack/xpack_static + run: | + npm install + npm run build:prod + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install build tools + run: | + python -m pip install --upgrade pip + pip install cython wheel twine + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v6 + with: + version: "latest" + - name: Install dependencies + run: uv sync + - name: Build encrypted + run: python build_scripts/build.py + - name: Pre build wheel + run: python ./build_scripts/format.py encrypted + - name: Build wheel + run: python -m pip wheel --no-deps -w dist . + - name: Finish build wheek + run: python ./build_scripts/format.py + - name: Show wheel + run: unzip -l dist/* + - name: Publish to repository + if: ${{ inputs.publish }} + run: twine upload --repository-url ${{ inputs.repository == 'pypi' && 'https://upload.pypi.org/legacy/' || 'https://test.pypi.org/legacy/' }} -u __token__ -p ${{ inputs.repository == 'pypi' && secrets.FORMAL_PYPI_API_TOKEN || secrets.PYPI_API_TOKEN }} dist/*.whl --verbose diff --git a/.gitignore b/.gitignore index fab542482..c53da76a2 100644 --- a/.gitignore +++ b/.gitignore @@ -189,3 +189,7 @@ test.py !/.venv/ + +sqlbot-xpack + +.claude diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..a45f1a63d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,30 @@ +# Contributing + +As a contributor, you should agree that: + +a. The producer can adjust the open-source agreement to be more strict or relaxed as deemed necessary. +b. Your contributed code may be used for commercial purposes, including but not limited to its cloud business operations. + +## Create pull request +PR are always welcome, even if they only contain small fixes like typos or a few lines of code. If there will be a significant effort, please document it as an issue and get a discussion going before starting to work on it. + +Please submit a PR broken down into small changes' bit by bit. A PR consisting of a lot of features and code changes may be hard to review. It is recommended to submit PRs in an incremental fashion. + +This [development guideline](https://sqlbot.org/docs/v1/installation/source_run/) contains information about repository structure, how to set up development environment, how to run it, and more. + +Note: If you split your pull request to small changes, please make sure any of the changes goes to master will not break anything. Otherwise, it can not be merged until this feature complete. + +## Report issues +It is a great way to contribute by reporting an issue. Well-written and complete bug reports are always welcome! Please open an issue and follow the template to fill in required information. + +Before opening any issue, please look up the existing issues to avoid submitting a duplication. +If you find a match, you can "subscribe" to it to get notified on updates. If you have additional helpful information about the issue, please leave a comment. + +When reporting issues, always include: + +* Which version you are using. +* Steps to reproduce the issue. +* Snapshots or log files if needed + +Because the issues are open to the public, when submitting files, be sure to remove any sensitive information, e.g. username, password, IP address, and company name. You can +replace those parts with "REDACTED" or other strings like "****". diff --git a/Dockerfile b/Dockerfile index cb6724dea..c8727b40a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,18 @@ # Build sqlbot -FROM registry.cn-qingdao.aliyuncs.com/dataease/sqlbot-base:latest AS sqlbot-builder +FROM ghcr.io/1panel-dev/maxkb-vector-model:v1.0.1 AS vector-model +FROM --platform=${BUILDPLATFORM} registry.cn-qingdao.aliyuncs.com/dataease/sqlbot-base:latest AS sqlbot-ui-builder +ENV SQLBOT_HOME=/opt/sqlbot +ENV APP_HOME=${SQLBOT_HOME}/app +ENV UI_HOME=${SQLBOT_HOME}/frontend +ENV DEBIAN_FRONTEND=noninteractive + +RUN mkdir -p ${APP_HOME} ${UI_HOME} + +COPY frontend /tmp/frontend +RUN cd /tmp/frontend && npm install && npm run build && mv dist ${UI_HOME}/dist + +FROM registry.cn-qingdao.aliyuncs.com/dataease/sqlbot-base:latest AS sqlbot-builder # Set build environment variables ENV PYTHONUNBUFFERED=1 ENV SQLBOT_HOME=/opt/sqlbot @@ -17,10 +29,7 @@ RUN mkdir -p ${APP_HOME} ${UI_HOME} WORKDIR ${APP_HOME} -COPY frontend /tmp/frontend - -RUN cd /tmp/frontend; npm install; npm run build; mv dist ${UI_HOME}/dist - +COPY --from=sqlbot-ui-builder ${UI_HOME} ${UI_HOME} # Install dependencies RUN test -f "./uv.lock" && \ --mount=type=cache,target=/root/.cache/uv \ @@ -32,20 +41,34 @@ COPY ./backend ${APP_HOME} # Final sync to ensure all dependencies are installed RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync + uv sync --extra cpu # Build g2-ssr FROM registry.cn-qingdao.aliyuncs.com/dataease/sqlbot-base:latest AS ssr-builder WORKDIR /app +# Install build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential python3 pkg-config \ + libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev \ + libpixman-1-dev libfreetype6-dev \ + && rm -rf /var/lib/apt/lists/* + +# configure npm +RUN npm config set fund false \ + && npm config set audit false \ + && npm config set progress false + COPY g2-ssr/app.js g2-ssr/package.json /app/ COPY g2-ssr/charts/* /app/charts/ - RUN npm install # Runtime stage -FROM registry.cn-qingdao.aliyuncs.com/dataease/sqlbot-base:latest +FROM registry.cn-qingdao.aliyuncs.com/dataease/sqlbot-python-pg:latest + +RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \ + echo "Asia/Shanghai" > /etc/timezone # Set runtime environment variables ENV PYTHONUNBUFFERED=1 @@ -53,20 +76,25 @@ ENV SQLBOT_HOME=/opt/sqlbot ENV PYTHONPATH=${SQLBOT_HOME}/app ENV PATH="${SQLBOT_HOME}/app/.venv/bin:$PATH" +ENV POSTGRES_DB=sqlbot +ENV POSTGRES_USER=root +ENV POSTGRES_PASSWORD=Password123@pg + # Copy necessary files from builder COPY start.sh /opt/sqlbot/app/start.sh COPY g2-ssr/*.ttf /usr/share/fonts/truetype/liberation/ COPY --from=sqlbot-builder ${SQLBOT_HOME} ${SQLBOT_HOME} COPY --from=ssr-builder /app /opt/sqlbot/g2-ssr +COPY --from=vector-model /opt/maxkb/app/model /opt/sqlbot/models WORKDIR ${SQLBOT_HOME}/app RUN mkdir -p /opt/sqlbot/images /opt/sqlbot/g2-ssr -EXPOSE 3000 8000 +EXPOSE 3000 8000 8001 5432 # Add health check HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:8000 || exit 1 + CMD python3 -c "import os, urllib.request; urllib.request.urlopen(f'http://localhost:8000/{os.environ.get(\"CONTEXT_PATH\", \"\")}', timeout=3)" || exit 1 -ENTRYPOINT ["sh", "start.sh"] \ No newline at end of file +ENTRYPOINT ["sh", "start.sh"] diff --git a/Dockerfile-base b/Dockerfile-base new file mode 100644 index 000000000..c78ab7c1b --- /dev/null +++ b/Dockerfile-base @@ -0,0 +1,58 @@ +FROM python:3.11-slim-bookworm AS python-builder +FROM registry.cn-qingdao.aliyuncs.com/dataease/postgres:17.6 + +# python environment +COPY --from=python-builder /usr/local /usr/local + +RUN python --version && pip --version + +# Install uv tool +COPY --from=ghcr.io/astral-sh/uv:0.7.8 /uv /uvx /bin/ + +RUN apt-get update && apt-get install -y --no-install-recommends \ + wait-for-it \ + build-essential \ + curl \ + gnupg \ + gcc \ + g++ \ + libcairo2-dev \ + libpango1.0-dev \ + libjpeg-dev \ + libgif-dev \ + librsvg2-dev \ + && curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \ + && curl -qL https://www.npmjs.com/install.sh | sh - \ + && apt-get install -y nodejs \ + && curl -L --connect-timeout 60 -m 1800 https://fit2cloud-support.oss-cn-beijing.aliyuncs.com/xpack-license/get-validator-linux | sh \ + && rm -rf /var/lib/apt/lists/* \ + && chmod g+xr /usr/bin/ld.so \ + && chmod g+x /usr/local/bin/python* + +# Download Oracle instant client +ENV DB_CLIENT=/opt/sqlbot/db_client +ENV LD_LIBRARY_PATH="${DB_CLIENT}/oracle_instant_client:${LD_LIBRARY_PATH}" +ARG TARGETARCH +RUN apt-get update && \ + apt-get install -y unzip libaio1 && \ + mkdir -p ${DB_CLIENT} && \ + if [ "$TARGETARCH" = "amd64" ]; then \ + echo "Building for x86_64"; \ + curl -L -o app.zip https://download.oracle.com/otn_software/linux/instantclient/2326000/instantclient-basic-linux.x64-23.26.0.0.0.zip; \ + elif [ "$TARGETARCH" = "arm64" ]; then \ + echo "Building for ARM64"; \ + curl -L -o app.zip https://download.oracle.com/otn_software/linux/instantclient/2390000/instantclient-basic-linux.arm64-23.9.0.25.07.zip; \ + fi && \ + unzip app.zip -d ${DB_CLIENT} && \ + rm app.zip && \ + rm -rf /var/lib/apt/lists/* && \ + mv ${DB_CLIENT}/instantclient* ${DB_CLIENT}/oracle_instant_client + +# Download DM library +ENV DM_HOME=/opt/dmdbms +ENV LD_LIBRARY_PATH=$DM_HOME/bin:$LD_LIBRARY_PATH +RUN curl -L -o dm.zip https://resource-fit2cloud-com.oss-cn-hangzhou.aliyuncs.com/sqlbot/dm/$TARGETARCH.zip && \ + mkdir -p $DM_HOME/bin && \ + unzip dm.zip -d $DM_HOME/bin && \ + chmod -R +x $DM_HOME/bin && \ + rm -rf dm.zip \ No newline at end of file diff --git a/README.md b/README.md index eadda3542..c5a258844 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,77 @@

SQLBot

基于大模型和 RAG 的智能问数系统

+ +

+ dataease%2FSQLBot | Trendshift +

+

Latest release Stars Download
+

+

+ 中文(简体) + English


-SQLBot 是一款基于大模型和 RAG 的智能问数系统。SQLBot 的优势包括: -- **开箱即用**: 只需配置大模型和数据源即可开启问数之旅,通过大模型和 RAG 的结合来实现高质量的 text2sql; -- **易于集成**: 支持快速嵌入到第三方业务系统,也支持被 n8n、MaxKB、Dify、Coze 等 AI 应用开发平台集成调用,让各类应用快速拥有智能问数能力; -- **安全可控**: 提供基于工作空间的资源隔离机制,能够实现细粒度的数据权限控制。 +SQLBot 是一款基于大语言模型和 RAG 的智能问数系统,由 DataEase 开源项目组匠心出品。借助 SQLBot,用户可以实现对话式数据分析(ChatBI),快速提炼获取所需的数据信息及可视化图表,并且支持进一步开展智能分析。 + +## 工作原理 + +image + +## 核心优势 + +- **开箱即用**:仅需简单配置大模型与数据源,无需复杂开发,即可快速开启智能问数;依托大模型自然语言理解与 SQL 生成能力,结合 RAG 技术,实现高质量 Text-to-SQL 转换。 +- **安全可控**:提供工作空间级资源隔离机制,构建清晰数据边界,保障数据访问安全;支持细粒度数据权限配置,强化权限管控能力,确保使用过程合规可控。 +- **易于集成**:支持多种集成方式,提供 Web 嵌入、弹窗嵌入、MCP 调用等能力;能够快速嵌入到 n8n、Dify、MaxKB、DataEase 等应用,让各类应用快速拥有智能问数能力。 +- **越问越准**:支持自定义提示词、术语库配置,可维护 SQL 示例校准逻辑,精准匹配业务场景;高效运营,基于用户交互数据持续迭代优化,问数效果随使用逐步提升,越问越准。 + +## 支持的大模型服务商 + +| 服务商 | API 兼容 | +|--------|----------| +| 阿里云百炼 | OpenAI 兼容 | +| 千帆大模型 | OpenAI 兼容 | +| DeepSeek | OpenAI 兼容 | +| 腾讯混元 | OpenAI 兼容 | +| 讯飞星火 | OpenAI 兼容 | +| Gemini | OpenAI 兼容 | +| OpenAI | 原生 | +| Kimi | OpenAI 兼容 | +| 腾讯云 | OpenAI 兼容 | +| 火山引擎 | OpenAI 兼容 | +| MiniMax | OpenAI 兼容 | +| 通用 OpenAI 兼容 | 自定义 | ## 快速开始 ### 安装部署 -准备一台 Linux 服务器,执行以下一键安装脚本。 -在运行 SQLBot 前,请确保已安装好 [Docker](https://docs.docker.com/get-docker/) 和 [Docker Compose](https://docs.docker.com/compose/install/)。 +准备一台 Linux 服务器,安装好 [Docker](https://docs.docker.com/get-docker/),执行以下一键安装脚本: ```bash -# 创建目录 -mkdir -p /opt/sqlbot -cd /opt/sqlbot - -# 下载 docker-compose.yaml -curl -o docker-compose.yaml https://raw.githubusercontent.com/dataease/SQLBot/main/docker-compose.yaml - -# 启动服务 -docker compose up -d +docker run -d \ + --name sqlbot \ + --restart unless-stopped \ + -p 8000:8000 \ + -p 8001:8001 \ + -v ./data/sqlbot/excel:/opt/sqlbot/data/excel \ + -v ./data/sqlbot/file:/opt/sqlbot/data/file \ + -v ./data/sqlbot/images:/opt/sqlbot/images \ + -v ./data/sqlbot/logs:/opt/sqlbot/app/logs \ + -v ./data/postgresql:/var/lib/postgresql/data \ + --privileged=true \ + dataease/sqlbot ``` -你也可以通过 [1Panel 应用商店](https://apps.fit2cloud.com/1panel) 快速部署 SQLBot; +你也可以通过 [1Panel 应用商店](https://apps.fit2cloud.com/1panel) 快速部署 SQLBot。 + +如果是内网环境,你可以通过 [离线安装包方式](https://community.fit2cloud.com/#/products/sqlbot/downloads) 部署 SQLBot。 ### 访问方式 @@ -45,8 +83,7 @@ docker compose up -d 如你有更多问题,可以加入我们的技术交流群与我们交流。 -contact_me_qr - +contact_me_qr ## UI 展示 @@ -64,9 +101,17 @@ docker compose up -d - [1Panel](https://github.com/1panel-dev/1panel/) - 现代化、开源的 Linux 服务器运维管理面板 - [MaxKB](https://github.com/1panel-dev/MaxKB/) - 强大易用的企业级智能体平台 - [JumpServer](https://github.com/jumpserver/jumpserver/) - 广受欢迎的开源堡垒机 +- [Cordys CRM](https://github.com/1Panel-dev/CordysCRM) - 新一代的开源 AI CRM 系统 - [Halo](https://github.com/halo-dev/halo/) - 强大易用的开源建站工具 - [MeterSphere](https://github.com/metersphere/metersphere/) - 新一代的开源持续测试工具 ## License 本仓库遵循 [FIT2CLOUD Open Source License](LICENSE) 开源协议,该许可证本质上是 GPLv3,但有一些额外的限制。 + +你可以基于 SQLBot 的源代码进行二次开发,但是需要遵守以下规定: + +- 不能替换和修改 SQLBot 的 Logo 和版权信息; +- 二次开发后的衍生作品必须遵守 GPL V3 的开源义务。 + +如需商业授权,请联系 support@fit2cloud.com 。 diff --git a/backend/alembic/env.py b/backend/alembic/env.py index db6e94130..19e19fce6 100755 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -24,9 +24,14 @@ # from apps.system.models.user import SQLModel # noqa # from apps.settings.models.setting_models import SQLModel -from apps.chat.models.chat_model import SQLModel +#from apps.chat.models.chat_model import SQLModel +from apps.terminology.models.terminology_model import SQLModel +from sqlbot_xpack.custom_prompt.models.custom_prompt_model import SQLModel +#from apps.data_training.models.data_training_model import SQLModel # from apps.dashboard.models.dashboard_model import SQLModel from common.core.config import settings # noqa +#from apps.datasource.models.datasource import SQLModel +from apps.system.models.system_model import SQLModel target_metadata = SQLModel.metadata diff --git a/backend/alembic/versions/034_field_sort.py b/backend/alembic/versions/034_field_sort.py new file mode 100644 index 000000000..de98161e1 --- /dev/null +++ b/backend/alembic/versions/034_field_sort.py @@ -0,0 +1,29 @@ +"""034_field_sort + +Revision ID: e8b470d2b150 +Revises: 3cb5d6a54f2e +Create Date: 2025-08-08 11:33:33.227564 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'e8b470d2b150' +down_revision = '3cb5d6a54f2e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('core_field', sa.Column('field_index', sa.BigInteger(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('core_field', 'field_index') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/035_sys_arg_ddl.py b/backend/alembic/versions/035_sys_arg_ddl.py new file mode 100644 index 000000000..65ba04d06 --- /dev/null +++ b/backend/alembic/versions/035_sys_arg_ddl.py @@ -0,0 +1,34 @@ +"""035_sys_arg_ddl + +Revision ID: 29559ee607af +Revises: e8b470d2b150 +Create Date: 2025-08-15 11:43:26.175792 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = '29559ee607af' +down_revision = 'e8b470d2b150' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'sys_arg', + sa.Column('id', sa.BigInteger(), primary_key=True, nullable=False, comment='ID'), + sa.Column('pkey', sa.String(255), nullable=False, comment='pkey'), + sa.Column('pval', sa.String(255), nullable=True, comment='pval'), + sa.Column('ptype', sa.String(255), nullable=False, server_default='str', comment='str or file'), + sa.Column('sort_no', sa.Integer(), nullable=False, server_default='1', comment='sort_no') + ) + op.create_index(op.f('ix_sys_arg_id'), 'sys_arg', ['id'], unique=False) + + +def downgrade(): + op.drop_index(op.f('ix_sys_arg_id'), table_name='sys_arg') + op.drop_table('sys_arg') diff --git a/backend/alembic/versions/036_modify_assistant.py b/backend/alembic/versions/036_modify_assistant.py new file mode 100644 index 000000000..bb6d1a3a1 --- /dev/null +++ b/backend/alembic/versions/036_modify_assistant.py @@ -0,0 +1,27 @@ +"""036_modify_assistant + +Revision ID: 646e7ca28e0e +Revises: 29559ee607af +Create Date: 2025-08-18 16:12:46.041413 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = '646e7ca28e0e' +down_revision = '29559ee607af' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('sys_assistant', sa.Column('app_id', sa.String(255), nullable=True, comment='app_id')) + op.add_column('sys_assistant', sa.Column('app_secret', sa.String(255), nullable=True, comment='app_secret')) + + +def downgrade(): + op.drop_column('sys_assistant', 'app_id') + op.drop_column('sys_assistant', 'app_secret') diff --git a/backend/alembic/versions/037_create_chat_log.py b/backend/alembic/versions/037_create_chat_log.py new file mode 100644 index 000000000..59a7f4258 --- /dev/null +++ b/backend/alembic/versions/037_create_chat_log.py @@ -0,0 +1,131 @@ +"""035_create_chat_log + +Revision ID: 68a06302cf70 +Revises: 29559ee607af +Create Date: 2025-08-18 16:02:43.353110 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '68a06302cf70' +down_revision = '646e7ca28e0e' +branch_labels = None +depends_on = None + + +sql=''' +CREATE OR REPLACE FUNCTION safe_jsonb_cast(text) RETURNS jsonb AS +$$ +BEGIN + RETURN $1::jsonb; +EXCEPTION + WHEN others THEN + RETURN to_json($1::text)::jsonb; +END; +$$ LANGUAGE plpgsql; + +INSERT INTO chat_log(type, operate, pid, ai_modal_id, messages, start_time, finish_time, token_usage, reasoning_content) +SELECT '0', + '0', + id, + ai_modal_id, + safe_jsonb_cast(full_sql_message), + create_time, + finish_time, + safe_jsonb_cast(token_sql), + safe_jsonb_cast(sql_answer)->>'reasoning_content' +FROM chat_record +WHERE full_sql_message IS NOT NULL; +INSERT INTO chat_log(type, operate, pid, ai_modal_id, messages, start_time, finish_time, token_usage, reasoning_content) +SELECT '0', + '1', + id, + ai_modal_id, + safe_jsonb_cast(full_chart_message), + create_time, + finish_time, + safe_jsonb_cast(token_chart), + safe_jsonb_cast(chart_answer)->>'reasoning_content' +FROM chat_record +WHERE full_chart_message IS NOT NULL; +INSERT INTO chat_log(type, operate, pid, ai_modal_id, messages, start_time, finish_time, token_usage, reasoning_content) +SELECT '0', + '2', + id, + ai_modal_id, + safe_jsonb_cast(full_analysis_message), + create_time, + finish_time, + safe_jsonb_cast(token_analysis), + safe_jsonb_cast(analysis)->>'reasoning_content' +FROM chat_record +WHERE full_analysis_message IS NOT NULL; +INSERT INTO chat_log(type, operate, pid, ai_modal_id, messages, start_time, finish_time, token_usage, reasoning_content) +SELECT '0', + '3', + id, + ai_modal_id, + safe_jsonb_cast(full_predict_message), + create_time, + finish_time, + safe_jsonb_cast(token_predict), + safe_jsonb_cast(predict)->>'reasoning_content' +FROM chat_record +WHERE full_predict_message IS NOT NULL; +INSERT INTO chat_log(type, operate, pid, ai_modal_id, messages, start_time, finish_time, token_usage, reasoning_content) +SELECT '0', + '4', + id, + ai_modal_id, + safe_jsonb_cast(full_recommended_question_message), + create_time, + finish_time, + safe_jsonb_cast(token_recommended_question), + safe_jsonb_cast(recommended_question_answer)->>'reasoning_content' +FROM chat_record +WHERE full_recommended_question_message IS NOT NULL; +INSERT INTO chat_log(type, operate, pid, ai_modal_id, messages, start_time, finish_time, token_usage, reasoning_content) +SELECT '0', + '6', + id, + ai_modal_id, + safe_jsonb_cast(full_select_datasource_message), + create_time, + finish_time, + safe_jsonb_cast(token_select_datasource_question), + safe_jsonb_cast(datasource_select_answer)->>'reasoning_content' +FROM chat_record +WHERE full_select_datasource_message IS NOT NULL; + +''' + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('chat_log', + sa.Column('id', sa.BigInteger(), sa.Identity(always=True), nullable=False), + sa.Column('type', sa.Enum('0', name='typeenum', native_enum=False, length=3), nullable=True), + sa.Column('operate', sa.Enum('0', '1', '2', '3', '4', '5', '6', name='operationenum', native_enum=False, length=3), nullable=True), + sa.Column('pid', sa.BigInteger(), nullable=True), + sa.Column('ai_modal_id', sa.BigInteger(), nullable=True), + sa.Column('base_modal', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('messages', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('reasoning_content', sa.Text(), nullable=True), + sa.Column('start_time', sa.DateTime(), nullable=True), + sa.Column('finish_time', sa.DateTime(), nullable=True), + sa.Column('token_usage', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + + op.execute(sql) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + + op.drop_table('chat_log') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/038_remove_chat_record_cloumns.py b/backend/alembic/versions/038_remove_chat_record_cloumns.py new file mode 100644 index 000000000..870c54777 --- /dev/null +++ b/backend/alembic/versions/038_remove_chat_record_cloumns.py @@ -0,0 +1,51 @@ +"""038_remove_chat_record_cloumns + +Revision ID: fc23c4f3e755 +Revises: 68a06302cf70 +Create Date: 2025-08-21 14:34:59.149410 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'fc23c4f3e755' +down_revision = '68a06302cf70' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('chat_record', 'token_predict') + op.drop_column('chat_record', 'token_select_datasource_question') + op.drop_column('chat_record', 'token_sql') + op.drop_column('chat_record', 'full_analysis_message') + op.drop_column('chat_record', 'full_recommended_question_message') + op.drop_column('chat_record', 'token_chart') + op.drop_column('chat_record', 'full_predict_message') + op.drop_column('chat_record', 'full_chart_message') + op.drop_column('chat_record', 'full_sql_message') + op.drop_column('chat_record', 'full_select_datasource_message') + op.drop_column('chat_record', 'token_recommended_question') + op.drop_column('chat_record', 'token_analysis') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('chat_record', sa.Column('token_analysis', sa.VARCHAR(length=256), autoincrement=False, nullable=True)) + op.add_column('chat_record', sa.Column('token_recommended_question', sa.VARCHAR(length=256), autoincrement=False, nullable=True)) + op.add_column('chat_record', sa.Column('full_select_datasource_message', sa.TEXT(), autoincrement=False, nullable=True)) + op.add_column('chat_record', sa.Column('full_sql_message', sa.TEXT(), autoincrement=False, nullable=True)) + op.add_column('chat_record', sa.Column('full_chart_message', sa.TEXT(), autoincrement=False, nullable=True)) + op.add_column('chat_record', sa.Column('full_predict_message', sa.TEXT(), autoincrement=False, nullable=True)) + op.add_column('chat_record', sa.Column('token_chart', sa.VARCHAR(length=256), autoincrement=False, nullable=True)) + op.add_column('chat_record', sa.Column('full_recommended_question_message', sa.TEXT(), autoincrement=False, nullable=True)) + op.add_column('chat_record', sa.Column('full_analysis_message', sa.TEXT(), autoincrement=False, nullable=True)) + op.add_column('chat_record', sa.Column('token_sql', sa.VARCHAR(length=256), autoincrement=False, nullable=True)) + op.add_column('chat_record', sa.Column('token_select_datasource_question', sa.VARCHAR(length=256), autoincrement=False, nullable=True)) + op.add_column('chat_record', sa.Column('token_predict', sa.VARCHAR(length=256), autoincrement=False, nullable=True)) + # ### end Alembic commands ### diff --git a/backend/alembic/versions/039_create_terminology.py b/backend/alembic/versions/039_create_terminology.py new file mode 100644 index 000000000..42ab588b5 --- /dev/null +++ b/backend/alembic/versions/039_create_terminology.py @@ -0,0 +1,41 @@ +"""039_create_terminology + +Revision ID: 25cbc85766fd +Revises: fc23c4f3e755 +Create Date: 2025-08-25 11:38:32.990973 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +import pgvector +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '25cbc85766fd' +down_revision = 'fc23c4f3e755' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.execute("CREATE EXTENSION IF NOT EXISTS vector;") + + op.create_table('terminology', + sa.Column('id', sa.BigInteger(), sa.Identity(always=True), nullable=False), + sa.Column('pid', sa.BigInteger(), nullable=True), + sa.Column('create_time', sa.DateTime(), nullable=True), + sa.Column('word', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('embedding', pgvector.sqlalchemy.vector.VECTOR(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('terminology') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/040_modify_ai_model.py b/backend/alembic/versions/040_modify_ai_model.py new file mode 100644 index 000000000..dc924881e --- /dev/null +++ b/backend/alembic/versions/040_modify_ai_model.py @@ -0,0 +1,51 @@ +"""040_modify_ai_model + +Revision ID: 0fc14c2cfe41 +Revises: 25cbc85766fd +Create Date: 2025-08-26 23:30:50.192799 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = '0fc14c2cfe41' +down_revision = '25cbc85766fd' +branch_labels = None +depends_on = None + + +def upgrade(): + op.alter_column( + 'ai_model', + 'api_key', + type_=sa.Text(), + existing_type=sa.String(length=255), + existing_nullable=True + ) + op.alter_column( + 'ai_model', + 'api_domain', + type_=sa.Text(), + existing_type=sa.String(length=255), + existing_nullable=False + ) + + +def downgrade(): + op.alter_column( + 'ai_model', + 'api_key', + type_=sa.String(), + existing_type=sa.Text(), + existing_nullable=True + ) + op.alter_column( + 'ai_model', + 'api_domain', + type_=sa.String(), + existing_type=sa.Text(), + existing_nullable=False + ) diff --git a/backend/alembic/versions/041_add_terminology_oid.py b/backend/alembic/versions/041_add_terminology_oid.py new file mode 100644 index 000000000..1b4d783c6 --- /dev/null +++ b/backend/alembic/versions/041_add_terminology_oid.py @@ -0,0 +1,33 @@ +"""041_add_terminology_oid + +Revision ID: c4c3c36b720d +Revises: 0fc14c2cfe41 +Create Date: 2025-08-28 16:41:33.977242 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'c4c3c36b720d' +down_revision = '0fc14c2cfe41' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + + op.add_column('terminology', sa.Column('oid', sa.BigInteger(), nullable=True)) + + op.execute('update terminology set oid=1 where oid is null') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('terminology', 'oid') + + # ### end Alembic commands ### diff --git a/backend/alembic/versions/042_data_training.py b/backend/alembic/versions/042_data_training.py new file mode 100644 index 000000000..ec44c89fb --- /dev/null +++ b/backend/alembic/versions/042_data_training.py @@ -0,0 +1,41 @@ +"""042_data_training + +Revision ID: a487d9c69341 +Revises: c4c3c36b720d +Create Date: 2025-09-15 15:41:43.332771 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql +import pgvector + +# revision identifiers, used by Alembic. +revision = 'a487d9c69341' +down_revision = 'c4c3c36b720d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('data_training', + sa.Column('id', sa.BigInteger(), sa.Identity(always=True), nullable=False), + sa.Column('oid', sa.BigInteger(), nullable=True), + sa.Column('datasource', sa.BigInteger(), nullable=True), + sa.Column('create_time', sa.DateTime(), nullable=True), + sa.Column('question', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('embedding', pgvector.sqlalchemy.vector.VECTOR(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + + op.drop_table('data_training') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/043_modify_ds_id_type.py b/backend/alembic/versions/043_modify_ds_id_type.py new file mode 100644 index 000000000..4ef303a06 --- /dev/null +++ b/backend/alembic/versions/043_modify_ds_id_type.py @@ -0,0 +1,55 @@ +"""043_modify_ds_id_type + +Revision ID: dac062c1f7b1 +Revises: a487d9c69341 +Create Date: 2025-09-22 17:20:44.465735 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = 'dac062c1f7b1' +down_revision = 'a487d9c69341' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('core_datasource', 'id', + existing_type=sa.INTEGER(), + type_=sa.BigInteger(), + existing_nullable=False, + autoincrement=True) + op.alter_column('core_field', 'id', + existing_type=sa.INTEGER(), + type_=sa.BigInteger(), + existing_nullable=False, + autoincrement=True) + op.alter_column('core_table', 'id', + existing_type=sa.INTEGER(), + type_=sa.BigInteger(), + existing_nullable=False, + autoincrement=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('core_table', 'id', + existing_type=sa.BigInteger(), + type_=sa.INTEGER(), + existing_nullable=False, + autoincrement=True) + op.alter_column('core_field', 'id', + existing_type=sa.BigInteger(), + type_=sa.INTEGER(), + existing_nullable=False, + autoincrement=True) + op.alter_column('core_datasource', 'id', + existing_type=sa.BigInteger(), + type_=sa.INTEGER(), + existing_nullable=False, + autoincrement=True) + # ### end Alembic commands ### diff --git a/backend/alembic/versions/044_table_relation.py b/backend/alembic/versions/044_table_relation.py new file mode 100644 index 000000000..9f655ed49 --- /dev/null +++ b/backend/alembic/versions/044_table_relation.py @@ -0,0 +1,29 @@ +"""044_table_relation + +Revision ID: 455b8ce69e80 +Revises: dac062c1f7b1 +Create Date: 2025-09-24 13:34:08.205659 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '455b8ce69e80' +down_revision = 'dac062c1f7b1' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('core_datasource', sa.Column('table_relation', postgresql.JSONB(astext_type=sa.Text()), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('core_datasource', 'table_relation') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/045_modify_terminolog.py b/backend/alembic/versions/045_modify_terminolog.py new file mode 100644 index 000000000..a452bb6c6 --- /dev/null +++ b/backend/alembic/versions/045_modify_terminolog.py @@ -0,0 +1,31 @@ +"""045_modify_terminolog + +Revision ID: 45e7e52bf2b8 +Revises: 455b8ce69e80 +Create Date: 2025-09-25 14:49:24.521795 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '45e7e52bf2b8' +down_revision = '455b8ce69e80' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('terminology', sa.Column('specific_ds', sa.Boolean(), nullable=True)) + op.add_column('terminology', sa.Column('datasource_ids', postgresql.JSONB(astext_type=sa.Text()), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('terminology', 'datasource_ids') + op.drop_column('terminology', 'specific_ds') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/046_add_custom_prompt.py b/backend/alembic/versions/046_add_custom_prompt.py new file mode 100644 index 000000000..692c0f3f5 --- /dev/null +++ b/backend/alembic/versions/046_add_custom_prompt.py @@ -0,0 +1,39 @@ +"""046_add_custom_prompt + +Revision ID: 8855aea2dd61 +Revises: 45e7e52bf2b8 +Create Date: 2025-09-28 13:57:01.509249 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '8855aea2dd61' +down_revision = '45e7e52bf2b8' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('custom_prompt', + sa.Column('id', sa.BigInteger(), sa.Identity(always=True), nullable=False), + sa.Column('oid', sa.BigInteger(), nullable=True), + sa.Column('type', sa.Enum('GENERATE_SQL', 'ANALYSIS', 'PREDICT_DATA', name='customprompttypeenum', native_enum=False, length=20), nullable=True), + sa.Column('create_time', sa.DateTime(), nullable=True), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('prompt', sa.Text(), nullable=True), + sa.Column('specific_ds', sa.Boolean(), nullable=True), + sa.Column('datasource_ids', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('custom_prompt') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/047_table_embedding.py b/backend/alembic/versions/047_table_embedding.py new file mode 100644 index 000000000..ba6b89604 --- /dev/null +++ b/backend/alembic/versions/047_table_embedding.py @@ -0,0 +1,31 @@ +"""047_table_embedding + +Revision ID: c1b794a961ce +Revises: 8855aea2dd61 +Create Date: 2025-10-09 11:32:10.578313 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'c1b794a961ce' +down_revision = '8855aea2dd61' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('core_table', sa.Column('embedding', sa.Text(), nullable=True)) + op.add_column('core_datasource', sa.Column('embedding', sa.Text(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('core_table', 'embedding') + op.drop_column('core_datasource', 'embedding') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/048_authentication_ddl.py b/backend/alembic/versions/048_authentication_ddl.py new file mode 100644 index 000000000..fe7e70426 --- /dev/null +++ b/backend/alembic/versions/048_authentication_ddl.py @@ -0,0 +1,40 @@ +"""048_authentication_ddl + +Revision ID: 073bf544b373 +Revises: c1b794a961ce +Create Date: 2025-10-30 14:11:29.786938 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + +# revision identifiers, used by Alembic. +revision = '073bf544b373' +down_revision = 'c1b794a961ce' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('sys_authentication', + sa.Column('id', sa.BigInteger(), nullable=False), + sa.Column('name', sa.String(255), nullable=False), + sa.Column('type', sa.Integer(), nullable=False), + sa.Column('config', sa.Text(), nullable=True), + sa.Column('enable', sa.Boolean(), nullable=False), + sa.Column('valid', sa.Boolean(), nullable=False), + sa.Column('create_time', sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_sys_authentication_id'), 'sys_authentication', ['id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_sys_authentication_id'), table_name='sys_authentication') + op.drop_table('sys_authentication') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/049_user_platform_ddl.py b/backend/alembic/versions/049_user_platform_ddl.py new file mode 100644 index 000000000..0e5ed35a4 --- /dev/null +++ b/backend/alembic/versions/049_user_platform_ddl.py @@ -0,0 +1,42 @@ +"""049_user_platform_ddl + +Revision ID: b58a71ca6ae3 +Revises: 073bf544b373 +Create Date: 2025-11-04 12:31:56.481582 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + +# revision identifiers, used by Alembic. +revision = 'b58a71ca6ae3' +down_revision = '073bf544b373' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('sys_user_platform', + sa.Column('uid', sa.BigInteger(), nullable=False), + sa.Column('origin', sa.Integer(), server_default='0', nullable=False), + sa.Column('platform_uid', sa.String(255), nullable=False), + sa.Column('id', sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_sys_user_platform_id'), 'sys_user_platform', ['id'], unique=False) + + op.add_column('sys_user', sa.Column('origin', sa.Integer(), server_default='0', nullable=False)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + + op.drop_column('sys_user', 'origin') + + op.drop_index(op.f('ix_sys_user_platform_id'), table_name='sys_user_platform') + op.drop_table('sys_user_platform') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/050_modify_ddl_py.py b/backend/alembic/versions/050_modify_ddl_py.py new file mode 100644 index 000000000..cb312439c --- /dev/null +++ b/backend/alembic/versions/050_modify_ddl_py.py @@ -0,0 +1,37 @@ +"""050_modify_ddl.py + +Revision ID: 2785e54dc1c4 +Revises: b58a71ca6ae3 +Create Date: 2025-11-06 13:43:50.820328 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '2785e54dc1c4' +down_revision = 'b58a71ca6ae3' +branch_labels = None +depends_on = None + +sql=''' +UPDATE data_training SET enabled = true; +UPDATE terminology SET enabled = true; +''' + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('data_training', sa.Column('enabled', sa.Boolean(), nullable=True)) + op.add_column('terminology', sa.Column('enabled', sa.Boolean(), nullable=True)) + + op.execute(sql) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('terminology', 'enabled') + op.drop_column('data_training', 'enabled') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/051_modify_data_training_ddl.py b/backend/alembic/versions/051_modify_data_training_ddl.py new file mode 100644 index 000000000..3615ed751 --- /dev/null +++ b/backend/alembic/versions/051_modify_data_training_ddl.py @@ -0,0 +1,29 @@ +"""051_modify_data_training_ddl + +Revision ID: cb12c4238120 +Revises: 2785e54dc1c4 +Create Date: 2025-11-10 16:11:29.753516 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'cb12c4238120' +down_revision = '2785e54dc1c4' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('data_training', sa.Column('advanced_application', sa.BigInteger(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('data_training', 'advanced_application') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/052_add_recommended_problem.py b/backend/alembic/versions/052_add_recommended_problem.py new file mode 100644 index 000000000..9718a7c5d --- /dev/null +++ b/backend/alembic/versions/052_add_recommended_problem.py @@ -0,0 +1,38 @@ +"""empty message + +Revision ID: e408f8766753 +Revises: cb12c4238120 +Create Date: 2025-11-24 17:34:04.436927 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'e408f8766753' +down_revision = 'cb12c4238120' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table('ds_recommended_problem', + sa.Column('id', sa.BIGINT(), + sa.Identity(always=True, start=1, increment=1, minvalue=1, maxvalue=9999999999, + cycle=False, cache=1), autoincrement=True, nullable=False), + sa.Column('datasource_id', sa.BIGINT(), autoincrement=False, nullable=True), + sa.Column('question', sa.TEXT(), autoincrement=False, nullable=True), + sa.Column('remark', sa.TEXT(), autoincrement=False, nullable=True), + sa.Column('sort', sa.BIGINT(), autoincrement=False, nullable=True), + sa.Column('create_time', postgresql.TIMESTAMP(precision=6), autoincrement=False, nullable=True), + sa.Column('create_by', sa.BIGINT(), autoincrement=False, nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('ds_recommended_problem_pkey')) + ) + op.add_column('core_datasource', sa.Column('recommended_config', sa.BigInteger(),default=0, nullable=True)) + +def downgrade(): + op.drop_table('ds_recommended_problem') + op.drop_column('core_datasource', 'recommended_config') + diff --git a/backend/alembic/versions/053_update_chat.py b/backend/alembic/versions/053_update_chat.py new file mode 100644 index 000000000..b99887444 --- /dev/null +++ b/backend/alembic/versions/053_update_chat.py @@ -0,0 +1,29 @@ +"""empty message + +Revision ID: 5755c0b95839 +Revises: e408f8766753 +Create Date: 2025-12-02 13:46:06.905576 + +""" +from alembic import op +import sqlalchemy as sa + +revision = '5755c0b95839' +down_revision = 'e408f8766753' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('chat', sa.Column('brief_generate', sa.Boolean(), nullable=True)) + op.execute("UPDATE chat SET brief_generate = true WHERE brief_generate IS NULL") + with op.batch_alter_table('chat') as batch_op: + batch_op.alter_column('brief_generate', + server_default=sa.text('false'), + nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + op.drop_column('chat', 'brief_generate') + # ### end Alembic commands ### \ No newline at end of file diff --git a/backend/alembic/versions/054_update_chat_record_dll.py b/backend/alembic/versions/054_update_chat_record_dll.py new file mode 100644 index 000000000..76647f541 --- /dev/null +++ b/backend/alembic/versions/054_update_chat_record_dll.py @@ -0,0 +1,29 @@ +"""054_update_chat_record_dll + +Revision ID: 24e961f6326b +Revises: 5755c0b95839 +Create Date: 2025-12-04 15:51:42.900778 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '24e961f6326b' +down_revision = '5755c0b95839' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('chat_record', sa.Column('regenerate_record_id', sa.BigInteger(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('chat_record', 'regenerate_record_id') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/055_add_system_logs.py b/backend/alembic/versions/055_add_system_logs.py new file mode 100644 index 000000000..184ae3043 --- /dev/null +++ b/backend/alembic/versions/055_add_system_logs.py @@ -0,0 +1,43 @@ +"""empty message + +Revision ID: 3d4bd2d673dc +Revises: 24e961f6326b +Create Date: 2025-12-19 13:30:54.743171 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '3d4bd2d673dc' +down_revision = '24e961f6326b' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table('sys_logs', + sa.Column('id', sa.BIGINT(), autoincrement=True, nullable=False), + sa.Column('operation_type', sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column('operation_detail', sa.TEXT(), autoincrement=False, nullable=True), + sa.Column('user_id', sa.BIGINT(), autoincrement=False, nullable=True), + sa.Column('operation_status', sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column('oid', sa.BIGINT(), autoincrement=False, nullable=True), + sa.Column('ip_address', sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column('user_agent', sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column('execution_time', sa.BIGINT(), autoincrement=False, nullable=True), + sa.Column('error_message', sa.TEXT(), autoincrement=False, nullable=True), + sa.Column('create_time', postgresql.TIMESTAMP(), autoincrement=False, nullable=True), + sa.Column('module', sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column('remark', sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column('resource_id',sa.TEXT(), autoincrement=False, nullable=True), + sa.Column('request_method', sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column('request_path', sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('sys_logs_pkey')) + ) + + +def downgrade(): + op.drop_table('sys_logs') diff --git a/backend/alembic/versions/056_api_key_ddl.py b/backend/alembic/versions/056_api_key_ddl.py new file mode 100644 index 000000000..e2657927d --- /dev/null +++ b/backend/alembic/versions/056_api_key_ddl.py @@ -0,0 +1,39 @@ +"""056_api_key_ddl + +Revision ID: d9a5589fc00b +Revises: 3d4bd2d673dc +Create Date: 2025-12-23 13:41:26.705947 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'd9a5589fc00b' +down_revision = '3d4bd2d673dc' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('sys_apikey', + sa.Column('access_key', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('secret_key', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('create_time', sa.BigInteger(), nullable=False), + sa.Column('uid', sa.BigInteger(), nullable=False), + sa.Column('status', sa.Boolean(), nullable=False), + sa.Column('id', sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_sys_apikey_id'), 'sys_apikey', ['id'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_sys_apikey_id'), table_name='sys_apikey') + op.drop_table('sys_apikey') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/057_update_sys_log.py b/backend/alembic/versions/057_update_sys_log.py new file mode 100644 index 000000000..db088ab33 --- /dev/null +++ b/backend/alembic/versions/057_update_sys_log.py @@ -0,0 +1,27 @@ +"""empty message + +Revision ID: c431a0bf478b +Revises: d9a5589fc00b +Create Date: 2025-12-25 12:50:59.790439 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'c431a0bf478b' +down_revision = 'd9a5589fc00b' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('sys_logs', sa.Column('user_name', sa.VARCHAR(length=255), autoincrement=False, nullable=True)) + op.add_column('sys_logs', sa.Column('resource_name', sa.TEXT(), autoincrement=False, nullable=True)) + +def downgrade(): + op.drop_column('sys_logs', 'user_name') + op.drop_column('sys_logs', 'resource_name') + diff --git a/backend/alembic/versions/058_update_chat.py b/backend/alembic/versions/058_update_chat.py new file mode 100644 index 000000000..72dd17b9d --- /dev/null +++ b/backend/alembic/versions/058_update_chat.py @@ -0,0 +1,31 @@ +"""empty message + +Revision ID: fb2e8dd19158 +Revises: c431a0bf478b +Create Date: 2025-12-29 17:18:49.072320 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'fb2e8dd19158' +down_revision = 'c431a0bf478b' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('chat', sa.Column('recommended_question_answer', sa.TEXT(), nullable=True)) + op.add_column('chat', sa.Column('recommended_question', sa.TEXT(), nullable=True)) + op.add_column('chat', sa.Column('recommended_generate', sa.Boolean(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + op.drop_column('chat', 'recommended_question_answer') + op.drop_column('chat', 'recommended_question') + op.drop_column('chat', 'recommended_generate') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/059_chat_log_resource.py b/backend/alembic/versions/059_chat_log_resource.py new file mode 100644 index 000000000..8d4a40f11 --- /dev/null +++ b/backend/alembic/versions/059_chat_log_resource.py @@ -0,0 +1,35 @@ +"""empty message + +Revision ID: db1a95567cbb +Revises: fb2e8dd19158 +Create Date: 2025-12-30 12:29:14.290394 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'db1a95567cbb' +down_revision = 'fb2e8dd19158' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table('sys_logs_resource', + sa.Column('id', sa.BIGINT(), + sa.Identity(always=True, start=1, increment=1, minvalue=1, maxvalue=999999999999999, + cycle=False, cache=1), autoincrement=True, nullable=False), + sa.Column('resource_id', sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column('module', sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column('resource_name', sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column('log_id', sa.BIGINT(), autoincrement=False, nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('sys_logs_resource_pkey')) + ) + # ### end Alembic commands ### +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('sys_logs_resource') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/060_platform_token_ddl.py b/backend/alembic/versions/060_platform_token_ddl.py new file mode 100644 index 000000000..3def2d331 --- /dev/null +++ b/backend/alembic/versions/060_platform_token_ddl.py @@ -0,0 +1,37 @@ +"""060_platform_token_ddl + +Revision ID: b40e41c67db3 +Revises: db1a95567cbb +Create Date: 2026-01-04 15:50:31.550287 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'b40e41c67db3' +down_revision = 'db1a95567cbb' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('sys_platform_token', + sa.Column('id', sa.BigInteger(), nullable=False), + sa.Column('token', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('create_time', sa.BigInteger(), nullable=False), + sa.Column('exp_time', sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_sys_platform_token_id'), 'sys_platform_token', ['id'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_sys_platform_token_id'), table_name='sys_platform_token') + op.drop_table('sys_platform_token') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/061_assistant_oid_ddl.py b/backend/alembic/versions/061_assistant_oid_ddl.py new file mode 100644 index 000000000..d4bdb4b5b --- /dev/null +++ b/backend/alembic/versions/061_assistant_oid_ddl.py @@ -0,0 +1,50 @@ +"""061_assistant_oid_ddl + +Revision ID: 547df942eb90 +Revises: b40e41c67db3 +Create Date: 2026-01-09 15:02:19.891766 + +""" +import json +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '547df942eb90' +down_revision = 'b40e41c67db3' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + + op.add_column('sys_assistant', sa.Column('oid', sa.BigInteger(), nullable=True)) + conn = op.get_bind() + conn.execute(sa.text("UPDATE sys_assistant SET oid = 1 WHERE oid IS NULL")) + rows = conn.execute( + sa.text("SELECT id, configuration FROM sys_assistant WHERE type = 0 AND configuration IS NOT NULL") + ) + for row in rows: + try: + config = json.loads(row.configuration) if isinstance(row.configuration, str) else row.configuration + oid_value = config.get('oid', 1) if isinstance(config, dict) else 1 + if oid_value != 1: + if not isinstance(oid_value, int): + oid_value = int(oid_value) + conn.execute( + sa.text("UPDATE sys_assistant SET oid = :oid WHERE id = :id"), + {"oid": oid_value, "id": row.id} + ) + except (json.JSONDecodeError, TypeError, AttributeError): + pass + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('sys_assistant', 'oid') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/062_update_chat_log_dll.py b/backend/alembic/versions/062_update_chat_log_dll.py new file mode 100644 index 000000000..9d729b875 --- /dev/null +++ b/backend/alembic/versions/062_update_chat_log_dll.py @@ -0,0 +1,36 @@ +"""062_update_chat_log_dll + +Revision ID: c9ab05247503 +Revises: 547df942eb90 +Create Date: 2026-01-27 14:20:35.069255 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'c9ab05247503' +down_revision = '547df942eb90' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('chat_log', sa.Column('local_operation', sa.Boolean(), nullable=True)) + sql = ''' + UPDATE chat_log SET local_operation = false + ''' + op.execute(sql) + op.alter_column('chat_log', 'local_operation', + existing_type=sa.BOOLEAN(), + nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('chat_log', 'local_operation') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/063_update_chat_log_dll.py b/backend/alembic/versions/063_update_chat_log_dll.py new file mode 100644 index 000000000..f4c8e01d1 --- /dev/null +++ b/backend/alembic/versions/063_update_chat_log_dll.py @@ -0,0 +1,36 @@ +"""063_update_chat_log_dll + +Revision ID: c8751179a8de +Revises: c9ab05247503 +Create Date: 2026-01-29 14:41:21.022781 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'c8751179a8de' +down_revision = 'c9ab05247503' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('chat_log', sa.Column('error', sa.Boolean(), nullable=True)) + sql = ''' + UPDATE chat_log SET error = false + ''' + op.execute(sql) + op.alter_column('chat_log', 'error', + existing_type=sa.BOOLEAN(), + nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('chat_log', 'error') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/064_system_variable.py b/backend/alembic/versions/064_system_variable.py new file mode 100644 index 000000000..330228fa9 --- /dev/null +++ b/backend/alembic/versions/064_system_variable.py @@ -0,0 +1,82 @@ +"""062_system_variable + +Revision ID: ed947895d470 +Revises: 547df942eb90 +Create Date: 2026-01-26 10:16:59.877303 + +""" +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from alembic import op +from sqlalchemy import String, BigInteger, DateTime +from sqlalchemy.dialects import postgresql +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.sql import table, column + +# revision identifiers, used by Alembic. +revision = 'ed947895d470' +down_revision = 'c8751179a8de' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('system_variable', + sa.Column('id', sa.BigInteger(), sa.Identity(always=True), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False), + sa.Column('var_type', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False), + sa.Column('type', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False), + sa.Column('value', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('create_time', sa.DateTime(), nullable=True), + sa.Column('create_by', sa.BigInteger(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + + variable_table = table( + "system_variable", + column("id", BigInteger), + column("name", String), + column("var_type", String), + column("type", String), + column("value", JSONB), + column("create_time", DateTime), + column("create_by", BigInteger), + ) + + op.bulk_insert( + variable_table, + [ + { + "name": "i18n_variable.name", + "var_type": "text", + "type": "system", + "value": ["name"], + "create_time": None, + "create_by": None + }, + { + "name": "i18n_variable.account", + "var_type": "text", + "type": "system", + "value": ["account"], + "create_time": None, + "create_by": None + }, + { + "name": "i18n_variable.email", + "var_type": "text", + "type": "system", + "value": ["email"], + "create_time": None, + "create_by": None + } + ] + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('system_variable') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/065_user_bind_var.py b/backend/alembic/versions/065_user_bind_var.py new file mode 100644 index 000000000..140198960 --- /dev/null +++ b/backend/alembic/versions/065_user_bind_var.py @@ -0,0 +1,29 @@ +"""063_user + +Revision ID: 8ff90df7871d +Revises: ed947895d470 +Create Date: 2026-01-26 15:30:11.348083 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '8ff90df7871d' +down_revision = 'ed947895d470' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('sys_user', sa.Column('system_variables', postgresql.JSONB(astext_type=sa.Text()), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('sys_user', 'system_variables') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/066_update_assistant_model.py b/backend/alembic/versions/066_update_assistant_model.py new file mode 100644 index 000000000..9e7ea17ce --- /dev/null +++ b/backend/alembic/versions/066_update_assistant_model.py @@ -0,0 +1,30 @@ +"""066_update_assistant_model + +Revision ID: 8adc3a4919be +Revises: 8ff90df7871d +Create Date: 2026-04-28 15:55:42.757276 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '8adc3a4919be' +down_revision = '8ff90df7871d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('sys_assistant', sa.Column('enable_custom_model', sa.Boolean(), nullable=True)) + op.add_column('sys_assistant', sa.Column('custom_model', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + op.drop_column('sys_assistant', 'custom_model') + op.drop_column('sys_assistant', 'enable_custom_model') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/067_ai_model_workspace_mapping.py b/backend/alembic/versions/067_ai_model_workspace_mapping.py new file mode 100644 index 000000000..aa558ba25 --- /dev/null +++ b/backend/alembic/versions/067_ai_model_workspace_mapping.py @@ -0,0 +1,34 @@ +"""067_ai_model_workspace_mapping + +Revision ID: e51127e9aa4a +Revises: 8adc3a4919be +Create Date: 2026-06-01 14:14:23.112843 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'e51127e9aa4a' +down_revision = '8adc3a4919be' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('ai_model_workspace_mapping', + sa.Column('id', sa.BigInteger(), nullable=False), + sa.Column('ai_model_id', sa.BigInteger(), nullable=True), + sa.Column('workspace_id', sa.BigInteger(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('ai_model_workspace_mapping') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/068_alter_sys_logs_user_agent_length.py b/backend/alembic/versions/068_alter_sys_logs_user_agent_length.py new file mode 100644 index 000000000..2ecbf966a --- /dev/null +++ b/backend/alembic/versions/068_alter_sys_logs_user_agent_length.py @@ -0,0 +1,29 @@ +"""068_alter_sys_logs_user_agent_length + +Revision ID: a1b2c3d4e5f6 +Revises: e51127e9aa4a +Create Date: 2026-06-09 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'a1b2c3d4e5f6' +down_revision = 'e51127e9aa4a' +branch_labels = None +depends_on = None + + +def upgrade(): + op.alter_column('sys_logs', 'user_agent', + existing_type=sa.VARCHAR(length=255), + type_=sa.VARCHAR(length=500), + existing_nullable=True) + + +def downgrade(): + op.alter_column('sys_logs', 'user_agent', + existing_type=sa.VARCHAR(length=500), + type_=sa.VARCHAR(length=255), + existing_nullable=True) diff --git a/backend/alembic/versions/069_term_custom_prompt.py b/backend/alembic/versions/069_term_custom_prompt.py new file mode 100644 index 000000000..e02aaac37 --- /dev/null +++ b/backend/alembic/versions/069_term_custom_prompt.py @@ -0,0 +1,31 @@ +"""069_term_custom_prompt + +Revision ID: 1f82cad3546e +Revises: a1b2c3d4e5f6 +Create Date: 2026-06-15 14:51:12.280391 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '1f82cad3546e' +down_revision = 'a1b2c3d4e5f6' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('custom_prompt', sa.Column('advanced_application', sa.BigInteger(), nullable=True)) + op.add_column('terminology', sa.Column('advanced_application', sa.BigInteger(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('terminology', 'advanced_application') + op.drop_column('custom_prompt', 'advanced_application') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/070_add_table_column_comments.py b/backend/alembic/versions/070_add_table_column_comments.py new file mode 100644 index 000000000..2b0aa4e5a --- /dev/null +++ b/backend/alembic/versions/070_add_table_column_comments.py @@ -0,0 +1,440 @@ +"""070_add_table_column_comments + +为所有数据库表和字段添加中文备注(COMMENT ON)。 +Revision ID: 070a1b2c3d4e5 +Revises: 1f82cad3546e +Create Date: 2026-06-25 00:00:00.000000 + +""" +from alembic import op + +# revision identifiers, used by Alembic. +revision = '070a1b2c3d4e5' +down_revision = '1f82cad3546e' +branch_labels = None +depends_on = None + +# 表和列备注定义:key 为 "表名" 或 "表名.列名",value 为备注文本 +_COMMENTS = { + # ========== alembic_version 迁移版本记录表 ========== + 'alembic_version': 'Alembic迁移版本记录表', + 'alembic_version.version_num': '当前已应用的迁移版本号', + + # ========== sys_user 用户表 ========== + 'sys_user': '系统用户表', + 'sys_user.id': '用户ID', + 'sys_user.account': '用户账号', + 'sys_user.name': '用户名称', + 'sys_user.password': '用户密码', + 'sys_user.email': '用户邮箱', + 'sys_user.oid': '组织ID', + 'sys_user.status': '用户状态', + 'sys_user.origin': '用户来源', + 'sys_user.create_time': '创建时间', + 'sys_user.language': '用户语言偏好', + 'sys_user.system_variables': '用户自定义系统变量', + + # ========== sys_user_platform 用户平台关联表 ========== + 'sys_user_platform': '用户平台关联表', + 'sys_user_platform.id': '主键ID', + 'sys_user_platform.uid': '用户ID', + 'sys_user_platform.origin': '平台来源', + 'sys_user_platform.platform_uid': '平台用户ID', + + # ========== ai_model AI模型表 ========== + 'ai_model': 'AI模型配置表', + 'ai_model.id': '模型ID', + 'ai_model.supplier': '模型供应商', + 'ai_model.name': '模型名称', + 'ai_model.model_type': '模型类型', + 'ai_model.base_model': '基础模型标识', + 'ai_model.default_model': '是否默认模型', + 'ai_model.api_key': 'API密钥', + 'ai_model.api_domain': 'API域名地址', + 'ai_model.protocol': '通信协议', + 'ai_model.config': '模型配置信息', + 'ai_model.status': '模型状态', + 'ai_model.create_time': '创建时间', + + # ========== ai_model_workspace_mapping 模型工作空间映射表 ========== + 'ai_model_workspace_mapping': 'AI模型与工作空间映射表', + 'ai_model_workspace_mapping.id': '主键ID', + 'ai_model_workspace_mapping.ai_model_id': 'AI模型ID', + 'ai_model_workspace_mapping.workspace_id': '工作空间ID', + + # ========== sys_workspace 工作空间表 ========== + 'sys_workspace': '工作空间表', + 'sys_workspace.id': '工作空间ID', + 'sys_workspace.name': '工作空间名称', + 'sys_workspace.create_time': '创建时间', + + # ========== sys_user_ws 用户工作空间关联表 ========== + 'sys_user_ws': '用户工作空间权限表', + 'sys_user_ws.id': '主键ID', + 'sys_user_ws.uid': '用户ID', + 'sys_user_ws.oid': '组织ID', + 'sys_user_ws.weight': '权重值', + + # ========== sys_assistant AI助手表 ========== + 'sys_assistant': 'AI助手配置表', + 'sys_assistant.id': '助手ID', + 'sys_assistant.name': '助手名称', + 'sys_assistant.type': '助手类型', + 'sys_assistant.domain': '助手适用领域', + 'sys_assistant.description': '助手描述信息', + 'sys_assistant.configuration': '助手配置', + 'sys_assistant.create_time': '创建时间', + 'sys_assistant.app_id': '应用ID', + 'sys_assistant.app_secret': '应用密钥', + 'sys_assistant.oid': '组织ID', + 'sys_assistant.enable_custom_model': '是否启用自定义模型', + 'sys_assistant.custom_model': '自定义模型名称', + + # ========== sys_authentication 认证配置表 ========== + 'sys_authentication': '认证配置表', + 'sys_authentication.id': '认证配置ID', + 'sys_authentication.name': '认证配置名称', + 'sys_authentication.type': '认证类型', + 'sys_authentication.config': '认证配置详情', + 'sys_authentication.create_time': '创建时间', + 'sys_authentication.enable': '是否启用', + 'sys_authentication.valid': '配置是否有效', + + # ========== sys_apikey API密钥表 ========== + 'sys_apikey': 'API密钥表', + 'sys_apikey.id': '密钥ID', + 'sys_apikey.access_key': '访问密钥', + 'sys_apikey.secret_key': '密钥', + 'sys_apikey.create_time': '创建时间', + 'sys_apikey.uid': '绑定用户ID', + 'sys_apikey.status': '密钥状态', + + # ========== system_variable 系统变量表 ========== + 'system_variable': '系统变量表', + 'system_variable.id': '变量ID', + 'system_variable.name': '变量名称', + 'system_variable.var_type': '变量数据类型', + 'system_variable.type': '变量分类', + 'system_variable.value': '变量值', + 'system_variable.create_time': '创建时间', + 'system_variable.create_by': '创建人ID', + + # ========== terms 术语设置表 ========== + 'terms': '术语设置表', + 'terms.id': '术语ID', + 'terms.term': '术语名称', + 'terms.definition': '术语定义', + 'terms.domain': '所属领域', + 'terms.create_time': '创建时间', + + # ========== custom_prompt 自定义提示词表 ========== + 'custom_prompt': '自定义提示词表', + 'custom_prompt.id': '提示词ID', + 'custom_prompt.oid': '组织ID', + 'custom_prompt.type': '提示词类型', + 'custom_prompt.create_time': '创建时间', + 'custom_prompt.name': '提示词名称', + 'custom_prompt.prompt': '提示词内容', + 'custom_prompt.specific_ds': '是否关联特定数据源', + 'custom_prompt.datasource_ids': '关联数据源ID列表', + 'custom_prompt.advanced_application': '高级应用ID', + + # ========== core_datasource 数据源表 ========== + 'core_datasource': '数据源配置表', + 'core_datasource.id': '数据源ID', + 'core_datasource.name': '数据源名称', + 'core_datasource.description': '数据源描述', + 'core_datasource.type': '数据源类型', + 'core_datasource.type_name': '数据源类型名称', + 'core_datasource.configuration': '连接配置信息', + 'core_datasource.create_time': '创建时间', + 'core_datasource.create_by': '创建人ID', + 'core_datasource.status': '连接状态', + 'core_datasource.num': '数据源编号', + 'core_datasource.oid': '组织ID', + 'core_datasource.table_relation': '表关系配置', + 'core_datasource.embedding': '向量嵌入信息', + 'core_datasource.recommended_config': '推荐问题配置', + + # ========== core_table 数据源表信息 ========== + 'core_table': '数据源表信息', + 'core_table.id': '表记录ID', + 'core_table.ds_id': '所属数据源ID', + 'core_table.checked': '是否启用', + 'core_table.table_name': '表名称', + 'core_table.table_comment': '表注释', + 'core_table.custom_comment': '自定义表注释', + 'core_table.embedding': '表向量嵌入信息', + + # ========== ds_recommended_problem 推荐问题表 ========== + 'ds_recommended_problem': '数据源推荐问题表', + 'ds_recommended_problem.id': '问题ID', + 'ds_recommended_problem.datasource_id': '数据源ID', + 'ds_recommended_problem.question': '推荐问题内容', + 'ds_recommended_problem.remark': '问题备注', + 'ds_recommended_problem.sort': '排序序号', + 'ds_recommended_problem.create_time': '创建时间', + 'ds_recommended_problem.create_by': '创建人ID', + + # ========== core_field 数据源字段信息 ========== + 'core_field': '数据源字段信息表', + 'core_field.id': '字段记录ID', + 'core_field.ds_id': '所属数据源ID', + 'core_field.table_id': '所属表ID', + 'core_field.checked': '是否启用', + 'core_field.field_name': '字段名称', + 'core_field.field_type': '字段类型', + 'core_field.field_comment': '字段注释', + 'core_field.custom_comment': '自定义字段注释', + 'core_field.field_index': '字段序号', + + # ========== data_training 数据训练(示例库)表 ========== + 'data_training': 'SQL示例训练库表', + 'data_training.id': '训练记录ID', + 'data_training.oid': '组织ID', + 'data_training.datasource': '关联数据源ID', + 'data_training.create_time': '创建时间', + 'data_training.question': '训练问题', + 'data_training.description': '训练描述', + 'data_training.embedding': '向量嵌入数据', + 'data_training.enabled': '是否启用', + 'data_training.advanced_application': '高级应用ID', + + # ========== terminology 术语表 ========== + 'terminology': '术语管理表', + 'terminology.id': '术语ID', + 'terminology.oid': '组织ID', + 'terminology.pid': '父级术语ID', + 'terminology.create_time': '创建时间', + 'terminology.word': '术语词条', + 'terminology.description': '术语描述', + 'terminology.embedding': '向量嵌入数据', + 'terminology.specific_ds': '是否关联特定数据源', + 'terminology.datasource_ids': '关联数据源ID列表', + 'terminology.enabled': '是否启用', + 'terminology.advanced_application': '高级应用ID', + + # ========== core_dashboard 仪表板表 ========== + 'core_dashboard': '仪表板表', + 'core_dashboard.id': '仪表板ID', + 'core_dashboard.name': '仪表板名称', + 'core_dashboard.pid': '父级仪表板ID', + 'core_dashboard.workspace_id': '所属工作空间ID', + 'core_dashboard.org_id': '组织ID', + 'core_dashboard.level': '层级', + 'core_dashboard.node_type': '节点类型', + 'core_dashboard.type': '仪表板类型', + 'core_dashboard.canvas_style_data': '画布样式数据', + 'core_dashboard.component_data': '组件数据', + 'core_dashboard.canvas_view_info': '画布视图信息', + 'core_dashboard.mobile_layout': '是否移动端布局', + 'core_dashboard.status': '状态', + 'core_dashboard.self_watermark_status': '水印状态', + 'core_dashboard.sort': '排序序号', + 'core_dashboard.create_time': '创建时间', + 'core_dashboard.create_by': '创建人', + 'core_dashboard.update_time': '更新时间', + 'core_dashboard.update_by': '更新人', + 'core_dashboard.remark': '备注', + 'core_dashboard.source': '来源标识', + 'core_dashboard.delete_flag': '删除标记', + 'core_dashboard.delete_time': '删除时间', + 'core_dashboard.delete_by': '删除人', + 'core_dashboard.version': '版本号', + 'core_dashboard.content_id': '内容ID', + 'core_dashboard.check_version': '检查版本', + + # ========== chat_log 聊天日志表 ========== + 'chat_log': 'AI聊天执行日志表', + 'chat_log.id': '日志ID', + 'chat_log.type': '聊天类型', + 'chat_log.operate': '操作类型', + 'chat_log.pid': '父级日志ID', + 'chat_log.ai_modal_id': '使用的AI模型ID', + 'chat_log.base_modal': '基础模型标识', + 'chat_log.messages': '消息内容', + 'chat_log.reasoning_content': '推理内容', + 'chat_log.start_time': '开始时间', + 'chat_log.finish_time': '结束时间', + 'chat_log.token_usage': 'Token消耗统计', + 'chat_log.local_operation': '是否本地操作', + 'chat_log.error': '是否发生错误', + + # ========== chat 聊天主表 ========== + 'chat': 'AI聊天会话表', + 'chat.id': '聊天会话ID', + 'chat.oid': '组织ID', + 'chat.create_time': '创建时间', + 'chat.create_by': '创建人ID', + 'chat.brief': '会话摘要', + 'chat.chat_type': '聊天类型', + 'chat.datasource': '关联数据源ID', + 'chat.engine_type': '数据引擎类型', + 'chat.origin': '会话来源', + 'chat.brief_generate': '是否已生成摘要', + 'chat.recommended_question_answer': '推荐问题回答', + 'chat.recommended_question': '推荐问题内容', + 'chat.recommended_generate': '是否已生成推荐问题', + + # ========== chat_record 聊天记录表 ========== + 'chat_record': 'AI聊天记录表', + 'chat_record.id': '记录ID', + 'chat_record.chat_id': '关联聊天会话ID', + 'chat_record.ai_modal_id': '使用的AI模型ID', + 'chat_record.first_chat': '是否首轮对话', + 'chat_record.create_time': '创建时间', + 'chat_record.finish_time': '完成时间', + 'chat_record.create_by': '创建人ID', + 'chat_record.datasource': '关联数据源ID', + 'chat_record.engine_type': '数据引擎类型', + 'chat_record.question': '用户问题内容', + 'chat_record.sql_answer': 'SQL生成回答', + 'chat_record.sql': '生成的SQL语句', + 'chat_record.sql_exec_result': 'SQL执行结果', + 'chat_record.data': '查询返回数据', + 'chat_record.chart_answer': '图表生成回答', + 'chat_record.chart': '图表配置信息', + 'chat_record.analysis': '分析结果内容', + 'chat_record.predict': '预测结果内容', + 'chat_record.predict_data': '预测数据', + 'chat_record.recommended_question_answer': '推荐问题回答', + 'chat_record.recommended_question': '推荐问题列表', + 'chat_record.datasource_select_answer': '数据源选择回答', + 'chat_record.finish': '是否已完成', + 'chat_record.error': '错误信息', + 'chat_record.analysis_record_id': '关联分析记录ID', + 'chat_record.predict_record_id': '关联预测记录ID', + 'chat_record.regenerate_record_id': '关联重新生成记录ID', + + # ========== sys_logs 系统操作日志表 ========== + 'sys_logs': '系统操作日志表', + 'sys_logs.id': '日志ID', + 'sys_logs.operation_type': '操作类型', + 'sys_logs.operation_detail': '操作详情', + 'sys_logs.user_id': '操作用户ID', + 'sys_logs.operation_status': '操作状态', + 'sys_logs.ip_address': '操作IP地址', + 'sys_logs.user_agent': '用户代理信息', + 'sys_logs.execution_time': '执行耗时', + 'sys_logs.error_message': '错误信息', + 'sys_logs.create_time': '创建时间', + 'sys_logs.module': '操作模块', + 'sys_logs.oid': '组织ID', + 'sys_logs.resource_id': '操作资源ID', + 'sys_logs.request_method': '请求方法', + 'sys_logs.request_path': '请求路径', + 'sys_logs.remark': '备注', + 'sys_logs.user_name': '操作用户名称', + 'sys_logs.resource_name': '操作资源名称', + + # ========== sys_logs_resource 日志关联资源表 ========== + 'sys_logs_resource': '操作日志关联资源表', + 'sys_logs_resource.id': '主键ID', + 'sys_logs_resource.log_id': '关联日志ID', + 'sys_logs_resource.resource_id': '资源ID', + 'sys_logs_resource.resource_name': '资源名称', + 'sys_logs_resource.module': '资源模块', + + # ========== ds_permission 数据权限表 ========== + 'ds_permission': '数据权限配置表', + 'ds_permission.id': '权限ID', + 'ds_permission.enable': '是否启用', + 'ds_permission.name': '权限名称', + 'ds_permission.auth_target_type': '授权对象类型', + 'ds_permission.auth_target_id': '授权对象ID', + 'ds_permission.type': '权限类型', + 'ds_permission.ds_id': '数据源ID', + 'ds_permission.table_id': '表ID', + 'ds_permission.expression_tree': '权限表达式树', + 'ds_permission.permissions': '权限配置', + 'ds_permission.white_list_user': '白名单用户列表', + 'ds_permission.create_time': '创建时间', + + # ========== ds_rules 数据规则表 ========== + 'ds_rules': '数据规则组表', + 'ds_rules.id': '规则组ID', + 'ds_rules.enable': '是否启用', + 'ds_rules.name': '规则组名称', + 'ds_rules.description': '规则组描述', + 'ds_rules.permission_list': '权限列表', + 'ds_rules.user_list': '用户列表', + 'ds_rules.white_list_user': '白名单用户列表', + 'ds_rules.oid': '组织ID', + 'ds_rules.create_time': '创建时间', + + # ========== license 许可证表 ========== + 'license': '系统许可证表', + 'license.id': '许可证ID', + 'license.license_key': '许可证密钥', + 'license.f2c_license': 'F2C许可证信息', + 'license.create_time': '创建时间', + 'license.update_time': '更新时间', + + # ========== rsa 密钥表 ========== + 'rsa': 'RSA密钥存储表', + 'rsa.id': '密钥ID', + 'rsa.private_key': 'RSA私钥', + 'rsa.public_key': 'RSA公钥', + 'rsa.salt': '加密盐值', + 'rsa.create_time': '创建时间', + 'rsa.update_time': '更新时间', + + # ========== sys_arg 系统参数表 ========== + 'sys_arg': '系统参数配置表', + 'sys_arg.id': '参数ID', + 'sys_arg.pkey': '参数键', + 'sys_arg.pval': '参数值', + 'sys_arg.ptype': '参数类型', + 'sys_arg.sort_no': '排序序号', + + # ========== sys_platform_token 平台令牌表 ========== + 'sys_platform_token': '平台认证令牌表', + 'sys_platform_token.id': '令牌ID', + 'sys_platform_token.token': '令牌值', + 'sys_platform_token.create_time': '创建时间', + 'sys_platform_token.exp_time': '过期时间', +} + + +def _escape_comment(comment): + return comment.replace("'", "''") + + +def _apply_table_comment(table, comment): + if comment is None: + op.execute(f"COMMENT ON TABLE {table} IS NULL") + else: + op.execute(f"COMMENT ON TABLE {table} IS '{_escape_comment(comment)}'") + + +def _apply_column_comment(table, column, comment): + if comment is None: + op.execute(f"COMMENT ON COLUMN {table}.{column} IS NULL") + else: + op.execute(f"COMMENT ON COLUMN {table}.{column} IS '{_escape_comment(comment)}'") + + +def upgrade(): + """为所有表和字段添加中文备注""" + table_keys = sorted(k for k in _COMMENTS if '.' not in k) + column_keys = sorted(k for k in _COMMENTS if '.' in k) + + for table in table_keys: + _apply_table_comment(table, _COMMENTS[table]) + + for key in column_keys: + table, column = key.split('.', 1) + _apply_column_comment(table, column, _COMMENTS[key]) + + +def downgrade(): + """移除所有表和字段的备注""" + table_keys = sorted((k for k in _COMMENTS if '.' not in k), reverse=True) + column_keys = sorted((k for k in _COMMENTS if '.' in k), reverse=True) + + for key in column_keys: + table, column = key.split('.', 1) + _apply_column_comment(table, column, None) + + for table in table_keys: + _apply_table_comment(table, None) diff --git a/backend/alembic/versions/071_modify_permission_jsonb.py b/backend/alembic/versions/071_modify_permission_jsonb.py new file mode 100644 index 000000000..cac4c64b9 --- /dev/null +++ b/backend/alembic/versions/071_modify_permission_jsonb.py @@ -0,0 +1,45 @@ +"""071_modify_permission_jsonb + +Revision ID: a2e2ecfa5a9c +Revises: 070a1b2c3d4e5 +Create Date: 2026-07-16 16:56:47.093944 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'a2e2ecfa5a9c' +down_revision = '070a1b2c3d4e5' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('ds_rules', 'permission_list', + existing_type=sa.Text(), + type_=postgresql.JSONB(astext_type=sa.Text()), + existing_nullable=True, + postgresql_using='permission_list::jsonb') + op.alter_column('ds_rules', 'user_list', + existing_type=sa.Text(), + type_=postgresql.JSONB(astext_type=sa.Text()), + existing_nullable=True, + postgresql_using='user_list::jsonb') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('ds_rules', 'permission_list', + existing_type=postgresql.JSONB(astext_type=sa.Text()), + type_=sa.Text(), + existing_nullable=True) + op.alter_column('ds_rules', 'user_list', + existing_type=postgresql.JSONB(astext_type=sa.Text()), + type_=sa.Text(), + existing_nullable=True) + # ### end Alembic commands ### diff --git a/backend/apps/ai_model/embedding.py b/backend/apps/ai_model/embedding.py new file mode 100644 index 000000000..315c84614 --- /dev/null +++ b/backend/apps/ai_model/embedding.py @@ -0,0 +1,63 @@ +import os.path +import threading +from typing import Optional + +from langchain_core.embeddings import Embeddings +from langchain_huggingface import HuggingFaceEmbeddings +from pydantic import BaseModel + +from common.core.config import settings + +os.environ["TOKENIZERS_PARALLELISM"] = "false" + + +class EmbeddingModelInfo(BaseModel): + folder: str + name: str + device: str = 'cpu' + + +local_embedding_model = EmbeddingModelInfo(folder=settings.LOCAL_MODEL_PATH, + name=os.path.join(settings.LOCAL_MODEL_PATH, 'embedding', + "shibing624_text2vec-base-chinese")) + +_lock = threading.Lock() +locks = {} + +_embedding_model: dict[str, Optional[Embeddings]] = {} + + +class EmbeddingModelCache: + + @staticmethod + def _new_instance(config: EmbeddingModelInfo = local_embedding_model): + return HuggingFaceEmbeddings(model_name=config.name, cache_folder=config.folder, + model_kwargs={'device': config.device}, + encode_kwargs={'normalize_embeddings': True} + ) + + @staticmethod + def _get_lock(key: str = settings.DEFAULT_EMBEDDING_MODEL): + lock = locks.get(key) + if lock is None: + with _lock: + lock = locks.get(key) + if lock is None: + lock = threading.Lock() + locks[key] = lock + + return lock + + @staticmethod + def get_model(key: str = settings.DEFAULT_EMBEDDING_MODEL, + config: EmbeddingModelInfo = local_embedding_model) -> Embeddings: + model_instance = _embedding_model.get(key) + if model_instance is None: + lock = EmbeddingModelCache._get_lock(key) + with lock: + model_instance = _embedding_model.get(key) + if model_instance is None: + model_instance = EmbeddingModelCache._new_instance(config) + _embedding_model[key] = model_instance + + return model_instance diff --git a/backend/apps/ai_model/model_factory.py b/backend/apps/ai_model/model_factory.py index 6391501c9..887005ce7 100644 --- a/backend/apps/ai_model/model_factory.py +++ b/backend/apps/ai_model/model_factory.py @@ -10,7 +10,10 @@ from apps.ai_model.openai.llm import BaseChatOpenAI from apps.system.models.system_model import AiModelDetail from common.core.db import engine +from common.utils.crypto import sqlbot_decrypt from common.utils.utils import prepare_model_arg +from langchain_community.llms import VLLMOpenAI +from langchain_openai import AzureChatOpenAI # from langchain_community.llms import Tongyi, VLLM @@ -23,16 +26,17 @@ class LLMConfig(BaseModel): api_key: Optional[str] = None api_base_url: Optional[str] = None additional_params: Dict[str, Any] = {} + class Config: frozen = True def __hash__(self): if hasattr(self, 'additional_params') and isinstance(self.additional_params, dict): - hashable_params = frozenset((k, tuple(v) if isinstance(v, (list, dict)) else v) - for k, v in self.additional_params.items()) + hashable_params = frozenset((k, tuple(v) if isinstance(v, (list, dict)) else v) + for k, v in self.additional_params.items()) else: hashable_params = None - + return hash(( self.model_id, self.model_type, @@ -61,11 +65,41 @@ def llm(self) -> BaseChatModel: return self._llm +class OpenAIvLLM(BaseLLM): + def _init_llm(self) -> VLLMOpenAI: + return VLLMOpenAI( + openai_api_key=self.config.api_key or 'Empty', + openai_api_base=self.config.api_base_url, + model_name=self.config.model_name, + streaming=True, + **self.config.additional_params, + ) + + +class OpenAIAzureLLM(BaseLLM): + def _init_llm(self) -> AzureChatOpenAI: + api_version = self.config.additional_params.get("api_version") + deployment_name = self.config.additional_params.get("deployment_name") + if api_version: + self.config.additional_params.pop("api_version") + if deployment_name: + self.config.additional_params.pop("deployment_name") + return AzureChatOpenAI( + azure_endpoint=self.config.api_base_url, + api_key=self.config.api_key or 'Empty', + model_name=self.config.model_name, + api_version=api_version, + deployment_name=deployment_name, + streaming=True, + **self.config.additional_params, + ) + + class OpenAILLM(BaseLLM): def _init_llm(self) -> BaseChatModel: return BaseChatOpenAI( model=self.config.model_name, - api_key=self.config.api_key, + api_key=self.config.api_key or 'Empty', base_url=self.config.api_base_url, stream_usage=True, **self.config.additional_params, @@ -81,7 +115,8 @@ class LLMFactory: _llm_types: Dict[str, Type[BaseLLM]] = { "openai": OpenAILLM, "tongyi": OpenAILLM, - "vllm": OpenAILLM + "vllm": OpenAIvLLM, + "azure": OpenAIAzureLLM, } @classmethod @@ -110,11 +145,15 @@ def register_llm(cls, model_type: str, llm_class: Type[BaseLLM]): return config """ -def get_default_config() -> LLMConfig: +async def get_default_config(custom_model_id: Optional[int] = None) -> LLMConfig: with Session(engine) as session: - db_model = session.exec( - select(AiModelDetail).where(AiModelDetail.default_model == True) - ).first() + db_model: AiModelDetail | None = None + if custom_model_id: + db_model = session.get(AiModelDetail, custom_model_id) + if not db_model: + db_model = session.exec( + select(AiModelDetail).where(AiModelDetail.default_model == True) + ).first() if not db_model: raise Exception("The system default model has not been set") @@ -122,14 +161,19 @@ def get_default_config() -> LLMConfig: if db_model.config: try: config_raw = json.loads(db_model.config) - additional_params = {item["key"]: prepare_model_arg(item.get('val')) for item in config_raw if "key" in item and "val" in item} + additional_params = {item["key"]: prepare_model_arg(item.get('val')) for item in config_raw if + "key" in item and "val" in item} except Exception: pass + if not db_model.api_domain.startswith("http"): + db_model.api_domain = await sqlbot_decrypt(db_model.api_domain) + if db_model.api_key: + db_model.api_key = await sqlbot_decrypt(db_model.api_key) # 构造 LLMConfig return LLMConfig( model_id=db_model.id, - model_type="openai", + model_type="openai" if db_model.protocol == 1 else "vllm", model_name=db_model.base_model, api_key=db_model.api_key, api_base_url=db_model.api_domain, diff --git a/backend/apps/ai_model/openai/llm.py b/backend/apps/ai_model/openai/llm.py index 2224868d2..603fd50ea 100644 --- a/backend/apps/ai_model/openai/llm.py +++ b/backend/apps/ai_model/openai/llm.py @@ -1,11 +1,20 @@ -from typing import Dict, Optional, Any, Iterator, cast, Mapping +from collections.abc import Iterator, Mapping +from typing import Any, Optional, cast from langchain_core.language_models import LanguageModelInput -from langchain_core.messages import BaseMessage, BaseMessageChunk, HumanMessageChunk, AIMessageChunk, \ - SystemMessageChunk, FunctionMessageChunk, ChatMessageChunk +from langchain_core.messages import ( + AIMessageChunk, + BaseMessage, + BaseMessageChunk, + ChatMessageChunk, + FunctionMessageChunk, + HumanMessageChunk, + SystemMessageChunk, +) from langchain_core.messages.ai import UsageMetadata -from langchain_core.messages.tool import tool_call_chunk, ToolMessageChunk +from langchain_core.messages.tool import ToolMessageChunk, tool_call_chunk from langchain_core.outputs import ChatGenerationChunk +from langchain_core.outputs.chat_generation import ChatGeneration from langchain_core.runnables import RunnableConfig, ensure_config from langchain_openai import ChatOpenAI from langchain_openai.chat_models.base import _create_usage_metadata @@ -18,8 +27,12 @@ def _convert_delta_to_message_chunk( role = cast(str, _dict.get("role")) content = cast(str, _dict.get("content") or "") additional_kwargs: dict = {} - if 'reasoning_content' in _dict: - additional_kwargs['reasoning_content'] = _dict.get('reasoning_content') + # 兼容 reasoning_content (DeepSeek等) 和 reasoning (Ollama/LMStudio GPT-OSS) 两种字段 + reasoning_content = _dict.get('reasoning_content') + if not reasoning_content: + reasoning_content = _dict.get('reasoning') + if reasoning_content: + additional_kwargs['reasoning_content'] = reasoning_content if _dict.get("function_call"): function_call = dict(_dict["function_call"]) if "name" in function_call and function_call["name"] is None: @@ -71,11 +84,32 @@ def _convert_delta_to_message_chunk( class BaseChatOpenAI(ChatOpenAI): + @property + def _default_params(self) -> dict[str, Any]: + max_tokens = self.max_tokens + params = super()._default_params + if max_tokens: + params["max_tokens"] = max_tokens + return params + + def _get_request_payload( + self, + input_: LanguageModelInput, + *, + stop: Optional[list[str]] = None, + **kwargs: Any, + ) -> dict: + max_tokens = self.max_tokens + payload = super()._get_request_payload(input_, stop=stop, **kwargs) + if max_tokens: + payload["max_tokens"] = max_tokens + return payload + usage_metadata: dict = {} # custom_get_token_ids = custom_get_token_ids - def get_last_generation_info(self) -> Optional[Dict[str, Any]]: + def get_last_generation_info(self) -> dict[str, Any] | None: return self.usage_metadata def _stream(self, *args: Any, **kwargs: Any) -> Iterator[ChatGenerationChunk]: @@ -89,8 +123,8 @@ def _convert_chunk_to_generation_chunk( self, chunk: dict, default_chunk_class: type, - base_generation_info: Optional[dict], - ) -> Optional[ChatGenerationChunk]: + base_generation_info: dict | None, + ) -> ChatGenerationChunk | None: if chunk.get("type") == "content.delta": # from beta.chat.completions.stream return None token_usage = chunk.get("usage") @@ -100,8 +134,10 @@ def _convert_chunk_to_generation_chunk( or chunk.get("chunk", {}).get("choices", []) ) - usage_metadata: Optional[UsageMetadata] = ( - _create_usage_metadata(token_usage) if token_usage and token_usage.get("prompt_tokens") else None + usage_metadata: UsageMetadata | None = ( + _create_usage_metadata(token_usage) + if token_usage and token_usage.get("prompt_tokens") + else None ) if len(choices) == 0: # logprobs is implicitly None @@ -141,14 +177,14 @@ def _convert_chunk_to_generation_chunk( def invoke( self, input: LanguageModelInput, - config: Optional[RunnableConfig] = None, + config: RunnableConfig | None = None, *, - stop: Optional[list[str]] = None, + stop: list[str] | None = None, **kwargs: Any, ) -> BaseMessage: config = ensure_config(config) chat_result = cast( - "ChatGeneration", + ChatGeneration, self.generate_prompt( [self._convert_input(input)], stop=stop, @@ -159,7 +195,6 @@ def invoke( run_id=config.pop("run_id", None), **kwargs, ).generations[0][0], - ).message self.usage_metadata = chat_result.response_metadata[ diff --git a/backend/apps/api.py b/backend/apps/api.py index 4f573134b..ec5a4209f 100644 --- a/backend/apps/api.py +++ b/backend/apps/api.py @@ -2,10 +2,14 @@ from apps.chat.api import chat from apps.dashboard.api import dashboard_api -from apps.datasource.api import datasource -from apps.settings.api import terminology -from apps.system.api import login, user, aimodel, workspace, assistant +from apps.data_training.api import data_training +from apps.datasource.api import datasource, table_relation, recommended_problem from apps.mcp import mcp +from apps.system.api import login, user, aimodel, workspace, assistant, parameter, apikey, variable_api +from apps.terminology.api import terminology +from apps.settings.api import base +#from audit.api import audit_api + api_router = APIRouter() api_router.include_router(login.router) @@ -13,9 +17,19 @@ api_router.include_router(workspace.router) api_router.include_router(assistant.router) api_router.include_router(aimodel.router) +api_router.include_router(base.router) api_router.include_router(terminology.router) +api_router.include_router(data_training.router) api_router.include_router(datasource.router) api_router.include_router(chat.router) api_router.include_router(dashboard_api.router) api_router.include_router(mcp.router) +api_router.include_router(table_relation.router) +api_router.include_router(parameter.router) +api_router.include_router(apikey.router) + +api_router.include_router(recommended_problem.router) + +api_router.include_router(variable_api.router) +#api_router.include_router(audit_api.router) diff --git a/backend/apps/chat/api/chat.py b/backend/apps/chat/api/chat.py index 3cc59380e..8a2bed721 100644 --- a/backend/apps/chat/api/chat.py +++ b/backend/apps/chat/api/chat.py @@ -1,37 +1,50 @@ import asyncio import io import traceback +from typing import Optional, List -import numpy as np +import orjson import pandas as pd -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Path from fastapi.responses import StreamingResponse from sqlalchemy import and_, select - -from apps.chat.curd.chat import list_chats, get_chat_with_records, create_chat, rename_chat, \ - delete_chat, get_chat_chart_data, get_chat_predict_data, get_chat_with_records_with_data, get_chat_record_by_id -from apps.chat.models.chat_model import CreateChat, ChatRecord, RenameChat, ChatQuestion, ExcelData +from starlette.responses import JSONResponse + +from apps.chat.curd.chat import delete_chat_with_user, get_chart_data_with_user, get_chat_predict_data_with_user, \ + list_chats, get_chat_with_records, create_chat, get_chat_chart_data, get_chat_predict_data, \ + get_chat_with_records_with_data, get_chat_record_by_id, \ + format_json_data, format_json_list_data, get_chart_config, list_recent_questions, rename_chat_with_user, \ + get_chat_log_history, get_chart_data_with_user_live +from apps.chat.models.chat_model import CreateChat, ChatRecord, RenameChat, ChatQuestion, AxisObj, QuickCommand, \ + ChatInfo, Chat, ChatFinishStep, ChatQuestionBase, SimpleChat from apps.chat.task.llm import LLMService -from common.core.deps import CurrentAssistant, SessionDep, CurrentUser +from apps.swagger.i18n import PLACEHOLDER_PREFIX +from apps.system.schemas.permission import SqlbotPermission, require_permissions +from common.audit.models.log_model import OperationType, OperationModules +from common.audit.schemas.logger_decorator import LogConfig, system_log +from common.core.deps import CurrentAssistant, SessionDep, CurrentUser, Trans +from common.utils.command_utils import parse_quick_command +from common.utils.data_format import DataFormat router = APIRouter(tags=["Data Q&A"], prefix="/chat") -@router.get("/list") +@router.get("/list", response_model=List[Chat], summary=f"{PLACEHOLDER_PREFIX}get_chat_list") async def chats(session: SessionDep, current_user: CurrentUser): return list_chats(session, current_user) -@router.get("/get/{chart_id}") -async def get_chat(session: SessionDep, current_user: CurrentUser, chart_id: int, current_assistant: CurrentAssistant): +@router.get("/{chart_id}", response_model=ChatInfo, summary=f"{PLACEHOLDER_PREFIX}get_chat") +async def get_chat(session: SessionDep, current_user: CurrentUser, chart_id: int, current_assistant: CurrentAssistant, + trans: Trans): def inner(): return get_chat_with_records(chart_id=chart_id, session=session, current_user=current_user, - current_assistant=current_assistant) + current_assistant=current_assistant, trans=trans) return await asyncio.to_thread(inner) -@router.get("/get/with_data/{chart_id}") +@router.get("/{chart_id}/with_data", response_model=ChatInfo, summary=f"{PLACEHOLDER_PREFIX}get_chat_with_data") async def get_chat_with_data(session: SessionDep, current_user: CurrentUser, chart_id: int, current_assistant: CurrentAssistant): def inner(): @@ -41,26 +54,77 @@ def inner(): return await asyncio.to_thread(inner) -@router.get("/record/get/{chart_record_id}/data") -async def chat_record_data(session: SessionDep, chart_record_id: int): +""" @router.get("/record/{chat_record_id}/data", summary=f"{PLACEHOLDER_PREFIX}get_chart_data") +async def chat_record_data(session: SessionDep, chat_record_id: int): + def inner(): + data = get_chat_chart_data(chat_record_id=chat_record_id, session=session) + return format_json_data(data) + + return await asyncio.to_thread(inner) + + +@router.get("/record/{chat_record_id}/predict_data", summary=f"{PLACEHOLDER_PREFIX}get_chart_predict_data") +async def chat_predict_data(session: SessionDep, chat_record_id: int): + def inner(): + data = get_chat_predict_data(chat_record_id=chat_record_id, session=session) + return format_json_list_data(data) + + return await asyncio.to_thread(inner) """ + + +@router.get("/record/{chat_record_id}/data", summary=f"{PLACEHOLDER_PREFIX}get_chart_data") +async def chat_record_data(session: SessionDep, current_user: CurrentUser, chat_record_id: int): + def inner(): + data = get_chart_data_with_user(chat_record_id=chat_record_id, session=session, current_user=current_user) + return format_json_data(data) + + return await asyncio.to_thread(inner) + + +@router.get("/record/{chat_record_id}/data_live", summary=f"{PLACEHOLDER_PREFIX}get_chart_data_live") +async def chat_record_data_live(session: SessionDep, current_user: CurrentUser, chat_record_id: int): + def inner(): + data = get_chart_data_with_user_live(chat_record_id=chat_record_id, session=session, current_user=current_user) + return format_json_data(data) + + return await asyncio.to_thread(inner) + + +@router.get("/record/{chat_record_id}/predict_data", summary=f"{PLACEHOLDER_PREFIX}get_chart_predict_data") +async def chat_predict_data(session: SessionDep, current_user: CurrentUser, chat_record_id: int): + def inner(): + data = get_chat_predict_data_with_user(chat_record_id=chat_record_id, session=session, + current_user=current_user) + return format_json_list_data(data) + + return await asyncio.to_thread(inner) + + +@router.get("/record/{chat_record_id}/log", summary=f"{PLACEHOLDER_PREFIX}get_record_log") +async def chat_record_log(session: SessionDep, current_user: CurrentUser, chat_record_id: int): def inner(): - return get_chat_chart_data(chart_record_id=chart_record_id, session=session) + return get_chat_log_history(session, chat_record_id, current_user) return await asyncio.to_thread(inner) -@router.get("/record/get/{chart_record_id}/predict_data") -async def chat_predict_data(session: SessionDep, chart_record_id: int): +@router.get("/record/{chat_record_id}/usage", summary=f"{PLACEHOLDER_PREFIX}get_record_usage") +async def chat_record_usage(session: SessionDep, current_user: CurrentUser, chat_record_id: int): def inner(): - return get_chat_predict_data(chart_record_id=chart_record_id, session=session) + return get_chat_log_history(session, chat_record_id, current_user, True) return await asyncio.to_thread(inner) -@router.post("/rename") -async def rename(session: SessionDep, chat: RenameChat): +@router.post("/rename", response_model=str, summary=f"{PLACEHOLDER_PREFIX}rename_chat") +@system_log(LogConfig( + operation_type=OperationType.UPDATE, + module=OperationModules.CHAT, + resource_id_expr="chat.id" +)) +async def rename(session: SessionDep, current_user: CurrentUser, chat: RenameChat): try: - return rename_chat(session=session, rename_object=chat) + return rename_chat_with_user(session=session, current_user=current_user, rename_object=chat) except Exception as e: raise HTTPException( status_code=500, @@ -68,10 +132,16 @@ async def rename(session: SessionDep, chat: RenameChat): ) -@router.get("/delete/{chart_id}") -async def delete(session: SessionDep, chart_id: int): +@router.delete("/{chart_id}", response_model=str, summary=f"{PLACEHOLDER_PREFIX}delete_chat") +@system_log(LogConfig( + operation_type=OperationType.DELETE, + module=OperationModules.CHAT, + resource_id_expr="chart_id", + remark_expr="chat.brief" +)) +async def delete(session: SessionDep, current_user: CurrentUser, chart_id: int, chat: SimpleChat): try: - return delete_chat(session=session, chart_id=chart_id) + return delete_chat_with_user(session=session, current_user=current_user, chart_id=chart_id) except Exception as e: raise HTTPException( status_code=500, @@ -79,7 +149,13 @@ async def delete(session: SessionDep, chart_id: int): ) -@router.post("/start") +@router.post("/start", response_model=ChatInfo, summary=f"{PLACEHOLDER_PREFIX}start_chat") +@require_permissions(permission=SqlbotPermission(type='ds', keyExpression="create_chat_obj.datasource")) +@system_log(LogConfig( + operation_type=OperationType.CREATE, + module=OperationModules.CHAT, + result_id_expr="id" +)) async def start_chat(session: SessionDep, current_user: CurrentUser, create_chat_obj: CreateChat): try: return create_chat(session, current_user, create_chat_obj) @@ -90,10 +166,17 @@ async def start_chat(session: SessionDep, current_user: CurrentUser, create_chat ) -@router.post("/assistant/start") -async def start_chat(session: SessionDep, current_user: CurrentUser): +@router.post("/assistant/start", response_model=ChatInfo, summary=f"{PLACEHOLDER_PREFIX}assistant_start_chat") +@system_log(LogConfig( + operation_type=OperationType.CREATE, + module=OperationModules.CHAT, + result_id_expr="id" +)) +async def start_chat(session: SessionDep, current_user: CurrentUser, current_assistant: CurrentAssistant, + create_chat_obj: CreateChat = CreateChat(origin=2)): try: - return create_chat(session, current_user, CreateChat(origin=2), False) + return create_chat(session, current_user, create_chat_obj, create_chat_obj and create_chat_obj.datasource, + current_assistant) except Exception as e: raise HTTPException( status_code=500, @@ -101,126 +184,355 @@ async def start_chat(session: SessionDep, current_user: CurrentUser): ) -@router.post("/recommend_questions/{chat_record_id}") -async def recommend_questions(session: SessionDep, current_user: CurrentUser, chat_record_id: int, - current_assistant: CurrentAssistant): +@router.post("/recommend_questions/{chat_record_id}", summary=f"{PLACEHOLDER_PREFIX}ask_recommend_questions") +async def ask_recommend_questions(session: SessionDep, current_user: CurrentUser, chat_record_id: int, + current_assistant: CurrentAssistant, articles_number: Optional[int] = 4): + def _return_empty(): + yield 'data:' + orjson.dumps({'content': '[]', 'type': 'recommended_question'}).decode() + '\n\n' + try: record = get_chat_record_by_id(session, chat_record_id) if not record: - raise HTTPException( - status_code=400, - detail=f"Chat record with id {chat_record_id} not found" - ) + return StreamingResponse(_return_empty(), media_type="text/event-stream") + request_question = ChatQuestion(chat_id=record.chat_id, question=record.question if record.question else '') - llm_service = LLMService(current_user, request_question, current_assistant, True) + llm_service = await LLMService.create(session, current_user, request_question, current_assistant, True) llm_service.set_record(record) + llm_service.set_articles_number(articles_number) llm_service.run_recommend_questions_task_async() except Exception as e: traceback.print_exc() - raise HTTPException( - status_code=500, - detail=str(e) - ) + + def _err(_e: Exception): + yield 'data:' + orjson.dumps({'content': str(_e), 'type': 'error'}).decode() + '\n\n' + + return StreamingResponse(_err(e), media_type="text/event-stream") return StreamingResponse(llm_service.await_result(), media_type="text/event-stream") -@router.post("/question") -async def stream_sql(session: SessionDep, current_user: CurrentUser, request_question: ChatQuestion, - current_assistant: CurrentAssistant): - """Stream SQL analysis results - - Args: - session: Database session - current_user: CurrentUser - request_question: User question model - - Returns: - Streaming response with analysis results - """ +@router.get("/recent_questions/{datasource_id}", response_model=List[str], + summary=f"{PLACEHOLDER_PREFIX}get_recommend_questions") +# @require_permissions(permission=SqlbotPermission(type='ds', keyExpression="datasource_id")) +async def recommend_questions(session: SessionDep, current_user: CurrentUser, + datasource_id: int = Path(..., description=f"{PLACEHOLDER_PREFIX}ds_id")): + return list_recent_questions(session=session, current_user=current_user, datasource_id=datasource_id) + + +def find_base_question(record_id: int, session: SessionDep): + stmt = select(ChatRecord.question, ChatRecord.regenerate_record_id).where( + and_(ChatRecord.id == record_id)) + _record = session.execute(stmt).fetchone() + if not _record: + raise Exception(f'Cannot find base chat record') + rec_question, rec_regenerate_record_id = _record + if rec_regenerate_record_id: + return find_base_question(rec_regenerate_record_id, session) + else: + return rec_question + + +@router.post("/question", summary=f"{PLACEHOLDER_PREFIX}ask_question") +@require_permissions(permission=SqlbotPermission(type='chat', keyExpression="request_question.chat_id")) +async def question_answer(session: SessionDep, current_user: CurrentUser, request_question: ChatQuestionBase, + current_assistant: CurrentAssistant): + question = ChatQuestion(chat_id=request_question.chat_id, question=request_question.question) + return await question_answer_inner(session, current_user, question, current_assistant, embedding=True) + + +async def question_answer_inner(session: SessionDep, current_user: CurrentUser, request_question: ChatQuestion, + current_assistant: Optional[CurrentAssistant] = None, in_chat: bool = True, + stream: bool = True, + finish_step: ChatFinishStep = ChatFinishStep.GENERATE_CHART, embedding: bool = False, + return_img: bool = True): + try: + command, text_before_command, record_id, warning_info = parse_quick_command(request_question.question) + if command: + # todo 对话界面下,暂不支持分析和预测,需要改造前端 + if in_chat and (command == QuickCommand.ANALYSIS or command == QuickCommand.PREDICT_DATA): + raise Exception(f'Command: {command.value} temporary not supported') + + if record_id is not None: + # 排除analysis和predict + stmt = select(ChatRecord.id, ChatRecord.chat_id, ChatRecord.analysis_record_id, + ChatRecord.predict_record_id, ChatRecord.regenerate_record_id, + ChatRecord.first_chat).where( + and_(ChatRecord.id == record_id)).order_by(ChatRecord.create_time.desc()) + _record = session.execute(stmt).fetchone() + if not _record: + raise Exception(f'Record id: {record_id} does not exist') + + rec_id, rec_chat_id, rec_analysis_record_id, rec_predict_record_id, rec_regenerate_record_id, rec_first_chat = _record + + if rec_chat_id != request_question.chat_id: + raise Exception(f'Record id: {record_id} does not belong to this chat') + if rec_first_chat: + raise Exception(f'Record id: {record_id} does not support this operation') + + if rec_analysis_record_id: + raise Exception('Analysis record does not support this operation') + if rec_predict_record_id: + raise Exception('Predict data record does not support this operation') + + else: # get last record id + stmt = select(ChatRecord.id, ChatRecord.chat_id, ChatRecord.regenerate_record_id).where( + and_(ChatRecord.chat_id == request_question.chat_id, + ChatRecord.first_chat == False, + ChatRecord.analysis_record_id.is_(None), + ChatRecord.predict_record_id.is_(None))).order_by( + ChatRecord.create_time.desc()).limit(1) + _record = session.execute(stmt).fetchone() + + if not _record: + raise Exception(f'You have not ask any question') + + rec_id, rec_chat_id, rec_regenerate_record_id = _record + + # 没有指定的,就查询上一个 + if not rec_regenerate_record_id: + rec_regenerate_record_id = rec_id + + # 针对已经是重新生成的提问,需要找到原来的提问是什么 + base_question_text = find_base_question(rec_regenerate_record_id, session) + text_before_command = text_before_command + ("\n" if text_before_command else "") + base_question_text + + if command == QuickCommand.REGENERATE: + request_question.question = text_before_command + request_question.regenerate_record_id = rec_id + return await stream_sql(session, current_user, request_question, current_assistant, in_chat, stream, + finish_step, embedding, return_img) + + elif command == QuickCommand.ANALYSIS: + return await analysis_or_predict(session, current_user, rec_id, 'analysis', current_assistant, in_chat, + stream) + + elif command == QuickCommand.PREDICT_DATA: + return await analysis_or_predict(session, current_user, rec_id, 'predict', current_assistant, in_chat, + stream) + else: + raise Exception(f'Unknown command: {command.value}') + else: + return await stream_sql(session, current_user, request_question, current_assistant, in_chat, stream, + finish_step, embedding, return_img) + except Exception as e: + traceback.print_exc() + if stream: + def _err(_e: Exception): + if in_chat: + yield 'data:' + orjson.dumps({'content': str(_e), 'type': 'error'}).decode() + '\n\n' + else: + yield f'❌ **ERROR:**\n' + yield f'> {str(_e)}\n' + + return StreamingResponse(_err(e), media_type="text/event-stream") + else: + return JSONResponse( + content={'message': str(e)}, + status_code=500, + ) + + +async def stream_sql(session: SessionDep, current_user: CurrentUser, request_question: ChatQuestion, + current_assistant: Optional[CurrentAssistant] = None, in_chat: bool = True, stream: bool = True, + finish_step: ChatFinishStep = ChatFinishStep.GENERATE_CHART, embedding: bool = False, + return_img: bool = True): try: - llm_service = LLMService(current_user, request_question, current_assistant) - llm_service.init_record() - llm_service.run_task_async() + llm_service = await LLMService.create(session, current_user, request_question, current_assistant, + embedding=embedding) + llm_service.init_record(session=session) + llm_service.run_task_async(in_chat=in_chat, stream=stream, finish_step=finish_step, return_img=return_img) except Exception as e: traceback.print_exc() - raise HTTPException( - status_code=500, - detail=str(e) + + if stream: + def _err(_e: Exception): + yield 'data:' + orjson.dumps({'content': str(_e), 'type': 'error'}).decode() + '\n\n' + + return StreamingResponse(_err(e), media_type="text/event-stream") + else: + return JSONResponse( + content={'message': str(e)}, + status_code=500, + ) + if stream: + return StreamingResponse(llm_service.await_result(), media_type="text/event-stream") + else: + res = llm_service.await_result() + raw_data = {} + for chunk in res: + if chunk: + raw_data = chunk + status_code = 200 + if not raw_data.get('success'): + status_code = 500 + + return JSONResponse( + content=raw_data, + status_code=status_code, ) - return StreamingResponse(llm_service.await_result(), media_type="text/event-stream") + +@router.post("/record/{chat_record_id}/{action_type}", summary=f"{PLACEHOLDER_PREFIX}analysis_or_predict") +async def analysis_or_predict_question(session: SessionDep, current_user: CurrentUser, + current_assistant: CurrentAssistant, chat_record_id: int, + action_type: str = Path(..., + description=f"{PLACEHOLDER_PREFIX}analysis_or_predict_action_type")): + return await analysis_or_predict(session, current_user, chat_record_id, action_type, current_assistant) -@router.post("/record/{chat_record_id}/{action_type}") async def analysis_or_predict(session: SessionDep, current_user: CurrentUser, chat_record_id: int, action_type: str, - current_assistant: CurrentAssistant): - if action_type != 'analysis' and action_type != 'predict': - raise HTTPException( - status_code=404, - detail="Not Found" + current_assistant: CurrentAssistant, in_chat: bool = True, stream: bool = True): + try: + if action_type != 'analysis' and action_type != 'predict': + raise Exception(f"Type {action_type} Not Found") + record: ChatRecord | None = None + + stmt = select(ChatRecord.id, ChatRecord.question, ChatRecord.chat_id, ChatRecord.datasource, + ChatRecord.engine_type, + ChatRecord.ai_modal_id, ChatRecord.create_by, ChatRecord.chart, ChatRecord.data).where( + and_(ChatRecord.id == chat_record_id)) + result = session.execute(stmt) + for r in result: + record = ChatRecord(id=r.id, question=r.question, chat_id=r.chat_id, datasource=r.datasource, + engine_type=r.engine_type, ai_modal_id=r.ai_modal_id, create_by=r.create_by, + chart=r.chart, + data=r.data) + + if not record: + raise Exception(f"Chat record with id {chat_record_id} not found") + + if not record.chart: + raise Exception( + f"Chat record with id {chat_record_id} has not generated chart, do not support to analyze it") + + request_question = ChatQuestion(chat_id=record.chat_id, question=record.question) + + llm_service = await LLMService.create(session, current_user, request_question, current_assistant) + llm_service.run_analysis_or_predict_task_async(session, action_type, record, in_chat, stream) + except Exception as e: + traceback.print_exc() + if stream: + def _err(_e: Exception): + if in_chat: + yield 'data:' + orjson.dumps({'content': str(_e), 'type': 'error'}).decode() + '\n\n' + else: + yield f'❌ **ERROR:**\n' + yield f'> {str(_e)}\n' + + return StreamingResponse(_err(e), media_type="text/event-stream") + else: + return JSONResponse( + content={'message': str(e)}, + status_code=500, + ) + if stream: + return StreamingResponse(llm_service.await_result(), media_type="text/event-stream") + else: + res = llm_service.await_result() + raw_data = {} + for chunk in res: + if chunk: + raw_data = chunk + status_code = 200 + if not raw_data.get('success'): + status_code = 500 + + return JSONResponse( + content=raw_data, + status_code=status_code, ) - record: ChatRecord | None = None - - stmt = select(ChatRecord.id, ChatRecord.question, ChatRecord.chat_id, ChatRecord.datasource, ChatRecord.engine_type, - ChatRecord.ai_modal_id, ChatRecord.create_by, ChatRecord.chart, ChatRecord.data).where( - and_(ChatRecord.id == chat_record_id)) - result = session.execute(stmt) - for r in result: - record = ChatRecord(id=r.id, question=r.question, chat_id=r.chat_id, datasource=r.datasource, - engine_type=r.engine_type, ai_modal_id=r.ai_modal_id, create_by=r.create_by, chart=r.chart, - data=r.data) - - if not record: + + +@router.get("/record/{chat_record_id}/excel/export/{chat_id}", summary=f"{PLACEHOLDER_PREFIX}export_chart_data") +@system_log(LogConfig(operation_type=OperationType.EXPORT, module=OperationModules.CHAT, resource_id_expr="chat_id", )) +async def export_excel(session: SessionDep, current_user: CurrentUser, chat_record_id: int, chat_id: int, trans: Trans): + chat_record = session.get(ChatRecord, chat_record_id) + if not chat_record: raise HTTPException( - status_code=400, - detail=f"Chat record with id {chat_record_id} not found" + status_code=500, + detail=f"ChatRecord with id {chat_record_id} not found" ) - - if not record.chart: + if chat_record.create_by != current_user.id: raise HTTPException( status_code=500, - detail=f"Chat record with id {chat_record_id} has not generated chart, do not support to analyze it" + detail=f"ChatRecord with id {chat_record_id} not Owned by the current user" ) + is_predict_data = chat_record.predict_record_id is not None - request_question = ChatQuestion(chat_id=record.chat_id, question='') + _origin_data = format_json_data(get_chat_chart_data(chat_record_id=chat_record_id, session=session)) - try: - llm_service = LLMService(current_user, request_question, current_assistant) - llm_service.run_analysis_or_predict_task_async(action_type, record) - except Exception as e: - traceback.print_exc() + _base_field = _origin_data.get('fields') + _data = _origin_data.get('data') + + if not _data: raise HTTPException( status_code=500, - detail=str(e) + detail=trans("i18n_excel_export.data_is_empty") ) - return StreamingResponse(llm_service.await_result(), media_type="text/event-stream") + chart_info = get_chart_config(session, chat_record_id) + + _title = chart_info.get('title') if chart_info.get('title') else 'Excel' + + fields = [] + if chart_info.get('columns') and len(chart_info.get('columns')) > 0: + for column in chart_info.get('columns'): + fields.append(AxisObj(name=column.get('name'), value=column.get('value'))) + # 处理 axis + if axis := chart_info.get('axis'): + # 处理 x 轴 + if x_axis := axis.get('x'): + if 'name' in x_axis or 'value' in x_axis: + fields.append(AxisObj(name=x_axis.get('name'), value=x_axis.get('value'))) + + # 处理 y 轴 - 兼容数组和对象格式 + if y_axis := axis.get('y'): + if isinstance(y_axis, list): + for column in y_axis: + if 'name' in column or 'value' in column: + fields.append(AxisObj(name=column.get('name'), value=column.get('value'))) + elif isinstance(y_axis, dict) and ('name' in y_axis or 'value' in y_axis): + fields.append(AxisObj(name=y_axis.get('name'), value=y_axis.get('value'))) + + # 处理 series + if series := axis.get('series'): + if 'name' in series or 'value' in series: + fields.append(AxisObj(name=series.get('name'), value=series.get('value'))) + + _predict_data = [] + if is_predict_data: + _predict_data = format_json_list_data(get_chat_predict_data(chat_record_id=chat_record_id, session=session)) - -@router.post("/excel/export") -async def export_excel(excel_data: ExcelData): def inner(): - _fields_list = [] - data = [] - for _data in excel_data.data: - _row = [] - for field in excel_data.axis: - _row.append(_data.get(field.value)) - data.append(_row) - for field in excel_data.axis: - _fields_list.append(field.name) - df = pd.DataFrame(np.array(data), columns=_fields_list) + + data_list = DataFormat.convert_large_numbers_in_object_array(obj_array=_data + _predict_data, + int_threshold=1e11) + + md_data, _fields_list = DataFormat.convert_object_array_for_pandas(fields, data_list) + + # data, _fields_list, col_formats = LLMService.format_pd_data(fields, _data + _predict_data) + + df = pd.DataFrame(md_data, columns=_fields_list) buffer = io.BytesIO() with pd.ExcelWriter(buffer, engine='xlsxwriter', - engine_kwargs={'options': {'strings_to_numbers': True}}) as writer: + engine_kwargs={'options': {'strings_to_numbers': False}}) as writer: df.to_excel(writer, sheet_name='Sheet1', index=False) + # 获取 xlsxwriter 的工作簿和工作表对象 + # workbook = writer.book + # worksheet = writer.sheets['Sheet1'] + # + # for col_idx, fmt_type in col_formats.items(): + # if fmt_type == 'text': + # worksheet.set_column(col_idx, col_idx, None, workbook.add_format({'num_format': '@'})) + # elif fmt_type == 'number': + # worksheet.set_column(col_idx, col_idx, None, workbook.add_format({'num_format': '0'})) + buffer.seek(0) return io.BytesIO(buffer.getvalue()) diff --git a/backend/apps/chat/curd/chat.py b/backend/apps/chat/curd/chat.py index b715b32e6..e261c2b6f 100644 --- a/backend/apps/chat/curd/chat.py +++ b/backend/apps/chat/curd/chat.py @@ -1,15 +1,25 @@ import datetime -from typing import List +from decimal import Decimal +from typing import List, Optional, Union, Dict, Any import orjson import sqlparse from sqlalchemy import and_, select, update +from sqlalchemy import desc, func +from sqlalchemy.orm import aliased -from apps.chat.models.chat_model import Chat, ChatRecord, CreateChat, ChatInfo, RenameChat, ChatQuestion +from apps.chat.models.chat_model import Chat, ChatRecord, CreateChat, ChatInfo, RenameChat, ChatQuestion, ChatLog, \ + TypeEnum, OperationEnum, ChatRecordResult, ChatLogHistory, ChatLogHistoryItem +from apps.datasource.crud.datasource import get_ds +from apps.datasource.crud.recommended_problem import get_datasource_recommended_chart from apps.datasource.models.datasource import CoreDatasource -from apps.system.crud.assistant import AssistantOutDsFactory -from common.core.deps import CurrentAssistant, SessionDep, CurrentUser -from common.utils.utils import extract_nested_json +from apps.db.constant import DB +from apps.db.db import exec_sql +from apps.system.crud.assistant import AssistantOutDs, AssistantOutDsFactory +from apps.system.schemas.system_schema import AssistantOutDsSchema +from common.core.deps import CurrentAssistant, SessionDep, CurrentUser, Trans +from common.utils.data_format import DataFormat +from common.utils.utils import extract_nested_json, SQLBotLogUtil def get_chat_record_by_id(session: SessionDep, record_id: int): @@ -25,6 +35,12 @@ def get_chat_record_by_id(session: SessionDep, record_id: int): return record +def get_chat(session: SessionDep, chat_id: int) -> Chat: + statement = select(Chat).where(Chat.id == chat_id) + chat = session.exec(statement).scalars().first() + return chat + + def list_chats(session: SessionDep, current_user: CurrentUser) -> List[Chat]: oid = current_user.oid if current_user.oid is not None else 1 chart_list = session.query(Chat).filter(and_(Chat.create_by == current_user.id, Chat.oid == oid)).order_by( @@ -32,12 +48,49 @@ def list_chats(session: SessionDep, current_user: CurrentUser) -> List[Chat]: return chart_list +def list_recent_questions(session: SessionDep, current_user: CurrentUser, datasource_id: int) -> List[str]: + chat_records = ( + session.query( + ChatRecord.question + ) + .join(Chat, ChatRecord.chat_id == Chat.id) # 关联Chat表 + .filter( + Chat.datasource == datasource_id, # 使用Chat表的datasource字段 + ChatRecord.question.isnot(None), + ChatRecord.create_by == current_user.id + ) + .group_by(ChatRecord.question) + .order_by(desc(func.max(ChatRecord.create_time))) + .limit(10) + .all() + ) + return [record[0] for record in chat_records] if chat_records else [] + + +def rename_chat_with_user(session: SessionDep, current_user: CurrentUser, rename_object: RenameChat) -> str: + chat = session.get(Chat, rename_object.id) + if not chat: + raise Exception(f"Chat with id {rename_object.id} not found") + if chat.create_by != current_user.id: + raise Exception(f"Chat with id {rename_object.id} not Owned by the current user") + chat.brief = rename_object.brief.strip()[:20] + chat.brief_generate = rename_object.brief_generate + session.add(chat) + session.flush() + session.refresh(chat) + + brief = chat.brief + session.commit() + return brief + + def rename_chat(session: SessionDep, rename_object: RenameChat) -> str: chat = session.get(Chat, rename_object.id) if not chat: raise Exception(f"Chat with id {rename_object.id} not found") chat.brief = rename_object.brief.strip()[:20] + chat.brief_generate = rename_object.brief_generate session.add(chat) session.flush() session.refresh(chat) @@ -58,6 +111,18 @@ def delete_chat(session, chart_id) -> str: return f'Chat with id {chart_id} has been deleted' +def delete_chat_with_user(session, current_user: CurrentUser, chart_id) -> str: + chat = session.query(Chat).filter(Chat.id == chart_id).first() + if not chat: + return f'Chat with id {chart_id} has been deleted' + if chat.create_by != current_user.id: + raise Exception(f"Chat with id {chart_id} not Owned by the current user") + session.delete(chat) + session.commit() + + return f'Chat with id {chart_id} has been deleted' + + def get_chart_config(session: SessionDep, chart_record_id: int): stmt = select(ChatRecord.chart).where(and_(ChatRecord.id == chart_record_id)) res = session.execute(stmt) @@ -69,26 +134,173 @@ def get_chart_config(session: SessionDep, chart_record_id: int): return {} -def get_chat_chart_data(session: SessionDep, chart_record_id: int): - stmt = select(ChatRecord.data).where(and_(ChatRecord.id == chart_record_id)) +def _format_column(column: dict) -> str: + """格式化单个column字段""" + value = column.get('value', '') + name = column.get('name', '') + if value != name and name: + return f"{value}({name})" + return value + + +def format_chart_fields(chart_info: dict) -> list: + fields = [] + + # 处理 columns + for column in chart_info.get('columns') or []: + fields.append(_format_column(column)) + + # 处理 axis + if axis := chart_info.get('axis'): + # 处理 x 轴 + if x_axis := axis.get('x'): + fields.append(_format_column(x_axis)) + + # 处理 y 轴 + if y_axis := axis.get('y'): + if isinstance(y_axis, list): + for column in y_axis: + fields.append(_format_column(column)) + else: + fields.append(_format_column(y_axis)) + + # 处理 series + if series := axis.get('series'): + fields.append(_format_column(series)) + + return [field for field in fields if field] # 过滤空字符串 + + +def get_last_execute_sql_error(session: SessionDep, chart_id: int): + stmt = select(ChatRecord.error).where(and_(ChatRecord.chat_id == chart_id)).order_by( + ChatRecord.create_time.desc()).limit(1) + res = session.execute(stmt).scalar() + if res: + try: + obj = orjson.loads(res) + if obj.get('type') and obj.get('type') == 'exec-sql-err': + return obj.get('traceback') + except Exception: + pass + + return None + + +def format_json_data(origin_data: dict): + result = {'fields': origin_data.get('fields') if origin_data.get('fields') else [], + 'fields_info': origin_data.get('fields_info') if origin_data.get('fields_info') else None} + _list = origin_data.get('data') if origin_data.get('data') else [] + data = format_json_list_data(_list) + result['data'] = data + + return result + + +def format_json_list_data(origin_data: list[dict]): + data = [] + for _data in origin_data if origin_data else []: + _row = {} + for key, value in _data.items(): + if value is not None: + # 检查是否为数字且需要特殊处理 + if isinstance(value, (int, float)): + # 整数且超过15位 → 转字符串并标记为文本列 + if isinstance(value, int) and len(str(abs(value))) > 15: + value = str(value) + # 小数且超过15位有效数字 → 转字符串并标记为文本列 + elif isinstance(value, float): + decimal_str = str(Decimal(str(value))).rstrip('0').rstrip('.') + if len(decimal_str) > 15: + value = str(value) + _row[key] = value + data.append(DataFormat.normalize_qualified_sql_column_keys(_row)) + + return data + + +def get_chat_chart_config(session: SessionDep, chat_record_id: int): + stmt = select(ChatRecord.chart).where(and_(ChatRecord.id == chat_record_id)) + res = session.execute(stmt) + for row in res: + try: + return orjson.loads(row.chart) + except Exception: + pass + return {} + + +def get_chart_data_with_user(session: SessionDep, current_user: CurrentUser, chat_record_id: int): + stmt = select(ChatRecord.data).where(and_(ChatRecord.id == chat_record_id, ChatRecord.create_by == current_user.id)) + res = session.execute(stmt) + for row in res: + try: + return orjson.loads(row.data) + except Exception: + pass + return {} + + +def get_chart_data_with_user_live(session: SessionDep, current_user: CurrentUser, chat_record_id: int): + stmt = select(ChatRecord.datasource, ChatRecord.sql).where( + and_(ChatRecord.id == chat_record_id, ChatRecord.create_by == current_user.id)) + row = session.execute(stmt).first() + return get_chart_data_ds(session, row.datasource, row.sql) + + +def get_chart_data_ds(session: SessionDep, ds_id, sql): + json_result: Dict[str, Any] = {'status': 'success', 'data': [], 'message': ''} + try: + datasource = get_ds(session, ds_id) + if datasource is None: + json_result['status'] = 'failed' + json_result['message'] = 'Datasource not found' + return json_result + else: + result = exec_sql(ds=datasource, sql=sql, origin_column=False) + _data = DataFormat.convert_large_numbers_in_object_array(result.get('data')) + _data = DataFormat.normalize_qualified_sql_column_keys_in_object_array(_data) + json_result['data'] = _data + return json_result + except Exception as e: + SQLBotLogUtil.error(f"Function failed: {e}") + json_result['status'] = 'failed' + json_result['message'] = f"{e}" + pass + return json_result + + +def get_chat_chart_data(session: SessionDep, chat_record_id: int): + stmt = select(ChatRecord.data).where(and_(ChatRecord.id == chat_record_id)) res = session.execute(stmt) for row in res: try: return orjson.loads(row.data) except Exception: pass - return [] + return {} -def get_chat_predict_data(session: SessionDep, chart_record_id: int): - stmt = select(ChatRecord.predict_data).where(and_(ChatRecord.id == chart_record_id)) +def get_chat_predict_data_with_user(session: SessionDep, current_user: CurrentUser, chat_record_id: int): + stmt = select(ChatRecord.predict_data).where( + and_(ChatRecord.id == chat_record_id, ChatRecord.create_by == current_user.id)) res = session.execute(stmt) for row in res: try: return orjson.loads(row.predict_data) except Exception: pass - return [] + return {} + + +def get_chat_predict_data(session: SessionDep, chat_record_id: int): + stmt = select(ChatRecord.predict_data).where(and_(ChatRecord.id == chat_record_id)) + res = session.execute(stmt) + for row in res: + try: + return orjson.loads(row.predict_data) + except Exception: + pass + return {} def get_chat_with_records_with_data(session: SessionDep, chart_id: int, current_user: CurrentUser, @@ -96,17 +308,22 @@ def get_chat_with_records_with_data(session: SessionDep, chart_id: int, current_ return get_chat_with_records(session, chart_id, current_user, current_assistant, True) +dynamic_ds_types = [1, 3] + + def get_chat_with_records(session: SessionDep, chart_id: int, current_user: CurrentUser, - current_assistant: CurrentAssistant, with_data: bool = False) -> ChatInfo: + current_assistant: CurrentAssistant, with_data: bool = False, + trans: Trans = None) -> ChatInfo: chat = session.get(Chat, chart_id) if not chat: raise Exception(f"Chat with id {chart_id} not found") - + if chat.create_by != current_user.id: + raise Exception(f"Chat with id {chart_id} not Owned by the current user") chat_info = ChatInfo(**chat.model_dump()) - if current_assistant and current_assistant.type == 1: + if current_assistant and current_assistant.type in dynamic_ds_types: out_ds_instance = AssistantOutDsFactory.get_instance(current_assistant) - ds = out_ds_instance.get_ds(chat.datasource) + ds = out_ds_instance.get_ds(chat.datasource, trans) else: ds = session.get(CoreDatasource, chat.datasource) if chat.datasource else None @@ -118,75 +335,179 @@ def get_chat_with_records(session: SessionDep, chart_id: int, current_user: Curr chat_info.datasource_name = ds.name chat_info.ds_type = ds.type - stmt = select(ChatRecord.id, ChatRecord.chat_id, ChatRecord.create_time, ChatRecord.finish_time, - ChatRecord.question, ChatRecord.sql_answer, ChatRecord.sql, - ChatRecord.chart_answer, ChatRecord.chart, ChatRecord.analysis, ChatRecord.predict, - ChatRecord.datasource_select_answer, ChatRecord.analysis_record_id, ChatRecord.predict_record_id, - ChatRecord.recommended_question, ChatRecord.first_chat, - ChatRecord.finish, ChatRecord.error).where( - and_(ChatRecord.create_by == current_user.id, ChatRecord.chat_id == chart_id)).order_by(ChatRecord.create_time) + sql_alias_log = aliased(ChatLog) + chart_alias_log = aliased(ChatLog) + analysis_alias_log = aliased(ChatLog) + predict_alias_log = aliased(ChatLog) + + stmt = (select(ChatRecord.id, ChatRecord.chat_id, ChatRecord.create_time, ChatRecord.finish_time, + ChatRecord.question, ChatRecord.sql_answer, ChatRecord.sql, ChatRecord.datasource, + ChatRecord.chart_answer, ChatRecord.chart, ChatRecord.analysis, ChatRecord.predict, + ChatRecord.datasource_select_answer, ChatRecord.analysis_record_id, ChatRecord.predict_record_id, + ChatRecord.regenerate_record_id, + ChatRecord.recommended_question, ChatRecord.first_chat, + ChatRecord.finish, ChatRecord.error, + sql_alias_log.reasoning_content.label('sql_reasoning_content'), + chart_alias_log.reasoning_content.label('chart_reasoning_content'), + analysis_alias_log.reasoning_content.label('analysis_reasoning_content'), + predict_alias_log.reasoning_content.label('predict_reasoning_content') + ) + .outerjoin(sql_alias_log, and_(sql_alias_log.pid == ChatRecord.id, + sql_alias_log.type == TypeEnum.CHAT, + sql_alias_log.operate == OperationEnum.GENERATE_SQL)) + .outerjoin(chart_alias_log, and_(chart_alias_log.pid == ChatRecord.id, + chart_alias_log.type == TypeEnum.CHAT, + chart_alias_log.operate == OperationEnum.GENERATE_CHART)) + .outerjoin(analysis_alias_log, and_(analysis_alias_log.pid == ChatRecord.id, + analysis_alias_log.type == TypeEnum.CHAT, + analysis_alias_log.operate == OperationEnum.ANALYSIS)) + .outerjoin(predict_alias_log, and_(predict_alias_log.pid == ChatRecord.id, + predict_alias_log.type == TypeEnum.CHAT, + predict_alias_log.operate == OperationEnum.PREDICT_DATA)) + .where(and_(ChatRecord.create_by == current_user.id, ChatRecord.chat_id == chart_id)).order_by( + ChatRecord.create_time)) if with_data: stmt = select(ChatRecord.id, ChatRecord.chat_id, ChatRecord.create_time, ChatRecord.finish_time, - ChatRecord.question, ChatRecord.sql_answer, ChatRecord.sql, + ChatRecord.question, ChatRecord.sql_answer, ChatRecord.sql, ChatRecord.datasource, ChatRecord.chart_answer, ChatRecord.chart, ChatRecord.analysis, ChatRecord.predict, ChatRecord.datasource_select_answer, ChatRecord.analysis_record_id, ChatRecord.predict_record_id, + ChatRecord.regenerate_record_id, ChatRecord.recommended_question, ChatRecord.first_chat, ChatRecord.finish, ChatRecord.error, ChatRecord.data, ChatRecord.predict_data).where( and_(ChatRecord.create_by == current_user.id, ChatRecord.chat_id == chart_id)).order_by( ChatRecord.create_time) result = session.execute(stmt).all() - record_list: list[ChatRecord] = [] + record_list: list[ChatRecordResult] = [] + + # 批量获取所有ChatRecord的token消耗 + record_ids = [row.id for row in result] + token_usage_map = {} + + if record_ids: + # 查询所有相关ChatLog的token_usage + log_stmt = select(ChatLog.pid, ChatLog.token_usage).where( + and_( + ChatLog.pid.in_(record_ids), + ChatLog.local_operation == False, + ChatLog.operate != OperationEnum.GENERATE_RECOMMENDED_QUESTIONS, + ChatLog.token_usage.is_not(None) # 排除token_usage为空的记录 + ) + ) + log_results = session.execute(log_stmt).all() + + # 按pid分组计算total_tokens总和 + for pid, token_usage in log_results: + if pid and token_usage is not None: + tokens_to_add = 0 + + if isinstance(token_usage, dict): + # 处理字典类型: {"input_tokens": 961, "total_tokens": 1006, "output_tokens": 45} + if token_usage: # 非空字典 + if "total_tokens" in token_usage: + token_value = token_usage["total_tokens"] + if isinstance(token_value, (int, float)): + tokens_to_add = int(token_value) + elif isinstance(token_usage, (int, float)): + tokens_to_add = int(token_usage) + if tokens_to_add > 0: + if pid not in token_usage_map: + token_usage_map[pid] = 0 + token_usage_map[pid] += tokens_to_add + for row in result: + # 计算耗时 + duration = None + if row.create_time and row.finish_time: + try: + time_diff = row.finish_time - row.create_time + duration = time_diff.total_seconds() # 转换为秒 + except Exception: + duration = None + + # 获取token总消耗 + total_tokens = token_usage_map.get(row.id, 0) + if not with_data: record_list.append( - ChatRecord(id=row.id, chat_id=row.chat_id, create_time=row.create_time, finish_time=row.finish_time, - question=row.question, sql_answer=row.sql_answer, sql=row.sql, - chart_answer=row.chart_answer, chart=row.chart, - analysis=row.analysis, predict=row.predict, - datasource_select_answer=row.datasource_select_answer, - analysis_record_id=row.analysis_record_id, predict_record_id=row.predict_record_id, - recommended_question=row.recommended_question, first_chat=row.first_chat, - finish=row.finish, error=row.error)) + ChatRecordResult(id=row.id, chat_id=row.chat_id, create_time=row.create_time, + finish_time=row.finish_time, + duration=duration, + total_tokens=total_tokens, + question=row.question, sql_answer=row.sql_answer, sql=row.sql, + datasource=row.datasource, + chart_answer=row.chart_answer, chart=row.chart, + analysis=row.analysis, predict=row.predict, + datasource_select_answer=row.datasource_select_answer, + analysis_record_id=row.analysis_record_id, predict_record_id=row.predict_record_id, + regenerate_record_id=row.regenerate_record_id, + recommended_question=row.recommended_question, first_chat=row.first_chat, + finish=row.finish, error=row.error, + sql_reasoning_content=row.sql_reasoning_content, + chart_reasoning_content=row.chart_reasoning_content, + analysis_reasoning_content=row.analysis_reasoning_content, + predict_reasoning_content=row.predict_reasoning_content, + )) else: record_list.append( - ChatRecord(id=row.id, chat_id=row.chat_id, create_time=row.create_time, finish_time=row.finish_time, - question=row.question, sql_answer=row.sql_answer, sql=row.sql, - chart_answer=row.chart_answer, chart=row.chart, - analysis=row.analysis, predict=row.predict, - datasource_select_answer=row.datasource_select_answer, - analysis_record_id=row.analysis_record_id, predict_record_id=row.predict_record_id, - recommended_question=row.recommended_question, first_chat=row.first_chat, - finish=row.finish, error=row.error, data=row.data, predict_data=row.predict_data)) + ChatRecordResult(id=row.id, chat_id=row.chat_id, create_time=row.create_time, + finish_time=row.finish_time, + duration=duration, + total_tokens=total_tokens, + question=row.question, sql_answer=row.sql_answer, sql=row.sql, + datasource=row.datasource, + chart_answer=row.chart_answer, chart=row.chart, + analysis=row.analysis, predict=row.predict, + datasource_select_answer=row.datasource_select_answer, + analysis_record_id=row.analysis_record_id, predict_record_id=row.predict_record_id, + regenerate_record_id=row.regenerate_record_id, + recommended_question=row.recommended_question, first_chat=row.first_chat, + finish=row.finish, error=row.error, data=row.data, predict_data=row.predict_data)) result = list(map(format_record, record_list)) + for row in result: + try: + data_value = row.get('data') + if data_value is not None: + row['data'] = format_json_data(data_value) + except Exception: + pass + chat_info.records = result return chat_info -def format_record(record: ChatRecord): +def format_record(record: ChatRecordResult): _dict = record.model_dump() if record.sql_answer and record.sql_answer.strip() != '' and record.sql_answer.strip()[0] == '{' and \ record.sql_answer.strip()[-1] == '}': _obj = orjson.loads(record.sql_answer) _dict['sql_answer'] = _obj.get('reasoning_content') + if record.sql_reasoning_content and record.sql_reasoning_content.strip() != '': + _dict['sql_answer'] = record.sql_reasoning_content if record.chart_answer and record.chart_answer.strip() != '' and record.chart_answer.strip()[0] == '{' and \ record.chart_answer.strip()[-1] == '}': _obj = orjson.loads(record.chart_answer) _dict['chart_answer'] = _obj.get('reasoning_content') + if record.chart_reasoning_content and record.chart_reasoning_content.strip() != '': + _dict['chart_answer'] = record.chart_reasoning_content if record.analysis and record.analysis.strip() != '' and record.analysis.strip()[0] == '{' and \ record.analysis.strip()[-1] == '}': _obj = orjson.loads(record.analysis) _dict['analysis_thinking'] = _obj.get('reasoning_content') _dict['analysis'] = _obj.get('content') + if record.analysis_reasoning_content and record.analysis_reasoning_content.strip() != '': + _dict['analysis_thinking'] = record.analysis_reasoning_content if record.predict and record.predict.strip() != '' and record.predict.strip()[0] == '{' and record.predict.strip()[ -1] == '}': _obj = orjson.loads(record.predict) _dict['predict'] = _obj.get('reasoning_content') _dict['predict_content'] = _obj.get('content') + if record.predict_reasoning_content and record.predict_reasoning_content.strip() != '': + _dict['predict'] = record.predict_reasoning_content if record.data and record.data.strip() != '': try: _obj = orjson.loads(record.data) @@ -205,26 +526,187 @@ def format_record(record: ChatRecord): except Exception: pass + # 格式化duration字段,保留2位小数 + if 'duration' in _dict and _dict['duration'] is not None: + try: + # 可以格式化为更易读的形式 + _dict['duration'] = round(_dict['duration'], 2) # 保留2位小数 + except Exception: + pass + + # 格式化total_tokens字段 + if 'total_tokens' in _dict and _dict['total_tokens'] is not None: + try: + # 确保是整数类型 + _dict['total_tokens'] = int(_dict['total_tokens']) if _dict['total_tokens'] else 0 + except Exception: + _dict['total_tokens'] = 0 + + # 去除返回前端多余的字段 + _dict.pop('sql_reasoning_content', None) + _dict.pop('chart_reasoning_content', None) + _dict.pop('analysis_reasoning_content', None) + _dict.pop('predict_reasoning_content', None) + return _dict -def list_base_records(session: SessionDep, chart_id: int, current_user: CurrentUser) -> List[ChatRecord]: - stmt = select(ChatRecord.id, ChatRecord.chat_id, ChatRecord.full_sql_message, ChatRecord.full_chart_message, - ChatRecord.first_chat, ChatRecord.create_time).where( - and_(ChatRecord.create_by == current_user.id, ChatRecord.chat_id == chart_id, - ChatRecord.analysis_record_id.is_(None), ChatRecord.predict_record_id.is_(None))).order_by( - ChatRecord.create_time) +def get_chat_log_history(session: SessionDep, chat_record_id: int, current_user: CurrentUser, + without_steps: bool = False) -> ChatLogHistory: + """ + 获取ChatRecord的详细历史记录 + + Args: + session: 数据库会话 + chat_record_id: ChatRecord的ID + current_user: 当前用户 + without_steps + + Returns: + ChatLogHistory: 包含历史步骤和时间信息的对象 + """ + # 1. 首先验证ChatRecord存在且属于当前用户 + chat_record = session.get(ChatRecord, chat_record_id) + if not chat_record: + raise Exception(f"ChatRecord with id {chat_record_id} not found") + + if chat_record.create_by != current_user.id: + raise Exception(f"ChatRecord with id {chat_record_id} not owned by the current user") + + # 2. 查询与该ChatRecord相关的所有ChatLog记录 + chat_logs = session.query(ChatLog).filter( + ChatLog.pid == chat_record_id, + ChatLog.operate != OperationEnum.GENERATE_RECOMMENDED_QUESTIONS + ).order_by(ChatLog.start_time).all() + + # 3. 计算总的时间和token信息 + total_tokens = 0 + steps = [] + + for log in chat_logs: + # 计算单条记录的token消耗 + log_tokens = 0 + if log.token_usage is not None: + if isinstance(log.token_usage, dict): + if log.token_usage and "total_tokens" in log.token_usage: + token_value = log.token_usage["total_tokens"] + if isinstance(token_value, (int, float)): + log_tokens = int(token_value) + elif isinstance(log.token_usage, (int, float)): + log_tokens = log.token_usage + + # 累加到总token消耗 + total_tokens += log_tokens + + if not without_steps: + # 计算单条记录的耗时 + duration = None + if log.start_time and log.finish_time: + try: + time_diff = log.finish_time - log.start_time + duration = round(time_diff.total_seconds(), 2) + except Exception: + duration = None + + # 获取操作类型的枚举名称 + operate_name = None + message = None + if log.operate: + # 如果是OperationEnum枚举实例 + if isinstance(log.operate, OperationEnum): + operate_name = log.operate.name + # 如果是字符串,尝试从枚举值获取名称 + elif isinstance(log.operate, str): + try: + # 通过枚举值找到对应的枚举实例 + for enum_item in OperationEnum: + if enum_item.value == log.operate: + operate_name = enum_item.name + break + except Exception: + operate_name = log.operate + else: + operate_name = str(log.operate) + + if log.messages is not None: + message = log.messages + if not log.operate == OperationEnum.CHOOSE_TABLE: + try: + message = orjson.loads(log.messages) + except Exception: + pass + + # 创建ChatLogHistoryItem + history_item = ChatLogHistoryItem( + start_time=log.start_time, + finish_time=log.finish_time, + duration=duration, + total_tokens=log_tokens, + operate=operate_name, + local_operation=log.local_operation, + error=log.error, + message=message, + ) + + steps.append(history_item) + + # 4. 计算总耗时(使用ChatRecord的时间) + total_duration = None + if chat_record.create_time and chat_record.finish_time: + try: + time_diff = chat_record.finish_time - chat_record.create_time + total_duration = round(time_diff.total_seconds(), 2) + except Exception: + total_duration = None + + # 5. 创建并返回ChatLogHistory对象 + chat_log_history = ChatLogHistory( + start_time=chat_record.create_time, # 使用ChatRecord的create_time + finish_time=chat_record.finish_time, # 使用ChatRecord的finish_time + duration=total_duration, + total_tokens=total_tokens, + steps=steps + ) + + return chat_log_history + + +def get_chat_brief_generate(session: SessionDep, chat_id: int): + chat = get_chat(session=session, chat_id=chat_id) + if chat is not None and chat.brief_generate is not None: + return chat.brief_generate + else: + return False + + +def list_generate_sql_logs(session: SessionDep, chart_id: int) -> List[ChatLog]: + stmt = select(ChatLog).where( + and_(ChatLog.pid.in_(select(ChatRecord.id).where(and_(ChatRecord.chat_id == chart_id))), + ChatLog.type == TypeEnum.CHAT, ChatLog.operate == OperationEnum.GENERATE_SQL)).order_by( + ChatLog.start_time) result = session.execute(stmt).all() - record_list: List[ChatRecord] = [] - for r in result: - record_list.append( - ChatRecord(id=r.id, chat_id=r.chat_id, create_time=r.create_time, full_sql_message=r.full_sql_message, - full_chart_message=r.full_chart_message, first_chat=r.first_chat)) - return record_list + _list = [] + for row in result: + for r in row: + _list.append(ChatLog(**r.model_dump())) + return _list + + +def list_generate_chart_logs(session: SessionDep, chart_id: int) -> List[ChatLog]: + stmt = select(ChatLog).where( + and_(ChatLog.pid.in_(select(ChatRecord.id).where(and_(ChatRecord.chat_id == chart_id))), + ChatLog.type == TypeEnum.CHAT, ChatLog.operate == OperationEnum.GENERATE_CHART)).order_by( + ChatLog.start_time) + result = session.execute(stmt).all() + _list = [] + for row in result: + for r in row: + _list.append(ChatLog(**r.model_dump())) + return _list def create_chat(session: SessionDep, current_user: CurrentUser, create_chat_obj: CreateChat, - require_datasource: bool = True) -> ChatInfo: + require_datasource: bool = True, current_assistant: CurrentAssistant = None) -> ChatInfo: if not create_chat_obj.datasource and require_datasource: raise Exception("Datasource cannot be None") @@ -236,10 +718,17 @@ def create_chat(session: SessionDep, current_user: CurrentUser, create_chat_obj: oid=current_user.oid if current_user.oid is not None else 1, brief=create_chat_obj.question.strip()[:20], origin=create_chat_obj.origin if create_chat_obj.origin is not None else 0) - ds: CoreDatasource | None = None + ds: CoreDatasource | AssistantOutDsSchema | None = None if create_chat_obj.datasource: chat.datasource = create_chat_obj.datasource - ds = session.get(CoreDatasource, create_chat_obj.datasource) + if current_assistant and current_assistant.type == 1: + out_ds_instance: AssistantOutDs = AssistantOutDsFactory.get_instance(current_assistant) + ds = out_ds_instance.get_ds(chat.datasource) + ds.type_name = DB.get_db(ds.type) + else: + ds = session.get(CoreDatasource, create_chat_obj.datasource) + if ds.oid != current_user.oid: + raise Exception(f"Datasource with id {create_chat_obj.datasource} does not belong to current workspace") if not ds: raise Exception(f"Datasource with id {create_chat_obj.datasource} not found") @@ -271,6 +760,12 @@ def create_chat(session: SessionDep, current_user: CurrentUser, create_chat_obj: record.finish = True record.create_time = datetime.datetime.now() record.create_by = current_user.id + if isinstance(ds, CoreDatasource) and ds.recommended_config == 2: + questions = get_datasource_recommended_chart(session, ds.id) + record.recommended_question = orjson.dumps(questions).decode() + record.recommended_question_answer = orjson.dumps({ + "content": questions + }).decode() _record = ChatRecord(**record.model_dump()) @@ -304,6 +799,7 @@ def save_question(session: SessionDep, current_user: CurrentUser, question: Chat record.datasource = chat.datasource record.engine_type = chat.engine_type record.ai_modal_id = question.ai_modal_id + record.regenerate_record_id = question.regenerate_record_id result = ChatRecord(**record.model_dump()) @@ -344,125 +840,130 @@ def save_analysis_predict_record(session: SessionDep, base_record: ChatRecord, a return result -def save_full_sql_message(session: SessionDep, record_id: int, full_message: str) -> ChatRecord: - return save_full_sql_message_and_answer(session=session, record_id=record_id, full_message=full_message, answer='') +def start_log(session: SessionDep, ai_modal_id: int = None, ai_modal_name: str = None, operate: OperationEnum = None, + record_id: int = None, full_message: Union[list[dict], dict] = None, + local_operation: bool = False) -> ChatLog: + log = ChatLog(type=TypeEnum.CHAT, operate=operate, pid=record_id, ai_modal_id=ai_modal_id, base_modal=ai_modal_name, + messages=full_message, start_time=datetime.datetime.now(), local_operation=local_operation) + result = ChatLog(**log.model_dump()) -def save_full_sql_message_and_answer(session: SessionDep, record_id: int, answer: str, full_message: str, - token_usage: dict = None) -> ChatRecord: - if not record_id: - raise Exception("Record id cannot be None") - record = get_chat_record_by_id(session, record_id) - - record.full_sql_message = full_message - record.sql_answer = answer + session.add(log) + session.flush() + session.refresh(log) + result.id = log.id + session.commit() - if token_usage: - record.token_sql = orjson.dumps(token_usage).decode() + return result - result = ChatRecord(**record.model_dump()) - stmt = update(ChatRecord).where(and_(ChatRecord.id == record.id)).values( - full_sql_message=record.full_sql_message, - sql_answer=record.sql_answer, - token_sql=record.token_sql, +def end_log(session: SessionDep, log: ChatLog, full_message: Union[list[dict], dict, str], + reasoning_content: str = None, + token_usage=None) -> ChatLog: + if token_usage is None: + token_usage = {} + log.messages = full_message + log.token_usage = token_usage + log.finish_time = datetime.datetime.now() + log.reasoning_content = reasoning_content if reasoning_content and len(reasoning_content.strip()) > 0 else None + + stmt = update(ChatLog).where(and_(ChatLog.id == log.id)).values( + messages=log.messages, + token_usage=log.token_usage, + finish_time=log.finish_time, + reasoning_content=log.reasoning_content ) - session.execute(stmt) - session.commit() - return result + return log -def save_full_analysis_message_and_answer(session: SessionDep, record_id: int, answer: str, - full_message: str, token_usage: dict = None) -> ChatRecord: - if not record_id: - raise Exception("Record id cannot be None") - record = get_chat_record_by_id(session, record_id) +def trigger_log_error(session: SessionDep, log: ChatLog) -> ChatLog: + log.error = True + stmt = update(ChatLog).where(and_(ChatLog.id == log.id)).values( + error=True + ) + session.execute(stmt) + session.commit() - record.full_analysis_message = full_message - record.analysis = answer + return log - if token_usage: - record.token_analysis = orjson.dumps(token_usage).decode() - result = ChatRecord(**record.model_dump()) +def save_sql_answer(session: SessionDep, record_id: int, answer: str) -> ChatRecord: + if not record_id: + raise Exception("Record id cannot be None") - stmt = update(ChatRecord).where(and_(ChatRecord.id == record.id)).values( - full_analysis_message=record.full_analysis_message, - analysis=record.analysis, - token_analysis=record.token_analysis, + stmt = update(ChatRecord).where(and_(ChatRecord.id == record_id)).values( + sql_answer=answer, ) session.execute(stmt) session.commit() - return result + record = get_chat_record_by_id(session, record_id) + + return record -def save_full_predict_message_and_answer(session: SessionDep, record_id: int, answer: str, - full_message: str, data: str, token_usage: dict = None) -> ChatRecord: +def save_analysis_answer(session: SessionDep, record_id: int, answer: str = '') -> ChatRecord: if not record_id: raise Exception("Record id cannot be None") + + stmt = update(ChatRecord).where(and_(ChatRecord.id == record_id)).values( + analysis=answer, + ) + + session.execute(stmt) + + session.commit() + record = get_chat_record_by_id(session, record_id) - record.full_predict_message = full_message - record.predict = answer - record.predict_data = data + return record - if token_usage: - record.token_predict = orjson.dumps(token_usage).decode() - result = ChatRecord(**record.model_dump()) +def save_predict_answer(session: SessionDep, record_id: int, answer: str) -> ChatRecord: + if not record_id: + raise Exception("Record id cannot be None") - stmt = update(ChatRecord).where(and_(ChatRecord.id == record.id)).values( - full_predict_message=record.full_predict_message, - predict=record.predict, - predict_data=record.predict_data, - token_predict=record.token_predict + stmt = update(ChatRecord).where(and_(ChatRecord.id == record_id)).values( + predict=answer, ) session.execute(stmt) session.commit() - return result + record = get_chat_record_by_id(session, record_id) + + return record -def save_full_select_datasource_message_and_answer(session: SessionDep, record_id: int, answer: str, - full_message: str, datasource: int = None, - engine_type: str = None, token_usage: dict = None) -> ChatRecord: +def save_select_datasource_answer(session: SessionDep, record_id: int, answer: str, + datasource: int = None, engine_type: str = None) -> ChatRecord: if not record_id: raise Exception("Record id cannot be None") record = get_chat_record_by_id(session, record_id) - record.full_select_datasource_message = full_message record.datasource_select_answer = answer if datasource: record.datasource = datasource record.engine_type = engine_type - if token_usage: - record.token_select_datasource_question = orjson.dumps(token_usage).decode() - result = ChatRecord(**record.model_dump()) if datasource: stmt = update(ChatRecord).where(and_(ChatRecord.id == record.id)).values( - full_select_datasource_message=record.full_select_datasource_message, datasource_select_answer=record.datasource_select_answer, - token_select_datasource_question=record.token_select_datasource_question, datasource=record.datasource, engine_type=record.engine_type, ) else: stmt = update(ChatRecord).where(and_(ChatRecord.id == record.id)).values( - full_select_datasource_message=record.full_select_datasource_message, datasource_select_answer=record.datasource_select_answer, - token_select_datasource_question=record.token_select_datasource_question, ) session.execute(stmt) @@ -472,16 +973,12 @@ def save_full_select_datasource_message_and_answer(session: SessionDep, record_i return result -def save_full_recommend_question_message_and_answer(session: SessionDep, record_id: int, answer: dict = None, - full_message: str = '[]', token_usage: dict = None) -> ChatRecord: +def save_recommend_question_answer(session: SessionDep, record_id: int, + answer: dict = None, articles_number: Optional[int] = 4) -> ChatRecord: if not record_id: raise Exception("Record id cannot be None") - record = get_chat_record_by_id(session, record_id) - - record.full_recommended_question_message = full_message - if answer: - record.recommended_question_answer = orjson.dumps(answer).decode() + recommended_question_answer = orjson.dumps(answer).decode() json_str = '[]' if answer and answer.get('content') and answer.get('content') != '': @@ -492,25 +989,29 @@ def save_full_recommend_question_message_and_answer(session: SessionDep, record_ json_str = '[]' except Exception as e: pass - record.recommended_question = json_str - - if token_usage: - record.token_recommended_question = orjson.dumps(token_usage).decode() + recommended_question = json_str - result = ChatRecord(**record.model_dump()) - - stmt = update(ChatRecord).where(and_(ChatRecord.id == record.id)).values( - full_recommended_question_message=record.full_recommended_question_message, - recommended_question_answer=record.recommended_question_answer, - recommended_question=record.recommended_question, - token_recommended_question=record.token_recommended_question + stmt = update(ChatRecord).where(and_(ChatRecord.id == record_id)).values( + recommended_question_answer=recommended_question_answer, + recommended_question=recommended_question, ) session.execute(stmt) - session.commit() - return result + record = get_chat_record_by_id(session, record_id) + record.recommended_question_answer = recommended_question_answer + record.recommended_question = recommended_question + if articles_number > 4: + stmt_chat = update(Chat).where(and_(Chat.id == record.chat_id)).values( + recommended_question_answer=recommended_question_answer, + recommended_question=recommended_question, + recommended_generate=True + ) + session.execute(stmt_chat) + session.commit() + + return record def save_sql(session: SessionDep, record_id: int, sql: str) -> ChatRecord: @@ -534,36 +1035,21 @@ def save_sql(session: SessionDep, record_id: int, sql: str) -> ChatRecord: return result -def save_full_chart_message(session: SessionDep, record_id: int, full_message: str) -> ChatRecord: - return save_full_chart_message_and_answer(session=session, record_id=record_id, full_message=full_message, - answer='') - - -def save_full_chart_message_and_answer(session: SessionDep, record_id: int, answer: str, - full_message: str, token_usage: dict = None) -> ChatRecord: +def save_chart_answer(session: SessionDep, record_id: int, answer: str) -> ChatRecord: if not record_id: raise Exception("Record id cannot be None") - record = get_chat_record_by_id(session, record_id) - - record.full_chart_message = full_message - record.chart_answer = answer - - if token_usage: - record.token_chart = orjson.dumps(token_usage).decode() - result = ChatRecord(**record.model_dump()) - - stmt = update(ChatRecord).where(and_(ChatRecord.id == record.id)).values( - full_chart_message=record.full_chart_message, - chart_answer=record.chart_answer, - token_chart=record.token_chart + stmt = update(ChatRecord).where(and_(ChatRecord.id == record_id)).values( + chart_answer=answer, ) session.execute(stmt) session.commit() - return result + record = get_chat_record_by_id(session, record_id) + + return record def save_chart(session: SessionDep, record_id: int, chart: str) -> ChatRecord: @@ -627,6 +1113,14 @@ def save_error_message(session: SessionDep, record_id: int, message: str) -> Cha session.commit() + # log error finish + stmt = update(ChatLog).where(and_(ChatLog.pid == record.id, ChatLog.finish_time.is_(None))).values( + finish_time=record.finish_time, + error=True + ) + session.execute(stmt) + session.commit() + return result @@ -677,7 +1171,8 @@ def get_old_questions(session: SessionDep, datasource: int): if not datasource: return records stmt = select(ChatRecord.question).where( - and_(ChatRecord.datasource == datasource, ChatRecord.question.isnot(None))).order_by( + and_(ChatRecord.datasource == datasource, ChatRecord.question.isnot(None), + ChatRecord.error.is_(None))).order_by( ChatRecord.create_time.desc()).limit(20) result = session.execute(stmt) for r in result: diff --git a/backend/apps/chat/models/chat_model.py b/backend/apps/chat/models/chat_model.py index 07dae5e2a..6e550afb5 100644 --- a/backend/apps/chat/models/chat_model.py +++ b/backend/apps/chat/models/chat_model.py @@ -1,21 +1,87 @@ from datetime import datetime -from typing import List, Optional +from enum import Enum +from typing import List, Optional, Any, Union from fastapi import Body +from langchain_core.messages import SystemMessage, HumanMessage, AIMessage from pydantic import BaseModel from sqlalchemy import Column, Integer, Text, BigInteger, DateTime, Identity, Boolean +from sqlalchemy import Enum as SQLAlchemyEnum +from sqlalchemy.dialects.postgresql import JSONB from sqlmodel import SQLModel, Field +from apps.db.constant import DB from apps.template.filter.generator import get_permissions_template from apps.template.generate_analysis.generator import get_analysis_template from apps.template.generate_chart.generator import get_chart_template from apps.template.generate_dynamic.generator import get_dynamic_template from apps.template.generate_guess_question.generator import get_guess_question_template from apps.template.generate_predict.generator import get_predict_template -from apps.template.generate_sql.generator import get_sql_template +from apps.template.generate_sql.generator import get_sql_template, get_sql_example_template from apps.template.select_datasource.generator import get_datasource_template +def enum_values(enum_class: type[Enum]) -> list: + """Get values for enum.""" + return [status.value for status in enum_class] + + +class TypeEnum(Enum): + CHAT = "0" + + +# TODO other usage + +class OperationEnum(Enum): + GENERATE_SQL = '0' + GENERATE_CHART = '1' + ANALYSIS = '2' + PREDICT_DATA = '3' + GENERATE_RECOMMENDED_QUESTIONS = '4' + GENERATE_SQL_WITH_PERMISSIONS = '5' + CHOOSE_DATASOURCE = '6' + GENERATE_DYNAMIC_SQL = '7' + CHOOSE_TABLE = '8' + FILTER_TERMS = '9' + FILTER_SQL_EXAMPLE = '10' + FILTER_CUSTOM_PROMPT = '11' + EXECUTE_SQL = '12' + GENERATE_PICTURE = '13' + + +class ChatFinishStep(Enum): + GENERATE_SQL = 1 + QUERY_DATA = 2 + GENERATE_CHART = 3 + + +class QuickCommand(Enum): + REGENERATE = '/regenerate' + ANALYSIS = '/analysis' + PREDICT_DATA = '/predict' + + +# TODO choose table / check connection / generate description + +class ChatLog(SQLModel, table=True): + __tablename__ = "chat_log" + id: Optional[int] = Field(sa_column=Column(BigInteger, Identity(always=True), primary_key=True)) + type: TypeEnum = Field( + sa_column=Column(SQLAlchemyEnum(TypeEnum, native_enum=False, values_callable=enum_values, length=3))) + operate: OperationEnum = Field( + sa_column=Column(SQLAlchemyEnum(OperationEnum, native_enum=False, values_callable=enum_values, length=3))) + pid: Optional[int] = Field(sa_column=Column(BigInteger, nullable=True)) + ai_modal_id: Optional[int] = Field(sa_column=Column(BigInteger)) + base_modal: Optional[str] = Field(max_length=255) + messages: Optional[list[dict]] = Field(sa_column=Column(JSONB)) + reasoning_content: Optional[str | None] = Field(sa_column=Column(Text, nullable=True)) + start_time: datetime = Field(sa_column=Column(DateTime(timezone=False), nullable=True)) + finish_time: datetime = Field(sa_column=Column(DateTime(timezone=False), nullable=True)) + token_usage: Optional[dict | None | int] = Field(sa_column=Column(JSONB)) + local_operation: bool = Field(default=False) + error: bool = Field(default=False) + + class Chat(SQLModel, table=True): __tablename__ = "chat" id: Optional[int] = Field(sa_column=Column(BigInteger, Identity(always=True), primary_key=True)) @@ -28,6 +94,10 @@ class Chat(SQLModel, table=True): engine_type: str = Field(max_length=64) origin: Optional[int] = Field( sa_column=Column(Integer, nullable=False, default=0)) # 0: default, 1: mcp, 2: assistant + brief_generate: bool = Field(default=False) + recommended_question_answer: str = Field(sa_column=Column(Text, nullable=True)) + recommended_question: str = Field(sa_column=Column(Text, nullable=True)) + recommended_generate: bool = Field(default=False) class ChatRecord(SQLModel, table=True): @@ -54,35 +124,60 @@ class ChatRecord(SQLModel, table=True): recommended_question_answer: str = Field(sa_column=Column(Text, nullable=True)) recommended_question: str = Field(sa_column=Column(Text, nullable=True)) datasource_select_answer: str = Field(sa_column=Column(Text, nullable=True)) - full_sql_message: str = Field(sa_column=Column(Text, nullable=True)) - token_sql: str = Field(max_length=256, nullable=True) - full_chart_message: str = Field(sa_column=Column(Text, nullable=True)) - token_chart: str = Field(max_length=256, nullable=True) - full_analysis_message: str = Field(sa_column=Column(Text, nullable=True)) - token_analysis: str = Field(max_length=256, nullable=True) - full_predict_message: str = Field(sa_column=Column(Text, nullable=True)) - token_predict: str = Field(max_length=256, nullable=True) - full_recommended_question_message: str = Field(sa_column=Column(Text, nullable=True)) - token_recommended_question: str = Field(max_length=256, nullable=True) - full_select_datasource_message: str = Field(sa_column=Column(Text, nullable=True)) - token_select_datasource_question: str = Field(max_length=256, nullable=True) finish: bool = Field(sa_column=Column(Boolean, nullable=True, default=False)) error: str = Field(sa_column=Column(Text, nullable=True)) analysis_record_id: int = Field(sa_column=Column(BigInteger, nullable=True)) predict_record_id: int = Field(sa_column=Column(BigInteger, nullable=True)) + regenerate_record_id: int = Field(sa_column=Column(BigInteger, nullable=True)) + + +class ChatRecordResult(BaseModel): + id: Optional[int] = None + chat_id: Optional[int] = None + ai_modal_id: Optional[int] = None + first_chat: bool = False + create_time: Optional[datetime] = None + finish_time: Optional[datetime] = None + question: Optional[str] = None + sql_answer: Optional[str] = None + sql: Optional[str] = None + datasource: Optional[int] = None + data: Optional[str] = None + chart_answer: Optional[str] = None + chart: Optional[str] = None + analysis: Optional[str] = None + predict: Optional[str] = None + predict_data: Optional[str] = None + recommended_question: Optional[str] = None + datasource_select_answer: Optional[str] = None + finish: Optional[bool] = None + error: Optional[str] = None + analysis_record_id: Optional[int] = None + predict_record_id: Optional[int] = None + regenerate_record_id: Optional[int] = None + sql_reasoning_content: Optional[str] = None + chart_reasoning_content: Optional[str] = None + analysis_reasoning_content: Optional[str] = None + predict_reasoning_content: Optional[str] = None + duration: Optional[float] = None # 耗时字段(单位:秒) + total_tokens: Optional[int] = None # token总消耗 class CreateChat(BaseModel): id: int = None question: str = None datasource: int = None - origin: Optional[int] = 0 + origin: Optional[int] = 0 # 0是页面上,mcp是1,小助手是2 class RenameChat(BaseModel): id: int = None brief: str = '' + brief_generate: bool = True +class SimpleChat(BaseModel): + id: int = None + brief: str = '' class ChatInfo(BaseModel): id: Optional[int] = None @@ -95,11 +190,34 @@ class ChatInfo(BaseModel): ds_type: str = '' datasource_name: str = '' datasource_exists: bool = True + recommended_question: Optional[str] = None + recommended_generate: Optional[bool] = False records: List[ChatRecord | dict] = [] +class ChatLogHistoryItem(BaseModel): + start_time: Optional[datetime] = None + finish_time: Optional[datetime] = None + duration: Optional[float] = None # 耗时字段(单位:秒) + total_tokens: Optional[int] = None # token总消耗 + operate: Optional[str] = None + local_operation: Optional[bool] = False + message: Optional[str | dict | list] = None + error: Optional[bool] = False + + +class ChatLogHistory(BaseModel): + start_time: Optional[datetime] = None + finish_time: Optional[datetime] = None + duration: Optional[float] = None # 耗时字段(单位:秒) + total_tokens: Optional[int] = None # token总消耗 + steps: List[ChatLogHistoryItem | dict] = [] + + class AiModelQuestion(BaseModel): + question: str = None ai_modal_id: int = None + ai_modal_name: str = None # Specific model name engine: str = "" db_schema: str = "" sql: str = "" @@ -109,77 +227,159 @@ class AiModelQuestion(BaseModel): lang: str = "简体中文" filter: str = [] sub_query: Optional[list[dict]] = None - - def sql_sys_question(self): - return get_sql_template()['system'].format(engine=self.engine, schema=self.db_schema, question=self.question, - lang=self.lang) - - def sql_user_question(self): - return get_sql_template()['user'].format(engine=self.engine, schema=self.db_schema, question=self.question, - rule=self.rule) + terminologies: str = "" + data_training: str = "" + custom_prompt: str = "" + error_msg: str = "" + regenerate_record_id: Optional[int] = None + sample_data: str = "" + sqlbot_name: str = "SQLBot" + + def sql_sys_question(self, db_type: Union[str, DB], enable_query_limit: bool = True): + templates: dict[str, str] = {} + _sql_template = get_sql_example_template(db_type) + _base_template = get_sql_template() + _process_check = _sql_template.get('process_check') if _sql_template.get('process_check') else _base_template[ + 'process_check'] + _query_limit = _base_template['query_limit'] if enable_query_limit else _base_template['no_query_limit'] + _other_rule = _sql_template['other_rule'].format(multi_table_condition=_base_template['multi_table_condition']) + _base_sql_rules = _sql_template['quot_rule'] + _query_limit + _sql_template['limit_rule'] + _other_rule + _sql_examples = _sql_template['basic_example'] + _example_engine = _sql_template['example_engine'] + _example_answer_1 = _sql_template['example_answer_1_with_limit'] if enable_query_limit else _sql_template[ + 'example_answer_1'] + _example_answer_2 = _sql_template['example_answer_2_with_limit'] if enable_query_limit else _sql_template[ + 'example_answer_2'] + _example_answer_3 = _sql_template['example_answer_3_with_limit'] if enable_query_limit else _sql_template[ + 'example_answer_3'] + + templates['system'] = _base_template['system'].format(lang=self.lang, process_check=_process_check, + sqlbot_name=self.sqlbot_name) + templates['rules'] = _base_template['generate_rules'].format(lang=self.lang, + sqlbot_name=self.sqlbot_name, + base_sql_rules=_base_sql_rules, + basic_sql_examples=_sql_examples, + example_engine=_example_engine, + example_answer_1=_example_answer_1, + example_answer_2=_example_answer_2, + example_answer_3=_example_answer_3) + templates['schema'] = _base_template['generate_basic_info'].format(engine=self.engine, schema=self.db_schema, + sample_data=self.sample_data) + + if self.terminologies: + templates['terminologies'] = _base_template['generate_terminologies_info'].format( + terminologies=self.terminologies) + + if self.data_training: + templates['data_training'] = _base_template['generate_data_training_info'].format( + data_training=self.data_training) + + if self.custom_prompt: + templates['custom_prompt'] = _base_template['generate_custom_prompt_info'].format( + custom_prompt=self.custom_prompt) + + return templates + + def sql_user_question(self, current_time: str, change_title: bool): + _question = self.question + if self.regenerate_record_id: + _question = get_sql_template()['regenerate_hint'] + self.question + return get_sql_template()['user'].format(lang=self.lang, engine=self.engine, schema=self.db_schema, + question=_question, + rule=self.rule, current_time=current_time, error_msg=self.error_msg, + change_title=change_title) def chart_sys_question(self): - return get_chart_template()['system'].format(sql=self.sql, question=self.question, lang=self.lang) + templates: dict[str, str] = { + 'system': get_chart_template()['system'].format(lang=self.lang, sqlbot_name=self.sqlbot_name), + 'rules': get_chart_template()['generate_rules'].format(lang=self.lang) + } + return templates - def chart_user_question(self): - return get_chart_template()['user'].format(sql=self.sql, question=self.question, rule=self.rule) + def chart_user_question(self, chart_type: Optional[str] = '', schema: Optional[str] = ''): + return get_chart_template()['user'].format(lang=self.lang, sql=self.sql, question=self.question, rule=self.rule, + chart_type=chart_type, schema=schema) def analysis_sys_question(self): - return get_analysis_template()['system'].format(lang=self.lang) + return get_analysis_template()['system'].format(lang=self.lang, terminologies=self.terminologies, + custom_prompt=self.custom_prompt, sqlbot_name=self.sqlbot_name) def analysis_user_question(self): return get_analysis_template()['user'].format(fields=self.fields, data=self.data) def predict_sys_question(self): - return get_predict_template()['system'].format(lang=self.lang) + return get_predict_template()['system'].format(lang=self.lang, custom_prompt=self.custom_prompt, + sqlbot_name=self.sqlbot_name) def predict_user_question(self): return get_predict_template()['user'].format(fields=self.fields, data=self.data) def datasource_sys_question(self): - return get_datasource_template()['system'].format(lang=self.lang) + return get_datasource_template()['system'].format(lang=self.lang, sqlbot_name=self.sqlbot_name) def datasource_user_question(self, datasource_list: str = "[]"): - return get_datasource_template()['user'].format(question=self.question, data=datasource_list) + return get_datasource_template()['user'].format(lang=self.lang, question=self.question, data=datasource_list) - def guess_sys_question(self): - return get_guess_question_template()['system'].format(lang=self.lang) + def guess_sys_question(self, articles_number: int = 4): + return get_guess_question_template()['system'].format(lang=self.lang, articles_number=articles_number, + sqlbot_name=self.sqlbot_name) def guess_user_question(self, old_questions: str = "[]"): return get_guess_question_template()['user'].format(question=self.question, schema=self.db_schema, old_questions=old_questions) def filter_sys_question(self): - return get_permissions_template()['system'].format(lang=self.lang, engine=self.engine) + return get_permissions_template()['system'].format(lang=self.lang, engine=self.engine, + sqlbot_name=self.sqlbot_name) def filter_user_question(self): return get_permissions_template()['user'].format(sql=self.sql, filter=self.filter) def dynamic_sys_question(self): - return get_dynamic_template()['system'].format(lang=self.lang, engine=self.engine) + return get_dynamic_template()['system'].format(lang=self.lang, engine=self.engine, sqlbot_name=self.sqlbot_name) def dynamic_user_question(self): return get_dynamic_template()['user'].format(sql=self.sql, sub_query=self.sub_query) class ChatQuestion(AiModelQuestion): - question: str chat_id: int + datasource_id: Optional[int] = None class ChatMcp(ChatQuestion): token: str -class ChatStart(BaseModel): +class McpDs(BaseModel): + token: str = Body(description='用户token') + oid: Optional[str] = Body(description='组织ID,如果不传则为最后一次登录SQLBot时所使用的组织ID', default=None) + + +class ChatToken(BaseModel): username: str = Body(description='用户名') password: str = Body(description='密码') -class McpQuestion(BaseModel): +class ChatStart(BaseModel): + username: str = Body(description='用户名', default=None) + password: str = Body(description='密码', default=None) + token: str = Body(description='token', default=None) + oid: Optional[str] = Body( + description='组织ID,仅当数据源ID为空时有效,如果不传则为最后一次登录SQLBot时所使用的组织ID', default=None) + + +class ChatQuestionBase(BaseModel): question: str = Body(description='用户提问') chat_id: int = Body(description='会话ID') + + +class McpQuestion(ChatQuestionBase): token: str = Body(description='token') + stream: Optional[bool] = Body(description='是否流式输出,默认为true开启, 关闭false则返回JSON对象', default=True) + lang: Optional[str] = Body(description='语言:zh-CN|zh-TW|en|ko-KR', default='zh-CN') + datasource_id: Optional[int | str] = Body(description='数据源ID,仅当当前对话没有确定数据源时有效', default=None) + return_img: Optional[bool] = Body(description='是否返回图表,默认为true开启, 关闭false则仅返回数据', default=True) class AxisObj(BaseModel): @@ -192,3 +392,37 @@ class ExcelData(BaseModel): axis: list[AxisObj] = [] data: list[dict] = [] name: str = 'Excel' + + +class McpAssistant(BaseModel): + question: str = Body(description='用户提问') + url: str = Body(description='第三方数据接口') + authorization: str = Body(description='第三方接口凭证') + stream: Optional[bool] = Body(description='是否流式输出,默认为true开启, 关闭false则返回JSON对象', default=True) + + +class SystemPromptMessage(SystemMessage): + sqlbot_system: bool = True + + def __init__( + self, content: Union[str, list[Union[str, dict]]], **kwargs: Any + ) -> None: + super().__init__(content=content, **kwargs) + + +class HumanPromptMessage(HumanMessage): + sqlbot_system: bool = True + + def __init__( + self, content: Union[str, list[Union[str, dict]]], **kwargs: Any + ) -> None: + super().__init__(content=content, **kwargs) + + +class AIPromptMessage(AIMessage): + sqlbot_system: bool = True + + def __init__( + self, content: Union[str, list[Union[str, dict]]], **kwargs: Any + ) -> None: + super().__init__(content=content, **kwargs) diff --git a/backend/apps/chat/task/llm.py b/backend/apps/chat/task/llm.py index 353df4ee1..308e2ad65 100644 --- a/backend/apps/chat/task/llm.py +++ b/backend/apps/chat/task/llm.py @@ -1,110 +1,190 @@ import concurrent import json +import os import traceback +import urllib.parse import warnings from concurrent.futures import ThreadPoolExecutor, Future -from typing import Any, List, Optional, Union, Dict +from datetime import datetime +from typing import Any, List, Optional, Union, Dict, Iterator -import numpy as np import orjson import pandas as pd import requests +import sqlglot import sqlparse from langchain.chat_models.base import BaseChatModel from langchain_community.utilities import SQLDatabase -from langchain_core.messages import BaseMessage, SystemMessage, HumanMessage, AIMessage, BaseMessageChunk -from sqlalchemy import select -from sqlalchemy.orm import sessionmaker -from sqlmodel import create_engine, Session +from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, BaseMessageChunk +from sqlalchemy import and_, select +from sqlalchemy.orm import sessionmaker, scoped_session +from sqlbot_xpack.config.model import SysArgModel +from sqlbot_xpack.custom_prompt.curd.custom_prompt import find_custom_prompts +from sqlbot_xpack.custom_prompt.models.custom_prompt_model import CustomPromptTypeEnum +from sqlbot_xpack.license.license_manage import SQLBotLicenseUtil +from sqlglot import exp +from sqlmodel import Session from apps.ai_model.model_factory import LLMConfig, LLMFactory, get_default_config -from apps.chat.curd.chat import save_question, save_full_sql_message, save_full_sql_message_and_answer, save_sql, \ - save_error_message, save_sql_exec_data, save_full_chart_message, save_full_chart_message_and_answer, save_chart, \ - finish_record, save_full_analysis_message_and_answer, save_full_predict_message_and_answer, save_predict_data, \ - save_full_select_datasource_message_and_answer, save_full_recommend_question_message_and_answer, \ - get_old_questions, save_analysis_predict_record, list_base_records, rename_chat, get_chart_config, \ - get_chat_chart_data -from apps.chat.models.chat_model import ChatQuestion, ChatRecord, Chat, RenameChat -from apps.datasource.crud.datasource import get_table_schema +from apps.chat.curd.chat import save_question, save_sql_answer, save_sql, \ + save_error_message, save_sql_exec_data, save_chart_answer, save_chart, \ + finish_record, save_analysis_answer, save_predict_answer, save_predict_data, \ + save_select_datasource_answer, save_recommend_question_answer, \ + get_old_questions, save_analysis_predict_record, rename_chat, get_chart_config, \ + get_chat_chart_data, list_generate_sql_logs, list_generate_chart_logs, start_log, end_log, \ + get_last_execute_sql_error, format_json_data, format_chart_fields, get_chat_brief_generate, get_chat_predict_data, \ + get_chat_chart_config, trigger_log_error +from apps.chat.models.chat_model import ChatQuestion, ChatRecord, Chat, RenameChat, ChatLog, OperationEnum, \ + ChatFinishStep, AxisObj, SystemPromptMessage, HumanPromptMessage, AIPromptMessage +from apps.data_training.curd.data_training import get_training_template +from apps.datasource.crud.datasource import get_table_schema, get_tables_sample_data from apps.datasource.crud.permission import get_row_permission_filters, is_normal_user +from apps.datasource.embedding.ds_embedding import get_ds_embedding from apps.datasource.models.datasource import CoreDatasource -from apps.db.db import exec_sql +from apps.db.db import exec_sql, get_version, check_connection, get_sqlglot_dialect +from apps.system.crud.aimodel_manage import get_ai_model_list_by_workspace from apps.system.crud.assistant import AssistantOutDs, AssistantOutDsFactory, get_assistant_ds +from apps.system.crud.parameter_manage import get_groups +from apps.system.crud.user import user_ws_list from apps.system.schemas.system_schema import AssistantOutDsSchema +from apps.terminology.curd.terminology import get_terminology_template from common.core.config import settings +from common.core.db import engine from common.core.deps import CurrentAssistant, CurrentUser -from common.error import SingleMessageError +from common.error import SingleMessageError, SQLBotDBError, ParseSQLResultError, SQLBotDBConnectionError +from common.utils.data_format import DataFormat +from common.utils.locale import I18n, I18nHelper from common.utils.utils import SQLBotLogUtil, extract_nested_json, prepare_for_orjson warnings.filterwarnings("ignore") -base_message_count_limit = 5 - executor = ThreadPoolExecutor(max_workers=200) +dynamic_ds_types = [1, 3] +dynamic_subsql_prefix = 'select * from sqlbot_dynamic_temp_table_' + +session_maker = scoped_session(sessionmaker(bind=engine, class_=Session)) + +i18n = I18n() + + +def extract_tables_from_sql(sql: str, ds_type: str = None) -> set: + """从 SQL 中提取表名(使用 sqlglot 解析,可信)""" + tables = set() + dialect = get_sqlglot_dialect(ds_type) + try: + statements = sqlglot.parse(sql, dialect=dialect) + for stmt in statements: + if stmt: + for table in stmt.find_all(exp.Table): + if table.name: + tables.add(table.name) + except Exception: + pass + return tables + class LLMService: ds: CoreDatasource chat_question: ChatQuestion + oid: int record: ChatRecord config: LLMConfig llm: BaseChatModel - sql_message: List[Union[BaseMessage, dict[str, Any]]] = [] - chart_message: List[Union[BaseMessage, dict[str, Any]]] = [] - history_records: List[ChatRecord] = [] - session: Session + sql_message: List[Union[BaseMessage, dict[str, Any]]] + chart_message: List[Union[BaseMessage, dict[str, Any]]] + + # session: Session = db_session current_user: CurrentUser current_assistant: Optional[CurrentAssistant] = None out_ds_instance: Optional[AssistantOutDs] = None change_title: bool = False - chunk_list: List[str] = [] + generate_sql_logs: List[ChatLog] + generate_chart_logs: List[ChatLog] + current_logs: dict[OperationEnum, ChatLog] + chunk_list: List[str] future: Future - def __init__(self, current_user: CurrentUser, chat_question: ChatQuestion, - current_assistant: Optional[CurrentAssistant] = None, no_reasoning: bool = False): + trans: I18nHelper = None + + last_execute_sql_error: str = None + articles_number: int = 4 + + enable_sql_row_limit: bool = settings.GENERATE_SQL_QUERY_LIMIT_ENABLED + base_message_round_count_limit: int = settings.GENERATE_SQL_QUERY_HISTORY_ROUND_COUNT + + def __init__(self, session: Session, current_user: CurrentUser, chat_question: ChatQuestion, + current_assistant: Optional[CurrentAssistant] = None, no_reasoning: bool = False, + embedding: bool = False, config: LLMConfig = None): + self.sql_message = [] + self.chart_message = [] + self.generate_sql_logs = [] + self.generate_chart_logs = [] + self.current_logs = {} self.chunk_list = [] - engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) - session_maker = sessionmaker(bind=engine) - self.session = session_maker() - self.session.exec = self.session.exec if hasattr(self.session, "exec") else self.session.execute self.current_user = current_user self.current_assistant = current_assistant - # chat = self.session.query(Chat).filter(Chat.id == chat_question.chat_id).first() + + self.table_name_list = [] + + chat_question.lang = get_lang_name(current_user.language) + self.trans = i18n(lang=current_user.language) + chat_id = chat_question.chat_id - chat: Chat | None = self.session.get(Chat, chat_id) + chat: Chat | None = session.get(Chat, chat_id) if not chat: raise SingleMessageError(f"Chat with id {chat_id} not found") + self.oid = chat.oid + + if self.oid and not current_assistant: + w_list = user_ws_list(session, self.current_user.id) + oid_list = [item.id for item in w_list] + if int(self.oid) not in oid_list: + raise SingleMessageError("Current user cannot not access this chat") + if self.oid and current_assistant: + if self.oid != self.current_user.oid: + raise SingleMessageError("Current assistant user cannot not access this chat") + + self.current_user.oid = chat.oid ds: CoreDatasource | AssistantOutDsSchema | None = None + if not chat.datasource and chat_question.datasource_id: + _ds = session.get(CoreDatasource, chat_question.datasource_id) + if _ds: + if _ds.oid != self.oid: + raise SingleMessageError( + f"Datasource with id {chat_question.datasource_id} does not belong to current workspace") + chat.datasource = _ds.id + chat.engine_type = _ds.type_name + # save chat + session.add(chat) + session.flush() + session.refresh(chat) + session.commit() + if chat.datasource: # Get available datasource - # ds = self.session.query(CoreDatasource).filter(CoreDatasource.id == chat.datasource).first() - if current_assistant and current_assistant.type == 1: + if current_assistant and current_assistant.type in dynamic_ds_types: self.out_ds_instance = AssistantOutDsFactory.get_instance(current_assistant) ds = self.out_ds_instance.get_ds(chat.datasource) if not ds: raise SingleMessageError("No available datasource configuration found") - chat_question.engine = ds.type - chat_question.db_schema = self.out_ds_instance.get_db_schema(ds.id) + chat_question.engine = ds.type + get_version(ds) else: - ds = self.session.get(CoreDatasource, chat.datasource) + ds = session.get(CoreDatasource, chat.datasource) if not ds: raise SingleMessageError("No available datasource configuration found") - chat_question.engine = ds.type_name if ds.type != 'excel' else 'PostgreSQL' - chat_question.db_schema = get_table_schema(session=self.session, current_user=current_user, ds=ds) + chat_question.engine = (ds.type_name if ds.type != 'excel' else 'PostgreSQL') + get_version(ds) - history_records: List[ChatRecord] = list( - map(lambda x: ChatRecord(**x.model_dump()), filter(lambda r: True if r.first_chat != True else False, - list_base_records(session=self.session, - current_user=current_user, - chart_id=chat_id)))) - self.change_title = len(history_records) == 0 + self.generate_sql_logs = list_generate_sql_logs(session=session, chart_id=chat_id) + self.generate_chart_logs = list_generate_chart_logs(session=session, chart_id=chat_id) - chat_question.lang = get_lang_name(current_user.language) + self.change_title = not get_chat_brief_generate(session=session, chat_id=chat_id) - self.ds = (ds if isinstance(ds, AssistantOutDsSchema) else CoreDatasource(**ds.model_dump())) if ds else None + self.ds = ( + ds if isinstance(ds, AssistantOutDsSchema) else CoreDatasource(**ds.model_dump())) if ds else None self.chat_question = chat_question - self.config = get_default_config() + self.config = config if no_reasoning: # only work while using qwen if self.config.additional_params: @@ -113,14 +193,56 @@ def __init__(self, current_user: CurrentUser, chat_question: ChatQuestion, del self.config.additional_params['extra_body']['enable_thinking'] self.chat_question.ai_modal_id = self.config.model_id + self.chat_question.ai_modal_name = self.config.model_name # Create LLM instance through factory llm_instance = LLMFactory.create_llm(self.config) self.llm = llm_instance.llm - self.history_records = history_records - - self.init_messages() + # get last_execute_sql_error + last_execute_sql_error = get_last_execute_sql_error(session, self.chat_question.chat_id) + if last_execute_sql_error: + self.chat_question.error_msg = f''' +{last_execute_sql_error} +''' + else: + self.chat_question.error_msg = '' + + @classmethod + async def create(cls, *args, **kwargs): + specialized_model_id = None + _ai_model_list = [] + if args[3]: + if args[1]: + ws_id = args[1].oid + _ai_model_list = get_ai_model_list_by_workspace(args[0], ws_id) + if args[3].enable_custom_model: + if args[3].custom_model: + if any(str(model.id) == str(args[3].custom_model) for model in _ai_model_list): + specialized_model_id = args[3].custom_model + print("use custom model: id[" + specialized_model_id + "]") + config: LLMConfig = await get_default_config(specialized_model_id) + instance = cls(*args, **kwargs, config=config) + + chat_params: list[SysArgModel] = await get_groups(args[0], "chat") + for config in chat_params: + if config.pkey == 'chat.sqlbot_name': + if config.pval.strip(): + instance.chat_question.sqlbot_name = config.pval + if config.pkey == 'chat.limit_rows': + if config.pval.lower().strip() == 'true': + instance.enable_sql_row_limit = True + else: + instance.enable_sql_row_limit = False + if config.pkey == 'chat.context_record_count': + count_value = config.pval + if count_value is None: + count_value = settings.GENERATE_SQL_QUERY_HISTORY_ROUND_COUNT + count_value = int(count_value) + if count_value < 0: + count_value = 0 + instance.base_message_round_count_limit = count_value + return instance def is_running(self, timeout=0.5): try: @@ -132,59 +254,88 @@ def is_running(self, timeout=0.5): except Exception as e: return True - def init_messages(self): - # self.agent_executor = create_react_agent(self.llm) - last_sql_messages = list( - filter(lambda r: True if r.full_sql_message is not None and r.full_sql_message.strip() != '' else False, - self.history_records)) - last_sql_message_str = "[]" if last_sql_messages is None or len(last_sql_messages) == 0 else last_sql_messages[ - -1].full_sql_message + def init_messages(self, session: Session): + + self.table_name_list = self.choose_table_schema(session) - last_chart_messages = list( - filter( - lambda r: True if r.full_chart_message is not None and r.full_chart_message.strip() != '' else False, - self.history_records)) - last_chart_message_str = "[]" if last_chart_messages is None or len(last_chart_messages) == 0 else \ - last_chart_messages[-1].full_chart_message + last_sql_messages: List[dict[str, Any]] = self.generate_sql_logs[-1].messages if len( + self.generate_sql_logs) > 0 else [] + if self.chat_question.regenerate_record_id: + # filter record before regenerate_record_id + _temp_log = next( + filter(lambda obj: obj.pid == self.chat_question.regenerate_record_id, self.generate_sql_logs), None) + last_sql_messages: List[dict[str, Any]] = _temp_log.messages if _temp_log else [] - last_sql_messages: List[dict[str, Any]] = orjson.loads(last_sql_message_str) + # 排除所有的系统提示词 + last_sql_messages = [obj for obj in last_sql_messages if obj.get("sqlbot_system") != True] - # todo maybe can configure - count_limit = 0 - base_message_count_limit + count_limit = self.base_message_round_count_limit self.sql_message = [] # add sys prompt - self.sql_message.append(SystemMessage(content=self.chat_question.sql_sys_question())) + _system_templates = self.chat_question.sql_sys_question(self.ds.type, self.enable_sql_row_limit) + self.sql_message.append(SystemPromptMessage(content=_system_templates['system'])) + self.sql_message.append(HumanPromptMessage(content=_system_templates['rules'])) + self.sql_message.append( + AIPromptMessage(content='我已掌握所有规则,包括表结构、SQL规范、安全限制和输出格式,我会严格遵守这些规则。')) + self.sql_message.append(HumanPromptMessage(content=_system_templates['schema'])) + self.sql_message.append( + AIPromptMessage(content='我已确认您提供的数据库信息与表结构schema,我生成的SQL不会超出您提供的范围。')) + if _system_templates.get('custom_prompt'): + self.sql_message.append(HumanPromptMessage(content=_system_templates['custom_prompt'])) + self.sql_message.append(AIPromptMessage(content='我已确认您提供的额外信息,我会进行参考。')) + if _system_templates.get('terminologies'): + self.sql_message.append(HumanPromptMessage(content=_system_templates['terminologies'])) + self.sql_message.append(AIPromptMessage(content='我已确认您提供的术语信息,我会进行参考。')) + if _system_templates.get('data_training'): + self.sql_message.append(HumanPromptMessage(content=_system_templates['data_training'])) + self.sql_message.append(AIPromptMessage(content='我已确认您提供的SQL示例,我会进行参考。')) + if last_sql_messages is not None and len(last_sql_messages) > 0: - # limit count - for last_sql_message in last_sql_messages[count_limit:]: + last_rounds = get_last_conversation_rounds(last_sql_messages, rounds=count_limit) + + for _msg_dict in last_rounds: _msg: BaseMessage - if last_sql_message['type'] == 'human': - _msg = HumanMessage(content=last_sql_message['content']) + if _msg_dict.get('type') == 'human': + _msg = HumanMessage(content=_msg_dict.get('content')) self.sql_message.append(_msg) - elif last_sql_message['type'] == 'ai': - _msg = AIMessage(content=last_sql_message['content']) + elif _msg_dict.get('type') == 'ai': + _msg = AIMessage(content=_msg_dict.get('content')) self.sql_message.append(_msg) - last_chart_messages: List[dict[str, Any]] = orjson.loads(last_chart_message_str) + last_chart_messages: List[dict[str, Any]] = self.generate_chart_logs[-1].messages if len( + self.generate_chart_logs) > 0 else [] + if self.chat_question.regenerate_record_id: + # filter record before regenerate_record_id + _temp_log = next( + filter(lambda obj: obj.pid == self.chat_question.regenerate_record_id, self.generate_chart_logs), None) + last_chart_messages: List[dict[str, Any]] = _temp_log.messages if _temp_log else [] + + # 排除所有的系统提示词 + last_chart_messages = [obj for obj in last_chart_messages if obj.get("sqlbot_system") != True] + + count_chart_limit = self.base_message_round_count_limit self.chart_message = [] # add sys prompt - self.chart_message.append(SystemMessage(content=self.chat_question.chart_sys_question())) - + _chart_system_templates = self.chat_question.chart_sys_question() + self.chart_message.append(SystemPromptMessage(content=_chart_system_templates['system'])) + self.chart_message.append(HumanPromptMessage(content=_chart_system_templates['rules'])) + self.chart_message.append(AIPromptMessage(content='我已掌握所有规则,我会严格遵守这些规则来生成符合要求的JSON。')) if last_chart_messages is not None and len(last_chart_messages) > 0: - # limit count - for last_chart_message in last_chart_messages: + last_rounds = get_last_conversation_rounds(last_chart_messages, rounds=count_chart_limit) + + for _msg_dict in last_rounds: _msg: BaseMessage - if last_chart_message['type'] == 'human': - _msg = HumanMessage(content=last_chart_message['content']) + if _msg_dict.get('type') == 'human': + _msg = HumanMessage(content=_msg_dict.get('content')) self.chart_message.append(_msg) - elif last_chart_message['type'] == 'ai': - _msg = AIMessage(content=last_chart_message['content']) + elif _msg_dict.get('type') == 'ai': + _msg = AIMessage(content=_msg_dict.get('content')) self.chart_message.append(_msg) - def init_record(self) -> ChatRecord: - self.record = save_question(session=self.session, current_user=self.current_user, question=self.chat_question) + def init_record(self, session: Session) -> ChatRecord: + self.record = save_question(session=session, current_user=self.current_user, question=self.chat_question) return self.record def get_record(self): @@ -193,262 +344,408 @@ def get_record(self): def set_record(self, record: ChatRecord): self.record = record - def get_fields_from_chart(self): - chart_info = get_chart_config(self.session, self.record.id) - fields = [] - if chart_info.get('columns') and len(chart_info.get('columns')) > 0: - for column in chart_info.get('columns'): - column_str = column.get('value') - if column.get('value') != column.get('name'): - column_str = column_str + '(' + column.get('name') + ')' - fields.append(column_str) - if chart_info.get('axis'): - for _type in ['x', 'y', 'series']: - if chart_info.get('axis').get(_type): - column = chart_info.get('axis').get(_type) - column_str = column.get('value') - if column.get('value') != column.get('name'): - column_str = column_str + '(' + column.get('name') + ')' - fields.append(column_str) - return fields - - def generate_analysis(self): - fields = self.get_fields_from_chart() + def set_articles_number(self, articles_number: int): + self.articles_number = articles_number + + def get_fields_from_chart(self, _session: Session): + chart_info = get_chart_config(_session, self.record.id) + return format_chart_fields(chart_info) + + def filter_terminology_template(self, _session: Session, oid: int = None, ds_id: int = None): + self.current_logs[OperationEnum.FILTER_TERMS] = start_log(session=_session, + operate=OperationEnum.FILTER_TERMS, + record_id=self.record.id, local_operation=True) + calculate_oid = oid + calculate_ds_id = ds_id + if self.current_assistant: + calculate_oid = self.current_assistant.oid if self.current_assistant.type != 4 else self.oid + if self.current_assistant.type == 1: + calculate_ds_id = None + if self.current_assistant and self.current_assistant.type == 1: + self.chat_question.terminologies, term_list = get_terminology_template(_session, + self.chat_question.question, + calculate_oid, + None, self.current_assistant.id) + else: + self.chat_question.terminologies, term_list = get_terminology_template(_session, + self.chat_question.question, + calculate_oid, + calculate_ds_id) + + self.current_logs[OperationEnum.FILTER_TERMS] = end_log(session=_session, + log=self.current_logs[OperationEnum.FILTER_TERMS], + full_message=term_list) + + def filter_custom_prompts(self, _session: Session, custom_prompt_type: CustomPromptTypeEnum, oid: int = None, + ds_id: int = None): + if SQLBotLicenseUtil.valid(): + self.current_logs[OperationEnum.FILTER_CUSTOM_PROMPT] = start_log(session=_session, + operate=OperationEnum.FILTER_CUSTOM_PROMPT, + record_id=self.record.id, + local_operation=True) + calculate_oid = oid + calculate_ds_id = ds_id + if self.current_assistant: + calculate_oid = self.current_assistant.oid if self.current_assistant.type != 4 else self.oid + if self.current_assistant.type == 1: + calculate_ds_id = None + if self.current_assistant and self.current_assistant.type == 1: + self.chat_question.custom_prompt, prompt_list = find_custom_prompts(_session, + custom_prompt_type, + calculate_oid, + None, self.current_assistant.id) + else: + self.chat_question.custom_prompt, prompt_list = find_custom_prompts(_session, + custom_prompt_type, + calculate_oid, + calculate_ds_id) + self.current_logs[OperationEnum.FILTER_CUSTOM_PROMPT] = end_log(session=_session, + log=self.current_logs[ + OperationEnum.FILTER_CUSTOM_PROMPT], + full_message=prompt_list) + + def filter_training_template(self, _session: Session, oid: int = None, ds_id: int = None): + self.current_logs[OperationEnum.FILTER_SQL_EXAMPLE] = start_log(session=_session, + operate=OperationEnum.FILTER_SQL_EXAMPLE, + record_id=self.record.id, + local_operation=True) + calculate_oid = oid + calculate_ds_id = ds_id + if self.current_assistant: + calculate_oid = self.current_assistant.oid if self.current_assistant.type != 4 else self.oid + if self.current_assistant.type == 1: + calculate_ds_id = None + if self.current_assistant and self.current_assistant.type == 1: + self.chat_question.data_training, example_list = get_training_template(_session, + self.chat_question.question, + calculate_oid, + None, self.current_assistant.id) + else: + self.chat_question.data_training, example_list = get_training_template(_session, + self.chat_question.question, + calculate_oid, + calculate_ds_id) + self.current_logs[OperationEnum.FILTER_SQL_EXAMPLE] = end_log(session=_session, + log=self.current_logs[ + OperationEnum.FILTER_SQL_EXAMPLE], + full_message=example_list) + + def choose_table_schema(self, _session: Session): + self.current_logs[OperationEnum.CHOOSE_TABLE] = start_log(session=_session, + operate=OperationEnum.CHOOSE_TABLE, + record_id=self.record.id, + local_operation=True) + self.chat_question.db_schema, tables = self.out_ds_instance.get_db_schema( + self.ds.id, self.chat_question.question) if self.out_ds_instance else get_table_schema( + session=_session, + current_user=self.current_user, + ds=self.ds, + question=self.chat_question.question) + + # Get sample data for all tables + if not self.out_ds_instance: + self.chat_question.sample_data = get_tables_sample_data( + session=_session, + current_user=self.current_user, + ds=self.ds, + table_list=tables) + + self.current_logs[OperationEnum.CHOOSE_TABLE] = end_log(session=_session, + log=self.current_logs[OperationEnum.CHOOSE_TABLE], + full_message=self.chat_question.db_schema) + return tables + + def generate_analysis(self, _session: Session): + fields = self.get_fields_from_chart(_session) self.chat_question.fields = orjson.dumps(fields).decode() - data = get_chat_chart_data(self.session, self.record.id) + data = get_chat_chart_data(_session, self.record.id) self.chat_question.data = orjson.dumps(data.get('data')).decode() analysis_msg: List[Union[BaseMessage, dict[str, Any]]] = [] - analysis_msg.append(SystemMessage(content=self.chat_question.analysis_sys_question())) + + ds_id = self.ds.id if isinstance(self.ds, CoreDatasource) else None + + self.filter_terminology_template(_session, self.oid, ds_id) + + self.filter_custom_prompts(_session, CustomPromptTypeEnum.ANALYSIS, self.oid, ds_id) + + analysis_msg.append(SystemPromptMessage(content=self.chat_question.analysis_sys_question())) analysis_msg.append(HumanMessage(content=self.chat_question.analysis_user_question())) - self.record = save_full_analysis_message_and_answer(session=self.session, record_id=self.record.id, answer='', - full_message=orjson.dumps([{'type': msg.type, - 'content': msg.content} for msg - in - analysis_msg]).decode()) + self.current_logs[OperationEnum.ANALYSIS] = start_log(session=_session, + ai_modal_id=self.chat_question.ai_modal_id, + ai_modal_name=self.chat_question.ai_modal_name, + operate=OperationEnum.ANALYSIS, + record_id=self.record.id, + full_message=[ + {'type': msg.type, + 'sqlbot_system': getattr(msg, 'sqlbot_system', + False) is True, + 'content': msg.content} for + msg + in analysis_msg]) full_thinking_text = '' full_analysis_text = '' - res = self.llm.stream(analysis_msg) token_usage = {} + res = process_stream(self.llm.stream(analysis_msg), token_usage) for chunk in res: - SQLBotLogUtil.info(chunk) - reasoning_content_chunk = '' - if 'reasoning_content' in chunk.additional_kwargs: - reasoning_content_chunk = chunk.additional_kwargs.get('reasoning_content', '') - # else: - # reasoning_content_chunk = chunk.get('reasoning_content') - if reasoning_content_chunk is None: - reasoning_content_chunk = '' - full_thinking_text += reasoning_content_chunk - - full_analysis_text += chunk.content - yield {'content': chunk.content, 'reasoning_content': reasoning_content_chunk} - get_token_usage(chunk, token_usage) + if chunk.get('content'): + full_analysis_text += chunk.get('content') + if chunk.get('reasoning_content'): + full_thinking_text += chunk.get('reasoning_content') + yield chunk analysis_msg.append(AIMessage(full_analysis_text)) - self.record = save_full_analysis_message_and_answer(session=self.session, record_id=self.record.id, - token_usage=token_usage, - answer=orjson.dumps({'content': full_analysis_text, - 'reasoning_content': full_thinking_text}).decode(), - full_message=orjson.dumps([{'type': msg.type, - 'content': msg.content} for msg - in - analysis_msg]).decode()) - - def generate_predict(self): - fields = self.get_fields_from_chart() + + self.current_logs[OperationEnum.ANALYSIS] = end_log(session=_session, + log=self.current_logs[ + OperationEnum.ANALYSIS], + full_message=[ + {'type': msg.type, + 'sqlbot_system': getattr(msg, 'sqlbot_system', + False) is True, + 'content': msg.content} + for msg in analysis_msg], + reasoning_content=full_thinking_text, + token_usage=token_usage) + self.record = save_analysis_answer(session=_session, record_id=self.record.id, + answer=orjson.dumps({'content': full_analysis_text}).decode()) + + def generate_predict(self, _session: Session): + fields = self.get_fields_from_chart(_session) self.chat_question.fields = orjson.dumps(fields).decode() - data = get_chat_chart_data(self.session, self.record.id) + data = get_chat_chart_data(_session, self.record.id) self.chat_question.data = orjson.dumps(data.get('data')).decode() + + ds_id = self.ds.id if isinstance(self.ds, CoreDatasource) else None + self.filter_custom_prompts(_session, CustomPromptTypeEnum.PREDICT_DATA, self.oid, ds_id) + predict_msg: List[Union[BaseMessage, dict[str, Any]]] = [] - predict_msg.append(SystemMessage(content=self.chat_question.predict_sys_question())) + predict_msg.append(SystemPromptMessage(content=self.chat_question.predict_sys_question())) predict_msg.append(HumanMessage(content=self.chat_question.predict_user_question())) - self.record = save_full_predict_message_and_answer(session=self.session, record_id=self.record.id, answer='', - data='', - full_message=orjson.dumps([{'type': msg.type, - 'content': msg.content} for msg - in - predict_msg]).decode()) + self.current_logs[OperationEnum.PREDICT_DATA] = start_log(session=_session, + ai_modal_id=self.chat_question.ai_modal_id, + ai_modal_name=self.chat_question.ai_modal_name, + operate=OperationEnum.PREDICT_DATA, + record_id=self.record.id, + full_message=[ + {'type': msg.type, + 'sqlbot_system': getattr(msg, 'sqlbot_system', + False) is True, + 'content': msg.content} for + msg + in predict_msg]) full_thinking_text = '' full_predict_text = '' - res = self.llm.stream(predict_msg) token_usage = {} + res = process_stream(self.llm.stream(predict_msg), token_usage) for chunk in res: - SQLBotLogUtil.info(chunk) - reasoning_content_chunk = '' - if 'reasoning_content' in chunk.additional_kwargs: - reasoning_content_chunk = chunk.additional_kwargs.get('reasoning_content', '') - # else: - # reasoning_content_chunk = chunk.get('reasoning_content') - if reasoning_content_chunk is None: - reasoning_content_chunk = '' - full_thinking_text += reasoning_content_chunk - - full_predict_text += chunk.content - yield {'content': chunk.content, 'reasoning_content': reasoning_content_chunk} - get_token_usage(chunk, token_usage) + if chunk.get('content'): + full_predict_text += chunk.get('content') + if chunk.get('reasoning_content'): + full_thinking_text += chunk.get('reasoning_content') + yield chunk predict_msg.append(AIMessage(full_predict_text)) - self.record = save_full_predict_message_and_answer(session=self.session, record_id=self.record.id, - token_usage=token_usage, - answer=orjson.dumps({'content': full_predict_text, - 'reasoning_content': full_thinking_text}).decode(), - data='', - full_message=orjson.dumps([{'type': msg.type, - 'content': msg.content} for msg - in - predict_msg]).decode()) - - def generate_recommend_questions_task(self): + self.record = save_predict_answer(session=_session, record_id=self.record.id, + answer=orjson.dumps({'content': full_predict_text}).decode()) + self.current_logs[OperationEnum.PREDICT_DATA] = end_log(session=_session, + log=self.current_logs[ + OperationEnum.PREDICT_DATA], + full_message=[ + {'type': msg.type, + 'sqlbot_system': getattr(msg, 'sqlbot_system', + False) is True, + 'content': msg.content} + for msg in predict_msg], + reasoning_content=full_thinking_text, + token_usage=token_usage) + + def generate_recommend_questions_task(self, _session: Session): # get schema if self.ds and not self.chat_question.db_schema: - self.chat_question.db_schema = self.out_ds_instance.get_db_schema( - self.ds.id) if self.out_ds_instance else get_table_schema(session=self.session, - current_user=self.current_user, ds=self.ds) + self.chat_question.db_schema, tables = self.out_ds_instance.get_db_schema( + self.ds.id, self.chat_question.question) if self.out_ds_instance else get_table_schema( + session=_session, + current_user=self.current_user, ds=self.ds, + question=self.chat_question.question, + embedding=False) + + # Get sample data for all tables + # if not self.out_ds_instance: + # self.chat_question.sample_data = get_tables_sample_data( + # session=_session, + # current_user=self.current_user, + # ds=self.ds) guess_msg: List[Union[BaseMessage, dict[str, Any]]] = [] - guess_msg.append(SystemMessage(content=self.chat_question.guess_sys_question())) + guess_msg.append(SystemPromptMessage(content=self.chat_question.guess_sys_question(self.articles_number))) - old_questions = list(map(lambda q: q.strip(), get_old_questions(self.session, self.record.datasource))) + old_questions = list(map(lambda q: q.strip(), get_old_questions(_session, self.record.datasource))) guess_msg.append( HumanMessage(content=self.chat_question.guess_user_question(orjson.dumps(old_questions).decode()))) - self.record = save_full_recommend_question_message_and_answer(session=self.session, record_id=self.record.id, - full_message=orjson.dumps([{'type': msg.type, - 'content': msg.content} - for msg - in - guess_msg]).decode()) + self.current_logs[OperationEnum.GENERATE_RECOMMENDED_QUESTIONS] = start_log(session=_session, + ai_modal_id=self.chat_question.ai_modal_id, + ai_modal_name=self.chat_question.ai_modal_name, + operate=OperationEnum.GENERATE_RECOMMENDED_QUESTIONS, + record_id=self.record.id, + full_message=[ + {'type': msg.type, + 'sqlbot_system': getattr(msg, + 'sqlbot_system', + False) is True, + 'content': msg.content} for + msg + in guess_msg]) full_thinking_text = '' full_guess_text = '' token_usage = {} - res = self.llm.stream(guess_msg) + res = process_stream(self.llm.stream(guess_msg), token_usage) for chunk in res: - SQLBotLogUtil.info(chunk) - reasoning_content_chunk = '' - if 'reasoning_content' in chunk.additional_kwargs: - reasoning_content_chunk = chunk.additional_kwargs.get('reasoning_content', '') - # else: - # reasoning_content_chunk = chunk.get('reasoning_content') - if reasoning_content_chunk is None: - reasoning_content_chunk = '' - full_thinking_text += reasoning_content_chunk - - full_guess_text += chunk.content - yield {'content': chunk.content, 'reasoning_content': reasoning_content_chunk} - get_token_usage(chunk, token_usage) + if chunk.get('content'): + full_guess_text += chunk.get('content') + if chunk.get('reasoning_content'): + full_thinking_text += chunk.get('reasoning_content') + yield chunk guess_msg.append(AIMessage(full_guess_text)) - self.record = save_full_recommend_question_message_and_answer(session=self.session, record_id=self.record.id, - token_usage=token_usage, - answer={'content': full_guess_text, - 'reasoning_content': full_thinking_text}, - full_message=orjson.dumps([{'type': msg.type, - 'content': msg.content} - for msg - in - guess_msg]).decode()) + + self.current_logs[OperationEnum.GENERATE_RECOMMENDED_QUESTIONS] = end_log(session=_session, + log=self.current_logs[ + OperationEnum.GENERATE_RECOMMENDED_QUESTIONS], + full_message=[ + {'type': msg.type, + 'sqlbot_system': getattr(msg, + 'sqlbot_system', + False) is True, + 'content': msg.content} + for msg in guess_msg], + reasoning_content=full_thinking_text, + token_usage=token_usage) + self.record = save_recommend_question_answer(session=_session, record_id=self.record.id, + answer={'content': full_guess_text}, + articles_number=self.articles_number) + yield {'recommended_question': self.record.recommended_question} - def select_datasource(self): + def select_datasource(self, _session: Session): datasource_msg: List[Union[BaseMessage, dict[str, Any]]] = [] - datasource_msg.append(SystemMessage(self.chat_question.datasource_sys_question())) - if self.current_assistant: - _ds_list = get_assistant_ds(session=self.session, llm_service=self) + datasource_msg.append(SystemPromptMessage(self.chat_question.datasource_sys_question())) + if self.current_assistant and self.current_assistant.type != 4: + _ds_list = get_assistant_ds(session=_session, llm_service=self) else: - oid: str = self.current_user.oid stmt = select(CoreDatasource.id, CoreDatasource.name, CoreDatasource.description).where( - CoreDatasource.oid == oid) + and_(CoreDatasource.oid == self.oid)) _ds_list = [ { "id": ds.id, "name": ds.name, "description": ds.description } - for ds in self.session.exec(stmt) + for ds in _session.exec(stmt) ] - """ _ds_list = self.session.exec(select(CoreDatasource).options( - load_only(CoreDatasource.id, CoreDatasource.name, CoreDatasource.description))).all() """ - + if not _ds_list: + raise SingleMessageError('No available datasource configuration found') ignore_auto_select = _ds_list and len(_ds_list) == 1 # ignore auto select ds + full_thinking_text = '' + full_text = '' if not ignore_auto_select: + if settings.TABLE_EMBEDDING_ENABLED and ( + not self.current_assistant or (self.current_assistant and self.current_assistant.type != 1)): + _ds_list = get_ds_embedding(_session, _ds_list, self.out_ds_instance, + self.chat_question.question, self.current_assistant) + # yield {'content': '{"id":' + str(ds.get('id')) + '}'} + _ds_list_dict = [] for _ds in _ds_list: _ds_list_dict.append(_ds) datasource_msg.append( HumanMessage(self.chat_question.datasource_user_question(orjson.dumps(_ds_list_dict).decode()))) - history_msg = [] - if self.record.full_select_datasource_message and self.record.full_select_datasource_message.strip() != '': - history_msg = orjson.loads(self.record.full_select_datasource_message) - - self.record = save_full_select_datasource_message_and_answer(session=self.session, record_id=self.record.id, - answer='', - full_message=orjson.dumps(history_msg + - [{'type': msg.type, - 'content': msg.content} - for msg - in - datasource_msg]).decode()) - full_thinking_text = '' - full_text = '' + self.current_logs[OperationEnum.CHOOSE_DATASOURCE] = start_log(session=_session, + ai_modal_id=self.chat_question.ai_modal_id, + ai_modal_name=self.chat_question.ai_modal_name, + operate=OperationEnum.CHOOSE_DATASOURCE, + record_id=self.record.id, + full_message=[{'type': msg.type, + 'sqlbot_system': getattr(msg, + 'sqlbot_system', + False) is True, + 'content': msg.content} + for + msg in datasource_msg]) + token_usage = {} - res = self.llm.stream(datasource_msg) + res = process_stream(self.llm.stream(datasource_msg), token_usage) for chunk in res: - SQLBotLogUtil.info(chunk) - reasoning_content_chunk = '' - if 'reasoning_content' in chunk.additional_kwargs: - reasoning_content_chunk = chunk.additional_kwargs.get('reasoning_content', '') - # else: - # reasoning_content_chunk = chunk.get('reasoning_content') - if reasoning_content_chunk is None: - reasoning_content_chunk = '' - full_thinking_text += reasoning_content_chunk - - full_text += chunk.content - yield {'content': chunk.content, 'reasoning_content': reasoning_content_chunk} - get_token_usage(chunk, token_usage) + if chunk.get('content'): + full_text += chunk.get('content') + if chunk.get('reasoning_content'): + full_thinking_text += chunk.get('reasoning_content') + yield chunk datasource_msg.append(AIMessage(full_text)) + self.current_logs[OperationEnum.CHOOSE_DATASOURCE] = end_log(session=_session, + log=self.current_logs[ + OperationEnum.CHOOSE_DATASOURCE], + full_message=[ + {'type': msg.type, + 'sqlbot_system': getattr(msg, + 'sqlbot_system', + False) is True, + 'content': msg.content} + for msg in datasource_msg], + reasoning_content=full_thinking_text, + token_usage=token_usage) + json_str = extract_nested_json(full_text) + if json_str is None: + raise SingleMessageError(f'Cannot parse datasource from answer: {full_text}') + ds = orjson.loads(json_str) _error: Exception | None = None _datasource: int | None = None _engine_type: str | None = None try: - data: dict = _ds_list[0] if ignore_auto_select else orjson.loads(json_str) + data: dict = _ds_list[0] if ignore_auto_select else ds if data.get('id') and data.get('id') != 0: _datasource = data['id'] - _chat = self.session.get(Chat, self.record.chat_id) + _chat = _session.get(Chat, self.record.chat_id) _chat.datasource = _datasource - if self.current_assistant and self.current_assistant.type == 1: + if self.current_assistant and self.current_assistant.type in dynamic_ds_types: _ds = self.out_ds_instance.get_ds(data['id']) self.ds = _ds - self.chat_question.engine = _ds.type - self.chat_question.db_schema = self.out_ds_instance.get_db_schema(self.ds.id) + self.chat_question.engine = _ds.type + get_version(self.ds) + _engine_type = self.chat_question.engine _chat.engine_type = _ds.type else: - _ds = self.session.get(CoreDatasource, _datasource) + _ds = _session.get(CoreDatasource, _datasource) if not _ds: _datasource = None raise SingleMessageError(f"Datasource configuration with id {_datasource} not found") self.ds = CoreDatasource(**_ds.model_dump()) - self.chat_question.engine = _ds.type_name if _ds.type != 'excel' else 'PostgreSQL' - self.chat_question.db_schema = get_table_schema(session=self.session, - current_user=self.current_user, ds=self.ds) + self.chat_question.engine = (_ds.type_name if _ds.type != 'excel' else 'PostgreSQL') + get_version( + self.ds) + _engine_type = self.chat_question.engine _chat.engine_type = _ds.type_name # save chat - self.session.add(_chat) - self.session.flush() - self.session.refresh(_chat) - self.session.commit() + with _session.begin_nested(): + # 为了能继续记日志,先单独处理下事务 + try: + _session.add(_chat) + _session.flush() + _session.refresh(_chat) + _session.commit() + except Exception as e: + _session.rollback() + raise e elif data['fail']: raise SingleMessageError(data['fail']) @@ -458,151 +755,190 @@ def select_datasource(self): except Exception as e: _error = e - if not ignore_auto_select: - self.record = save_full_select_datasource_message_and_answer(session=self.session, record_id=self.record.id, - answer=orjson.dumps({'content': full_text, - 'reasoning_content': full_thinking_text}).decode(), - datasource=_datasource, - engine_type=_engine_type, - full_message=orjson.dumps(history_msg + - [{'type': msg.type, - 'content': msg.content} - for msg - in - datasource_msg]).decode()) - self.init_messages() + if not ignore_auto_select and not settings.TABLE_EMBEDDING_ENABLED: + self.record = save_select_datasource_answer(session=_session, record_id=self.record.id, + answer=orjson.dumps({'content': full_text}).decode(), + datasource=_datasource, + engine_type=_engine_type) + if self.ds: + oid = self.ds.oid if isinstance(self.ds, CoreDatasource) else 1 + ds_id = self.ds.id if isinstance(self.ds, CoreDatasource) else None + + self.filter_terminology_template(_session, oid, ds_id) + + self.filter_training_template(_session, oid, ds_id) + + self.filter_custom_prompts(_session, CustomPromptTypeEnum.GENERATE_SQL, oid, ds_id) + + self.init_messages(_session) if _error: raise _error - def generate_sql(self): + def generate_sql(self, _session: Session): # append current question - self.sql_message.append(HumanMessage(self.chat_question.sql_user_question())) - self.record = save_full_sql_message(session=self.session, record_id=self.record.id, - full_message=orjson.dumps( - [{'type': msg.type, 'content': msg.content} for msg in - self.sql_message]).decode()) + self.sql_message.append(HumanMessage( + self.chat_question.sql_user_question(current_time=datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + change_title=self.change_title))) + + self.current_logs[OperationEnum.GENERATE_SQL] = start_log(session=_session, + ai_modal_id=self.chat_question.ai_modal_id, + ai_modal_name=self.chat_question.ai_modal_name, + operate=OperationEnum.GENERATE_SQL, + record_id=self.record.id, + full_message=[ + {'type': msg.type, + 'sqlbot_system': getattr(msg, 'sqlbot_system', + False) is True, + 'content': msg.content} for msg + in self.sql_message]) full_thinking_text = '' full_sql_text = '' token_usage = {} - res = self.llm.stream(self.sql_message) + res = process_stream(self.llm.stream(self.sql_message), token_usage) for chunk in res: - SQLBotLogUtil.info(chunk) - reasoning_content_chunk = '' - if 'reasoning_content' in chunk.additional_kwargs: - reasoning_content_chunk = chunk.additional_kwargs.get('reasoning_content', '') - # else: - # reasoning_content_chunk = chunk.get('reasoning_content') - if reasoning_content_chunk is None: - reasoning_content_chunk = '' - full_thinking_text += reasoning_content_chunk - - full_sql_text += chunk.content - yield {'content': chunk.content, 'reasoning_content': reasoning_content_chunk} - get_token_usage(chunk, token_usage) + if chunk.get('content'): + full_sql_text += chunk.get('content') + if chunk.get('reasoning_content'): + full_thinking_text += chunk.get('reasoning_content') + yield chunk self.sql_message.append(AIMessage(full_sql_text)) - self.record = save_full_sql_message_and_answer(session=self.session, record_id=self.record.id, - token_usage=token_usage, - answer=orjson.dumps({'content': full_sql_text, - 'reasoning_content': full_thinking_text}).decode(), - full_message=orjson.dumps( - [{'type': msg.type, 'content': msg.content} for msg in - self.sql_message]).decode()) - - def generate_with_sub_sql(self, sql, sub_mappings: list): + + self.current_logs[OperationEnum.GENERATE_SQL] = end_log(session=_session, + log=self.current_logs[OperationEnum.GENERATE_SQL], + full_message=[{'type': msg.type, + 'sqlbot_system': getattr(msg, + 'sqlbot_system', + False) is True, + 'content': msg.content} + for msg in self.sql_message], + reasoning_content=full_thinking_text, + token_usage=token_usage) + self.record = save_sql_answer(session=_session, record_id=self.record.id, + answer=orjson.dumps({'content': full_sql_text}).decode()) + + def generate_with_sub_sql(self, session: Session, sql, sub_mappings: list): sub_query = json.dumps(sub_mappings, ensure_ascii=False) self.chat_question.sql = sql self.chat_question.sub_query = sub_query - msg: List[Union[BaseMessage, dict[str, Any]]] = [] - msg.append(SystemMessage(content=self.chat_question.dynamic_sys_question())) - msg.append(HumanMessage(content=self.chat_question.dynamic_user_question())) + dynamic_sql_msg: List[Union[BaseMessage, dict[str, Any]]] = [] + dynamic_sql_msg.append(SystemPromptMessage(content=self.chat_question.dynamic_sys_question())) + dynamic_sql_msg.append(HumanMessage(content=self.chat_question.dynamic_user_question())) + + self.current_logs[OperationEnum.GENERATE_DYNAMIC_SQL] = start_log(session=session, + ai_modal_id=self.chat_question.ai_modal_id, + ai_modal_name=self.chat_question.ai_modal_name, + operate=OperationEnum.GENERATE_DYNAMIC_SQL, + record_id=self.record.id, + full_message=[{'type': msg.type, + 'sqlbot_system': getattr(msg, + 'sqlbot_system', + False) is True, + 'content': msg.content} + for + msg in dynamic_sql_msg]) + full_thinking_text = '' full_dynamic_text = '' - res = self.llm.stream(msg) token_usage = {} + res = process_stream(self.llm.stream(dynamic_sql_msg), token_usage) for chunk in res: - SQLBotLogUtil.info(chunk) - reasoning_content_chunk = '' - if 'reasoning_content' in chunk.additional_kwargs: - reasoning_content_chunk = chunk.additional_kwargs.get('reasoning_content', '') - if reasoning_content_chunk is None: - reasoning_content_chunk = '' - full_thinking_text += reasoning_content_chunk - full_dynamic_text += chunk.content - get_token_usage(chunk, token_usage) + if chunk.get('content'): + full_dynamic_text += chunk.get('content') + if chunk.get('reasoning_content'): + full_thinking_text += chunk.get('reasoning_content') + + dynamic_sql_msg.append(AIMessage(full_dynamic_text)) + + self.current_logs[OperationEnum.GENERATE_DYNAMIC_SQL] = end_log(session=session, + log=self.current_logs[ + OperationEnum.GENERATE_DYNAMIC_SQL], + full_message=[ + {'type': msg.type, + 'sqlbot_system': getattr(msg, + 'sqlbot_system', + False) is True, + 'content': msg.content} + for msg in dynamic_sql_msg], + reasoning_content=full_thinking_text, + token_usage=token_usage) SQLBotLogUtil.info(full_dynamic_text) return full_dynamic_text - def generate_assistant_dynamic_sql(self, sql, tables: List): + def generate_assistant_dynamic_sql(self, _session: Session, sql, tables: List): ds: AssistantOutDsSchema = self.ds sub_query = [] + result_dict = {} for table in ds.tables: if table.name in tables and table.sql: - sub_query.append({"table": table.name, "query": table.sql}) + # sub_query.append({"table": table.name, "query": table.sql}) + result_dict[table.name] = table.sql + sub_query.append({"table": table.name, "query": f'{dynamic_subsql_prefix}{table.name}'}) if not sub_query: return None - return self.generate_with_sub_sql(sql=sql, sub_mappings=sub_query) + temp_sql_text = self.generate_with_sub_sql(session=_session, sql=sql, sub_mappings=sub_query) + result_dict['sqlbot_temp_sql_text'] = temp_sql_text + return result_dict - def build_table_filter(self, sql: str, filters: list): + def build_table_filter(self, session: Session, sql: str, filters: list): filter = json.dumps(filters, ensure_ascii=False) self.chat_question.sql = sql self.chat_question.filter = filter - msg: List[Union[BaseMessage, dict[str, Any]]] = [] - msg.append(SystemMessage(content=self.chat_question.filter_sys_question())) - msg.append(HumanMessage(content=self.chat_question.filter_user_question())) - - history_msg = [] - # if self.record.full_analysis_message and self.record.full_analysis_message.strip() != '': - # history_msg = orjson.loads(self.record.full_analysis_message) - - # self.record = save_full_analysis_message_and_answer(session=self.session, record_id=self.record.id, answer='', - # full_message=orjson.dumps(history_msg + - # [{'type': msg.type, - # 'content': msg.content} for msg - # in - # msg]).decode()) + permission_sql_msg: List[Union[BaseMessage, dict[str, Any]]] = [] + permission_sql_msg.append(SystemPromptMessage(content=self.chat_question.filter_sys_question())) + permission_sql_msg.append(HumanMessage(content=self.chat_question.filter_user_question())) + + self.current_logs[OperationEnum.GENERATE_SQL_WITH_PERMISSIONS] = start_log(session=session, + ai_modal_id=self.chat_question.ai_modal_id, + ai_modal_name=self.chat_question.ai_modal_name, + operate=OperationEnum.GENERATE_SQL_WITH_PERMISSIONS, + record_id=self.record.id, + full_message=[ + {'type': msg.type, + 'sqlbot_system': getattr(msg, + 'sqlbot_system', + False) is True, + 'content': msg.content} for + msg + in permission_sql_msg]) full_thinking_text = '' full_filter_text = '' - res = self.llm.stream(msg) token_usage = {} + res = process_stream(self.llm.stream(permission_sql_msg), token_usage) for chunk in res: - SQLBotLogUtil.info(chunk) - reasoning_content_chunk = '' - if 'reasoning_content' in chunk.additional_kwargs: - reasoning_content_chunk = chunk.additional_kwargs.get('reasoning_content', '') - # else: - # reasoning_content_chunk = chunk.get('reasoning_content') - if reasoning_content_chunk is None: - reasoning_content_chunk = '' - full_thinking_text += reasoning_content_chunk - - full_filter_text += chunk.content - # yield {'content': chunk.content, 'reasoning_content': reasoning_content_chunk} - get_token_usage(chunk, token_usage) + if chunk.get('content'): + full_filter_text += chunk.get('content') + if chunk.get('reasoning_content'): + full_thinking_text += chunk.get('reasoning_content') + + permission_sql_msg.append(AIMessage(full_filter_text)) + + self.current_logs[OperationEnum.GENERATE_SQL_WITH_PERMISSIONS] = end_log(session=session, + log=self.current_logs[ + OperationEnum.GENERATE_SQL_WITH_PERMISSIONS], + full_message=[ + {'type': msg.type, + 'sqlbot_system': getattr(msg, + 'sqlbot_system', + False) is True, + 'content': msg.content} + for msg in permission_sql_msg], + reasoning_content=full_thinking_text, + token_usage=token_usage) - msg.append(AIMessage(full_filter_text)) - # self.record = save_full_analysis_message_and_answer(session=self.session, record_id=self.record.id, - # token_usage=token_usage, - # answer=orjson.dumps({'content': full_analysis_text, - # 'reasoning_content': full_thinking_text}).decode(), - # full_message=orjson.dumps(history_msg + - # [{'type': msg.type, - # 'content': msg.content} for msg - # in - # analysis_msg]).decode()) SQLBotLogUtil.info(full_filter_text) return full_filter_text - def generate_filter(self, sql: str, tables: List): - filters = get_row_permission_filters(session=self.session, current_user=self.current_user, ds=self.ds, + def generate_filter(self, _session: Session, sql: str, tables: List): + filters = get_row_permission_filters(session=_session, current_user=self.current_user, ds=self.ds, tables=tables) if not filters: return None - return self.build_table_filter(sql=sql, filters=filters) + return self.build_table_filter(session=_session, sql=sql, filters=filters) - def generate_assistant_filter(self, sql, tables: List): + def generate_assistant_filter(self, _session: Session, sql, tables: List): ds: AssistantOutDsSchema = self.ds filters = [] for table in ds.tables: @@ -610,48 +946,59 @@ def generate_assistant_filter(self, sql, tables: List): filters.append({"table": table.name, "filter": table.rule}) if not filters: return None - return self.build_table_filter(sql=sql, filters=filters) + return self.build_table_filter(session=_session, sql=sql, filters=filters) - def generate_chart(self): + def generate_chart(self, _session: Session, chart_type: Optional[str] = '', schema: Optional[str] = ''): # append current question - self.chart_message.append(HumanMessage(self.chat_question.chart_user_question())) - self.record = save_full_chart_message(session=self.session, record_id=self.record.id, - full_message=orjson.dumps( - [{'type': msg.type, 'content': msg.content} for msg in - self.chart_message]).decode()) + self.chart_message.append(HumanMessage(self.chat_question.chart_user_question(chart_type, schema))) + + self.current_logs[OperationEnum.GENERATE_CHART] = start_log(session=_session, + ai_modal_id=self.chat_question.ai_modal_id, + ai_modal_name=self.chat_question.ai_modal_name, + operate=OperationEnum.GENERATE_CHART, + record_id=self.record.id, + full_message=[ + {'type': msg.type, + 'sqlbot_system': getattr(msg, 'sqlbot_system', + False) is True, + 'content': msg.content} for + msg + in self.chart_message]) full_thinking_text = '' full_chart_text = '' token_usage = {} - res = self.llm.stream(self.chart_message) + res = process_stream(self.llm.stream(self.chart_message), token_usage) for chunk in res: - SQLBotLogUtil.info(chunk) - reasoning_content_chunk = '' - if 'reasoning_content' in chunk.additional_kwargs: - reasoning_content_chunk = chunk.additional_kwargs.get('reasoning_content', '') - # else: - # reasoning_content_chunk = chunk.get('reasoning_content') - if reasoning_content_chunk is None: - reasoning_content_chunk = '' - full_thinking_text += reasoning_content_chunk - - full_chart_text += chunk.content - yield {'content': chunk.content, 'reasoning_content': reasoning_content_chunk} - get_token_usage(chunk, token_usage) + if chunk.get('content'): + full_chart_text += chunk.get('content') + if chunk.get('reasoning_content'): + full_thinking_text += chunk.get('reasoning_content') + yield chunk self.chart_message.append(AIMessage(full_chart_text)) - self.record = save_full_chart_message_and_answer(session=self.session, record_id=self.record.id, - token_usage=token_usage, - answer=orjson.dumps({'content': full_chart_text, - 'reasoning_content': full_thinking_text}).decode(), - full_message=orjson.dumps( - [{'type': msg.type, 'content': msg.content} for msg in - self.chart_message]).decode()) - - def check_sql(self, res: str) -> tuple[any]: + + self.record = save_chart_answer(session=_session, record_id=self.record.id, + answer=orjson.dumps({'content': full_chart_text}).decode()) + self.current_logs[OperationEnum.GENERATE_CHART] = end_log(session=_session, + log=self.current_logs[OperationEnum.GENERATE_CHART], + full_message=[ + {'type': msg.type, + 'sqlbot_system': getattr(msg, 'sqlbot_system', + False) is True, + 'content': msg.content} + for msg in self.chart_message], + reasoning_content=full_thinking_text, + token_usage=token_usage) + + def check_sql(self, session: Session, res: str, operate: OperationEnum) -> tuple[str, Optional[list]]: json_str = extract_nested_json(res) + + log = self.current_logs[operate] + if json_str is None: - raise SingleMessageError(orjson.dumps({'message': 'Cannot parse sql from answer', - 'traceback': "Cannot parse sql from answer:\n" + res}).decode()) + trigger_log_error(session, log) + raise SingleMessageError(orjson.dumps({'message': 'SQL answer is not a valid json object', + 'traceback': "SQL answer is not a valid json object:\n" + res}).decode()) sql: str data: dict try: @@ -663,24 +1010,67 @@ def check_sql(self, res: str) -> tuple[any]: message = data['message'] raise SingleMessageError(message) except SingleMessageError as e: + trigger_log_error(session, log) raise e except Exception: + trigger_log_error(session, log) raise SingleMessageError(orjson.dumps({'message': 'Cannot parse sql from answer', 'traceback': "Cannot parse sql from answer:\n" + res}).decode()) if sql.strip() == '': + trigger_log_error(session, log) raise SingleMessageError("SQL query is empty") return sql, data.get('tables') - def check_save_sql(self, res: str) -> str: - sql, *_ = self.check_sql(res=res) - save_sql(session=self.session, sql=sql, record_id=self.record.id) + @staticmethod + def get_chart_type_from_sql_answer(res: str) -> Optional[str]: + json_str = extract_nested_json(res) + if json_str is None: + return None + + chart_type: Optional[str] + data: dict + try: + data = orjson.loads(json_str) + + if data['success']: + chart_type = data['chart-type'] + else: + return None + except Exception: + return None + + return chart_type + + @staticmethod + def get_brief_from_sql_answer(res: str) -> Optional[str]: + json_str = extract_nested_json(res) + if json_str is None: + return None + + brief: Optional[str] + data: dict + try: + data = orjson.loads(json_str) + + if data['success']: + brief = data['brief'] + else: + return None + except Exception: + return None + + return brief + + def check_save_sql(self, session: Session, res: str, operate: OperationEnum) -> str: + sql, *_ = self.check_sql(session=session, res=res, operate=operate) + save_sql(session=session, sql=sql, record_id=self.record.id) self.chat_question.sql = sql return sql - def check_save_chart(self, res: str) -> Dict[str, Any]: + def check_save_chart(self, session: Session, res: str) -> Dict[str, Any]: json_str = extract_nested_json(res) if json_str is None: @@ -703,10 +1093,27 @@ def check_save_chart(self, res: str) -> Dict[str, Any]: if chart.get('axis'): if chart.get('axis').get('x'): chart.get('axis').get('x')['value'] = chart.get('axis').get('x').get('value').lower() - if chart.get('axis').get('y'): - chart.get('axis').get('y')['value'] = chart.get('axis').get('y').get('value').lower() + y_axis = chart.get('axis').get('y') + if y_axis: + if isinstance(y_axis, list): + # 数组格式: y: [{name, value}, ...] + for item in y_axis: + if item.get('value'): + item['value'] = item['value'].lower() + elif isinstance(y_axis, dict) and y_axis.get('value'): + # 旧格式: y: {name, value} + y_axis['value'] = y_axis['value'].lower() if chart.get('axis').get('series'): chart.get('axis').get('series')['value'] = chart.get('axis').get('series').get('value').lower() + if chart.get('axis') and chart['axis'].get('multi-quota'): + multi_quota = chart['axis']['multi-quota'] + if multi_quota.get('value'): + if isinstance(multi_quota['value'], list): + # 将数组中的每个值转换为小写 + multi_quota['value'] = [v.lower() if v else v for v in multi_quota['value']] + elif isinstance(multi_quota['value'], str): + # 如果是字符串,也转换为小写 + multi_quota['value'] = multi_quota['value'].lower() elif data['type'] == 'error': message = data['reason'] error = True @@ -720,40 +1127,46 @@ def check_save_chart(self, res: str) -> Dict[str, Any]: if error: raise SingleMessageError(message) - save_chart(session=self.session, chart=orjson.dumps(chart).decode(), record_id=self.record.id) + save_chart(session=session, chart=orjson.dumps(chart).decode(), record_id=self.record.id) return chart - def check_save_predict_data(self, res: str) -> bool: + def check_save_predict_data(self, session: Session, res: str) -> bool: json_str = extract_nested_json(res) if not json_str: json_str = '' - save_predict_data(session=self.session, record_id=self.record.id, data=json_str) + save_predict_data(session=session, record_id=self.record.id, data=json_str) if json_str == '': return False return True - def save_error(self, message: str): - return save_error_message(session=self.session, record_id=self.record.id, message=message) + def save_error(self, session: Session, message: str): + return save_error_message(session=session, record_id=self.record.id, message=message) - def save_sql_data(self, data_obj: Dict[str, Any]): + def save_sql_data(self, session: Session, data_obj: Dict[str, Any]): try: data_result = data_obj.get('data') + limit = 1000 if data_result: data_result = prepare_for_orjson(data_result) - data_obj['data'] = data_result - return save_sql_exec_data(session=self.session, record_id=self.record.id, + if data_result and len(data_result) > limit and self.enable_sql_row_limit: + data_obj['data'] = data_result[:limit] + data_obj['limit'] = limit + else: + data_obj['data'] = data_result + data_obj['datasource'] = self.ds.id + return save_sql_exec_data(session=session, record_id=self.record.id, data=orjson.dumps(data_obj).decode()) except Exception as e: raise e - def finish(self): - return finish_record(session=self.session, record_id=self.record.id) + def finish(self, session: Session): + return finish_record(session=session, record_id=self.record.id) def execute_sql(self, sql: str): """Execute SQL query @@ -766,7 +1179,14 @@ def execute_sql(self, sql: str): Query results """ SQLBotLogUtil.info(f"Executing SQL on ds_id {self.ds.id}: {sql}") - return exec_sql(self.ds, sql) + try: + return exec_sql(ds=self.ds, sql=sql, origin_column=False) + except Exception as e: + if isinstance(e, ParseSQLResultError): + raise e + else: + err = traceback.format_exc(limit=1, chain=True) + raise SQLBotDBError(err) def pop_chunk(self): try: @@ -789,171 +1209,333 @@ def await_result(self): break yield chunk - def run_task_async(self, in_chat: bool = True): - self.future = executor.submit(self.run_task_cache, in_chat) + def run_task_async(self, in_chat: bool = True, stream: bool = True, + finish_step: ChatFinishStep = ChatFinishStep.GENERATE_CHART, return_img: bool = True): + if in_chat: + stream = True + self.future = executor.submit(self.run_task_cache, in_chat, stream, finish_step, return_img) - def run_task_cache(self, in_chat: bool = True): - for chunk in self.run_task(in_chat): + def run_task_cache(self, in_chat: bool = True, stream: bool = True, + finish_step: ChatFinishStep = ChatFinishStep.GENERATE_CHART, return_img: bool = True): + for chunk in self.run_task(in_chat, stream, finish_step, return_img): self.chunk_list.append(chunk) - def run_task(self, in_chat: bool = True): + def run_task(self, in_chat: bool = True, stream: bool = True, + finish_step: ChatFinishStep = ChatFinishStep.GENERATE_CHART, return_img: bool = True): + json_result: Dict[str, Any] = {'success': True} + _session = None try: + _session = session_maker() + if self.ds: + oid = self.ds.oid if isinstance(self.ds, CoreDatasource) else 1 + ds_id = self.ds.id if isinstance(self.ds, CoreDatasource) else None + + self.filter_terminology_template(_session, oid, ds_id) + + self.filter_training_template(_session, oid, ds_id) + + self.filter_custom_prompts(_session, CustomPromptTypeEnum.GENERATE_SQL, oid, ds_id) + + self.init_messages(_session) + # return id if in_chat: - yield orjson.dumps({'type': 'id', 'id': self.get_record().id}).decode() + '\n\n' - - # return title - if self.change_title: - if self.chat_question.question or self.chat_question.question.strip() != '': - brief = rename_chat(session=self.session, - rename_object=RenameChat(id=self.get_record().chat_id, - brief=self.chat_question.question.strip()[:20])) - if in_chat: - yield orjson.dumps({'type': 'brief', 'brief': brief}).decode() + '\n\n' + yield 'data:' + orjson.dumps({'type': 'id', 'id': self.get_record().id}).decode() + '\n\n' + if self.get_record().regenerate_record_id: + yield 'data:' + orjson.dumps({'type': 'regenerate_record_id', + 'regenerate_record_id': self.get_record().regenerate_record_id}).decode() + '\n\n' + yield 'data:' + orjson.dumps( + {'type': 'question', 'question': self.get_record().question}).decode() + '\n\n' + else: + if stream: + yield '> ' + self.trans('i18n_chat.record_id_in_mcp') + str(self.get_record().id) + '\n' + yield '> ' + self.get_record().question + '\n\n' + if not stream: + json_result['record_id'] = self.get_record().id - # select datasource if datasource is none + # select datasource if datasource is none if not self.ds: - ds_res = self.select_datasource() + ds_res = self.select_datasource(_session) for chunk in ds_res: SQLBotLogUtil.info(chunk) if in_chat: - yield orjson.dumps( + yield 'data:' + orjson.dumps( {'content': chunk.get('content'), 'reasoning_content': chunk.get('reasoning_content'), 'type': 'datasource-result'}).decode() + '\n\n' if in_chat: - yield orjson.dumps({'id': self.ds.id, 'datasource_name': self.ds.name, - 'engine_type': self.ds.type_name or self.ds.type, - 'type': 'datasource'}).decode() + '\n\n' - - self.chat_question.db_schema = self.out_ds_instance.get_db_schema( - self.ds.id) if self.out_ds_instance else get_table_schema(session=self.session, - current_user=self.current_user, - ds=self.ds) + yield 'data:' + orjson.dumps({'id': self.ds.id, 'datasource_name': self.ds.name, + 'engine_type': self.ds.type_name or self.ds.type, + 'type': 'datasource'}).decode() + '\n\n' + else: - self.validate_history_ds() + self.validate_history_ds(_session) + + # check connection + connected = check_connection(ds=self.ds, trans=None) + if not connected: + raise SQLBotDBConnectionError('Connect DB failed') + # generate sql - sql_res = self.generate_sql() + sql_res = self.generate_sql(_session) full_sql_text = '' for chunk in sql_res: full_sql_text += chunk.get('content') if in_chat: - yield orjson.dumps( + yield 'data:' + orjson.dumps( {'content': chunk.get('content'), 'reasoning_content': chunk.get('reasoning_content'), 'type': 'sql-result'}).decode() + '\n\n' if in_chat: - yield orjson.dumps({'type': 'info', 'msg': 'sql generated'}).decode() + '\n\n' - + yield 'data:' + orjson.dumps({'type': 'info', 'msg': 'sql generated'}).decode() + '\n\n' # filter sql SQLBotLogUtil.info(full_sql_text) - use_dynamic_ds: bool = self.current_assistant and self.current_assistant.type == 1 - # todo row permission - if (not self.current_assistant and is_normal_user(self.current_user)) or use_dynamic_ds: - sql, tables = self.check_sql(res=full_sql_text) + chart_type = self.get_chart_type_from_sql_answer(full_sql_text) + + # return title + if self.change_title: + llm_brief = self.get_brief_from_sql_answer(full_sql_text) + llm_brief_generated = bool(llm_brief) + if llm_brief_generated or (self.chat_question.question and self.chat_question.question.strip() != ''): + save_brief = llm_brief if (llm_brief and llm_brief != '') else self.chat_question.question.strip()[ + :20] + brief = rename_chat(session=_session, + rename_object=RenameChat(id=self.get_record().chat_id, + brief=save_brief, brief_generate=llm_brief_generated)) + if in_chat: + yield 'data:' + orjson.dumps({'type': 'brief', 'brief': brief}).decode() + '\n\n' + if not stream: + json_result['title'] = brief + + use_dynamic_ds: bool = self.current_assistant and self.current_assistant.type in dynamic_ds_types + is_page_embedded: bool = self.current_assistant and self.current_assistant.type == 4 + dynamic_sql_result = None + sqlbot_temp_sql_text = None + assistant_dynamic_sql = None + # row permission + + sql_operate = OperationEnum.GENERATE_SQL + sql, tables = self.check_sql(session=_session, res=full_sql_text, operate=sql_operate) + + # 表名安全检查:用 sqlglot 解析真实 SQL,不信任 AI 返回的 tables + actual_tables = extract_tables_from_sql(sql, ds_type=self.ds.type) + if not actual_tables: + raise SingleMessageError( + "SQL parsing failed: unable to extract table names. " + "This may indicate an unsupported SQL syntax or a security issue." + ) + allowed_tables = set(self.table_name_list) + unauthorized_tables = actual_tables - allowed_tables + if unauthorized_tables: + raise SingleMessageError( + f"SQL contains unauthorized tables: {', '.join(unauthorized_tables)}. " + f"Allowed tables: {', '.join(allowed_tables)}" + ) + + if ((not self.current_assistant or is_page_embedded) and is_normal_user( + self.current_user)) or use_dynamic_ds: sql_result = None - dynamic_sql_result = None - if self.current_assistant: - dynamic_sql_result = self.generate_assistant_dynamic_sql(sql, tables) - if dynamic_sql_result: - SQLBotLogUtil.info(dynamic_sql_result) - sql, *_ = self.check_sql(res=dynamic_sql_result) - - sql_result = self.generate_assistant_filter(sql, tables) + + if use_dynamic_ds: + dynamic_sql_result = self.generate_assistant_dynamic_sql(_session, sql, tables) + sqlbot_temp_sql_text = dynamic_sql_result.get( + 'sqlbot_temp_sql_text') if dynamic_sql_result else None else: - sql_result = self.generate_filter(sql, tables) # maybe no sql and tables + sql_result = self.generate_filter(_session, sql, tables) # maybe no sql and tables if sql_result: SQLBotLogUtil.info(sql_result) - sql = self.check_save_sql(res=sql_result) - elif dynamic_sql_result: - sql = self.check_save_sql(res=dynamic_sql_result) + sql_operate = OperationEnum.GENERATE_SQL_WITH_PERMISSIONS + sql = self.check_save_sql(session=_session, res=sql_result, operate=sql_operate) + elif dynamic_sql_result and sqlbot_temp_sql_text: + sql_operate = OperationEnum.GENERATE_DYNAMIC_SQL + assistant_dynamic_sql = self.check_save_sql(session=_session, res=sqlbot_temp_sql_text, + operate=sql_operate) else: - sql = self.check_save_sql(res=full_sql_text) + sql = self.check_save_sql(session=_session, res=full_sql_text, operate=sql_operate) else: - sql = self.check_save_sql(res=full_sql_text) + sql = self.check_save_sql(session=_session, res=full_sql_text, operate=sql_operate) + + SQLBotLogUtil.info('sql: ' + sql) + + if not stream: + json_result['sql'] = sql - SQLBotLogUtil.info(sql) format_sql = sqlparse.format(sql, reindent=True) if in_chat: - yield orjson.dumps({'content': format_sql, 'type': 'sql'}).decode() + '\n\n' + yield 'data:' + orjson.dumps({'content': format_sql, 'type': 'sql'}).decode() + '\n\n' else: - yield f'```sql\n{format_sql}\n```\n\n' + if stream: + yield f'```sql\n{format_sql}\n```\n\n' # execute sql - result = self.execute_sql(sql=sql) - self.save_sql_data(data_obj=result) + real_execute_sql = sql + if sqlbot_temp_sql_text and assistant_dynamic_sql: + dynamic_sql_result.pop('sqlbot_temp_sql_text') + for origin_table, subsql in dynamic_sql_result.items(): + assistant_dynamic_sql = assistant_dynamic_sql.replace(f'{dynamic_subsql_prefix}{origin_table}', + subsql) + real_execute_sql = assistant_dynamic_sql + + if finish_step.value <= ChatFinishStep.GENERATE_SQL.value: + if in_chat: + yield 'data:' + orjson.dumps({'type': 'finish'}).decode() + '\n\n' + if not stream: + yield json_result + return + + self.current_logs[OperationEnum.EXECUTE_SQL] = start_log(session=_session, + operate=OperationEnum.EXECUTE_SQL, + record_id=self.record.id, local_operation=True) + result = self.execute_sql(sql=real_execute_sql) + self.current_logs[OperationEnum.EXECUTE_SQL] = end_log(session=_session, + log=self.current_logs[OperationEnum.EXECUTE_SQL], + full_message={'sql': real_execute_sql, + 'count': len(result.get('data'))}) + + _data = DataFormat.convert_large_numbers_in_object_array(result.get('data')) + _data = DataFormat.normalize_qualified_sql_column_keys_in_object_array(_data) + result["data"] = _data + + self.save_sql_data(session=_session, data_obj=result) if in_chat: - yield orjson.dumps({'content': 'execute-success', 'type': 'sql-data'}).decode() + '\n\n' + yield 'data:' + orjson.dumps({'content': 'execute-success', 'type': 'sql-data'}).decode() + '\n\n' + if not stream: + json_result['data'] = get_chat_chart_data(_session, self.record.id) + + if finish_step.value <= ChatFinishStep.QUERY_DATA.value: + if stream: + if in_chat: + yield 'data:' + orjson.dumps({'type': 'finish'}).decode() + '\n\n' + else: + _column_list = [] + for field in result.get('fields'): + _column_list.append(AxisObj(name=field, value=field)) + + md_data, _fields_list = DataFormat.convert_object_array_for_pandas(_column_list, + result.get('data')) + + # data, _fields_list, col_formats = self.format_pd_data(_column_list, result.get('data')) + + if not _data or not _fields_list: + yield 'The SQL execution result is empty.\n\n' + else: + df = pd.DataFrame(_data, columns=_fields_list) + df_safe = DataFormat.safe_convert_to_string(df) + markdown_table = df_safe.to_markdown(index=False) + yield markdown_table + '\n\n' + else: + yield json_result + return # generate chart - chart_res = self.generate_chart() + used_tables_schema, used_tables = self.out_ds_instance.get_db_schema( + self.ds.id, self.chat_question.question, embedding=False, + table_list=tables) if self.out_ds_instance else get_table_schema( + session=_session, + current_user=self.current_user, + ds=self.ds, + question=self.chat_question.question, + embedding=False, table_list=tables) + SQLBotLogUtil.info('used_tables_schema: \n' + used_tables_schema) + chart_res = self.generate_chart(_session, chart_type, used_tables_schema) full_chart_text = '' for chunk in chart_res: full_chart_text += chunk.get('content') if in_chat: - yield orjson.dumps( + yield 'data:' + orjson.dumps( {'content': chunk.get('content'), 'reasoning_content': chunk.get('reasoning_content'), 'type': 'chart-result'}).decode() + '\n\n' if in_chat: - yield orjson.dumps({'type': 'info', 'msg': 'chart generated'}).decode() + '\n\n' + yield 'data:' + orjson.dumps({'type': 'info', 'msg': 'chart generated'}).decode() + '\n\n' # filter chart SQLBotLogUtil.info(full_chart_text) - chart = self.check_save_chart(res=full_chart_text) + chart = self.check_save_chart(session=_session, res=full_chart_text) SQLBotLogUtil.info(chart) + + if not stream: + json_result['chart'] = chart + if in_chat: - yield orjson.dumps({'content': orjson.dumps(chart).decode(), 'type': 'chart'}).decode() + '\n\n' + yield 'data:' + orjson.dumps( + {'content': orjson.dumps(chart).decode(), 'type': 'chart'}).decode() + '\n\n' else: - data = [] - _fields = {} - if chart.get('columns'): - for _column in chart.get('columns'): - if _column: - _fields[_column.get('value')] = _column.get('name') - if chart.get('axis'): - if chart.get('axis').get('x'): - _fields[chart.get('axis').get('x').get('value')] = chart.get('axis').get('x').get('name') - if chart.get('axis').get('y'): - _fields[chart.get('axis').get('y').get('value')] = chart.get('axis').get('y').get('name') - if chart.get('axis').get('series'): - _fields[chart.get('axis').get('series').get('value')] = chart.get('axis').get('series').get( - 'name') - _fields_list = [] - _fields_skip = False - for _data in result.get('data'): - _row = [] - for field in result.get('fields'): - _row.append(_data.get(field)) - if not _fields_skip: - _fields_list.append(field if not _fields.get(field) else _fields.get(field)) - data.append(_row) - _fields_skip = True - df = pd.DataFrame(np.array(data), columns=_fields_list) - markdown_table = df.to_markdown(index=False) - yield markdown_table + '\n\n' - - record = self.finish() + if stream: + md_data, _fields_list = DataFormat.convert_data_fields_for_pandas(chart, result.get('fields'), + result.get('data')) + # data, _fields_list, col_formats = self.format_pd_data(_column_list, result.get('data')) + + if not md_data or not _fields_list: + yield 'The SQL execution result is empty.\n\n' + else: + df = pd.DataFrame(md_data, columns=_fields_list) + df_safe = DataFormat.safe_convert_to_string(df) + markdown_table = df_safe.to_markdown(index=False) + yield markdown_table + '\n\n' + if in_chat: - yield orjson.dumps({'type': 'finish'}).decode() + '\n\n' + yield 'data:' + orjson.dumps({'type': 'finish'}).decode() + '\n\n' else: - # todo generate picture - if chart['type'] != 'table': - yield '### generated chart picture\n\n' - image_url = request_picture(self.record.chat_id, self.record.id, chart, result) - SQLBotLogUtil.info(image_url) - yield f'![{chart["type"]}]({image_url})' + # generate picture + try: + if chart.get('type') != 'table' and return_img: + # yield '### generated chart picture\n\n' + self.current_logs[OperationEnum.GENERATE_PICTURE] = start_log(session=_session, + operate=OperationEnum.GENERATE_PICTURE, + record_id=self.record.id, + local_operation=True) + image_url, error = request_picture(self.record.chat_id, self.record.id, chart, + format_json_data(result)) + SQLBotLogUtil.info(image_url) + if stream: + yield f'![{chart.get("type")}]({image_url})' + else: + json_result['image_url'] = image_url + if error is not None: + raise error + + self.current_logs[OperationEnum.GENERATE_PICTURE] = end_log(session=_session, + log=self.current_logs[ + OperationEnum.GENERATE_PICTURE], + full_message=image_url) + except Exception as e: + if stream: + if chart.get('type') != 'table': + yield 'generate or fetch chart picture error.\n\n' + raise e + + if not stream: + yield json_result + except Exception as e: + traceback.print_exc() error_msg: str if isinstance(e, SingleMessageError): error_msg = str(e) + elif isinstance(e, SQLBotDBConnectionError): + error_msg = orjson.dumps( + {'message': str(e), 'type': 'db-connection-err'}).decode() + elif isinstance(e, SQLBotDBError): + error_msg = orjson.dumps( + {'message': 'Execute SQL Failed', 'traceback': str(e), 'type': 'exec-sql-err'}).decode() else: error_msg = orjson.dumps({'message': str(e), 'traceback': traceback.format_exc(limit=1)}).decode() - self.save_error(message=error_msg) + if _session: + self.save_error(session=_session, message=error_msg) if in_chat: - yield orjson.dumps({'content': error_msg, 'type': 'error'}).decode() + '\n\n' + yield 'data:' + orjson.dumps({'content': error_msg, 'type': 'error'}).decode() + '\n\n' else: - yield f'> ❌ **ERROR**\n\n> \n\n> {error_msg}。' + if stream: + yield f'❌ **ERROR:**\n' + yield f'> {error_msg}\n' + else: + json_result['success'] = False + json_result['message'] = error_msg + yield json_result + finally: + self.finish(_session) + session_maker.remove() def run_recommend_questions_task_async(self): self.future = executor.submit(self.run_recommend_questions_task_cache) @@ -963,89 +1545,184 @@ def run_recommend_questions_task_cache(self): self.chunk_list.append(chunk) def run_recommend_questions_task(self): - res = self.generate_recommend_questions_task() + try: + _session = session_maker() + res = self.generate_recommend_questions_task(_session) - for chunk in res: - if chunk.get('recommended_question'): - yield orjson.dumps( - {'content': chunk.get('recommended_question'), 'type': 'recommended_question'}).decode() + '\n\n' - else: - yield orjson.dumps( - {'content': chunk.get('content'), 'reasoning_content': chunk.get('reasoning_content'), - 'type': 'recommended_question_result'}).decode() + '\n\n' + for chunk in res: + if chunk.get('recommended_question'): + yield 'data:' + orjson.dumps( + {'content': chunk.get('recommended_question'), + 'type': 'recommended_question'}).decode() + '\n\n' + else: + yield 'data:' + orjson.dumps( + {'content': chunk.get('content'), 'reasoning_content': chunk.get('reasoning_content'), + 'type': 'recommended_question_result'}).decode() + '\n\n' + except Exception: + traceback.print_exc() + finally: + session_maker.remove() - def run_analysis_or_predict_task_async(self, action_type: str, base_record: ChatRecord): - self.set_record(save_analysis_predict_record(self.session, base_record, action_type)) - self.future = executor.submit(self.run_analysis_or_predict_task_cache, action_type) + def run_analysis_or_predict_task_async(self, session: Session, action_type: str, base_record: ChatRecord, + in_chat: bool = True, stream: bool = True): + self.set_record(save_analysis_predict_record(session, base_record, action_type)) + self.future = executor.submit(self.run_analysis_or_predict_task_cache, action_type, in_chat, stream) - def run_analysis_or_predict_task_cache(self, action_type: str): - for chunk in self.run_analysis_or_predict_task(action_type): + def run_analysis_or_predict_task_cache(self, action_type: str, in_chat: bool = True, stream: bool = True): + for chunk in self.run_analysis_or_predict_task(action_type, in_chat, stream): self.chunk_list.append(chunk) - def run_analysis_or_predict_task(self, action_type: str): + def run_analysis_or_predict_task(self, action_type: str, in_chat: bool = True, stream: bool = True): + json_result: Dict[str, Any] = {'success': True} + _session = None try: - - yield orjson.dumps({'type': 'id', 'id': self.get_record().id}).decode() + '\n\n' + _session = session_maker() + if in_chat: + yield 'data:' + orjson.dumps({'type': 'id', 'id': self.get_record().id}).decode() + '\n\n' + else: + if stream: + yield '> ' + self.trans('i18n_chat.record_id_in_mcp') + str(self.get_record().id) + '\n' + yield '> ' + self.get_record().question + '\n\n' + if not stream: + json_result['record_id'] = self.get_record().id if action_type == 'analysis': # generate analysis - analysis_res = self.generate_analysis() + analysis_res = self.generate_analysis(_session) + full_text = '' for chunk in analysis_res: - yield orjson.dumps( - {'content': chunk.get('content'), 'reasoning_content': chunk.get('reasoning_content'), - 'type': 'analysis-result'}).decode() + '\n\n' - yield orjson.dumps({'type': 'info', 'msg': 'analysis generated'}).decode() + '\n\n' - - yield orjson.dumps({'type': 'analysis_finish'}).decode() + '\n\n' + full_text += chunk.get('content') + if in_chat: + yield 'data:' + orjson.dumps( + {'content': chunk.get('content'), 'reasoning_content': chunk.get('reasoning_content'), + 'type': 'analysis-result'}).decode() + '\n\n' + else: + if stream: + yield chunk.get('content') + if in_chat: + yield 'data:' + orjson.dumps({'type': 'info', 'msg': 'analysis generated'}).decode() + '\n\n' + yield 'data:' + orjson.dumps({'type': 'analysis_finish'}).decode() + '\n\n' + else: + if stream: + yield '\n\n' + if not stream: + json_result['content'] = full_text elif action_type == 'predict': # generate predict - analysis_res = self.generate_predict() + analysis_res = self.generate_predict(_session) full_text = '' for chunk in analysis_res: - yield orjson.dumps( - {'content': chunk.get('content'), 'reasoning_content': chunk.get('reasoning_content'), - 'type': 'predict-result'}).decode() + '\n\n' full_text += chunk.get('content') - yield orjson.dumps({'type': 'info', 'msg': 'predict generated'}).decode() + '\n\n' + if in_chat: + yield 'data:' + orjson.dumps( + {'content': chunk.get('content'), 'reasoning_content': chunk.get('reasoning_content'), + 'type': 'predict-result'}).decode() + '\n\n' + if in_chat: + yield 'data:' + orjson.dumps({'type': 'info', 'msg': 'predict generated'}).decode() + '\n\n' - _data = self.check_save_predict_data(res=full_text) - if _data: - yield orjson.dumps({'type': 'predict-success'}).decode() + '\n\n' + has_data = self.check_save_predict_data(session=_session, res=full_text) + if has_data: + if in_chat: + yield 'data:' + orjson.dumps({'type': 'predict-success'}).decode() + '\n\n' + else: + chart = get_chat_chart_config(_session, self.record.id) + origin_data = get_chat_chart_data(_session, self.record.id) + predict_data = get_chat_predict_data(_session, self.record.id) + + if stream: + md_data, _fields_list = DataFormat.convert_data_fields_for_pandas(chart, + origin_data.get('fields'), + predict_data) + if not md_data or not _fields_list: + yield 'Predict data result is empty.\n\n' + else: + df = pd.DataFrame(md_data, columns=_fields_list) + df_safe = DataFormat.safe_convert_to_string(df) + markdown_table = df_safe.to_markdown(index=False) + yield markdown_table + '\n\n' + + else: + json_result['origin_data'] = origin_data + json_result['predict_data'] = predict_data + + # generate picture + try: + if chart.get('type') != 'table': + # yield '### generated chart picture\n\n' + + _data = get_chat_chart_data(_session, self.record.id) + _data['data'] = _data.get('data') + predict_data + + image_url, error = request_picture(self.record.chat_id, self.record.id, chart, + format_json_data(_data)) + SQLBotLogUtil.info(image_url) + if stream: + yield f'![{chart.get("type")}]({image_url})' + else: + json_result['image_url'] = image_url + if error is not None: + raise error + except Exception as e: + if stream: + if chart.get('type') != 'table': + yield 'generate or fetch chart picture error.\n\n' + raise e else: - yield orjson.dumps({'type': 'predict-failed'}).decode() + '\n\n' + if in_chat: + yield 'data:' + orjson.dumps({'type': 'predict-failed'}).decode() + '\n\n' + else: + if stream: + yield full_text + '\n\n' + if not stream: + json_result['success'] = False + json_result['message'] = full_text + if in_chat: + yield 'data:' + orjson.dumps({'type': 'predict_finish'}).decode() + '\n\n' - yield orjson.dumps({'type': 'predict_finish'}).decode() + '\n\n' + self.finish(_session) - self.finish() + if not stream: + yield json_result except Exception as e: + traceback.print_exc() error_msg: str if isinstance(e, SingleMessageError): error_msg = str(e) else: error_msg = orjson.dumps({'message': str(e), 'traceback': traceback.format_exc(limit=1)}).decode() - self.save_error(message=error_msg) - yield orjson.dumps({'content': error_msg, 'type': 'error'}).decode() + '\n\n' + if _session: + self.save_error(session=_session, message=error_msg) + if in_chat: + yield 'data:' + orjson.dumps({'content': error_msg, 'type': 'error'}).decode() + '\n\n' + else: + if stream: + yield f'❌ **ERROR:**\n' + yield f'> {error_msg}\n' + else: + json_result['success'] = False + json_result['message'] = error_msg + yield json_result finally: # end - pass + session_maker.remove() - def validate_history_ds(self): + def validate_history_ds(self, session: Session): _ds = self.ds - if not self.current_assistant: + if not self.current_assistant or self.current_assistant.type == 4: try: - current_ds = self.session.get(CoreDatasource, _ds.id) + current_ds = session.get(CoreDatasource, _ds.id) if not current_ds: raise SingleMessageError('chat.ds_is_invalid') except Exception as e: raise SingleMessageError("chat.ds_is_invalid") else: try: - _ds_list: list[dict] = get_assistant_ds(session=self.session, llm_service=self) + _ds_list: list[dict] = get_assistant_ds(session=session, llm_service=self) match_ds = any(item.get("id") == _ds.id for item in _ds_list) if not match_ds: type = self.current_assistant.type - msg = f"ds is invalid [please check ds list and public ds list]" if type == 0 else f"ds is invalid [please check ds api]" + msg = f"[please check ds list and public ds list]" if type == 0 else f"[please check ds api]" raise SingleMessageError(msg) except Exception as e: raise SingleMessageError(f"ds is invalid [{str(e)}]") @@ -1084,10 +1761,18 @@ def request_picture(chat_id: int, record_id: int, chart: dict, data: dict): x = None y = None series = None + multi_quota_fields = [] + multi_quota_name = None + if chart.get('axis'): - x = chart.get('axis').get('x') - y = chart.get('axis').get('y') - series = chart.get('axis').get('series') + axis_data = chart.get('axis') + x = axis_data.get('x') + y = axis_data.get('y') + series = axis_data.get('series') + # 获取multi-quota字段列表 + if axis_data.get('multi-quota') and 'value' in axis_data.get('multi-quota'): + multi_quota_fields = axis_data.get('multi-quota').get('value', []) + multi_quota_name = axis_data.get('multi-quota').get('name') axis = [] for v in columns: @@ -1095,26 +1780,47 @@ def request_picture(chat_id: int, record_id: int, chart: dict, data: dict): if x: axis.append({'name': x.get('name'), 'value': x.get('value'), 'type': 'x'}) if y: - axis.append({'name': y.get('name'), 'value': y.get('value'), 'type': 'y'}) + y_list = y if isinstance(y, list) else [y] + + for y_item in y_list: + if isinstance(y_item, dict) and 'value' in y_item: + y_obj = { + 'name': y_item.get('name'), + 'value': y_item.get('value'), + 'type': 'y' + } + # 如果是multi-quota字段,添加标志 + if y_item.get('value') in multi_quota_fields: + y_obj['multi-quota'] = True + axis.append(y_obj) if series: axis.append({'name': series.get('name'), 'value': series.get('value'), 'type': 'series'}) + if multi_quota_name: + axis.append({'name': multi_quota_name, 'value': multi_quota_name, 'type': 'other-info'}) request_obj = { - "path": (settings.MCP_IMAGE_PATH if settings.MCP_IMAGE_PATH[-1] == '/' else ( - settings.MCP_IMAGE_PATH + '/')) + file_name, - "type": chart['type'], + "path": os.path.join(settings.MCP_IMAGE_PATH, file_name), + "type": chart.get('type'), "data": orjson.dumps(data.get('data') if data.get('data') else []).decode(), "axis": orjson.dumps(axis).decode(), } - requests.post(url=settings.MCP_IMAGE_HOST, json=request_obj) + _error = None + try: + requests.post(url=settings.MCP_IMAGE_HOST, json=request_obj, timeout=settings.SERVER_IMAGE_TIMEOUT) + except Exception as e: + _error = e + + request_path = urllib.parse.urljoin(settings.SERVER_IMAGE_HOST, f"{file_name}.png") - return f'{(settings.SERVER_IMAGE_HOST if settings.SERVER_IMAGE_HOST[-1] == "/" else (settings.SERVER_IMAGE_HOST + "/"))}{file_name}.png' + return request_path, _error -def get_token_usage(chunk: BaseMessageChunk, token_usage: dict = {}): +def get_token_usage(chunk: BaseMessageChunk, token_usage: dict = None): try: if chunk.usage_metadata: + if token_usage is None: + token_usage = {} token_usage['input_tokens'] = chunk.usage_metadata.get('input_tokens') token_usage['output_tokens'] = chunk.usage_metadata.get('output_tokens') token_usage['total_tokens'] = chunk.usage_metadata.get('total_tokens') @@ -1122,7 +1828,138 @@ def get_token_usage(chunk: BaseMessageChunk, token_usage: dict = {}): pass +def process_stream(res: Iterator[BaseMessageChunk], + token_usage: Dict[str, Any] = None, + enable_tag_parsing: bool = settings.PARSE_REASONING_BLOCK_ENABLED, + start_tag: str = settings.DEFAULT_REASONING_CONTENT_START, + end_tag: str = settings.DEFAULT_REASONING_CONTENT_END + ): + if token_usage is None: + token_usage = {} + in_thinking_block = False # 标记是否在思考过程块中 + current_thinking = '' # 当前收集的思考过程内容 + pending_start_tag = '' # 用于缓存可能被截断的开始标签部分 + + for chunk in res: + SQLBotLogUtil.info(chunk) + reasoning_content_chunk = '' + content = chunk.content + output_content = '' # 实际要输出的内容 + + # 检查additional_kwargs中的reasoning_content + if 'reasoning_content' in chunk.additional_kwargs: + reasoning_content = chunk.additional_kwargs.get('reasoning_content', '') + if reasoning_content is None: + reasoning_content = '' + + # 累积additional_kwargs中的思考内容到current_thinking + current_thinking += reasoning_content + reasoning_content_chunk = reasoning_content + + # 只有当current_thinking不是空字符串时才跳过标签解析 + if not in_thinking_block and current_thinking.strip() != '': + output_content = content # 正常输出content + yield { + 'content': output_content, + 'reasoning_content': reasoning_content_chunk + } + get_token_usage(chunk, token_usage) + continue # 跳过后续的标签解析逻辑 + + # 如果没有有效的思考内容,并且启用了标签解析,才执行标签解析逻辑 + # 如果有缓存的开始标签部分,先拼接当前内容 + if pending_start_tag: + content = pending_start_tag + content + pending_start_tag = '' + + # 检查是否开始思考过程块(处理可能被截断的开始标签) + if enable_tag_parsing and not in_thinking_block and start_tag: + if start_tag in content: + start_idx = content.index(start_tag) + # 只有当开始标签前面没有其他文本时才认为是真正的思考块开始 + if start_idx == 0 or content[:start_idx].strip() == '': + # 完整标签存在且前面没有其他文本 + output_content += content[:start_idx] # 输出开始标签之前的内容 + content = content[start_idx + len(start_tag):] # 移除开始标签 + in_thinking_block = True + else: + # 开始标签前面有其他文本,不认为是思考块开始 + output_content += content + content = '' + else: + # 检查是否可能有部分开始标签 + for i in range(1, len(start_tag)): + if content.endswith(start_tag[:i]): + # 只有当当前内容全是空白时才缓存部分标签 + if content[:-i].strip() == '': + pending_start_tag = start_tag[:i] + content = content[:-i] # 移除可能的部分标签 + output_content += content + content = '' + break + + # 处理思考块内容 + if enable_tag_parsing and in_thinking_block and end_tag: + if end_tag in content: + # 找到结束标签 + end_idx = content.index(end_tag) + current_thinking += content[:end_idx] # 收集思考内容 + reasoning_content_chunk += current_thinking # 添加到当前块的思考内容 + content = content[end_idx + len(end_tag):] # 移除结束标签后的内容 + current_thinking = '' # 重置当前思考内容 + in_thinking_block = False + output_content += content # 输出结束标签之后的内容 + else: + # 在遇到结束标签前,持续收集思考内容 + current_thinking += content + reasoning_content_chunk += content + content = '' + + else: + # 不在思考块中或标签解析未启用,正常输出 + output_content += content + + yield { + 'content': output_content, + 'reasoning_content': reasoning_content_chunk + } + get_token_usage(chunk, token_usage) + + def get_lang_name(lang: str): - if lang and lang == 'en': + if not lang: + return '简体中文' + normalized = lang.lower() + if normalized.startswith('zh-tw'): + return '繁体中文' + if normalized.startswith('en'): return '英文' + if normalized.startswith('ko'): + return '韩语' return '简体中文' + + +def get_last_conversation_rounds(messages, rounds=settings.GENERATE_SQL_QUERY_HISTORY_ROUND_COUNT): + """获取最后N轮对话,处理不完整对话的情况""" + if not messages or rounds <= 0: + return [] + + # 找到所有用户消息的位置 + human_indices = [] + for index, msg in enumerate(messages): + if msg.get('type') == 'human': + human_indices.append(index) + + # 如果没有用户消息,返回空 + if not human_indices: + return [] + + # 计算从哪个索引开始 + if len(human_indices) <= rounds: + # 如果用户消息数少于等于需要的轮数,从第一个用户消息开始 + start_index = human_indices[0] + else: + # 否则,从倒数第N个用户消息开始 + start_index = human_indices[-rounds] + + return messages[start_index:] diff --git a/backend/apps/dashboard/api/dashboard_api.py b/backend/apps/dashboard/api/dashboard_api.py index 17de0030c..8ccc0c98c 100644 --- a/backend/apps/dashboard/api/dashboard_api.py +++ b/backend/apps/dashboard/api/dashboard_api.py @@ -1,48 +1,81 @@ +from typing import List + from fastapi import APIRouter, File, UploadFile, HTTPException from apps.dashboard.crud.dashboard_service import list_resource, load_resource, \ create_resource, create_canvas, validate_name, delete_resource, update_resource, update_canvas -from apps.dashboard.models.dashboard_model import CreateDashboard, BaseDashboard, QueryDashboard, DashboardResponse +from apps.dashboard.models.dashboard_model import CreateDashboard, BaseDashboard, QueryDashboard +from apps.swagger.i18n import PLACEHOLDER_PREFIX +from common.audit.models.log_model import OperationType, OperationModules +from common.audit.schemas.logger_decorator import LogConfig, system_log from common.core.deps import SessionDep, CurrentUser -router = APIRouter(tags=["dashboard"], prefix="/dashboard") +router = APIRouter(tags=["Dashboard"], prefix="/dashboard") -@router.post("/list_resource") +@router.post("/list_resource", summary=f"{PLACEHOLDER_PREFIX}list_resource_api") async def list_resource_api(session: SessionDep, dashboard: QueryDashboard, current_user: CurrentUser): return list_resource(session=session, dashboard=dashboard, current_user=current_user) -@router.post("/load_resource") -async def load_resource_api(session: SessionDep, dashboard: QueryDashboard): - return load_resource(session=session, dashboard=dashboard) +@router.post("/load_resource", summary=f"{PLACEHOLDER_PREFIX}load_resource_api") +async def load_resource_api(session: SessionDep, current_user: CurrentUser, dashboard: QueryDashboard): + resource_dict = load_resource(session=session, dashboard=dashboard) + if resource_dict and resource_dict.get("create_by") != str(current_user.id): + raise HTTPException( + status_code=403, + detail="You do not have permission to access this resource" + ) + + return resource_dict -@router.post("/create_resource", response_model=BaseDashboard) +@router.post("/create_resource", response_model=BaseDashboard, summary=f"{PLACEHOLDER_PREFIX}create_resource_api") async def create_resource_api(session: SessionDep, user: CurrentUser, dashboard: CreateDashboard): return create_resource(session, user, dashboard) -@router.post("/update_resource", response_model=BaseDashboard) +@router.post("/update_resource", response_model=BaseDashboard, summary=f"{PLACEHOLDER_PREFIX}update_resource") +@system_log(LogConfig( + operation_type=OperationType.UPDATE, + module=OperationModules.DASHBOARD, + resource_id_expr="dashboard.id" +)) async def update_resource_api(session: SessionDep, user: CurrentUser, dashboard: QueryDashboard): return update_resource(session=session, user=user, dashboard=dashboard) -@router.delete("/delete_resource/{resource_id}") -async def delete_resource_api(session: SessionDep, resource_id: str): - return delete_resource(session, resource_id) +@router.delete("/delete_resource/{resource_id}/{name}", summary=f"{PLACEHOLDER_PREFIX}delete_resource_api") +@system_log(LogConfig( + operation_type=OperationType.DELETE, + module=OperationModules.DASHBOARD, + resource_id_expr="resource_id", + remark_expr="name" +)) +async def delete_resource_api(session: SessionDep, current_user: CurrentUser, resource_id: str, name: str): + return delete_resource(session, current_user, resource_id) -@router.post("/create_canvas", response_model=BaseDashboard) +@router.post("/create_canvas", response_model=BaseDashboard, summary=f"{PLACEHOLDER_PREFIX}create_canvas_api") +@system_log(LogConfig( + operation_type=OperationType.CREATE, + module=OperationModules.DASHBOARD, + result_id_expr="id" +)) async def create_canvas_api(session: SessionDep, user: CurrentUser, dashboard: CreateDashboard): return create_canvas(session, user, dashboard) -@router.post("/update_canvas", response_model=BaseDashboard) +@router.post("/update_canvas", response_model=BaseDashboard, summary=f"{PLACEHOLDER_PREFIX}update_canvas_api") +@system_log(LogConfig( + operation_type=OperationType.UPDATE, + module=OperationModules.DASHBOARD, + resource_id_expr="dashboard.id" +)) async def update_canvas_api(session: SessionDep, user: CurrentUser, dashboard: CreateDashboard): return update_canvas(session, user, dashboard) -@router.post("/check_name") +@router.post("/check_name", summary=f"{PLACEHOLDER_PREFIX}check_name_api") async def check_name_api(session: SessionDep, user: CurrentUser, dashboard: QueryDashboard): return validate_name(session, user, dashboard) diff --git a/backend/apps/dashboard/crud/dashboard_service.py b/backend/apps/dashboard/crud/dashboard_service.py index 6abc5df40..ff33f8352 100644 --- a/backend/apps/dashboard/crud/dashboard_service.py +++ b/backend/apps/dashboard/crud/dashboard_service.py @@ -1,4 +1,9 @@ +import base64 + +from orjson import orjson from sqlalchemy import select, and_, text + +from apps.chat.curd.chat import get_chart_data_ds from apps.dashboard.models.dashboard_model import CoreDashboard, CreateDashboard, QueryDashboard, DashboardBaseResponse from common.core.deps import SessionDep, CurrentUser import uuid @@ -33,15 +38,25 @@ def load_resource(session: SessionDep, dashboard: QueryDashboard): sql = text(""" SELECT cd.*, creator.name AS create_name, - updator.name AS update_name + updater.name AS update_name FROM core_dashboard cd LEFT JOIN sys_user creator ON cd.create_by = creator.id::varchar - LEFT JOIN sys_user updator - ON cd.update_by = updator.id:: varchar + LEFT JOIN sys_user updater + ON cd.update_by = updater.id:: varchar WHERE cd.id = :dashboard_id """) result = session.execute(sql, {"dashboard_id": dashboard.id}).mappings().first() - return result + + result_dict = dict(result) + canvas_view_obj = orjson.loads(result_dict['canvas_view_info']) + for item in canvas_view_obj.values(): + if all(key in item for key in ['datasource', 'sql']) and item['datasource'] is not None and item['sql'] is not None: + data_result = get_chart_data_ds(session, item['datasource'], item['sql']) + item['data']['data'] = data_result['data'] + item['status'] = data_result['status'] + item['message'] = data_result['message'] + result_dict['canvas_view_info'] = orjson.dumps(canvas_view_obj) + return result_dict def get_create_base_info(user: CurrentUser, dashboard: CreateDashboard): @@ -130,7 +145,12 @@ def validate_name(session: SessionDep,user: CurrentUser, dashboard: QueryDashbo return not session.query(query.exists()).scalar() -def delete_resource(session: SessionDep, resource_id: str): +def delete_resource(session: SessionDep, current_user: CurrentUser, resource_id: str): + coreDashboard = session.get(CoreDashboard, resource_id) + if not coreDashboard: + raise ValueError(f"Resource with id {resource_id} does not exist") + if coreDashboard.create_by != str(current_user.id): + raise ValueError(f"Resource with id {resource_id} not owned by the current user") sql = text("DELETE FROM core_dashboard WHERE id = :resource_id") result = session.execute(sql, {"resource_id": resource_id}) session.commit() diff --git a/backend/apps/data_training/__init__.py b/backend/apps/data_training/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/apps/data_training/api/__init__.py b/backend/apps/data_training/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/apps/data_training/api/data_training.py b/backend/apps/data_training/api/data_training.py new file mode 100644 index 000000000..d3e662cfb --- /dev/null +++ b/backend/apps/data_training/api/data_training.py @@ -0,0 +1,262 @@ +import asyncio +import hashlib +import io +import os +import uuid +from http.client import HTTPException +from typing import Optional + +import pandas as pd +from fastapi import APIRouter, File, UploadFile, Query +from fastapi.responses import StreamingResponse + +from apps.chat.models.chat_model import AxisObj +from apps.data_training.curd.data_training import page_data_training, create_training, update_training, delete_training, \ + enable_training, get_all_data_training, batch_create_training +from apps.data_training.models.data_training_model import DataTrainingInfo +from apps.swagger.i18n import PLACEHOLDER_PREFIX +from apps.system.schemas.permission import SqlbotPermission, require_permissions +from common.core.config import settings +from common.core.deps import SessionDep, CurrentUser, Trans +from common.utils.data_format import DataFormat +from common.utils.excel import get_excel_column_count +from common.audit.models.log_model import OperationType, OperationModules +from common.audit.schemas.logger_decorator import LogConfig, system_log + +router = APIRouter(tags=["SQL Examples"], prefix="/system/data-training") + + +@router.get("/page/{current_page}/{page_size}", summary=f"{PLACEHOLDER_PREFIX}get_dt_page") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +async def pager(session: SessionDep, current_user: CurrentUser, current_page: int, page_size: int, + question: Optional[str] = Query(None, description="搜索问题(可选)")): + current_page, page_size, total_count, total_pages, _list = page_data_training(session, current_page, page_size, + question, + current_user.oid) + + return { + "current_page": current_page, + "page_size": page_size, + "total_count": total_count, + "total_pages": total_pages, + "data": _list + } + + +@router.put("", response_model=int, summary=f"{PLACEHOLDER_PREFIX}create_or_update_dt") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'], type='ds', keyExpression="info.datasource")) +@system_log(LogConfig(operation_type=OperationType.CREATE_OR_UPDATE, module=OperationModules.DATA_TRAINING, + resource_id_expr='info.id', result_id_expr="result_self")) +async def create_or_update(session: SessionDep, current_user: CurrentUser, trans: Trans, info: DataTrainingInfo): + oid = current_user.oid + if info.id: + return update_training(session, info, oid, trans) + else: + return create_training(session, info, oid, trans) + + +@router.delete("", summary=f"{PLACEHOLDER_PREFIX}delete_dt") +@system_log( + LogConfig(operation_type=OperationType.DELETE, module=OperationModules.DATA_TRAINING, resource_id_expr='id_list')) +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +async def delete(session: SessionDep, id_list: list[int]): + delete_training(session, id_list) + + +@router.get("/{id}/enable/{enabled}", summary=f"{PLACEHOLDER_PREFIX}enable_dt") +@system_log( + LogConfig(operation_type=OperationType.UPDATE, module=OperationModules.DATA_TRAINING, resource_id_expr='id')) +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +async def enable(session: SessionDep, id: int, enabled: bool, trans: Trans): + enable_training(session, id, enabled, trans) + + +@router.get("/export", summary=f"{PLACEHOLDER_PREFIX}export_dt") +@system_log(LogConfig(operation_type=OperationType.EXPORT, module=OperationModules.DATA_TRAINING)) +async def export_excel(session: SessionDep, trans: Trans, current_user: CurrentUser, + question: Optional[str] = Query(None, description="搜索术语(可选)")): + def inner(): + _list = get_all_data_training(session, question, oid=current_user.oid) + + data_list = [] + for obj in _list: + _data = { + "question": obj.question, + "description": obj.description, + "datasource_name": obj.datasource_name, + "advanced_application_name": obj.advanced_application_name, + } + data_list.append(_data) + + fields = [] + fields.append(AxisObj(name=trans('i18n_data_training.problem_description'), value='question')) + fields.append(AxisObj(name=trans('i18n_data_training.sample_sql'), value='description')) + fields.append(AxisObj(name=trans('i18n_data_training.effective_data_sources'), value='datasource_name')) + fields.append(AxisObj(name=trans('i18n_data_training.advanced_application'), value='advanced_application_name')) + + md_data, _fields_list = DataFormat.convert_object_array_for_pandas(fields, data_list) + + df = pd.DataFrame(md_data, columns=_fields_list) + + buffer = io.BytesIO() + + with pd.ExcelWriter(buffer, engine='xlsxwriter', + engine_kwargs={'options': {'strings_to_numbers': False}}) as writer: + df.to_excel(writer, sheet_name='Sheet1', index=False) + + buffer.seek(0) + return io.BytesIO(buffer.getvalue()) + + result = await asyncio.to_thread(inner) + return StreamingResponse(result, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + + +@router.get("/template", summary=f"{PLACEHOLDER_PREFIX}excel_template_dt") +async def excel_template(trans: Trans, current_user: CurrentUser): + def inner(): + data_list = [] + _data1 = { + "question": '查询TEST表内所有ID', + "description": 'SELECT id FROM TEST', + "datasource_name": '生效数据源1', + "advanced_application_name": '生效高级应用名称', + } + data_list.append(_data1) + + fields = [] + fields.append(AxisObj(name=trans('i18n_data_training.problem_description_template'), value='question')) + fields.append(AxisObj(name=trans('i18n_data_training.sample_sql_template'), value='description')) + fields.append( + AxisObj(name=trans('i18n_data_training.effective_data_sources_template'), value='datasource_name')) + fields.append( + AxisObj(name=trans('i18n_data_training.advanced_application_template'), + value='advanced_application_name')) + + md_data, _fields_list = DataFormat.convert_object_array_for_pandas(fields, data_list) + + df = pd.DataFrame(md_data, columns=_fields_list) + + buffer = io.BytesIO() + + with pd.ExcelWriter(buffer, engine='xlsxwriter', + engine_kwargs={'options': {'strings_to_numbers': False}}) as writer: + df.to_excel(writer, sheet_name='Sheet1', index=False) + + buffer.seek(0) + return io.BytesIO(buffer.getvalue()) + + result = await asyncio.to_thread(inner) + return StreamingResponse(result, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + + +path = settings.EXCEL_PATH + +from sqlalchemy.orm import sessionmaker, scoped_session +from common.core.db import engine +from sqlmodel import Session + +session_maker = scoped_session(sessionmaker(bind=engine, class_=Session)) + + +@router.post("/uploadExcel", summary=f"{PLACEHOLDER_PREFIX}upload_excel_dt") +@system_log(LogConfig(operation_type=OperationType.IMPORT, module=OperationModules.DATA_TRAINING)) +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +async def upload_excel(trans: Trans, current_user: CurrentUser, file: UploadFile = File(...)): + ALLOWED_EXTENSIONS = {"xlsx", "xls"} + if not file.filename.lower().endswith(tuple(ALLOWED_EXTENSIONS)): + raise HTTPException(400, "Only support .xlsx/.xls") + + os.makedirs(path, exist_ok=True) + base_filename = f"{file.filename.split('.')[0]}_{hashlib.sha256(uuid.uuid4().bytes).hexdigest()[:10]}" + filename = f"{base_filename}.{file.filename.split('.')[1]}" + save_path = os.path.join(path, filename) + with open(save_path, "wb") as f: + f.write(await file.read()) + + oid = current_user.oid + + use_cols = [0, 1, 2, 3] # 问题, 描述, 数据源名称, 高级应用名称 + + def inner(): + + session = session_maker() + + sheet_names = pd.ExcelFile(save_path).sheet_names + + import_data = [] + + for sheet_name in sheet_names: + + if get_excel_column_count(save_path, sheet_name) < len(use_cols): + raise Exception(trans("i18n_excel_import.col_num_not_match")) + + df = pd.read_excel( + save_path, + sheet_name=sheet_name, + engine='calamine', + header=0, + usecols=use_cols, + dtype=str + ).fillna("") + + for index, row in df.iterrows(): + # 跳过空行 + if row.isnull().all(): + continue + + question = row[0].strip() if pd.notna(row[0]) and row[0].strip() else '' + description = row[1].strip() if pd.notna(row[1]) and row[1].strip() else '' + datasource_name = row[2].strip() if pd.notna(row[2]) and row[2].strip() else '' + + advanced_application_name = None + if len(row) > 3: + advanced_application_name = row[3].strip() if pd.notna(row[3]) and row[3].strip() else '' + + import_data.append( + DataTrainingInfo(oid=oid, question=question, description=description, + datasource_name=datasource_name, + advanced_application_name=advanced_application_name)) + + res = batch_create_training(session, import_data, oid, trans) + + failed_records = res['failed_records'] + + error_excel_filename = None + + if len(failed_records) > 0: + data_list = [] + for obj in failed_records: + _data = { + "question": obj['data'].question, + "description": obj['data'].description, + "datasource_name": obj['data'].datasource_name, + "advanced_application_name": obj['data'].advanced_application_name, + "errors": obj['errors'] + } + data_list.append(_data) + + fields = [] + fields.append(AxisObj(name=trans('i18n_data_training.problem_description'), value='question')) + fields.append(AxisObj(name=trans('i18n_data_training.sample_sql'), value='description')) + fields.append(AxisObj(name=trans('i18n_data_training.effective_data_sources'), value='datasource_name')) + fields.append( + AxisObj(name=trans('i18n_data_training.advanced_application'), value='advanced_application_name')) + fields.append(AxisObj(name=trans('i18n_data_training.error_info'), value='errors')) + + md_data, _fields_list = DataFormat.convert_object_array_for_pandas(fields, data_list) + + df = pd.DataFrame(md_data, columns=_fields_list) + error_excel_filename = f"{base_filename}_error.xlsx" + save_error_path = os.path.join(path, error_excel_filename) + # 保存 DataFrame 到 Excel + df.to_excel(save_error_path, index=False) + + return { + 'success_count': res['success_count'], + 'failed_count': len(failed_records), + 'duplicate_count': res['duplicate_count'], + 'original_count': res['original_count'], + 'error_excel_filename': error_excel_filename, + } + + return await asyncio.to_thread(inner) diff --git a/backend/apps/data_training/curd/__init__.py b/backend/apps/data_training/curd/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/apps/data_training/curd/data_training.py b/backend/apps/data_training/curd/data_training.py new file mode 100644 index 000000000..07cee6bfe --- /dev/null +++ b/backend/apps/data_training/curd/data_training.py @@ -0,0 +1,614 @@ +import datetime +import logging +import traceback +from typing import List, Optional +from xml.dom.minidom import parseString + +import dicttoxml +from sqlalchemy import and_, select, func, delete, update, or_ +from sqlalchemy import text + +from apps.ai_model.embedding import EmbeddingModelCache +from apps.data_training.models.data_training_model import DataTrainingInfo, DataTraining, DataTrainingInfoResult +from apps.datasource.models.datasource import CoreDatasource +from apps.system.models.system_model import AssistantModel +from apps.template.generate_chart.generator import get_base_data_training_template +from common.core.config import settings +from common.core.deps import SessionDep, Trans +from common.utils.embedding_threads import run_save_data_training_embeddings + + +def get_data_training_base_query(oid: int, name: Optional[str] = None): + """ + 获取数据训练查询的基础查询结构 + """ + if name and name.strip() != "": + keyword_pattern = f"%{name.strip()}%" + parent_ids_subquery = ( + select(DataTraining.id) + .where(and_(DataTraining.question.ilike(keyword_pattern), DataTraining.oid == oid)) + ) + else: + parent_ids_subquery = ( + select(DataTraining.id).where(and_(DataTraining.oid == oid)) + ) + + return parent_ids_subquery + + +def build_data_training_query(session: SessionDep, oid: int, name: Optional[str] = None, + paginate: bool = True, current_page: int = 1, page_size: int = 10): + """ + 构建数据训练查询的通用方法 + """ + parent_ids_subquery = get_data_training_base_query(oid, name) + + # 计算总数 + count_stmt = select(func.count()).select_from(parent_ids_subquery.subquery()) + total_count = session.execute(count_stmt).scalar() + + if paginate: + # 分页处理 + page_size = max(10, page_size) + total_pages = (total_count + page_size - 1) // page_size + current_page = max(1, min(current_page, total_pages)) if total_pages > 0 else 1 + + paginated_parent_ids = ( + parent_ids_subquery + .order_by(DataTraining.create_time.desc()) + .offset((current_page - 1) * page_size) + .limit(page_size) + .subquery() + ) + else: + # 不分页,获取所有数据 + total_pages = 1 + current_page = 1 + page_size = total_count if total_count > 0 else 1 + + paginated_parent_ids = ( + parent_ids_subquery + .order_by(DataTraining.create_time.desc()) + .subquery() + ) + + # 构建主查询 + stmt = ( + select( + DataTraining.id, + DataTraining.oid, + DataTraining.datasource, + CoreDatasource.name, + DataTraining.question, + DataTraining.create_time, + DataTraining.description, + DataTraining.enabled, + DataTraining.advanced_application, + AssistantModel.name.label('advanced_application_name'), + ) + .outerjoin(CoreDatasource, and_(DataTraining.datasource == CoreDatasource.id)) + .outerjoin(AssistantModel, + and_(DataTraining.advanced_application == AssistantModel.id, AssistantModel.type == 1)) + .where(and_(DataTraining.id.in_(paginated_parent_ids))) + .order_by(DataTraining.create_time.desc()) + ) + + return stmt, total_count, total_pages, current_page, page_size + + +def execute_data_training_query(session: SessionDep, stmt) -> List[DataTrainingInfoResult]: + """ + 执行查询并返回数据训练信息列表 + """ + _list = [] + result = session.execute(stmt) + + for row in result: + _list.append(DataTrainingInfoResult( + id=str(row.id), + oid=str(row.oid), + datasource=row.datasource, + datasource_name=row.name, + question=row.question, + create_time=row.create_time, + description=row.description, + enabled=row.enabled, + advanced_application=str(row.advanced_application) if row.advanced_application else None, + advanced_application_name=row.advanced_application_name, + )) + + return _list + + +def page_data_training(session: SessionDep, current_page: int = 1, page_size: int = 10, + name: Optional[str] = None, oid: Optional[int] = 1): + """ + 分页查询数据训练(原方法保持不变) + """ + stmt, total_count, total_pages, current_page, page_size = build_data_training_query( + session, oid, name, True, current_page, page_size + ) + _list = execute_data_training_query(session, stmt) + + return current_page, page_size, total_count, total_pages, _list + + +def get_all_data_training(session: SessionDep, name: Optional[str] = None, oid: Optional[int] = 1): + """ + 获取所有数据训练(不分页) + """ + stmt, total_count, total_pages, current_page, page_size = build_data_training_query( + session, oid, name, False + ) + _list = execute_data_training_query(session, stmt) + + return _list + + +def create_training(session: SessionDep, info: DataTrainingInfo, oid: int, trans: Trans, skip_embedding: bool = False): + """ + 创建单个数据训练记录 + Args: + skip_embedding: 是否跳过embedding处理(用于批量插入) + """ + # 基本验证 + if not info.question or not info.question.strip(): + raise Exception(trans("i18n_data_training.question_cannot_be_empty")) + + if not info.description or not info.description.strip(): + raise Exception(trans("i18n_data_training.description_cannot_be_empty")) + + create_time = datetime.datetime.now() + + # 检查数据源和高级应用不能同时为空 + if info.datasource is None and info.advanced_application is None: + raise Exception(trans("i18n_data_training.datasource_assistant_cannot_be_none")) + + # 检查重复记录 + stmt = select(DataTraining.id).where( + and_(DataTraining.question == info.question.strip(), DataTraining.oid == oid) + ) + + if info.datasource is not None and info.advanced_application is not None: + stmt = stmt.where( + or_( + DataTraining.datasource == info.datasource, + DataTraining.advanced_application == info.advanced_application + ) + ) + elif info.datasource is not None and info.advanced_application is None: + stmt = stmt.where(DataTraining.datasource == info.datasource) + elif info.datasource is None and info.advanced_application is not None: + stmt = stmt.where(DataTraining.advanced_application == info.advanced_application) + + exists = session.query(stmt.exists()).scalar() + + if exists: + raise Exception(trans("i18n_data_training.exists_in_db")) + + # 创建记录 + data_training = DataTraining( + question=info.question.strip(), + description=info.description.strip(), + oid=oid, + datasource=info.datasource, + advanced_application=info.advanced_application, + create_time=create_time, + enabled=info.enabled if info.enabled is not None else True + ) + + session.add(data_training) + session.flush() + session.refresh(data_training) + session.commit() + + # 处理embedding(批量插入时跳过) + if not skip_embedding: + run_save_data_training_embeddings([data_training.id]) + + return data_training.id + + +def update_training(session: SessionDep, info: DataTrainingInfo, oid: int, trans: Trans): + # 基本验证 + if not info.question or not info.question.strip(): + raise Exception(trans("i18n_data_training.question_cannot_be_empty")) + + if not info.description or not info.description.strip(): + raise Exception(trans("i18n_data_training.description_cannot_be_empty")) + + if info.datasource is None and info.advanced_application is None: + raise Exception(trans("i18n_data_training.datasource_assistant_cannot_be_none")) + + count = session.query(DataTraining).filter( + DataTraining.id == info.id + ).count() + if count == 0: + raise Exception(trans('i18n_data_training.data_training_not_exists')) + + stmt = select(DataTraining.id).where( + and_(DataTraining.question == info.question, DataTraining.oid == oid, DataTraining.id != info.id)) + + if info.datasource is not None and info.advanced_application is not None: + stmt = stmt.where( + or_(DataTraining.datasource == info.datasource, + DataTraining.advanced_application == info.advanced_application)) + elif info.datasource is not None and info.advanced_application is None: + stmt = stmt.where(and_(DataTraining.datasource == info.datasource)) + elif info.datasource is None and info.advanced_application is not None: + stmt = stmt.where(and_(DataTraining.advanced_application == info.advanced_application)) + + exists = session.query(stmt.exists()).scalar() + + if exists: + raise Exception(trans("i18n_data_training.exists_in_db")) + + stmt = update(DataTraining).where(and_(DataTraining.id == info.id)).values( + question=info.question.strip(), + description=info.description.strip(), + datasource=info.datasource, + advanced_application=info.advanced_application, + enabled=info.enabled if info.enabled is not None else True + ) + session.execute(stmt) + session.commit() + + # embedding + run_save_data_training_embeddings([info.id]) + + return info.id + + +def batch_create_training(session: SessionDep, info_list: List[DataTrainingInfo], oid: int, trans: Trans): + """ + 批量创建数据训练记录(复用单条插入逻辑) + """ + if not info_list: + return { + 'success_count': 0, + 'failed_records': [], + 'duplicate_count': 0, + 'original_count': 0, + 'deduplicated_count': 0 + } + + failed_records = [] + success_count = 0 + inserted_ids = [] + + # 第一步:数据去重 + unique_records = {} + duplicate_records = [] + + for info in info_list: + # 创建唯一标识 + unique_key = ( + info.question.strip().lower() if info.question else "", + info.datasource_name.strip().lower() if info.datasource_name else "", + info.advanced_application_name.strip().lower() if info.advanced_application_name else "" + ) + + if unique_key in unique_records: + duplicate_records.append(info) + else: + unique_records[unique_key] = info + + # 将去重后的数据转换为列表 + deduplicated_list = list(unique_records.values()) + + # 预加载数据源和高级应用名称到ID的映射 + datasource_name_to_id = {} + datasource_stmt = select(CoreDatasource.id, CoreDatasource.name).where(CoreDatasource.oid == oid) + datasource_result = session.execute(datasource_stmt).all() + for ds in datasource_result: + datasource_name_to_id[ds.name.strip()] = ds.id + + assistant_name_to_id = {} + + assistant_stmt = select(AssistantModel.id, AssistantModel.name).where(and_(AssistantModel.type == 1, AssistantModel.oid == oid)) + assistant_result = session.execute(assistant_stmt).all() + for assistant in assistant_result: + assistant_name_to_id[assistant.name.strip()] = assistant.id + + # 验证和转换数据 + valid_records = [] + for info in deduplicated_list: + error_messages = [] + + # 基本验证 + if not info.question or not info.question.strip(): + error_messages.append(trans("i18n_data_training.question_cannot_be_empty")) + + if not info.description or not info.description.strip(): + error_messages.append(trans("i18n_data_training.description_cannot_be_empty")) + + # 数据源验证和转换 + datasource_id = None + if info.datasource_name and info.datasource_name.strip(): + if info.datasource_name.strip() in datasource_name_to_id: + datasource_id = datasource_name_to_id[info.datasource_name.strip()] + else: + error_messages.append(trans("i18n_data_training.datasource_not_found").format(info.datasource_name)) + + # 高级应用验证和转换 + advanced_application_id = None + if info.advanced_application_name and info.advanced_application_name.strip(): + if info.advanced_application_name.strip() in assistant_name_to_id: + advanced_application_id = assistant_name_to_id[info.advanced_application_name.strip()] + else: + error_messages.append( + trans("i18n_data_training.advanced_application_not_found").format(info.advanced_application_name)) + + # 检查数据源和高级应用不能同时为空 + if not datasource_id and not advanced_application_id: + error_messages.append(trans("i18n_data_training.datasource_assistant_cannot_be_none")) + + if error_messages: + failed_records.append({ + 'data': info, + 'errors': error_messages + }) + continue + + # 创建处理后的DataTrainingInfo对象 + processed_info = DataTrainingInfo( + question=info.question.strip(), + description=info.description.strip(), + datasource=datasource_id, + datasource_name=info.datasource_name, + advanced_application=advanced_application_id, + advanced_application_name=info.advanced_application_name, + enabled=info.enabled if info.enabled is not None else True + ) + + valid_records.append(processed_info) + + # 使用事务处理有效记录 + if valid_records: + for info in valid_records: + try: + # 直接复用create_training方法,跳过embedding处理 + training_id = create_training(session, info, oid, trans, skip_embedding=True) + inserted_ids.append(training_id) + success_count += 1 + + except Exception as e: + # 如果单条插入失败,回滚当前记录 + session.rollback() + failed_records.append({ + 'data': info, + 'errors': [str(e)] + }) + + # 批量处理embedding(只在最后执行一次) + if success_count > 0 and inserted_ids: + try: + run_save_data_training_embeddings(inserted_ids) + except Exception as e: + # 如果embedding处理失败,记录错误但不回滚数据 + print(f"Embedding processing failed: {str(e)}") + # 可以选择将embedding失败的信息记录到日志或返回给调用方 + + return { + 'success_count': success_count, + 'failed_records': failed_records, + 'duplicate_count': len(duplicate_records), + 'original_count': len(info_list), + 'deduplicated_count': len(deduplicated_list) + } + + +def delete_training(session: SessionDep, ids: list[int]): + stmt = delete(DataTraining).where(and_(DataTraining.id.in_(ids))) + session.execute(stmt) + session.commit() + + +def enable_training(session: SessionDep, id: int, enabled: bool, trans: Trans): + count = session.query(DataTraining).filter( + DataTraining.id == id + ).count() + if count == 0: + raise Exception(trans('i18n_data_training.data_training_not_exists')) + + stmt = update(DataTraining).where(and_(DataTraining.id == id)).values( + enabled=enabled, + ) + session.execute(stmt) + session.commit() + + +# def run_save_embeddings(ids: List[int]): +# executor.submit(save_embeddings, ids) +# +# +# def fill_empty_embeddings(): +# executor.submit(run_fill_empty_embeddings) + + +def run_fill_empty_embeddings(session_maker): + try: + if not settings.EMBEDDING_ENABLED: + return + + session = session_maker() + stmt = select(DataTraining.id).where(and_(DataTraining.embedding.is_(None))) + results = session.execute(stmt).scalars().all() + + save_embeddings(session_maker, results) + except Exception: + traceback.print_exc() + finally: + session_maker.remove() + + +def save_embeddings(session_maker, ids: List[int]): + if not settings.EMBEDDING_ENABLED: + return + + if not ids or len(ids) == 0: + return + try: + session = session_maker() + _list = session.query(DataTraining).filter(and_(DataTraining.id.in_(ids))).all() + + _question_list = [item.question for item in _list] + + model = EmbeddingModelCache.get_model() + + results = model.embed_documents(_question_list) + + for index in range(len(results)): + item = results[index] + stmt = update(DataTraining).where(and_(DataTraining.id == _list[index].id)).values(embedding=item) + session.execute(stmt) + session.commit() + + except Exception: + traceback.print_exc() + finally: + session_maker.remove() + + +embedding_sql = f""" +SELECT id, datasource, question, similarity +FROM +(SELECT id, datasource, question, oid, enabled, +( 1 - (embedding <=> :embedding_array) ) AS similarity +FROM data_training AS child +) TEMP +WHERE similarity > {settings.EMBEDDING_DATA_TRAINING_SIMILARITY} and oid = :oid and datasource = :datasource and enabled = true +ORDER BY similarity DESC +LIMIT {settings.EMBEDDING_DATA_TRAINING_TOP_COUNT} +""" +embedding_sql_in_advanced_application = f""" +SELECT id, advanced_application, question, similarity +FROM +(SELECT id, advanced_application, question, oid, enabled, +( 1 - (embedding <=> :embedding_array) ) AS similarity +FROM data_training AS child +) TEMP +WHERE similarity > {settings.EMBEDDING_DATA_TRAINING_SIMILARITY} and oid = :oid and advanced_application = :advanced_application and enabled = true +ORDER BY similarity DESC +LIMIT {settings.EMBEDDING_DATA_TRAINING_TOP_COUNT} +""" + + +def select_training_by_question(session: SessionDep, question: str, oid: int, datasource: Optional[int] = None, + advanced_application_id: Optional[int] = None): + if question.strip() == "": + return [] + + _list: List[DataTraining] = [] + + # maybe use label later? + stmt = ( + select( + DataTraining.id, + DataTraining.question, + ) + .where( + and_(or_(text(":sentence ILIKE '%' || question || '%'"), text("question ILIKE '%' || :sentence || '%'")), + DataTraining.oid == oid, + DataTraining.enabled == True) + ) + ) + if advanced_application_id is not None: + stmt = stmt.where(and_(DataTraining.advanced_application == advanced_application_id)) + else: + stmt = stmt.where(and_(DataTraining.datasource == datasource)) + + results = session.execute(stmt, {'sentence': question}).fetchall() + + for row in results: + _list.append(DataTraining(id=row.id, question=row.question)) + + if settings.EMBEDDING_ENABLED: + with session.begin_nested(): + try: + model = EmbeddingModelCache.get_model() + + embedding = model.embed_query(question) + + if advanced_application_id is not None: + results = session.execute(text(embedding_sql_in_advanced_application), + {'embedding_array': str(embedding), 'oid': oid, + 'advanced_application': advanced_application_id}) + else: + results = session.execute(text(embedding_sql), + {'embedding_array': str(embedding), 'oid': oid, 'datasource': datasource}) + + for row in results: + _list.append(DataTraining(id=row.id, question=row.question)) + + except Exception: + traceback.print_exc() + session.rollback() + + _map: dict = {} + _ids: list[int] = [] + for row in _list: + if row.id in _ids: + continue + else: + _ids.append(row.id) + + if len(_ids) == 0: + return [] + + t_list = session.query(DataTraining.id, DataTraining.question, DataTraining.description).filter( + and_(DataTraining.id.in_(_ids))).all() + + for row in t_list: + _map[row.id] = {'question': row.question, 'suggestion-answer': row.description} + + _results: list[dict] = [] + for key in _map.keys(): + _results.append(_map.get(key)) + + return _results + + +def to_xml_string(_dict: list[dict] | dict, root: str = 'sql-examples') -> str: + item_name_func = lambda x: 'sql-example' if x == 'sql-examples' else 'item' + dicttoxml.LOG.setLevel(logging.ERROR) + xml = dicttoxml.dicttoxml(_dict, + cdata=['question', 'suggestion-answer'], + custom_root=root, + item_func=item_name_func, + xml_declaration=False, + encoding='utf-8', + attr_type=False).decode('utf-8') + pretty_xml = parseString(xml).toprettyxml() + + if pretty_xml.startswith('') + 1 + pretty_xml = pretty_xml[end_index:].lstrip() + + # 替换所有 XML 转义字符 + escape_map = { + '<': '<', + '>': '>', + '&': '&', + '"': '"', + ''': "'" + } + for escaped, original in escape_map.items(): + pretty_xml = pretty_xml.replace(escaped, original) + + return pretty_xml + + +def get_training_template(session: SessionDep, question: str, oid: Optional[int] = 1, datasource: Optional[int] = None, + advanced_application_id: Optional[int] = None) -> tuple[str, list[dict]]: + if not oid: + oid = 1 + if not datasource and not advanced_application_id: + return '', [] + _results = select_training_by_question(session, question, oid, datasource, advanced_application_id) + if _results and len(_results) > 0: + data_training = to_xml_string(_results) + template = get_base_data_training_template().format(data_training=data_training) + return template, _results + else: + return '', [] diff --git a/backend/apps/data_training/models/__init__.py b/backend/apps/data_training/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/apps/data_training/models/data_training_model.py b/backend/apps/data_training/models/data_training_model.py new file mode 100644 index 000000000..45e8c3a8c --- /dev/null +++ b/backend/apps/data_training/models/data_training_model.py @@ -0,0 +1,46 @@ +from datetime import datetime +from typing import List, Optional + +from pgvector.sqlalchemy import VECTOR +from pydantic import BaseModel +from sqlalchemy import Column, Text, BigInteger, DateTime, Identity, Boolean +from sqlmodel import SQLModel, Field + + +class DataTraining(SQLModel, table=True): + __tablename__ = "data_training" + id: Optional[int] = Field(sa_column=Column(BigInteger, Identity(always=True), primary_key=True)) + oid: Optional[int] = Field(sa_column=Column(BigInteger, nullable=True, default=1)) + datasource: Optional[int] = Field(sa_column=Column(BigInteger, nullable=True)) + create_time: Optional[datetime] = Field(sa_column=Column(DateTime(timezone=False), nullable=True)) + question: Optional[str] = Field(max_length=255) + description: Optional[str] = Field(sa_column=Column(Text, nullable=True)) + embedding: Optional[List[float]] = Field(sa_column=Column(VECTOR(), nullable=True)) + enabled: Optional[bool] = Field(sa_column=Column(Boolean, default=True)) + advanced_application: Optional[int] = Field(sa_column=Column(BigInteger, nullable=True)) + + +class DataTrainingInfo(BaseModel): + id: Optional[int] = None + oid: Optional[int] = None + datasource: Optional[int] = None + datasource_name: Optional[str] = None + create_time: Optional[datetime] = None + question: Optional[str] = None + description: Optional[str] = None + enabled: Optional[bool] = True + advanced_application: Optional[int] = None + advanced_application_name: Optional[str] = None + + +class DataTrainingInfoResult(BaseModel): + id: Optional[str] = None + oid: Optional[str] = None + datasource: Optional[int] = None + datasource_name: Optional[str] = None + create_time: Optional[datetime] = None + question: Optional[str] = None + description: Optional[str] = None + enabled: Optional[bool] = True + advanced_application: Optional[str] = None + advanced_application_name: Optional[str] = None diff --git a/backend/apps/datasource/api/datasource.py b/backend/apps/datasource/api/datasource.py index f89b052bd..90801fdf2 100644 --- a/backend/apps/datasource/api/datasource.py +++ b/backend/apps/datasource/api/datasource.py @@ -1,48 +1,62 @@ import asyncio import hashlib +import io import os import traceback import uuid +import re from io import StringIO from typing import List +from urllib.parse import quote -import orjson import pandas as pd -from fastapi import APIRouter, File, UploadFile, HTTPException +from fastapi import APIRouter, File, UploadFile, HTTPException, Path +from fastapi.responses import StreamingResponse +from psycopg2 import sql +from sqlalchemy import and_ +from apps.db.db import get_schema from apps.db.engine import get_engine_conn +from apps.swagger.i18n import PLACEHOLDER_PREFIX +from apps.system.schemas.permission import SqlbotPermission, require_permissions +from common.audit.models.log_model import OperationType, OperationModules +from common.audit.schemas.logger_decorator import LogConfig, system_log from common.core.config import settings from common.core.deps import SessionDep, CurrentUser, Trans from common.utils.utils import SQLBotLogUtil from ..crud.datasource import get_datasource_list, check_status, create_ds, update_ds, delete_ds, getTables, getFields, \ - execSql, update_table_and_fields, getTablesByDs, chooseTables, preview, updateTable, updateField, get_ds, fieldEnum, \ - check_status_by_id + update_table_and_fields, getTablesByDs, chooseTables, preview, updateTable, updateField, get_ds, fieldEnum, \ + check_status_by_id, sync_single_fields from ..crud.field import get_fields_by_table_id from ..crud.table import get_tables_by_ds_id -from ..models.datasource import CoreDatasource, CreateDatasource, TableObj, CoreTable, CoreField +from ..models.datasource import CoreDatasource, CreateDatasource, TableObj, CoreTable, CoreField, FieldObj, \ + TableSchemaResponse, ColumnSchemaResponse, PreviewResponse, ImportRequest +from ..utils.excel import parse_excel_preview, USER_TYPE_TO_PANDAS -router = APIRouter(tags=["datasource"], prefix="/datasource") +router = APIRouter(tags=["Datasource"], prefix="/datasource") path = settings.EXCEL_PATH @router.get("/ws/{oid}", include_in_schema=False) +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) async def query_by_oid(session: SessionDep, user: CurrentUser, oid: int) -> List[CoreDatasource]: - if not user.isAdmin: - raise Exception("no permission to execute") return get_datasource_list(session=session, user=user, oid=oid) -@router.get("/list") +@router.get("/list", response_model=List[CoreDatasource], summary=f"{PLACEHOLDER_PREFIX}ds_list", + description=f"{PLACEHOLDER_PREFIX}ds_list_description") async def datasource_list(session: SessionDep, user: CurrentUser): return get_datasource_list(session=session, user=user) -@router.post("/get/{id}") -async def get_datasource(session: SessionDep, id: int): +@router.post("/get/{id}", response_model=CoreDatasource, summary=f"{PLACEHOLDER_PREFIX}ds_get") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'], keyExpression="id", type='ds')) +async def get_datasource(session: SessionDep, id: int = Path(..., description=f"{PLACEHOLDER_PREFIX}ds_id")): return get_ds(session, id) -@router.post("/check") +@router.post("/check", response_model=bool, summary=f"{PLACEHOLDER_PREFIX}ds_check") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) async def check(session: SessionDep, trans: Trans, ds: CoreDatasource): def inner(): return check_status(session, trans, ds, True) @@ -50,31 +64,37 @@ def inner(): return await asyncio.to_thread(inner) -@router.get("/check/{ds_id}") -async def check_by_id(session: SessionDep, trans: Trans, ds_id: int): +@router.get("/check/{ds_id}", response_model=bool, summary=f"{PLACEHOLDER_PREFIX}ds_check") +@require_permissions(permission=SqlbotPermission(type='ds', keyExpression="ds_id")) +async def check_by_id(session: SessionDep, trans: Trans, + ds_id: int = Path(..., description=f"{PLACEHOLDER_PREFIX}ds_id")): def inner(): return check_status_by_id(session, trans, ds_id, True) return await asyncio.to_thread(inner) -@router.post("/add", response_model=CoreDatasource) +@router.post("/add", response_model=CoreDatasource, summary=f"{PLACEHOLDER_PREFIX}ds_add") +@system_log(LogConfig(operation_type=OperationType.CREATE, module=OperationModules.DATASOURCE, result_id_expr="id")) +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) async def add(session: SessionDep, trans: Trans, user: CurrentUser, ds: CreateDatasource): - def inner(): - return create_ds(session, trans, user, ds) - - return await asyncio.to_thread(inner) + return await create_ds(session, trans, user, ds) -@router.post("/chooseTables/{id}") -async def choose_tables(session: SessionDep, trans: Trans, id: int, tables: List[CoreTable]): +@router.post("/chooseTables/{id}", response_model=None, summary=f"{PLACEHOLDER_PREFIX}ds_choose_tables") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'], type='ds', keyExpression="id")) +async def choose_tables(session: SessionDep, trans: Trans, tables: List[CoreTable], + id: int = Path(..., description=f"{PLACEHOLDER_PREFIX}ds_id")): def inner(): chooseTables(session, trans, id, tables) await asyncio.to_thread(inner) -@router.post("/update", response_model=CoreDatasource) +@router.post("/update", response_model=CoreDatasource, summary=f"{PLACEHOLDER_PREFIX}ds_update") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'], type='ds', keyExpression="ds.id")) +@system_log( + LogConfig(operation_type=OperationType.UPDATE, module=OperationModules.DATASOURCE, resource_id_expr="ds.id")) async def update(session: SessionDep, trans: Trans, user: CurrentUser, ds: CoreDatasource): def inner(): return update_ds(session, trans, user, ds) @@ -82,20 +102,28 @@ def inner(): return await asyncio.to_thread(inner) -@router.post("/delete/{id}", response_model=CoreDatasource) -async def delete(session: SessionDep, id: int): - return delete_ds(session, id) +@router.post("/delete/{id}/{name}", response_model=None, summary=f"{PLACEHOLDER_PREFIX}ds_delete") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'], type='ds', keyExpression="id")) +@system_log(LogConfig(operation_type=OperationType.DELETE, module=OperationModules.DATASOURCE, resource_id_expr="id", + )) +async def delete(session: SessionDep, id: int = Path(..., description=f"{PLACEHOLDER_PREFIX}ds_id"), name: str = None): + return await delete_ds(session, id) -@router.post("/getTables/{id}") -async def get_tables(session: SessionDep, id: int): +@router.post("/getTables/{id}", response_model=List[TableSchemaResponse], summary=f"{PLACEHOLDER_PREFIX}ds_get_tables") +@require_permissions(permission=SqlbotPermission(type='ds', keyExpression="id")) +async def get_tables(session: SessionDep, id: int = Path(..., description=f"{PLACEHOLDER_PREFIX}ds_id")): return getTables(session, id) -@router.post("/getTablesByConf") +@router.post("/getTablesByConf", response_model=List[TableSchemaResponse], summary=f"{PLACEHOLDER_PREFIX}ds_get_tables") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) async def get_tables_by_conf(session: SessionDep, trans: Trans, ds: CoreDatasource): try: - return getTablesByDs(session, ds) + def inner(): + return getTablesByDs(session, ds) + + return await asyncio.to_thread(inner) except Exception as e: # check ds status def inner(): @@ -107,11 +135,41 @@ def inner(): raise HTTPException(status_code=500, detail=f'Get table Failed: {e.args}') -@router.post("/getFields/{id}/{table_name}") -async def get_fields(session: SessionDep, id: int, table_name: str): +@router.post("/getSchemaByConf", response_model=List[str], summary=f"{PLACEHOLDER_PREFIX}ds_get_schema") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +async def get_schema_by_conf(session: SessionDep, trans: Trans, ds: CoreDatasource): + try: + def inner(): + return get_schema(ds) + + return await asyncio.to_thread(inner) + except Exception as e: + # check ds status + def inner(): + return check_status(session, trans, ds, True) + + status = await asyncio.to_thread(inner) + if status: + SQLBotLogUtil.error(f"get table failed: {e}") + raise HTTPException(status_code=500, detail=f'Get table Failed: {e.args}') + + +@router.post("/getFields/{id}/{table_name}", response_model=List[ColumnSchemaResponse], + summary=f"{PLACEHOLDER_PREFIX}ds_get_fields") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'], type='ds', keyExpression="id")) +async def get_fields(session: SessionDep, + id: int = Path(..., description=f"{PLACEHOLDER_PREFIX}ds_id"), + table_name: str = Path(..., description=f"{PLACEHOLDER_PREFIX}ds_table_name")): return getFields(session, id, table_name) +@router.post("/syncFields/{id}", response_model=None, summary=f"{PLACEHOLDER_PREFIX}ds_sync_fields") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +async def sync_fields(session: SessionDep, trans: Trans, + id: int = Path(..., description=f"{PLACEHOLDER_PREFIX}ds_table_id")): + return sync_single_fields(session, trans, id) + + from pydantic import BaseModel @@ -119,7 +177,8 @@ class TestObj(BaseModel): sql: str = None -@router.post("/execSql/{id}") +# not used, just do test +""" @router.post("/execSql/{id}", include_in_schema=False) async def exec_sql(session: SessionDep, id: int, obj: TestObj): def inner(): data = execSql(session, id, obj.sql) @@ -132,36 +191,44 @@ def inner(): return data - return await asyncio.to_thread(inner) + return await asyncio.to_thread(inner) """ -@router.post("/tableList/{id}") -async def table_list(session: SessionDep, id: int): +@router.post("/tableList/{id}", response_model=List[CoreTable], summary=f"{PLACEHOLDER_PREFIX}ds_table_list") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'], type='ds', keyExpression="id")) +async def table_list(session: SessionDep, id: int = Path(..., description=f"{PLACEHOLDER_PREFIX}ds_id")): return get_tables_by_ds_id(session, id) -@router.post("/fieldList/{id}") -async def field_list(session: SessionDep, id: int): - return get_fields_by_table_id(session, id) +@router.post("/fieldList/{id}", response_model=List[CoreField], summary=f"{PLACEHOLDER_PREFIX}ds_field_list") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +async def field_list(session: SessionDep, field: FieldObj, + id: int = Path(..., description=f"{PLACEHOLDER_PREFIX}ds_table_id")): + return get_fields_by_table_id(session, id, field) -@router.post("/editLocalComment") +@router.post("/editLocalComment", include_in_schema=False) +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) async def edit_local(session: SessionDep, data: TableObj): update_table_and_fields(session, data) -@router.post("/editTable") +@router.post("/editTable", response_model=None, summary=f"{PLACEHOLDER_PREFIX}ds_edit_table") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) async def edit_table(session: SessionDep, table: CoreTable): updateTable(session, table) -@router.post("/editField") +@router.post("/editField", response_model=None, summary=f"{PLACEHOLDER_PREFIX}ds_edit_field") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) async def edit_field(session: SessionDep, field: CoreField): updateField(session, field) -@router.post("/previewData/{id}") -async def preview_data(session: SessionDep, trans: Trans, current_user: CurrentUser, id: int, data: TableObj): +@router.post("/previewData/{id}", response_model=PreviewResponse, summary=f"{PLACEHOLDER_PREFIX}ds_preview_data") +@require_permissions(permission=SqlbotPermission(type='ds', keyExpression="id")) +async def preview_data(session: SessionDep, trans: Trans, current_user: CurrentUser, data: TableObj, + id: int = Path(..., description=f"{PLACEHOLDER_PREFIX}ds_id")): def inner(): try: return preview(session, current_user, id, data) @@ -176,8 +243,10 @@ def inner(): return await asyncio.to_thread(inner) -@router.post("/fieldEnum/{id}") -async def field_enum(session: SessionDep, id: int): +# not used +@router.post("/fieldEnum/{ds_id}/{id}", include_in_schema=False) +@require_permissions(permission=SqlbotPermission(type='ds', keyExpression="ds_id")) +async def field_enum(session: SessionDep, ds_id: int, id: int): def inner(): return fieldEnum(session, id) @@ -248,8 +317,10 @@ def inner(): # return await asyncio.to_thread(inner) -@router.post("/uploadExcel") -async def upload_excel(session: SessionDep, file: UploadFile = File(...)): +# deprecated +@router.post("/uploadExcel", response_model=None, summary=f"{PLACEHOLDER_PREFIX}ds_upload_excel") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +async def upload_excel(session: SessionDep, file: UploadFile = File(..., description=f"{PLACEHOLDER_PREFIX}ds_excel")): ALLOWED_EXTENSIONS = {"xlsx", "xls", "csv"} if not file.filename.lower().endswith(tuple(ALLOWED_EXTENSIONS)): raise HTTPException(400, "Only support .xlsx/.xls/.csv") @@ -273,9 +344,9 @@ def inner(): for sheet_name in sheet_names: tableName = f"{sheet_name}_{hashlib.sha256(uuid.uuid4().bytes).hexdigest()[:10]}" sheets.append({"tableName": tableName, "tableComment": ""}) - df_temp = pd.read_excel(path, nrows=5) - non_empty_cols = df_temp.columns[df_temp.notna().any()].tolist() - df = pd.read_excel(save_path, sheet_name=sheet_name, engine='calamine', usecols=non_empty_cols) + # df_temp = pd.read_excel(save_path, nrows=5) + # non_empty_cols = df_temp.columns[df_temp.notna().any()].tolist() + df = pd.read_excel(save_path, sheet_name=sheet_name, engine='calamine') insert_pg(df, tableName, engine) # os.remove(save_path) @@ -285,6 +356,11 @@ def inner(): def insert_pg(df, tableName, engine): + # fix field type + for i in range(len(df.dtypes)): + if str(df.dtypes[i]) == 'uint64': + df[str(df.columns[i])] = df[str(df.columns[i])].astype('string') + conn = engine.raw_connection() cursor = conn.cursor() try: @@ -300,13 +376,253 @@ def insert_pg(df, tableName, engine): # output.seek(0) # pg copy - cursor.copy_expert( - sql=f"""COPY "{tableName}" FROM STDIN WITH CSV DELIMITER E'\t'""", - file=output + query = sql.SQL("COPY {} FROM STDIN WITH CSV DELIMITER E'\t'").format( + sql.Identifier(tableName) ) + cursor.copy_expert(sql=query.as_string(cursor.connection), file=output) conn.commit() except Exception as e: - pass + traceback.print_exc() + raise HTTPException(400, str(e)) finally: cursor.close() conn.close() + + +t_sheet = "数据表列表" +t_s_col = "Sheet名称" +t_n_col = "表名" +t_c_col = "表备注" +f_n_col = "字段名" +f_c_col = "字段备注" + + +@router.get("/exportDsSchema/{id}", response_model=None, summary=f"{PLACEHOLDER_PREFIX}ds_export_ds_schema") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'], type='ds', keyExpression="id")) +async def export_ds_schema(session: SessionDep, id: int = Path(..., description=f"{PLACEHOLDER_PREFIX}ds_id")): + # { + # 'sheet':'', sheet name + # 'c1_h':'', column1 column name + # 'c2_h':'', column2 column name + # 'c1':[], column1 data + # 'c2':[], column2 data + # } + def inner(): + if id == 0: # download template + file_name = '批量上传备注' + df_list = [ + {'sheet': t_sheet, 'c0_h': t_s_col, 'c1_h': t_n_col, 'c2_h': t_c_col, 'c0': ["数据表1", "数据表2"], + 'c1': ["user", "score"], + 'c2': ["用来存放用户信息的数据表", "用来存放用户课程信息的数据表"]}, + {'sheet': '数据表1', 'c1_h': f_n_col, 'c2_h': f_c_col, 'c1': ["id", "name"], + 'c2': ["用户id", "用户姓名"]}, + {'sheet': '数据表2', 'c1_h': f_n_col, 'c2_h': f_c_col, 'c1': ["course", "user_id", "score"], + 'c2': ["课程名称", "用户ID", "课程得分"]}, + ] + else: + ds = session.query(CoreDatasource).filter(CoreDatasource.id == id).first() + file_name = ds.name + tables = session.query(CoreTable).filter(CoreTable.ds_id == id).order_by( + CoreTable.table_name.asc()).all() + if len(tables) == 0: + raise HTTPException(400, "No tables") + + df_list = [] + df1 = {'sheet': t_sheet, 'c0_h': t_s_col, 'c1_h': t_n_col, 'c2_h': t_c_col, 'c0': [], 'c1': [], 'c2': []} + df_list.append(df1) + for index, table in enumerate(tables): + df1['c0'].append(f"Sheet{index}") + df1['c1'].append(table.table_name) + df1['c2'].append(table.custom_comment) + + fields = session.query(CoreField).filter(CoreField.table_id == table.id).order_by( + CoreField.field_index.asc()).all() + df_fields = {'sheet': f"Sheet{index}", 'c1_h': f_n_col, 'c2_h': f_c_col, 'c1': [], 'c2': []} + for field in fields: + df_fields['c1'].append(field.field_name) + df_fields['c2'].append(field.custom_comment) + df_list.append(df_fields) + + # build dataframe and export + output = io.BytesIO() + + with (pd.ExcelWriter(output, engine='xlsxwriter') as writer): + for index, df in enumerate(df_list): + if index == 0: + pd.DataFrame({df['c0_h']: df['c0'], df['c1_h']: df['c1'], df['c2_h']: df['c2']} + ).to_excel(writer, sheet_name=df['sheet'], index=False) + else: + pd.DataFrame({df['c1_h']: df['c1'], df['c2_h']: df['c2']}).to_excel(writer, sheet_name=df['sheet'], + index=False) + + output.seek(0) + + filename = f'{file_name}.xlsx' + encoded_filename = quote(filename) + return io.BytesIO(output.getvalue()) + + # headers = { + # 'Content-Disposition': f"attachment; filename*=UTF-8''{encoded_filename}" + # } + + result = await asyncio.to_thread(inner) + return StreamingResponse( + result, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + + +@router.post("/uploadDsSchema/{id}", response_model=None, summary=f"{PLACEHOLDER_PREFIX}ds_upload_ds_schema") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'], type='ds', keyExpression="id")) +async def upload_ds_schema(session: SessionDep, id: int = Path(..., description=f"{PLACEHOLDER_PREFIX}ds_id"), + file: UploadFile = File(...)): + ALLOWED_EXTENSIONS = {"xlsx", "xls"} + if not file.filename.lower().endswith(tuple(ALLOWED_EXTENSIONS)): + raise HTTPException(400, "Only support .xlsx/.xls") + + try: + contents = await file.read() + excel_file = io.BytesIO(contents) + + sheet_names = pd.ExcelFile(excel_file, engine="openpyxl").sheet_names + + excel_file.seek(0) + + field_sheets = [] + table_sheet = None # [] + for sheet in sheet_names: + df = pd.read_excel(excel_file, sheet_name=sheet, engine="openpyxl").fillna('') + if sheet == t_sheet: + table_sheet = df.where(pd.notnull(df), None).to_dict(orient="records") + else: + field_sheets.append( + {'sheet_name': sheet, 'data': df.where(pd.notnull(df), None).to_dict(orient="records")}) + + # print(field_sheets) + + # sheet table mapping + sheet_table_map = {} + + # get data and update + # update table comment + if table_sheet and len(table_sheet) > 0: + for table in table_sheet: + sheet_table_map[table[t_s_col]] = table[t_n_col] + session.query(CoreTable).filter( + and_(CoreTable.ds_id == id, CoreTable.table_name == table[t_n_col])).update( + {'custom_comment': table[t_c_col]}) + + # update field comment + if field_sheets and len(field_sheets) > 0: + for fields in field_sheets: + if len(fields['data']) > 0: + # get table id + table_name = sheet_table_map.get(fields['sheet_name']) + table = session.query(CoreTable).filter( + and_(CoreTable.ds_id == id, CoreTable.table_name == table_name)).first() + if table: + for field in fields['data']: + session.query(CoreField).filter( + and_(CoreField.ds_id == id, + CoreField.table_id == table.id, + CoreField.field_name == field[f_n_col])).update( + {'custom_comment': field[f_c_col]}) + session.commit() + + return True + except Exception as e: + raise HTTPException(status_code=500, detail=f"Parse Excel Failed: {str(e)}") + + +@router.post("/parseExcel", response_model=None, summary=f"{PLACEHOLDER_PREFIX}ds_parse_excel") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +async def parse_excel(file: UploadFile = File(..., description=f"{PLACEHOLDER_PREFIX}ds_excel")): + ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"} + if not file.filename.lower().endswith(tuple(ALLOWED_EXTENSIONS)): + raise HTTPException(400, "Only support .xlsx/.xls/.csv") + + os.makedirs(path, exist_ok=True) + filename = f"{file.filename.split('.')[0].split('/')[-1]}_{hashlib.sha256(uuid.uuid4().bytes).hexdigest()[:10]}.{file.filename.split('.')[-1]}" + save_path = os.path.join(path, filename) + with open(save_path, "wb") as f: + f.write(await file.read()) + + def inner(): + sheets_data = parse_excel_preview(save_path) + return { + "filePath": filename, + "data": sheets_data + } + + return await asyncio.to_thread(inner) + + +@router.post("/importToDb", response_model=None, summary=f"{PLACEHOLDER_PREFIX}ds_import_to_db") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +async def import_to_db(session: SessionDep, trans: Trans, import_req: ImportRequest): + save_path = os.path.join(path, import_req.filePath) + if not os.path.exists(save_path): + raise HTTPException(400, "File not found") + + def inner(): + engine = get_engine_conn() + results = [] + + for sheet_info in import_req.sheets: + sheet_name = sheet_info.sheetName + table_name = f"excel_{filter_string(sheet_name)}_{hashlib.sha256(uuid.uuid4().bytes).hexdigest()[:10]}" + fields = sheet_info.fields + + field_mapping = {f.fieldName: f.fieldType for f in fields} + dtype_dict = { + col: USER_TYPE_TO_PANDAS.get(field_mapping.get(col, 'string'), 'string') + for col in field_mapping.keys() + } + + try: + if save_path.endswith(".csv"): + df = pd.read_csv(save_path, engine='c', dtype=dtype_dict) + sheet_name = "Sheet1" + else: + df = pd.read_excel(save_path, sheet_name=sheet_name, engine='calamine', dtype=dtype_dict) + except Exception as e: + raise HTTPException(500, f"{trans('i18n_ds_upload_error')}: {str(e)}") + + conn = engine.raw_connection() + cursor = conn.cursor() + try: + df.to_sql( + table_name, + engine, + if_exists='replace', + index=False + ) + output = StringIO() + df.to_csv(output, sep='\t', header=False, index=False) + + query = sql.SQL("COPY {} FROM STDIN WITH CSV DELIMITER E'\t'").format( + sql.Identifier(table_name) + ) + cursor.copy_expert(sql=query.as_string(cursor.connection), file=output) + conn.commit() + results.append({ + "sheetName": sheet_name, + "tableName": table_name, + "tableComment": "", + "rows": len(df) + }) + except Exception as e: + raise HTTPException(500, f"Insert data failed for {table_name}: {str(e)}") + finally: + cursor.close() + conn.close() + + return {"filename": import_req.filePath, "sheets": results} + + return await asyncio.to_thread(inner) + + +# only allow chinese, a-z, A-Z, 0-9 +def filter_string(text): + pattern = r'[^\u4e00-\u9fa5a-zA-Z0-9]' + return re.sub(pattern, '', text) diff --git a/backend/apps/datasource/api/recommended_problem.py b/backend/apps/datasource/api/recommended_problem.py new file mode 100644 index 000000000..6a0fb725f --- /dev/null +++ b/backend/apps/datasource/api/recommended_problem.py @@ -0,0 +1,31 @@ +from fastapi import APIRouter + +from apps.datasource.crud.datasource import update_ds_recommended_config +from apps.datasource.crud.recommended_problem import get_datasource_recommended, \ + save_recommended_problem, get_datasource_recommended_base +from apps.datasource.models.datasource import RecommendedProblemBase +from apps.swagger.i18n import PLACEHOLDER_PREFIX +from common.audit.models.log_model import OperationType, OperationModules +from common.audit.schemas.logger_decorator import LogConfig, system_log +from common.core.deps import SessionDep, CurrentUser + +router = APIRouter(tags=["recommended problem"], prefix="/recommended_problem") + + +@router.get("/get_datasource_recommended/{ds_id}", response_model=None, summary=f"{PLACEHOLDER_PREFIX}rp_get") +async def datasource_recommended(session: SessionDep, ds_id: int): + return get_datasource_recommended(session, ds_id) + + +@router.get("/get_datasource_recommended_base/{ds_id}", response_model=None, summary=f"{PLACEHOLDER_PREFIX}rp_base") +async def datasource_recommended(session: SessionDep, ds_id: int): + return get_datasource_recommended_base(session, ds_id) + + +@router.post("/save_recommended_problem", response_model=None, summary=f"{PLACEHOLDER_PREFIX}rp_save") +@system_log( + LogConfig(operation_type=OperationType.UPDATE, module=OperationModules.DATASOURCE, + resource_id_expr="data_info.datasource_id")) +async def datasource_recommended(session: SessionDep, user: CurrentUser, data_info: RecommendedProblemBase): + update_ds_recommended_config(session, data_info.datasource_id, data_info.recommended_config) + return save_recommended_problem(session, user, data_info) diff --git a/backend/apps/datasource/api/table_relation.py b/backend/apps/datasource/api/table_relation.py new file mode 100644 index 000000000..6454dc8b6 --- /dev/null +++ b/backend/apps/datasource/api/table_relation.py @@ -0,0 +1,36 @@ +# Author: Junjun +# Date: 2025/9/24 +from typing import List + +from fastapi import APIRouter, Path + +from apps.datasource.models.datasource import CoreDatasource +from apps.swagger.i18n import PLACEHOLDER_PREFIX +from apps.system.schemas.permission import SqlbotPermission, require_permissions +from common.core.deps import SessionDep +from common.audit.models.log_model import OperationType, OperationModules +from common.audit.schemas.logger_decorator import LogConfig, system_log +router = APIRouter(tags=["Table Relation"], prefix="/table_relation") + + +@router.post("/save/{ds_id}", response_model=None, summary=f"{PLACEHOLDER_PREFIX}tr_save") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'], keyExpression="ds_id", type='ds')) +@system_log(LogConfig(operation_type=OperationType.UPDATE_TABLE_RELATION,module=OperationModules.DATASOURCE,resource_id_expr="ds_id")) +async def save_relation(session: SessionDep, relation: List[dict], + ds_id: int = Path(..., description=f"{PLACEHOLDER_PREFIX}ds_id")): + ds = session.get(CoreDatasource, ds_id) + if ds: + ds.table_relation = relation + session.commit() + else: + raise Exception("no datasource") + return True + + +@router.post("/get/{ds_id}", response_model=List, summary=f"{PLACEHOLDER_PREFIX}tr_get") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'], keyExpression="ds_id", type='ds')) +async def get_relation(session: SessionDep, ds_id: int = Path(..., description=f"{PLACEHOLDER_PREFIX}ds_id")): + ds = session.get(CoreDatasource, ds_id) + if ds: + return ds.table_relation if ds.table_relation else [] + return [] diff --git a/backend/apps/datasource/crud/datasource.py b/backend/apps/datasource/crud/datasource.py index 3d6dc7352..acd7522e5 100644 --- a/backend/apps/datasource/crud/datasource.py +++ b/backend/apps/datasource/crud/datasource.py @@ -4,16 +4,21 @@ from fastapi import HTTPException from sqlalchemy import and_, text +from sqlbot_xpack.permissions.models.ds_rules import DsRules from sqlmodel import select from apps.datasource.crud.permission import get_column_permission_fields, get_row_permission_filters, is_normal_user +from apps.datasource.embedding.table_embedding import calc_table_embedding from apps.datasource.utils.utils import aes_decrypt from apps.db.constant import DB -from apps.db.db import get_engine, get_tables, get_fields, exec_sql +from apps.db.db import get_tables, get_fields, exec_sql, check_connection from apps.db.engine import get_engine_config, get_engine_conn -from apps.db.type import db_type_relation +from apps.system.schemas.auth import CacheName, CacheNamespace +from common.core.config import settings from common.core.deps import SessionDep, CurrentUser, Trans -from common.utils.utils import SQLBotLogUtil, deepcopy_ignore_extra +from common.utils.embedding_threads import run_save_table_embeddings, run_save_ds_embeddings +from common.utils.utils import SQLBotLogUtil, deepcopy_ignore_extra, equals_ignore_case +from common.core.sqlbot_cache import cache, clear_cache from .table import get_tables_by_ds_id from ..crud.field import delete_field_by_ds_id, update_field from ..crud.table import delete_table_by_ds_id, update_table @@ -26,7 +31,7 @@ def get_datasource_list(session: SessionDep, user: CurrentUser, oid: Optional[in if user.isAdmin and oid: current_oid = oid return session.exec( - select(CoreDatasource).where(CoreDatasource.oid == current_oid).order_by(CoreDatasource.name)).all() + select(CoreDatasource).where(CoreDatasource.oid == int(current_oid)).order_by(CoreDatasource.name)).all() def get_ds(session: SessionDep, id: int): @@ -45,16 +50,7 @@ def check_status_by_id(session: SessionDep, trans: Trans, ds_id: int, is_raise: def check_status(session: SessionDep, trans: Trans, ds: CoreDatasource, is_raise: bool = False): - conn = get_engine(ds, 10) - try: - with conn.connect() as connection: - SQLBotLogUtil.info("success") - return True - except Exception as e: - SQLBotLogUtil.error(f"Datasource {ds.id} connection failed: {e}") - if is_raise: - raise HTTPException(status_code=500, detail=trans('i18n_ds_invalid') + f': {e.args}') - return False + return check_connection(trans, ds, is_raise) def check_name(session: SessionDep, trans: Trans, user: CurrentUser, ds: CoreDatasource): @@ -70,7 +66,8 @@ def check_name(session: SessionDep, trans: Trans, user: CurrentUser, ds: CoreDat raise HTTPException(status_code=500, detail=trans('i18n_ds_name_exist')) -def create_ds(session: SessionDep, trans: Trans, user: CurrentUser, create_ds: CreateDatasource): +@clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.DS_ID_LIST, keyExpression="user.oid") +async def create_ds(session: SessionDep, trans: Trans, user: CurrentUser, create_ds: CreateDatasource): ds = CoreDatasource() deepcopy_ignore_extra(create_ds, ds) check_name(session, trans, user, ds) @@ -79,7 +76,7 @@ def create_ds(session: SessionDep, trans: Trans, user: CurrentUser, create_ds: C ds.create_by = user.id ds.oid = user.oid if user.oid is not None else 1 ds.status = "Success" - ds.type_name = db_type_relation()[ds.type] + ds.type_name = DB.get_db(ds.type).db_name record = CoreDatasource(**ds.model_dump()) session.add(record) session.flush() @@ -103,18 +100,27 @@ def chooseTables(session: SessionDep, trans: Trans, id: int, tables: List[CoreTa def update_ds(session: SessionDep, trans: Trans, user: CurrentUser, ds: CoreDatasource): ds.id = int(ds.id) check_name(session, trans, user, ds) - status = check_status(session, trans, ds) - ds.status = "Success" if status is True else "Fail" + # status = check_status(session, trans, ds) + ds.status = "Success" record = session.exec(select(CoreDatasource).where(CoreDatasource.id == ds.id)).first() update_data = ds.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(record, field, value) session.add(record) session.commit() + + run_save_ds_embeddings([ds.id]) return ds -def delete_ds(session: SessionDep, id: int): +def update_ds_recommended_config(session: SessionDep, datasource_id: int, recommended_config: int): + record = session.exec(select(CoreDatasource).where(CoreDatasource.id == datasource_id)).first() + record.recommended_config = recommended_config + session.add(record) + session.commit() + + +async def delete_ds(session: SessionDep, id: int): term = session.exec(select(CoreDatasource).where(CoreDatasource.id == id)).first() if term.type == "excel": # drop all tables for current datasource @@ -129,6 +135,8 @@ def delete_ds(session: SessionDep, id: int): session.commit() delete_table_by_ds_id(session, id) delete_field_by_ds_id(session, id) + if term: + await clear_ws_ds_cache(term.oid) return { "message": f"Datasource with ID {id} deleted successfully." } @@ -159,7 +167,28 @@ def getFieldsByDs(session: SessionDep, ds: CoreDatasource, table_name: str): def execSql(session: SessionDep, id: int, sql: str): ds = session.exec(select(CoreDatasource).where(CoreDatasource.id == id)).first() - return exec_sql(ds, sql) + return exec_sql(ds, sql, True) + + +def sync_single_fields(session: SessionDep, trans: Trans, id: int): + table = session.query(CoreTable).filter(CoreTable.id == id).first() + ds = session.query(CoreDatasource).filter(CoreDatasource.id == table.ds_id).first() + + tables = getTablesByDs(session, ds) + t_name = [] + for _t in tables: + t_name.append(_t.tableName) + + if not table.table_name in t_name: + raise HTTPException(status_code=500, detail=trans('i18n_table_not_exist')) + + # sync field + fields = getFieldsByDs(session, ds, table.table_name) + sync_fields(session, ds, table, fields) + + # do table embedding + run_save_table_embeddings([table.id]) + run_save_ds_embeddings([ds.id]) def sync_table(session: SessionDep, ds: CoreDatasource, tables: List[CoreTable]): @@ -201,10 +230,14 @@ def sync_table(session: SessionDep, ds: CoreDatasource, tables: List[CoreTable]) session.query(CoreField).filter(CoreField.ds_id == ds.id).delete(synchronize_session=False) session.commit() + # do table embedding + run_save_table_embeddings(id_list) + run_save_ds_embeddings([ds.id]) + def sync_fields(session: SessionDep, ds: CoreDatasource, table: CoreTable, fields: List[ColumnSchema]): id_list = [] - for item in fields: + for index, item in enumerate(fields): statement = select(CoreField).where( and_(CoreField.table_id == table.id, CoreField.field_name == item.fieldName)) record = session.exec(statement).first() @@ -213,12 +246,14 @@ def sync_fields(session: SessionDep, ds: CoreDatasource, table: CoreTable, field id_list.append(record.id) record.field_comment = item.fieldComment + record.field_index = index + record.field_type = item.fieldType session.add(record) session.commit() else: field = CoreField(ds_id=ds.id, table_id=table.id, checked=True, field_name=item.fieldName, field_type=item.fieldType, field_comment=item.fieldComment, - custom_comment=item.fieldComment) + custom_comment=item.fieldComment, field_index=index) session.add(field) session.flush() session.refresh(field) @@ -237,28 +272,48 @@ def update_table_and_fields(session: SessionDep, data: TableObj): for field in data.fields: update_field(session, field) + # do table embedding + run_save_table_embeddings([data.table.id]) + run_save_ds_embeddings([data.table.ds_id]) + def updateTable(session: SessionDep, table: CoreTable): update_table(session, table) + # do table embedding + run_save_table_embeddings([table.id]) + run_save_ds_embeddings([table.ds_id]) + def updateField(session: SessionDep, field: CoreField): update_field(session, field) + # do table embedding + run_save_table_embeddings([field.table_id]) + run_save_ds_embeddings([field.ds_id]) + def preview(session: SessionDep, current_user: CurrentUser, id: int, data: TableObj): ds = session.query(CoreDatasource).filter(CoreDatasource.id == id).first() # check_status(session, ds, True) - if data.fields is None or len(data.fields) == 0: + # ignore data's fields param, query fields from database + if not data.table.id: + return {"fields": [], "data": [], "sql": ''} + + fields = session.query(CoreField).filter(CoreField.table_id == data.table.id).order_by( + CoreField.field_index.asc()).all() + + if fields is None or len(fields) == 0: return {"fields": [], "data": [], "sql": ''} where = '' - f_list = [f for f in data.fields if f.checked] + f_list = [f for f in fields if f.checked] if is_normal_user(current_user): # column is checked, and, column permission for data.fields + contain_rules = session.query(DsRules).all() f_list = get_column_permission_fields(session=session, current_user=current_user, table=data.table, - fields=f_list) or f_list + fields=f_list, contain_rules=contain_rules) # row permission tree where_str = '' @@ -273,26 +328,45 @@ def preview(session: SessionDep, current_user: CurrentUser, id: int, data: Table if fields is None or len(fields) == 0: return {"fields": [], "data": [], "sql": ''} + table = session.query(CoreTable).filter(CoreTable.id == data.table.id).first() conf = DatasourceConf(**json.loads(aes_decrypt(ds.configuration))) if ds.type != "excel" else get_engine_config() sql: str = "" - if ds.type == "mysql": - sql = f"""SELECT `{"`, `".join(fields)}` FROM `{data.table.table_name}` + if ds.type == "mysql" or ds.type == "doris" or ds.type == "starrocks" or ds.type == "hive": + sql = f"""SELECT `{"`, `".join(fields)}` FROM `{table.table_name}` {where} LIMIT 100""" elif ds.type == "sqlServer": - sql = f"""SELECT TOP 100 [{"], [".join(fields)}] FROM [{conf.dbSchema}].[{data.table.table_name}] + sql = f"""SELECT TOP 100 [{"], [".join(fields)}] FROM [{conf.dbSchema}].[{table.table_name}] {where} """ - elif ds.type == "pg" or ds.type == "excel": - sql = f"""SELECT "{'", "'.join(fields)}" FROM "{conf.dbSchema}"."{data.table.table_name}" + elif ds.type == "pg" or ds.type == "excel" or ds.type == "redshift" or ds.type == "kingbase": + sql = f"""SELECT "{'", "'.join(fields)}" FROM "{conf.dbSchema}"."{table.table_name}" {where} LIMIT 100""" elif ds.type == "oracle": - sql = f"""SELECT "{'", "'.join(fields)}" FROM "{conf.dbSchema}"."{data.table.table_name}" + # sql = f"""SELECT "{'", "'.join(fields)}" FROM "{conf.dbSchema}"."{data.table.table_name}" + # {where} + # ORDER BY "{fields[0]}" + # OFFSET 0 ROWS FETCH NEXT 100 ROWS ONLY""" + sql = f"""SELECT * FROM + (SELECT "{'", "'.join(fields)}" FROM "{conf.dbSchema}"."{table.table_name}" + {where} + ORDER BY "{fields[0]}") + WHERE ROWNUM <= 100 + """ + elif ds.type == "ck": + sql = f"""SELECT "{'", "'.join(fields)}" FROM "{table.table_name}" {where} - ORDER BY "{fields[0]}" - OFFSET 0 ROWS FETCH NEXT 100 ROWS ONLY""" - return exec_sql(ds, sql) + LIMIT 100""" + elif ds.type == "dm": + sql = f"""SELECT "{'", "'.join(fields)}" FROM "{conf.dbSchema}"."{table.table_name}" + {where} + LIMIT 100""" + elif ds.type == "es": + sql = f"""SELECT "{'", "'.join(fields)}" FROM "{table.table_name}" + {where} + LIMIT 100""" + return exec_sql(ds, sql, True) def fieldEnum(session: SessionDep, id: int): @@ -308,7 +382,7 @@ def fieldEnum(session: SessionDep, id: int): db = DB.get_db(ds.type) sql = f"""SELECT DISTINCT {db.prefix}{field.field_name}{db.suffix} FROM {db.prefix}{table.table_name}{db.suffix}""" - res = exec_sql(ds, sql) + res = exec_sql(ds, sql, True) return [item.get(res.get('fields')[0]) for item in res.get('data')] @@ -328,46 +402,225 @@ def updateNum(session: SessionDep, ds: CoreDatasource): def get_table_obj_by_ds(session: SessionDep, current_user: CurrentUser, ds: CoreDatasource) -> List[TableAndFields]: _list: List = [] - tables = session.query(CoreTable).filter(CoreTable.ds_id == ds.id).all() + tables = session.query(CoreTable).filter( + and_(CoreTable.ds_id == ds.id, CoreTable.checked == True) + ).all() conf = DatasourceConf(**json.loads(aes_decrypt(ds.configuration))) if ds.type != "excel" else get_engine_config() schema = conf.dbSchema if conf.dbSchema is not None and conf.dbSchema != "" else conf.database + + # get all field + table_ids = [table.id for table in tables] + all_fields = session.query(CoreField).filter( + and_(CoreField.table_id.in_(table_ids), CoreField.checked == True)).all() + # build dict + fields_dict = {} + for field in all_fields: + if fields_dict.get(field.table_id): + fields_dict.get(field.table_id).append(field) + else: + fields_dict[field.table_id] = [field] + + contain_rules = session.query(DsRules).all() for table in tables: - fields = session.query(CoreField).filter(and_(CoreField.table_id == table.id, CoreField.checked == True)).all() + # fields = session.query(CoreField).filter(and_(CoreField.table_id == table.id, CoreField.checked == True)).all() + fields = fields_dict.get(table.id) # do column permissions, filter fields - fields = get_column_permission_fields(session=session, current_user=current_user, table=table, - fields=fields) or fields + fields = get_column_permission_fields(session=session, current_user=current_user, table=table, fields=fields, + contain_rules=contain_rules) _list.append(TableAndFields(schema=schema, table=table, fields=fields)) return _list -def get_table_schema(session: SessionDep, current_user: CurrentUser, ds: CoreDatasource) -> str: +def get_table_sample_data(ds: CoreDatasource, table_name: str, fields: list) -> str: + """Get 3 sample rows from a table in JSON format to help AI understand the data""" + if not fields: + return "" + + db = DB.get_db(ds.type) + # Get prefix/suffix for identifier quoting + prefix = db.prefix if hasattr(db, 'prefix') else '"' + suffix = db.suffix if hasattr(db, 'suffix') else '"' + + # Build field list with proper quoting + field_names = [] + for field in fields[:10]: # Limit to first 10 fields to avoid too wide results + field_name = f"{prefix}{field.field_name}{suffix}" + field_names.append(field_name) + + # Build LIMIT query based on database type + if equals_ignore_case(ds.type, "sqlServer"): + query = f"SELECT TOP 3 {','.join(field_names)} FROM {prefix}{table_name}{suffix}" + elif equals_ignore_case(ds.type, "ck"): + query = f"SELECT {','.join(field_names)} FROM {table_name} LIMIT 3" + elif equals_ignore_case(ds.type, "hive"): + query = f"SELECT {','.join(field_names)} FROM {table_name} LIMIT 3" + elif equals_ignore_case(ds.type, "oracle"): + query = f"SELECT {','.join(field_names)} FROM \"{table_name}\" WHERE ROWNUM <= 3" + elif equals_ignore_case(ds.type, "dm"): + query = f"SELECT {','.join(field_names)} FROM \"{table_name}\" WHERE ROWNUM <= 3" + else: + query = f"SELECT {','.join(field_names)} FROM {prefix}{table_name}{suffix} LIMIT 3" + + try: + result = exec_sql(ds=ds, sql=query, origin_column=True) + if result and result.get('data') and len(result['data']) > 0: + import json + # Truncate long string values for readability + json_rows = [] + for row in result['data'][:3]: + truncated_row = {} + for key, value in row.items(): + if value is None: + truncated_row[key] = None + elif isinstance(value, str): + # Truncate long strings + if len(value) > 100: + value = value[:100] + '...' + truncated_row[key] = value.replace('\n', ' ').replace('\r', ' ') + else: + truncated_row[key] = value + json_rows.append(truncated_row) + return json.dumps(json_rows, ensure_ascii=False, indent=2) + except Exception: + pass + return "" + + +def get_tables_sample_data(session: SessionDep, current_user: CurrentUser, ds: CoreDatasource, + table_list: list[str] = None) -> str: + """Get sample data (3 rows) for all tables to help AI understand the data""" + table_objs = get_table_obj_by_ds(session=session, current_user=current_user, ds=ds) + if len(table_objs) == 0: + return "" + + sample_data_parts = [] + for obj in table_objs: + if table_list is not None and obj.table.table_name not in table_list: + continue + if obj.fields: + sample = get_table_sample_data(ds, obj.table.table_name, obj.fields) + if sample: + sample_data_parts.append(f"# Table: {obj.table.table_name}\n{sample}") + return "\n".join(sample_data_parts) + + +def get_table_schema(session: SessionDep, current_user: CurrentUser, ds: CoreDatasource, question: str, + embedding: bool = True, table_list: list[str] = None) -> tuple[str, list]: schema_str = "" table_objs = get_table_obj_by_ds(session=session, current_user=current_user, ds=ds) if len(table_objs) == 0: - return schema_str + return schema_str, [] db_name = table_objs[0].schema schema_str += f"【DB_ID】 {db_name}\n【Schema】\n" + tables = [] + all_tables = [] # temp save all tables + table_name_list = [] for obj in table_objs: - schema_str += f"# Table: {db_name}.{obj.table.table_name}" if ds.type != "mysql" else f"# Table: {obj.table.table_name}" + # 如果传入了table_list,则只处理在列表中的表 + if table_list is not None and obj.table.table_name not in table_list: + continue + + schema_table = '' + no_schema_types = ["mysql", "es", "sqlite", "hive", "doris", "starrocks"] + schema_table += f"# Table: {db_name}.{obj.table.table_name}" if ds.type not in no_schema_types and db_name else f"# Table: {obj.table.table_name}" table_comment = '' if obj.table.custom_comment: table_comment = obj.table.custom_comment.strip() if table_comment == '': - schema_str += '\n[\n' + schema_table += '\n[\n' else: - schema_str += f", {table_comment}\n[\n" - - field_list = [] - for field in obj.fields: - field_comment = '' - if field.custom_comment: - field_comment = field.custom_comment.strip() - if field_comment == '': - field_list.append(f"({field.field_name}:{field.field_type})") - else: - field_list.append(f"({field.field_name}:{field.field_type}, {field_comment})") - schema_str += ",\n".join(field_list) - schema_str += '\n]\n' - # todo 外键 - return schema_str + schema_table += f", {table_comment}\n[\n" + + if obj.fields: + field_list = [] + for field in obj.fields: + field_comment = '' + if field.custom_comment: + field_comment = field.custom_comment.strip() + if field_comment == '': + field_list.append(f"({field.field_name}:{field.field_type})") + else: + field_list.append(f"({field.field_name}:{field.field_type}, {field_comment})") + schema_table += ",\n".join(field_list) + schema_table += '\n]\n' + + t_obj = {"id": obj.table.id, "table_name": obj.table.table_name, "schema_table": schema_table, + "embedding": obj.table.embedding} + tables.append(t_obj) + all_tables.append(t_obj) + + # 如果没有符合过滤条件的表,直接返回 + if not tables: + return schema_str, [] + + # do table embedding + if embedding and tables and settings.TABLE_EMBEDDING_ENABLED: + tables = calc_table_embedding(tables, question) + # splice schema + if tables: + for s in tables: + schema_str += s.get('schema_table') + table_name_list.append(s.get('table_name')) + + # field relation + if tables and ds.table_relation: + relations = list(filter(lambda x: x.get('shape') == 'edge', ds.table_relation)) + if relations: + # Complete the missing table + # get tables in relation, remove irrelevant relation + embedding_table_ids = [s.get('id') for s in tables] + all_relations = list( + filter(lambda x: x.get('source').get('cell') in embedding_table_ids or x.get('target').get( + 'cell') in embedding_table_ids, relations)) + + # get relation table ids, sub embedding table ids + relation_table_ids = [] + for r in all_relations: + relation_table_ids.append(r.get('source').get('cell')) + relation_table_ids.append(r.get('target').get('cell')) + relation_table_ids = list(set(relation_table_ids)) + # get table dict + table_records = session.query(CoreTable).filter(CoreTable.id.in_(list(map(int, relation_table_ids)))).all() + table_dict = {} + for ele in table_records: + table_dict[ele.id] = ele.table_name + + # get lost table ids + lost_table_ids = list(set(relation_table_ids) - set(embedding_table_ids)) + # get lost table schema and splice it + lost_tables = list(filter(lambda x: x.get('id') in lost_table_ids, all_tables)) + if lost_tables: + for s in lost_tables: + schema_str += s.get('schema_table') + table_name_list.append(s.get('table_name')) + + # get field dict + relation_field_ids = [] + for relation in all_relations: + relation_field_ids.append(relation.get('source').get('port')) + relation_field_ids.append(relation.get('target').get('port')) + relation_field_ids = list(set(relation_field_ids)) + field_records = session.query(CoreField).filter(CoreField.id.in_(list(map(int, relation_field_ids)))).all() + field_dict = {} + for ele in field_records: + field_dict[ele.id] = ele.field_name + + if all_relations: + schema_str += '【Foreign keys】\n' + for ele in all_relations: + schema_str += f"{table_dict.get(int(ele.get('source').get('cell')))}.{field_dict.get(int(ele.get('source').get('port')))}={table_dict.get(int(ele.get('target').get('cell')))}.{field_dict.get(int(ele.get('target').get('port')))}\n" + + return schema_str, table_name_list + + +@cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.DS_ID_LIST, keyExpression="oid") +async def get_ws_ds(session, oid) -> list: + stmt = select(CoreDatasource.id).distinct().where(CoreDatasource.oid == oid) + db_list = session.exec(stmt).all() + return db_list + + +@clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.DS_ID_LIST, keyExpression="oid") +async def clear_ws_ds_cache(oid): + SQLBotLogUtil.info(f"ds cache for ws [{oid}] has been cleaned") diff --git a/backend/apps/datasource/crud/field.py b/backend/apps/datasource/crud/field.py index 2b478ff53..371b86e78 100644 --- a/backend/apps/datasource/crud/field.py +++ b/backend/apps/datasource/crud/field.py @@ -1,5 +1,6 @@ from common.core.deps import SessionDep -from ..models.datasource import CoreField +from ..models.datasource import CoreField, FieldObj +from sqlalchemy import or_, and_ def delete_field_by_ds_id(session: SessionDep, id: int): @@ -7,8 +8,15 @@ def delete_field_by_ds_id(session: SessionDep, id: int): session.commit() -def get_fields_by_table_id(session: SessionDep, id: int): - return session.query(CoreField).filter(CoreField.table_id == id).all() +def get_fields_by_table_id(session: SessionDep, id: int, field: FieldObj): + if field and field.fieldName: + return session.query(CoreField).filter( + and_(CoreField.table_id == id, or_(CoreField.field_name.like(f'%{field.fieldName}%'), + CoreField.field_name.like(f'%{field.fieldName.lower()}%'), + CoreField.field_name.like(f'%{field.fieldName.upper()}%')))).order_by( + CoreField.field_index.asc()).all() + else: + return session.query(CoreField).filter(CoreField.table_id == id).order_by(CoreField.field_index.asc()).all() def update_field(session: SessionDep, item: CoreField): diff --git a/backend/apps/datasource/crud/permission.py b/backend/apps/datasource/crud/permission.py index 80c32efd9..dfa56c75f 100644 --- a/backend/apps/datasource/crud/permission.py +++ b/backend/apps/datasource/crud/permission.py @@ -1,14 +1,17 @@ import json from typing import List, Optional -from sqlalchemy import and_ -from apps.datasource.crud.row_permission import transFilterTree -from apps.datasource.models.datasource import CoreDatasource, CoreField, CoreTable -from common.core.deps import CurrentUser, SessionDep +from sqlalchemy import and_, cast, or_ from sqlbot_xpack.permissions.api.permission import transRecord2DTO from sqlbot_xpack.permissions.models.ds_permission import DsPermission, PermissionDTO from sqlbot_xpack.permissions.models.ds_rules import DsRules +from apps.datasource.crud.row_permission import transFilterTree +from apps.datasource.models.datasource import CoreDatasource, CoreField, CoreTable +from common.core.deps import CurrentUser, SessionDep +from sqlalchemy.dialects.postgresql import JSONB + + def get_row_permission_filters(session: SessionDep, current_user: CurrentUser, ds: CoreDatasource, tables: Optional[list] = None, single_table: Optional[CoreTable] = None): if single_table: @@ -20,52 +23,66 @@ def get_row_permission_filters(session: SessionDep, current_user: CurrentUser, d filters = [] if is_normal_user(current_user): + # contain_rules = session.query(DsRules).all() for table in table_list: row_permissions = session.query(DsPermission).filter( and_(DsPermission.table_id == table.id, DsPermission.type == 'row')).all() - contain_rules = session.query(DsRules).all() res: List[PermissionDTO] = [] if row_permissions is not None: for permission in row_permissions: # check permission and user in same rules - flag = False - for r in contain_rules: - p_list = json.loads(r.permission_list) - u_list = json.loads(r.user_list) - if p_list is not None and u_list is not None and permission.id in p_list and ( - current_user.id in u_list or f'{current_user.id}' in u_list): - flag = True - if flag: + obj = session.query(DsRules).filter( + and_(DsRules.permission_list.op('@>')(cast([permission.id], JSONB)), + or_(DsRules.user_list.op('@>')(cast([f'{current_user.id}'], JSONB)), + DsRules.user_list.op('@>')(cast([current_user.id], JSONB)))) + ).first() + if obj is not None: res.append(transRecord2DTO(session, permission)) - where_str = transFilterTree(session, res, ds) - filters.append({"table": table.table_name, "filter": where_str}) + + # flag = False + # for r in contain_rules: + # p_list = json.loads(r.permission_list) + # u_list = json.loads(r.user_list) + # if p_list is not None and u_list is not None and permission.id in p_list and ( + # current_user.id in u_list or f'{current_user.id}' in u_list): + # flag = True + # break + # if flag: + # res.append(transRecord2DTO(session, permission)) + where_str = transFilterTree(session, current_user, res, ds) + if where_str: + filters.append({"table": table.table_name, "filter": where_str}) return filters def get_column_permission_fields(session: SessionDep, current_user: CurrentUser, table: CoreTable, - fields: list[CoreField]): + fields: list[CoreField], contain_rules: list[DsRules]): if is_normal_user(current_user): column_permissions = session.query(DsPermission).filter( and_(DsPermission.table_id == table.id, DsPermission.type == 'column')).all() - contain_rules = session.query(DsRules).all() if column_permissions is not None: for permission in column_permissions: # check permission and user in same rules - # obj = session.query(DsRules).filter( - # and_(DsRules.permission_list.op('@>')(cast([permission.id], JSONB)), - # or_(DsRules.user_list.op('@>')(cast([f'{current_user.id}'], JSONB)), - # DsRules.user_list.op('@>')(cast([current_user.id], JSONB)))) - # ).first() - flag = False - for r in contain_rules: - p_list = json.loads(r.permission_list) - u_list = json.loads(r.user_list) - if p_list is not None and u_list is not None and permission.id in p_list and ( - current_user.id in u_list or f'{current_user.id}' in u_list): - flag = True - if flag: + obj = session.query(DsRules).filter( + and_(DsRules.permission_list.op('@>')(cast([permission.id], JSONB)), + or_(DsRules.user_list.op('@>')(cast([f'{current_user.id}'], JSONB)), + DsRules.user_list.op('@>')(cast([current_user.id], JSONB)))) + ).first() + if obj is not None: permission_list = json.loads(permission.permissions) fields = filter_list(fields, permission_list) + + # flag = False + # for r in contain_rules: + # p_list = json.loads(r.permission_list) + # u_list = json.loads(r.user_list) + # if p_list is not None and u_list is not None and permission.id in p_list and ( + # current_user.id in u_list or f'{current_user.id}' in u_list): + # flag = True + # break + # if flag: + # permission_list = json.loads(permission.permissions) + # fields = filter_list(fields, permission_list) return fields diff --git a/backend/apps/datasource/crud/recommended_problem.py b/backend/apps/datasource/crud/recommended_problem.py new file mode 100644 index 000000000..d09a9b957 --- /dev/null +++ b/backend/apps/datasource/crud/recommended_problem.py @@ -0,0 +1,44 @@ +import datetime + +from sqlmodel import select + +from common.core.deps import SessionDep, CurrentUser, Trans +from ..models.datasource import DsRecommendedProblem, RecommendedProblemBase, CoreDatasource, RecommendedProblemResponse +import orjson + + +def get_datasource_recommended(session: SessionDep, ds_id: int): + statement = select(DsRecommendedProblem).where(DsRecommendedProblem.datasource_id == ds_id) + dsRecommendedProblem = session.exec(statement).all() + return dsRecommendedProblem + +def get_datasource_recommended_chart(session: SessionDep, ds_id: int): + statement = select(DsRecommendedProblem.question).where(DsRecommendedProblem.datasource_id == ds_id) + dsRecommendedProblems = session.exec(statement).all() + return dsRecommendedProblems + + +def get_datasource_recommended_base(session: SessionDep, ds_id: int): + statement = select(CoreDatasource.id,CoreDatasource.recommended_config).where(CoreDatasource.id == ds_id) + datasourceBase = session.exec(statement).first() + if datasourceBase is None: + return RecommendedProblemResponse(ds_id,0,None) + elif datasourceBase.recommended_config == 1: + return RecommendedProblemResponse(ds_id,1,None) + else: + dsRecommendedProblems = session.exec(select(DsRecommendedProblem.question).where(DsRecommendedProblem.datasource_id == ds_id)).all() + return RecommendedProblemResponse(ds_id,datasourceBase.recommended_config, orjson.dumps(dsRecommendedProblems).decode()) + +def save_recommended_problem(session: SessionDep,user: CurrentUser, data_info: RecommendedProblemBase): + session.query(DsRecommendedProblem).filter(DsRecommendedProblem.datasource_id == data_info.datasource_id).delete(synchronize_session=False) + problemInfo = data_info.problemInfo + if problemInfo is not None: + for problemItem in problemInfo: + problemItem.id = None + problemItem.create_time = datetime.datetime.now() + problemItem.create_by = user.id + record = DsRecommendedProblem(**problemItem.model_dump()) + session.add(record) + session.flush() + session.refresh(record) + session.commit() diff --git a/backend/apps/datasource/crud/row_permission.py b/backend/apps/datasource/crud/row_permission.py index 16f97971c..b19063af5 100644 --- a/backend/apps/datasource/crud/row_permission.py +++ b/backend/apps/datasource/crud/row_permission.py @@ -2,12 +2,30 @@ # Date: 2025/6/25 from typing import List, Dict + from apps.datasource.models.datasource import CoreField, CoreDatasource from apps.db.constant import DB -from common.core.deps import SessionDep +from apps.system.models.system_variable_model import SystemVariable +from common.core.deps import SessionDep, CurrentUser + + +def _escape_sql_value(value: str) -> str: + """Escape a string value for safe inclusion in a SQL literal. + Replaces single quotes with two single quotes (standard SQL escaping) + and strips characters that could break out of the string context. + """ + if value is None: + return value + # Standard SQL escaping: double any embedded single-quote characters + escaped = str(value).replace("'", "''") + # Remove backslashes that some drivers interpret as escape characters + escaped = escaped.replace("\\", "\\\\") + return escaped -def transFilterTree(session: SessionDep, tree_list: List[any], ds: CoreDatasource) -> str | None: + +def transFilterTree(session: SessionDep, current_user: CurrentUser, tree_list: List[any], + ds: CoreDatasource) -> str | None: if tree_list is None: return None res: List[str] = [] @@ -15,16 +33,22 @@ def transFilterTree(session: SessionDep, tree_list: List[any], ds: CoreDatasourc tree = dto.tree if tree is None: continue - tree_exp = transTreeToWhere(session, tree, ds) + tree_exp = transTreeToWhere(session, current_user, tree, ds) if tree_exp is not None: res.append(tree_exp) return " AND ".join(res) -def transTreeToWhere(session: SessionDep, tree: any, ds: CoreDatasource) -> str | None: +_VALID_LOGIC_OPS = {"AND", "OR"} + + +def transTreeToWhere(session: SessionDep, current_user: CurrentUser, tree: any, ds: CoreDatasource) -> str | None: if tree is None: return None logic = tree['logic'] + # Validate the logic operator to prevent injection via this field + if logic.upper() not in _VALID_LOGIC_OPS: + return None items = tree['items'] list: List[str] = [] @@ -32,16 +56,16 @@ def transTreeToWhere(session: SessionDep, tree: any, ds: CoreDatasource) -> str for item in items: exp: str = None if item['type'] == 'item': - exp = transTreeItem(session, item, ds) + exp = transTreeItem(session, current_user, item, ds) elif item['type'] == 'tree': - exp = transTreeToWhere(session, item['sub_tree'], ds) + exp = transTreeToWhere(session, current_user, item['sub_tree'], ds) if exp is not None: list.append(exp) return '(' + f' {logic} '.join(list) + ')' if len(list) > 0 else None -def transTreeItem(session: SessionDep, item: Dict, ds: CoreDatasource) -> str | None: +def transTreeItem(session: SessionDep, current_user: CurrentUser, item: Dict, ds: CoreDatasource) -> str | None: res: str = None field = session.query(CoreField).filter(CoreField.id == int(item['field_id'])).first() if field is None: @@ -49,46 +73,133 @@ def transTreeItem(session: SessionDep, item: Dict, ds: CoreDatasource) -> str | db = DB.get_db(ds.type) whereName = db.prefix + field.field_name + db.suffix + whereTerm = transFilterTerm(item['term']) + if item['filter_type'] == 'enum': if len(item['enum_value']) > 0: + escaped_values = [_escape_sql_value(v) for v in item['enum_value']] if ds['type'] == 'sqlServer' and ( field.field_type == 'nchar' or field.field_type == 'NCHAR' or field.field_type == 'nvarchar' or field.field_type == 'NVARCHAR'): - res = "(" + whereName + " IN (N'" + "',N'".join(item['enum_value']) + "'))" + res = "(" + whereName + " IN (N'" + "',N'".join(escaped_values) + "'))" else: - res = "(" + whereName + " IN ('" + "','".join(item['enum_value']) + "'))" + res = "(" + whereName + " IN ('" + "','".join(escaped_values) + "'))" else: - value = item['value'] - whereTerm = transFilterTerm(item['term']) - whereValue = '' + # if system variable, do check and get value + # new field: value_type(variable or normal), variable_id + value_type = item.get('value_type') + if value_type and value_type == 'variable': + # get system variable + variable_id = item.get('variable_id') + if variable_id is not None: + sys_variable = session.query(SystemVariable).filter(SystemVariable.id == variable_id).first() + if sys_variable is None: + return None - if item['term'] == 'null': - whereValue = '' - elif item['term'] == 'not_null': - whereValue = '' - elif item['term'] == 'empty': - whereValue = "''" - elif item['term'] == 'not_empty': - whereValue = "''" - elif item['term'] == 'in' or item['term'] == 'not in': - if ds.type == 'sqlServer' and ( - field.field_type == 'nchar' or field.field_type == 'NCHAR' or field.field_type == 'nvarchar' or field.field_type == 'NVARCHAR'): - whereValue = "(N'" + "', N'".join(value.split(",")) + "')" - else: - whereValue = "('" + "', '".join(value.split(",")) + "')" - elif item['term'] == 'like' or item['term'] == 'not like': - if ds.type == 'sqlServer' and ( - field.field_type == 'nchar' or field.field_type == 'NCHAR' or field.field_type == 'nvarchar' or field.field_type == 'NVARCHAR'): - whereValue = f"N'%{value}%'" + # do inner system variable + if sys_variable.type == 'system': + res = whereName + whereTerm + getSysVariableValue(sys_variable, current_user, ds, field, item) + else: + # check user variable + user_variables = current_user.system_variables + if user_variables is None or len(user_variables) == 0 or not userHaveVariable(user_variables, + sys_variable): + return None + else: + # get user variable + u_variable = None + for u in user_variables: + if u.get('variableId') == sys_variable.id: + u_variable = u + break + if u_variable is None: + return None + + # check value + values = u_variable.get('variableValues') + if sys_variable.var_type == 'text': + set_sys = set(sys_variable.value) + values = [x for x in values if x in set_sys] + if values is None or len(values) == 0: + return None + elif sys_variable.var_type == 'number': + if (sys_variable.value[0] is not None and values[0] < sys_variable.value[0]) or ( + sys_variable.value[1] is not None and values[0] > sys_variable.value[1]): + return None + elif sys_variable.var_type == 'datetime': + if (sys_variable.value[0] is not None and values[0] < sys_variable.value[0]) or ( + sys_variable.value[1] is not None and values[0] > sys_variable.value[1]): + return None + + # build exp + whereValue = '' + if item['term'] == 'null': + whereValue = '' + elif item['term'] == 'not_null': + whereValue = '' + elif item['term'] == 'empty': + whereValue = "''" + elif item['term'] == 'not_empty': + whereValue = "''" + elif item['term'] == 'in' or item['term'] == 'not in': + escaped_values = [_escape_sql_value(v) for v in values] + if ds.type == 'sqlServer' and ( + field.field_type == 'nchar' or field.field_type == 'NCHAR' or field.field_type == 'nvarchar' or field.field_type == 'NVARCHAR'): + whereValue = "(N'" + "', N'".join(escaped_values) + "')" + else: + whereValue = "('" + "', '".join(escaped_values) + "')" + elif item['term'] == 'like' or item['term'] == 'not like': + escaped_v = _escape_sql_value(values[0]) + if ds.type == 'sqlServer' and ( + field.field_type == 'nchar' or field.field_type == 'NCHAR' or field.field_type == 'nvarchar' or field.field_type == 'NVARCHAR'): + whereValue = f"N'%{escaped_v}%'" + else: + whereValue = f"'%{escaped_v}%'" + else: + escaped_v = _escape_sql_value(values[0]) + if ds.type == 'sqlServer' and ( + field.field_type == 'nchar' or field.field_type == 'NCHAR' or field.field_type == 'nvarchar' or field.field_type == 'NVARCHAR'): + whereValue = f"N'{escaped_v}'" + else: + whereValue = f"'{escaped_v}'" + + res = whereName + whereTerm + whereValue else: - whereValue = f"'%{value}%'" + return None else: - if ds.type == 'sqlServer' and ( - field.field_type == 'nchar' or field.field_type == 'NCHAR' or field.field_type == 'nvarchar' or field.field_type == 'NVARCHAR'): - whereValue = f"N'{value}'" + value = item['value'] + whereValue = '' + + if item['term'] == 'null': + whereValue = '' + elif item['term'] == 'not_null': + whereValue = '' + elif item['term'] == 'empty': + whereValue = "''" + elif item['term'] == 'not_empty': + whereValue = "''" + elif item['term'] == 'in' or item['term'] == 'not in': + escaped_values = [_escape_sql_value(v) for v in value.split(",")] + if ds.type == 'sqlServer' and ( + field.field_type == 'nchar' or field.field_type == 'NCHAR' or field.field_type == 'nvarchar' or field.field_type == 'NVARCHAR'): + whereValue = "(N'" + "', N'".join(escaped_values) + "')" + else: + whereValue = "('" + "', '".join(escaped_values) + "')" + elif item['term'] == 'like' or item['term'] == 'not like': + escaped_v = _escape_sql_value(value) + if ds.type == 'sqlServer' and ( + field.field_type == 'nchar' or field.field_type == 'NCHAR' or field.field_type == 'nvarchar' or field.field_type == 'NVARCHAR'): + whereValue = f"N'%{escaped_v}%'" + else: + whereValue = f"'%{escaped_v}%'" else: - whereValue = f"'{value}'" + escaped_v = _escape_sql_value(value) + if ds.type == 'sqlServer' and ( + field.field_type == 'nchar' or field.field_type == 'NCHAR' or field.field_type == 'nvarchar' or field.field_type == 'NVARCHAR'): + whereValue = f"N'{escaped_v}'" + else: + whereValue = f"'{escaped_v}'" - res = whereName + whereTerm + whereValue + res = whereName + whereTerm + whereValue return res @@ -124,3 +235,53 @@ def transFilterTerm(term: str) -> str: if term == "between": return " BETWEEN " return "" + + +def userHaveVariable(user_variables: List, sys_variable: SystemVariable): + for u in user_variables: + if sys_variable.id == u.get('variableId'): + return True + return False + + +def getSysVariableValue(sys_variable: SystemVariable, current_user: CurrentUser, ds: CoreDatasource, field: CoreField, + item: Dict, ): + v = None + if sys_variable.value[0] == 'name': + v = current_user.name + if sys_variable.value[0] == 'account': + v = current_user.account + if sys_variable.value[0] == 'email': + v = current_user.email + + escaped_v = _escape_sql_value(v) if v is not None else v + + whereValue = '' + if item['term'] == 'null': + whereValue = '' + elif item['term'] == 'not_null': + whereValue = '' + elif item['term'] == 'empty': + whereValue = "''" + elif item['term'] == 'not_empty': + whereValue = "''" + elif item['term'] == 'in' or item['term'] == 'not in': + if ds.type == 'sqlServer' and ( + field.field_type == 'nchar' or field.field_type == 'NCHAR' or field.field_type == 'nvarchar' or field.field_type == 'NVARCHAR'): + whereValue = f"(N'{escaped_v}')" + else: + whereValue = f"('{escaped_v}')" + elif item['term'] == 'like' or item['term'] == 'not like': + if ds.type == 'sqlServer' and ( + field.field_type == 'nchar' or field.field_type == 'NCHAR' or field.field_type == 'nvarchar' or field.field_type == 'NVARCHAR'): + whereValue = f"N'%{escaped_v}%'" + else: + whereValue = f"'%{escaped_v}%'" + else: + if ds.type == 'sqlServer' and ( + field.field_type == 'nchar' or field.field_type == 'NCHAR' or field.field_type == 'nvarchar' or field.field_type == 'NVARCHAR'): + whereValue = f"N'{escaped_v}'" + else: + whereValue = f"'{escaped_v}'" + + return whereValue diff --git a/backend/apps/datasource/crud/table.py b/backend/apps/datasource/crud/table.py index f76d5fa20..d5685e575 100644 --- a/backend/apps/datasource/crud/table.py +++ b/backend/apps/datasource/crud/table.py @@ -1,6 +1,15 @@ +import json +import time +import traceback +from typing import List + +from sqlalchemy import and_, select, update + +from apps.ai_model.embedding import EmbeddingModelCache +from common.core.config import settings from common.core.deps import SessionDep -from ..models.datasource import CoreDatasource, CreateDatasource, CoreTable, CoreField, ColumnSchema -from sqlalchemy import and_ +from common.utils.utils import SQLBotLogUtil +from ..models.datasource import CoreTable, CoreField, CoreDatasource def delete_table_by_ds_id(session: SessionDep, id: int): @@ -19,3 +28,134 @@ def update_table(session: SessionDep, item: CoreTable): record.custom_comment = item.custom_comment session.add(record) session.commit() + + +def run_fill_empty_table_and_ds_embedding(session_maker): + try: + if not settings.TABLE_EMBEDDING_ENABLED: + return + + session = session_maker() + + SQLBotLogUtil.info('get tables') + stmt = select(CoreTable.id).where(and_(CoreTable.embedding.is_(None))) + results = session.execute(stmt).scalars().all() + SQLBotLogUtil.info('table result: ' + str(len(results))) + save_table_embedding(session_maker, results) + + SQLBotLogUtil.info('get datasource') + ds_stmt = select(CoreDatasource.id).where(and_(CoreDatasource.embedding.is_(None))) + ds_results = session.execute(ds_stmt).scalars().all() + SQLBotLogUtil.info('datasource result: ' + str(len(ds_results))) + save_ds_embedding(session_maker, ds_results) + except Exception: + traceback.print_exc() + finally: + session_maker.remove() + + +def save_table_embedding(session_maker, ids: List[int]): + if not settings.TABLE_EMBEDDING_ENABLED: + return + + if not ids or len(ids) == 0: + return + try: + SQLBotLogUtil.info('start table embedding') + start_time = time.time() + model = EmbeddingModelCache.get_model() + session = session_maker() + for _id in ids: + table = session.query(CoreTable).filter(CoreTable.id == _id).first() + fields = session.query(CoreField).filter(CoreField.table_id == table.id).all() + + schema_table = '' + schema_table += f"# Table: {table.table_name}" + table_comment = '' + if table.custom_comment: + table_comment = table.custom_comment.strip() + if table_comment == '': + schema_table += '\n[\n' + else: + schema_table += f", {table_comment}\n[\n" + + if fields: + field_list = [] + for field in fields: + field_comment = '' + if field.custom_comment: + field_comment = field.custom_comment.strip() + if field_comment == '': + field_list.append(f"({field.field_name}:{field.field_type})") + else: + field_list.append(f"({field.field_name}:{field.field_type}, {field_comment})") + schema_table += ",\n".join(field_list) + schema_table += '\n]\n' + # table_schema.append(schema_table) + emb = json.dumps(model.embed_query(schema_table)) + + stmt = update(CoreTable).where(and_(CoreTable.id == _id)).values(embedding=emb) + session.execute(stmt) + session.commit() + + end_time = time.time() + SQLBotLogUtil.info('table embedding finished in: ' + str(end_time - start_time) + ' seconds') + except Exception: + traceback.print_exc() + finally: + session_maker.remove() + + +def save_ds_embedding(session_maker, ids: List[int]): + if not settings.TABLE_EMBEDDING_ENABLED: + return + + if not ids or len(ids) == 0: + return + try: + SQLBotLogUtil.info('start datasource embedding') + start_time = time.time() + model = EmbeddingModelCache.get_model() + session = session_maker() + for _id in ids: + schema_table = '' + ds = session.query(CoreDatasource).filter(CoreDatasource.id == _id).first() + schema_table += f"{ds.name}, {ds.description}\n" + tables = session.query(CoreTable).filter(CoreTable.ds_id == ds.id).all() + for table in tables: + fields = session.query(CoreField).filter(CoreField.table_id == table.id).all() + + schema_table += f"# Table: {table.table_name}" + table_comment = '' + if table.custom_comment: + table_comment = table.custom_comment.strip() + if table_comment == '': + schema_table += '\n[\n' + else: + schema_table += f", {table_comment}\n[\n" + + if fields: + field_list = [] + for field in fields: + field_comment = '' + if field.custom_comment: + field_comment = field.custom_comment.strip() + if field_comment == '': + field_list.append(f"({field.field_name}:{field.field_type})") + else: + field_list.append(f"({field.field_name}:{field.field_type}, {field_comment})") + schema_table += ",\n".join(field_list) + schema_table += '\n]\n' + # table_schema.append(schema_table) + emb = json.dumps(model.embed_query(schema_table)) + + stmt = update(CoreDatasource).where(and_(CoreDatasource.id == _id)).values(embedding=emb) + session.execute(stmt) + session.commit() + + end_time = time.time() + SQLBotLogUtil.info('datasource embedding finished in: ' + str(end_time - start_time) + ' seconds') + except Exception: + traceback.print_exc() + finally: + session_maker.remove() diff --git a/backend/apps/datasource/embedding/__init__.py b/backend/apps/datasource/embedding/__init__.py new file mode 100644 index 000000000..87bb6f5dd --- /dev/null +++ b/backend/apps/datasource/embedding/__init__.py @@ -0,0 +1,2 @@ +# Author: Junjun +# Date: 2025/9/18 diff --git a/backend/apps/datasource/embedding/ds_embedding.py b/backend/apps/datasource/embedding/ds_embedding.py new file mode 100644 index 000000000..f75ee7df5 --- /dev/null +++ b/backend/apps/datasource/embedding/ds_embedding.py @@ -0,0 +1,91 @@ +# Author: Junjun +# Date: 2025/9/18 +import json +import time +import traceback +from typing import Optional + +from apps.ai_model.embedding import EmbeddingModelCache +from apps.datasource.embedding.utils import cosine_similarity +from apps.datasource.models.datasource import CoreDatasource +from apps.system.crud.assistant import AssistantOutDs +from common.core.config import settings +from common.core.deps import CurrentAssistant +from common.core.deps import SessionDep +from common.utils.utils import SQLBotLogUtil + + +def get_ds_embedding(session: SessionDep, _ds_list, out_ds: AssistantOutDs, + question: str, + current_assistant: Optional[CurrentAssistant] = None): + _list = [] + if current_assistant and current_assistant.type == 1: + if out_ds.ds_list: + for _ds in out_ds.ds_list: + ds = out_ds.get_ds(_ds.id) + table_schema, tables = out_ds.get_db_schema(_ds.id, question, embedding=False) + ds_info = f"{ds.name}, {ds.description}\n" + ds_schema = ds_info + table_schema + _list.append({"id": ds.id, "ds_schema": ds_schema, "cosine_similarity": 0.0, "ds": ds}) + + if _list: + try: + text = [s.get('ds_schema') for s in _list] + + model = EmbeddingModelCache.get_model() + results = model.embed_documents(text) + + q_embedding = model.embed_query(question) + for index in range(len(results)): + item = results[index] + _list[index]['cosine_similarity'] = cosine_similarity(q_embedding, item) + + _list.sort(key=lambda x: x['cosine_similarity'], reverse=True) + # print(len(_list)) + _list = _list[:settings.DS_EMBEDDING_COUNT] + SQLBotLogUtil.info(json.dumps( + [{"id": ele.get("id"), "name": ele.get("ds").name, + "cosine_similarity": ele.get("cosine_similarity")} + for ele in _list])) + return [{"id": obj.get('ds').id, "name": obj.get('ds').name, "description": obj.get('ds').description} + for obj in _list] + except Exception: + traceback.print_exc() + else: + for _ds in _ds_list: + if _ds.get('id'): + ds = session.get(CoreDatasource, _ds.get('id')) + # table_schema = get_table_schema(session, current_user, ds, question, embedding=False) + # ds_info = f"{ds.name}, {ds.description}\n" + # ds_schema = ds_info + table_schema + _list.append({"id": ds.id, "cosine_similarity": 0.0, "ds": ds, "embedding": ds.embedding}) + + if _list: + try: + # text = [s.get('ds_schema') for s in _list] + + model = EmbeddingModelCache.get_model() + start_time = time.time() + # results = model.embed_documents(text) + results = [item.get('embedding') for item in _list] + + q_embedding = model.embed_query(question) + for index in range(len(results)): + item = results[index] + if item: + _list[index]['cosine_similarity'] = cosine_similarity(q_embedding, json.loads(item)) + + _list.sort(key=lambda x: x['cosine_similarity'], reverse=True) + # print(len(_list)) + end_time = time.time() + SQLBotLogUtil.info(str(end_time - start_time)) + _list = _list[:settings.DS_EMBEDDING_COUNT] + SQLBotLogUtil.info(json.dumps( + [{"id": ele.get("id"), "name": ele.get("ds").name, + "cosine_similarity": ele.get("cosine_similarity")} + for ele in _list])) + return [{"id": obj.get('ds').id, "name": obj.get('ds').name, "description": obj.get('ds').description} + for obj in _list] + except Exception: + traceback.print_exc() + return _list diff --git a/backend/apps/datasource/embedding/table_embedding.py b/backend/apps/datasource/embedding/table_embedding.py new file mode 100644 index 000000000..186debec4 --- /dev/null +++ b/backend/apps/datasource/embedding/table_embedding.py @@ -0,0 +1,78 @@ +# Author: Junjun +# Date: 2025/9/23 +import json +import time +import traceback + +from apps.ai_model.embedding import EmbeddingModelCache +from apps.datasource.embedding.utils import cosine_similarity +from common.core.config import settings +from common.utils.utils import SQLBotLogUtil + + +def get_table_embedding(tables: list[dict], question: str): + _list = [] + for table in tables: + _list.append({"id": table.get('id'), "schema_table": table.get('schema_table'), "cosine_similarity": 0.0}) + + if _list: + try: + text = [s.get('schema_table') for s in _list] + + model = EmbeddingModelCache.get_model() + start_time = time.time() + results = model.embed_documents(text) + end_time = time.time() + SQLBotLogUtil.info(str(end_time - start_time)) + + q_embedding = model.embed_query(question) + for index in range(len(results)): + item = results[index] + _list[index]['cosine_similarity'] = cosine_similarity(q_embedding, item) + + _list.sort(key=lambda x: x['cosine_similarity'], reverse=True) + _list = _list[:settings.TABLE_EMBEDDING_COUNT] + # print(len(_list)) + SQLBotLogUtil.info(json.dumps(_list)) + return _list + except Exception: + traceback.print_exc() + return _list + + +def calc_table_embedding(tables: list[dict], question: str): + _list = [] + for table in tables: + _list.append( + {"id": table.get('id'), "schema_table": table.get('schema_table'), "embedding": table.get('embedding'), + "cosine_similarity": 0.0, "table_name": table.get('table_name')}) + + if _list: + try: + # text = [s.get('schema_table') for s in _list] + # + model = EmbeddingModelCache.get_model() + start_time = time.time() + # results = model.embed_documents(text) + # end_time = time.time() + # SQLBotLogUtil.info(str(end_time - start_time)) + results = [item.get('embedding') for item in _list] + + q_embedding = model.embed_query(question) + for index in range(len(results)): + item = results[index] + if item: + _list[index]['cosine_similarity'] = cosine_similarity(q_embedding, json.loads(item)) + + _list.sort(key=lambda x: x['cosine_similarity'], reverse=True) + _list = _list[:settings.TABLE_EMBEDDING_COUNT] + # print(len(_list)) + end_time = time.time() + SQLBotLogUtil.info(str(end_time - start_time)) + SQLBotLogUtil.info(json.dumps([{"id": ele.get('id'), "schema_table": ele.get('schema_table'), + "cosine_similarity": ele.get('cosine_similarity'), "table_name": ele.get('table_name')} + for ele in _list])) + return _list + except Exception: + traceback.print_exc() + return _list diff --git a/backend/apps/datasource/embedding/utils.py b/backend/apps/datasource/embedding/utils.py new file mode 100644 index 000000000..3f6ddced6 --- /dev/null +++ b/backend/apps/datasource/embedding/utils.py @@ -0,0 +1,18 @@ +# Author: Junjun +# Date: 2025/9/23 +import math + + +def cosine_similarity(vec_a, vec_b): + if len(vec_a) != len(vec_b): + raise ValueError("The vector dimension must be the same") + + dot_product = sum(a * b for a, b in zip(vec_a, vec_b)) + + norm_a = math.sqrt(sum(a * a for a in vec_a)) + norm_b = math.sqrt(sum(b * b for b in vec_b)) + + if norm_a == 0 or norm_b == 0: + return 0.0 + + return dot_product / (norm_a * norm_b) diff --git a/backend/apps/datasource/models/datasource.py b/backend/apps/datasource/models/datasource.py index 1f810e9ec..42f1dcd25 100644 --- a/backend/apps/datasource/models/datasource.py +++ b/backend/apps/datasource/models/datasource.py @@ -2,13 +2,14 @@ from typing import List, Optional from pydantic import BaseModel -from sqlalchemy import Column, Text, BigInteger, DateTime, Integer, Identity +from sqlalchemy import Column, Text, BigInteger, DateTime, Identity +from sqlalchemy.dialects.postgresql import JSONB from sqlmodel import SQLModel, Field class CoreDatasource(SQLModel, table=True): __tablename__ = "core_datasource" - id: int = Field(sa_column=Column(Integer, Identity(always=True), nullable=False, primary_key=True)) + id: int = Field(sa_column=Column(BigInteger, Identity(always=True), nullable=False, primary_key=True)) name: str = Field(max_length=128, nullable=False) description: str = Field(max_length=512, nullable=True) type: str = Field(max_length=64) @@ -19,21 +20,36 @@ class CoreDatasource(SQLModel, table=True): status: str = Field(max_length=64, nullable=True) num: str = Field(max_length=256, nullable=True) oid: int = Field(sa_column=Column(BigInteger())) + table_relation: List = Field(sa_column=Column(JSONB, nullable=True)) + embedding: str = Field(sa_column=Column(Text, nullable=True)) + recommended_config: int = Field(sa_column=Column(BigInteger())) class CoreTable(SQLModel, table=True): __tablename__ = "core_table" - id: int = Field(sa_column=Column(Integer, Identity(always=True), nullable=False, primary_key=True)) + id: int = Field(sa_column=Column(BigInteger, Identity(always=True), nullable=False, primary_key=True)) ds_id: int = Field(sa_column=Column(BigInteger())) checked: bool = Field(default=True) table_name: str = Field(sa_column=Column(Text)) table_comment: str = Field(sa_column=Column(Text)) custom_comment: str = Field(sa_column=Column(Text)) + embedding: str = Field(sa_column=Column(Text, nullable=True)) + + +class DsRecommendedProblem(SQLModel, table=True): + __tablename__ = "ds_recommended_problem" + id: int = Field(sa_column=Column(BigInteger, Identity(always=True), nullable=False, primary_key=True)) + datasource_id: int = Field(sa_column=Column(BigInteger())) + question: str = Field(sa_column=Column(Text)) + remark: str = Field(sa_column=Column(Text)) + sort: int = Field(sa_column=Column(BigInteger())) + create_time: datetime = Field(sa_column=Column(DateTime(timezone=False), nullable=True)) + create_by: int = Field(sa_column=Column(BigInteger())) class CoreField(SQLModel, table=True): __tablename__ = "core_field" - id: int = Field(sa_column=Column(Integer, Identity(always=True), nullable=False, primary_key=True)) + id: int = Field(sa_column=Column(BigInteger, Identity(always=True), nullable=False, primary_key=True)) ds_id: int = Field(sa_column=Column(BigInteger())) table_id: int = Field(sa_column=Column(BigInteger())) checked: bool = Field(default=True) @@ -41,6 +57,7 @@ class CoreField(SQLModel, table=True): field_type: str = Field(max_length=128, nullable=True) field_comment: str = Field(sa_column=Column(Text)) custom_comment: str = Field(sa_column=Column(Text)) + field_index: int = Field(sa_column=Column(BigInteger())) # datasource create obj @@ -56,6 +73,31 @@ class CreateDatasource(BaseModel): num: str = '' oid: int = 1 tables: List[CoreTable] = [] + recommended_config: int = 1 + + +class RecommendedProblemResponse: + def __init__(self, datasource_id, recommended_config, questions): + self.datasource_id = datasource_id + self.recommended_config = recommended_config + self.questions = questions + + datasource_id: int = None + recommended_config: int = None + questions: str = None + + +class RecommendedProblemBase(BaseModel): + datasource_id: int = None + recommended_config: int = None + problemInfo: List[DsRecommendedProblem] = [] + + +class RecommendedProblemBaseChat: + def __init__(self, content): + self.content = content + + content: List[str] = [] # edit local saved table and fields @@ -78,6 +120,9 @@ class DatasourceConf(BaseModel): sheets: List = '' mode: str = '' timeout: int = 30 + lowVersion: bool = False + ssl: bool = False + poolSize: int = 5 def to_dict(self): return { @@ -92,12 +137,15 @@ def to_dict(self): "filename": self.filename, "sheets": self.sheets, "mode": self.mode, - "timeout": self.timeout + "timeout": self.timeout, + "lowVersion": self.lowVersion, + "ssl": self.ssl, + "poolSize": self.poolSize } class TableSchema: - def __init__(self, attr1, attr2): + def __init__(self, attr1, attr2=None): self.tableName = attr1 self.tableComment = attr2 if attr2 is None or isinstance(attr2, str) else attr2.decode("utf-8") @@ -105,6 +153,11 @@ def __init__(self, attr1, attr2): tableComment: str +class TableSchemaResponse(BaseModel): + tableName: str = '' + tableComment: str | None = '' + + class ColumnSchema: def __init__(self, attr1, attr2, attr3): self.fieldName = attr1 @@ -116,6 +169,12 @@ def __init__(self, attr1, attr2, attr3): fieldComment: str +class ColumnSchemaResponse(BaseModel): + fieldName: str | None = '' + fieldType: str | None = '' + fieldComment: str | None = '' + + class TableAndFields: def __init__(self, schema, table, fields): self.schema = schema @@ -125,3 +184,28 @@ def __init__(self, schema, table, fields): schema: str table: CoreTable fields: List[CoreField] + + +class FieldObj(BaseModel): + fieldName: str | None + + +class PreviewResponse(BaseModel): + fields: List | None = [] + data: List | None = [] + sql: str | None = '' + + +class FieldInfo(BaseModel): + fieldName: object + fieldType: str + + +class SheetFields(BaseModel): + sheetName: str + fields: List[FieldInfo] + + +class ImportRequest(BaseModel): + filePath: str + sheets: List[SheetFields] diff --git a/backend/apps/datasource/utils/__init__.py b/backend/apps/datasource/utils/__init__.py index e69de29bb..5c7cf4850 100644 --- a/backend/apps/datasource/utils/__init__.py +++ b/backend/apps/datasource/utils/__init__.py @@ -0,0 +1,6 @@ +from .excel import ( + FIELD_TYPE_MAP, + USER_TYPE_TO_PANDAS, + infer_field_type, + parse_excel_preview, +) diff --git a/backend/apps/datasource/utils/excel.py b/backend/apps/datasource/utils/excel.py new file mode 100644 index 000000000..dd4044358 --- /dev/null +++ b/backend/apps/datasource/utils/excel.py @@ -0,0 +1,64 @@ +import pandas as pd + +FIELD_TYPE_MAP = { + 'int64': 'int', + 'int32': 'int', + 'float64': 'float', + 'float32': 'float', + 'datetime64': 'datetime', + 'datetime64[ns]': 'datetime', + 'object': 'string', + 'string': 'string', + 'bool': 'string', +} + +USER_TYPE_TO_PANDAS = { + 'int': 'int64', + 'float': 'float64', + 'datetime': 'datetime64[ns]', + 'string': 'string', +} + + +def infer_field_type(dtype) -> str: + dtype_str = str(dtype) + return FIELD_TYPE_MAP.get(dtype_str, 'string') + + +def parse_excel_preview(save_path: str, max_rows: int = 10): + sheets_data = [] + if save_path.endswith(".csv"): + df = pd.read_csv(save_path, engine='c') + fields = [] + for col in df.columns: + fields.append({ + "fieldName": col, + "fieldType": infer_field_type(df[col].dtype) + }) + preview_df = df.head(max_rows).replace({pd.NA: None, float('nan'): None}) + preview_data = preview_df.to_dict(orient='records') + sheets_data.append({ + "sheetName": "Sheet1", + "fields": fields, + "data": preview_data, + "rows": len(df) + }) + else: + sheet_names = pd.ExcelFile(save_path).sheet_names + for sheet_name in sheet_names: + df = pd.read_excel(save_path, sheet_name=sheet_name, engine='calamine') + fields = [] + for col in df.columns: + fields.append({ + "fieldName": col, + "fieldType": infer_field_type(df[col].dtype) + }) + preview_df = df.head(max_rows).replace({pd.NA: None, float('nan'): None}) + preview_data = preview_df.to_dict(orient='records') + sheets_data.append({ + "sheetName": sheet_name, + "fields": fields, + "data": preview_data, + "rows": len(df) + }) + return sheets_data diff --git a/backend/apps/db/constant.py b/backend/apps/db/constant.py index 51126272f..dcec81901 100644 --- a/backend/apps/db/constant.py +++ b/backend/apps/db/constant.py @@ -2,23 +2,51 @@ # Date: 2025/7/16 from enum import Enum +from typing import List + +from common.utils.utils import equals_ignore_case + + +class ConnectType(Enum): + sqlalchemy = ('sqlalchemy') + py_driver = ('py_driver') + + def __init__(self, type_name): + self.type_name = type_name class DB(Enum): - mysql = ('mysql', '`', '`') - sqlServer = ('sqlServer', '[', ']') - pg = ('pg', '"', '"') - excel = ('excel', '"', '"') - oracle = ('oracle', '"', '"') + excel = ('excel', 'Excel/CSV', '"', '"', ConnectType.sqlalchemy, 'PostgreSQL', []) + redshift = ('redshift', 'AWS Redshift', '"', '"', ConnectType.py_driver, 'AWS_Redshift', []) + ck = ('ck', 'ClickHouse', '"', '"', ConnectType.sqlalchemy, 'ClickHouse', []) + dm = ('dm', '达梦', '"', '"', ConnectType.py_driver, 'DM', []) + doris = ('doris', 'Apache Doris', '`', '`', ConnectType.py_driver, 'Doris', []) + es = ('es', 'Elasticsearch', '"', '"', ConnectType.py_driver, 'Elasticsearch', []) + kingbase = ('kingbase', 'Kingbase', '"', '"', ConnectType.py_driver, 'Kingbase', []) + sqlServer = ('sqlServer', 'Microsoft SQL Server', '[', ']', ConnectType.sqlalchemy, 'Microsoft_SQL_Server', []) + mysql = ('mysql', 'MySQL', '`', '`', ConnectType.sqlalchemy, 'MySQL', ['local_infile']) + oracle = ('oracle', 'Oracle', '"', '"', ConnectType.sqlalchemy, 'Oracle', []) + pg = ('pg', 'PostgreSQL', '"', '"', ConnectType.sqlalchemy, 'PostgreSQL', []) + starrocks = ('starrocks', 'StarRocks', '`', '`', ConnectType.py_driver, 'StarRocks', []) + hive = ('hive', 'Apache Hive', '`', '`', ConnectType.py_driver, 'Hive', []) - def __init__(self, type, prefix, suffix): + def __init__(self, type, db_name, prefix, suffix, connect_type: ConnectType, template_name: str, + illegalParams: List[str]): self.type = type + self.db_name = db_name self.prefix = prefix self.suffix = suffix + self.connect_type = connect_type + self.template_name = template_name + self.illegalParams = illegalParams @classmethod - def get_db(cls, type): + def get_db(cls, type, default_if_none=False): for db in cls: - if db.type == type: + """ if db.type == type: """ + if equals_ignore_case(db.type, type): return db - raise ValueError(f"Invalid db type: {type}") + if default_if_none: + return DB.pg + else: + raise ValueError(f"Invalid db type: {type}") diff --git a/backend/apps/db/db.py b/backend/apps/db/db.py index d081cd040..738dd0526 100644 --- a/backend/apps/db/db.py +++ b/backend/apps/db/db.py @@ -1,43 +1,81 @@ import base64 import json +import os +import platform import urllib.parse +from datetime import datetime, date, time, timedelta from decimal import Decimal -from typing import Any +from typing import Optional, List -from sqlalchemy import create_engine, text, Result, Engine +import oracledb +import psycopg2 +import pymssql + +from apps.db.db_sql import get_table_sql, get_field_sql, get_version_sql +from common.error import ParseSQLResultError + +if platform.system() != "Darwin": + import dmPython +import pymysql +import redshift_connector +from sqlalchemy import create_engine, text, Engine from sqlalchemy.orm import sessionmaker from apps.datasource.models.datasource import DatasourceConf, CoreDatasource, TableSchema, ColumnSchema from apps.datasource.utils.utils import aes_decrypt +from apps.db.constant import DB, ConnectType from apps.db.engine import get_engine_config -from apps.system.crud.assistant import get_ds_engine +from apps.system.crud.assistant import get_out_ds_conf from apps.system.schemas.system_schema import AssistantOutDsSchema +from common.core.deps import Trans +from common.utils.utils import SQLBotLogUtil, equals_ignore_case +from fastapi import HTTPException +from apps.db.es_engine import get_es_connect, get_es_index, get_es_fields, get_es_data_by_http +from common.core.config import settings +import sqlglot +from sqlglot import expressions as exp +from pyhive import hive +from sqlalchemy.pool import NullPool +from dbutils.pooled_db import PooledDB + +try: + if os.path.exists(settings.ORACLE_CLIENT_PATH): + oracledb.init_oracle_client( + lib_dir=settings.ORACLE_CLIENT_PATH + ) + SQLBotLogUtil.info("init oracle client success, use thick mode") + else: + SQLBotLogUtil.info("init oracle client failed, because not found oracle client, use thin mode") +except Exception as e: + SQLBotLogUtil.error("init oracle client failed, check your client is installed, use thin mode") def get_uri(ds: CoreDatasource) -> str: - conf = DatasourceConf(**json.loads(aes_decrypt(ds.configuration))) if ds.type != "excel" else get_engine_config() + conf = DatasourceConf(**json.loads(aes_decrypt(ds.configuration))) if not equals_ignore_case(ds.type, + "excel") else get_engine_config() return get_uri_from_config(ds.type, conf) def get_uri_from_config(type: str, conf: DatasourceConf) -> str: db_url: str - if type == "mysql": + if equals_ignore_case(type, "mysql"): + checkParams(conf.extraJdbc, DB.mysql.illegalParams) if conf.extraJdbc is not None and conf.extraJdbc != '': db_url = f"mysql+pymysql://{urllib.parse.quote(conf.username)}:{urllib.parse.quote(conf.password)}@{conf.host}:{conf.port}/{conf.database}?{conf.extraJdbc}" else: db_url = f"mysql+pymysql://{urllib.parse.quote(conf.username)}:{urllib.parse.quote(conf.password)}@{conf.host}:{conf.port}/{conf.database}" - elif type == "sqlServer": + elif equals_ignore_case(type, "sqlServer"): if conf.extraJdbc is not None and conf.extraJdbc != '': db_url = f"mssql+pymssql://{urllib.parse.quote(conf.username)}:{urllib.parse.quote(conf.password)}@{conf.host}:{conf.port}/{conf.database}?{conf.extraJdbc}" else: db_url = f"mssql+pymssql://{urllib.parse.quote(conf.username)}:{urllib.parse.quote(conf.password)}@{conf.host}:{conf.port}/{conf.database}" - elif type == "pg" or type == "excel": + elif equals_ignore_case(type, "pg", "excel"): if conf.extraJdbc is not None and conf.extraJdbc != '': db_url = f"postgresql+psycopg2://{urllib.parse.quote(conf.username)}:{urllib.parse.quote(conf.password)}@{conf.host}:{conf.port}/{conf.database}?{conf.extraJdbc}" else: db_url = f"postgresql+psycopg2://{urllib.parse.quote(conf.username)}:{urllib.parse.quote(conf.password)}@{conf.host}:{conf.port}/{conf.database}" - elif type == "oracle": - if conf.mode == "service_name": + elif equals_ignore_case(type, "oracle"): + if equals_ignore_case(conf.mode, "service_name", "serviceName"): if conf.extraJdbc is not None and conf.extraJdbc != '': db_url = f"oracle+oracledb://{urllib.parse.quote(conf.username)}:{urllib.parse.quote(conf.password)}@{conf.host}:{conf.port}?service_name={conf.database}&{conf.extraJdbc}" else: @@ -47,220 +85,1147 @@ def get_uri_from_config(type: str, conf: DatasourceConf) -> str: db_url = f"oracle+oracledb://{urllib.parse.quote(conf.username)}:{urllib.parse.quote(conf.password)}@{conf.host}:{conf.port}/{conf.database}?{conf.extraJdbc}" else: db_url = f"oracle+oracledb://{urllib.parse.quote(conf.username)}:{urllib.parse.quote(conf.password)}@{conf.host}:{conf.port}/{conf.database}" + elif equals_ignore_case(type, "ck"): + if conf.extraJdbc is not None and conf.extraJdbc != '': + db_url = f"clickhouse+http://{urllib.parse.quote(conf.username)}:{urllib.parse.quote(conf.password)}@{conf.host}:{conf.port}/{conf.database}?{conf.extraJdbc}" + else: + db_url = f"clickhouse+http://{urllib.parse.quote(conf.username)}:{urllib.parse.quote(conf.password)}@{conf.host}:{conf.port}/{conf.database}" else: raise 'The datasource type not support.' return db_url -def get_engine(ds: CoreDatasource, timeout: int = 0) -> Engine: - conf = DatasourceConf(**json.loads(aes_decrypt(ds.configuration))) if ds.type != "excel" else get_engine_config() +def get_extra_config(conf: DatasourceConf): + config_dict = {} + if conf.extraJdbc: + config_arr = conf.extraJdbc.split("&") + for config in config_arr: + kv = config.split("=") + if len(kv) == 2 and kv[0] and kv[1]: + config_dict[kv[0]] = kv[1] + else: + raise Exception(f'param: {config} is error') + return config_dict + + +def get_origin_connect(type: str, conf: DatasourceConf): + extra_config_dict = get_extra_config(conf) + if equals_ignore_case(type, "sqlServer"): + # none or true, set tds_version = 7.0 + if conf.lowVersion is None or conf.lowVersion: + return pymssql.connect( + server=conf.host, + port=str(conf.port), + user=conf.username, + password=conf.password, + database=conf.database, + timeout=conf.timeout, + tds_version='7.0', # options: '4.2', '7.0', '8.0' ..., + **extra_config_dict + ) + else: + return pymssql.connect( + server=conf.host, + port=str(conf.port), + user=conf.username, + password=conf.password, + database=conf.database, + timeout=conf.timeout, + **extra_config_dict + ) + + +# use sqlalchemy +def get_engine(ds: CoreDatasource, timeout: int = 0, use_pool: bool = False) -> Engine: + conf = DatasourceConf(**json.loads(aes_decrypt(ds.configuration))) if not equals_ignore_case(ds.type, + "excel") else get_engine_config() if conf.timeout is None: conf.timeout = timeout if timeout > 0: conf.timeout = timeout - if ds.type == "pg" and (conf.dbSchema is not None and conf.dbSchema != ""): - engine = create_engine(get_uri(ds), - connect_args={"options": f"-c search_path={urllib.parse.quote(conf.dbSchema)}", - "connect_timeout": conf.timeout}, - pool_timeout=conf.timeout) - elif ds.type == 'sqlServer': - engine = create_engine(get_uri(ds), pool_timeout=conf.timeout) - elif ds.type == 'oracle': - engine = create_engine(get_uri(ds), - pool_timeout=conf.timeout) - else: - engine = create_engine(get_uri(ds), connect_args={"connect_timeout": conf.timeout}, pool_timeout=conf.timeout) + + db_config = { + 'pool_size': conf.poolSize if conf.poolSize else 5, + 'max_overflow': 20, + 'pool_recycle': 3600 + } if use_pool else { + 'poolclass': NullPool + } + + if equals_ignore_case(ds.type, "pg"): + if conf.dbSchema is not None and conf.dbSchema != "": + engine = create_engine(get_uri(ds), + connect_args={"options": f"-c search_path={urllib.parse.quote(conf.dbSchema)}", + "connect_timeout": conf.timeout}, **db_config) + else: + engine = create_engine(get_uri(ds), connect_args={"connect_timeout": conf.timeout}, **db_config) + elif equals_ignore_case(ds.type, 'sqlServer'): + engine = create_engine('mssql+pymssql://', creator=lambda: get_origin_connect(ds.type, conf), + **db_config) + elif equals_ignore_case(ds.type, 'oracle'): + engine = create_engine(get_uri(ds), **db_config) + elif equals_ignore_case(ds.type, 'mysql'): # mysql + ssl_mode = {"require": True} if conf.ssl else None + engine = create_engine(get_uri(ds), connect_args={"connect_timeout": conf.timeout, "ssl": ssl_mode}, + **db_config) + else: # ck + engine = create_engine(get_uri(ds), connect_args={"connect_timeout": conf.timeout}, **db_config) return engine def get_session(ds: CoreDatasource | AssistantOutDsSchema): - engine = get_engine(ds) if isinstance(ds, CoreDatasource) else get_ds_engine(ds) - session_maker = sessionmaker(bind=engine) - session = session_maker() - return session + # engine = get_engine(ds) if isinstance(ds, CoreDatasource) else get_ds_engine(ds) + if isinstance(ds, AssistantOutDsSchema): + out_conf = get_out_ds_conf(ds, 30) + ds.configuration = out_conf + # engine = get_engine(ds) + # session_maker = sessionmaker(bind=engine) -def get_tables(ds: CoreDatasource): - conf = DatasourceConf(**json.loads(aes_decrypt(ds.configuration))) if ds.type != "excel" else get_engine_config() - session = get_session(ds) - result: Result[Any] - sql: str = '' + # get session from pool + session = pool_manager.get_pool(ds=ds, **{}) + return session() + + +def get_driver_connection(ds: CoreDatasource | AssistantOutDsSchema, db_config: dict = {}, use_pool: bool = False): + conf = DatasourceConf(**json.loads(aes_decrypt(ds.configuration))) + extra_config_dict = get_extra_config(conf) + + pool_config = { + 'maxconnections': conf.poolSize if conf.poolSize else 5, + 'mincached': 5, + 'maxcached': 10, + 'blocking': True, + 'maxusage': 100, + 'ping': 1, + } if use_pool else {} + conn_conf = extra_config_dict | db_config | pool_config + + conn = None + if equals_ignore_case(ds.type, 'dm'): + if not use_pool: + conn = dmPython.connect(user=conf.username, password=conf.password, server=conf.host, + port=conf.port, **conn_conf) + else: + conn = PooledDB( + creator=dmPython, + user=conf.username, + password=conf.password, + server=conf.host, + port=conf.port, + **conn_conf + ) + elif equals_ignore_case(ds.type, 'doris', 'starrocks'): + ssl_args = {'ssl': {'ssl_mode': 'REQUIRE'}} if conf.ssl else {} + args = conn_conf | ssl_args + if not use_pool: + conn = pymysql.connect(user=conf.username, passwd=conf.password, host=conf.host, + port=conf.port, db=conf.database, connect_timeout=conf.timeout, + read_timeout=conf.timeout, **conn_conf, + **args) + else: + conn = PooledDB( + creator=pymysql, + user=conf.username, + passwd=conf.password, + host=conf.host, + port=conf.port, + db=conf.database, + connect_timeout=conf.timeout, + read_timeout=conf.timeout, + **args + ) + elif equals_ignore_case(ds.type, 'redshift'): + if not use_pool: + conn = redshift_connector.connect(host=conf.host, port=conf.port, database=conf.database, user=conf.username, + password=conf.password, + timeout=conf.timeout, **conn_conf) + else: + conn = PooledDB( + creator=redshift_connector, + host=conf.host, + port=conf.port, + database=conf.database, + user=conf.username, + password=conf.password, + timeout=conf.timeout, + **conn_conf + ) + elif equals_ignore_case(ds.type, 'kingbase'): + if not use_pool: + conn = psycopg2.connect(host=conf.host, port=conf.port, database=conf.database, user=conf.username, + password=conf.password, + options=f"-c statement_timeout={conf.timeout * 1000}", + **conn_conf) + else: + conn = PooledDB( + creator=psycopg2, + host=conf.host, + port=conf.port, + database=conf.database, + user=conf.username, + password=conf.password, + options=f"-c statement_timeout={conf.timeout * 1000}", + **conn_conf + ) + elif equals_ignore_case(ds.type, 'hive'): + if not use_pool: + conn = hive.connect(host=conf.host, port=conf.port, username=conf.username, + database=conf.database, **conn_conf) + else: + conn = PooledDB( + creator=hive, + host=conf.host, + port=conf.port, + username=conf.username, + database=conf.database, **conn_conf + ) + + return conn + + +def get_driver_pool(ds: CoreDatasource | AssistantOutDsSchema, db_config: dict = {}): + pool = driver_pool_manager.get_pool(ds, db_config) + return pool + + +def check_connection(trans: Optional[Trans], ds: CoreDatasource | AssistantOutDsSchema, is_raise: bool = False): + if isinstance(ds, AssistantOutDsSchema): + out_conf = get_out_ds_conf(ds, 10) + ds.configuration = out_conf + + db = DB.get_db(ds.type) + if db.connect_type == ConnectType.sqlalchemy: + conn = get_engine(ds, 10) + try: + with conn.connect() as connection: + SQLBotLogUtil.info("success") + return True + except Exception as e: + SQLBotLogUtil.error(f"Datasource {ds.id} connection failed: {e}") + if is_raise: + raise HTTPException(status_code=500, detail=trans('i18n_ds_invalid') + f': {e.args}') + return False + else: + conf = DatasourceConf(**json.loads(aes_decrypt(ds.configuration))) + extra_config_dict = get_extra_config(conf) + if equals_ignore_case(ds.type, 'dm'): + with dmPython.connect(user=conf.username, password=conf.password, server=conf.host, + port=conf.port, **extra_config_dict) as conn, conn.cursor() as cursor: + try: + cursor.execute('select 1', timeout=10).fetchall() + SQLBotLogUtil.info("success") + return True + except Exception as e: + SQLBotLogUtil.error(f"Datasource {ds.id} connection failed: {e}") + if is_raise: + raise HTTPException(status_code=500, detail=trans('i18n_ds_invalid') + f': {e.args}') + return False + elif equals_ignore_case(ds.type, 'doris', 'starrocks'): + ssl_args = {'ssl': {'ssl_mode': 'REQUIRE'}} if conf.ssl else {} + with pymysql.connect(user=conf.username, passwd=conf.password, host=conf.host, + port=conf.port, db=conf.database, connect_timeout=10, + read_timeout=10, **extra_config_dict, **ssl_args) as conn, conn.cursor() as cursor: + try: + cursor.execute('select 1') + SQLBotLogUtil.info("success") + return True + except Exception as e: + SQLBotLogUtil.error(f"Datasource {ds.id} connection failed: {e}") + if is_raise: + raise HTTPException(status_code=500, detail=trans('i18n_ds_invalid') + f': {e.args}') + return False + elif equals_ignore_case(ds.type, 'redshift'): + with redshift_connector.connect(host=conf.host, port=conf.port, database=conf.database, + user=conf.username, + password=conf.password, + timeout=10, **extra_config_dict) as conn, conn.cursor() as cursor: + try: + cursor.execute('select 1') + SQLBotLogUtil.info("success") + return True + except Exception as e: + SQLBotLogUtil.error(f"Datasource {ds.id} connection failed: {e}") + if is_raise: + raise HTTPException(status_code=500, detail=trans('i18n_ds_invalid') + f': {e.args}') + return False + elif equals_ignore_case(ds.type, 'kingbase'): + with psycopg2.connect(host=conf.host, port=conf.port, database=conf.database, + user=conf.username, + password=conf.password, + connect_timeout=10, **extra_config_dict) as conn, conn.cursor() as cursor: + try: + cursor.execute('select 1') + SQLBotLogUtil.info("success") + return True + except Exception as e: + SQLBotLogUtil.error(f"Datasource {ds.id} connection failed: {e}") + if is_raise: + raise HTTPException(status_code=500, detail=trans('i18n_ds_invalid') + f': {e.args}') + return False + elif equals_ignore_case(ds.type, 'hive'): + with hive.connect(host=conf.host, port=conf.port, username=conf.username, + database=conf.database, **extra_config_dict) as conn, conn.cursor() as cursor: + try: + cursor.execute('select 1') + SQLBotLogUtil.info("success") + return True + except Exception as e: + SQLBotLogUtil.error(f"Datasource {ds.id} connection failed: {e}") + if is_raise: + raise HTTPException(status_code=500, detail=trans('i18n_ds_invalid') + f': {e.args}') + return False + + elif equals_ignore_case(ds.type, 'es'): + es_conn = get_es_connect(conf) + if es_conn.ping(): + SQLBotLogUtil.info("success") + return True + else: + SQLBotLogUtil.info("failed") + return False + # else: + # conn = get_ds_engine(ds) + # try: + # with conn.connect() as connection: + # SQLBotLogUtil.info("success") + # return True + # except Exception as e: + # SQLBotLogUtil.error(f"Datasource {ds.id} connection failed: {e}") + # if is_raise: + # raise HTTPException(status_code=500, detail=trans('i18n_ds_invalid') + f': {e.args}') + # return False + + return False + + +def get_version(ds: CoreDatasource | AssistantOutDsSchema): + version = '' + if isinstance(ds, CoreDatasource): + conf = DatasourceConf( + **json.loads(aes_decrypt(ds.configuration))) if not equals_ignore_case(ds.type, + "excel") else get_engine_config() + else: + conf = DatasourceConf(**json.loads(aes_decrypt(get_out_ds_conf(ds, 10)))) + # if isinstance(ds, AssistantOutDsSchema): + # conf = DatasourceConf() + # conf.host = ds.host + # conf.port = ds.port + # conf.username = ds.user + # conf.password = ds.password + # conf.database = ds.dataBase + # conf.dbSchema = ds.db_schema + # conf.timeout = 10 + db = DB.get_db(ds.type) + sql = get_version_sql(ds, conf) + if not sql: + return '' try: - if ds.type == "mysql": - sql = f""" - SELECT - TABLE_NAME, - TABLE_COMMENT - FROM - information_schema.TABLES - WHERE - TABLE_SCHEMA = '{conf.database}' - """ - elif ds.type == "sqlServer": - sql = f""" - SELECT - TABLE_NAME AS [TABLE_NAME], - ISNULL(ep.value, '') AS [TABLE_COMMENT] - FROM - INFORMATION_SCHEMA.TABLES t - LEFT JOIN - sys.extended_properties ep - ON ep.major_id = OBJECT_ID(t.TABLE_SCHEMA + '.' + t.TABLE_NAME) - AND ep.minor_id = 0 - AND ep.name = 'MS_Description' - WHERE - t.TABLE_TYPE IN ('BASE TABLE', 'VIEW') - AND t.TABLE_SCHEMA = '{conf.dbSchema}' - """ - elif ds.type == "pg" or ds.type == "excel": - sql = """ - SELECT c.relname AS TABLE_NAME, - COALESCE(d.description, obj_description(c.oid)) AS TABLE_COMMENT - FROM pg_class c - LEFT JOIN - pg_namespace n ON n.oid = c.relnamespace - LEFT JOIN - pg_description d ON d.objoid = c.oid AND d.objsubid = 0 - WHERE n.nspname = current_schema() - AND c.relkind IN ('r', 'v', 'p', 'm') - AND c.relname NOT LIKE 'pg_%' - AND c.relname NOT LIKE 'sql_%' - ORDER BY c.relname \ - """ - elif ds.type == "oracle": - sql = f""" - SELECT - t.TABLE_NAME AS "TABLE_NAME", - NVL(c.COMMENTS, '') AS "TABLE_COMMENT" - FROM ( - SELECT TABLE_NAME, 'TABLE' AS OBJECT_TYPE - FROM DBA_TABLES - WHERE OWNER = '{conf.dbSchema}' - UNION ALL - SELECT VIEW_NAME AS TABLE_NAME, 'VIEW' AS OBJECT_TYPE - FROM DBA_VIEWS - WHERE OWNER = '{conf.dbSchema}' - ) t - LEFT JOIN DBA_TAB_COMMENTS c - ON t.TABLE_NAME = c.TABLE_NAME - AND c.TABLE_TYPE = t.OBJECT_TYPE - AND c.OWNER = '{conf.dbSchema}' - ORDER BY t.TABLE_NAME - """ - - result = session.execute(text(sql)) - res = result.fetchall() - res_list = [TableSchema(*item) for item in res] - return res_list - finally: - if result is not None: - result.close() - if session is not None: - session.close() + if db.connect_type == ConnectType.sqlalchemy: + with get_session(ds) as session: + with session.execute(text(sql)) as result: + res = result.fetchall() + version = res[0][0] + else: + extra_config_dict = get_extra_config(conf) + if equals_ignore_case(ds.type, 'dm'): + with get_driver_pool(ds).connection() as conn, conn.cursor() as cursor: + cursor.execute(sql, timeout=10, **extra_config_dict) + res = cursor.fetchall() + version = res[0][0] + elif equals_ignore_case(ds.type, 'doris', 'starrocks'): + t_conf = {'connect_timeout': 10, 'read_timeout': 10} + with get_driver_pool(ds, t_conf).connection() as conn, conn.cursor() as cursor: + cursor.execute(sql) + res = cursor.fetchall() + version = res[0][0] + elif equals_ignore_case(ds.type, 'redshift', 'es', 'hive'): + version = '' + except Exception as e: + print(e) + version = '' + return version.decode() if isinstance(version, bytes) else version -def get_fields(ds: CoreDatasource, table_name: str = None): +def get_schema(ds: CoreDatasource): conf = DatasourceConf(**json.loads(aes_decrypt(ds.configuration))) if ds.type != "excel" else get_engine_config() - session = get_session(ds) - result: Result[Any] - sql: str = '' - try: - if ds.type == "mysql": - sql1 = f""" - SELECT - COLUMN_NAME, - DATA_TYPE, - COLUMN_COMMENT - FROM - INFORMATION_SCHEMA.COLUMNS - WHERE - TABLE_SCHEMA = '{conf.database}' - """ - sql2 = f" AND TABLE_NAME = '{table_name}'" if table_name is not None and table_name != "" else "" - sql = sql1 + sql2 - elif ds.type == "sqlServer": - sql1 = f""" - SELECT - COLUMN_NAME AS [COLUMN_NAME], - DATA_TYPE AS [DATA_TYPE], - ISNULL(EP.value, '') AS [COLUMN_COMMENT] - FROM - INFORMATION_SCHEMA.COLUMNS C - LEFT JOIN - sys.extended_properties EP - ON EP.major_id = OBJECT_ID(C.TABLE_SCHEMA + '.' + C.TABLE_NAME) - AND EP.minor_id = C.ORDINAL_POSITION - AND EP.name = 'MS_Description' - WHERE - C.TABLE_SCHEMA = '{conf.dbSchema}' - """ - sql2 = f" AND C.TABLE_NAME = '{table_name}'" if table_name is not None and table_name != "" else "" - sql = sql1 + sql2 - elif ds.type == "pg" or ds.type == "excel": - sql1 = """ - SELECT a.attname AS COLUMN_NAME, - pg_catalog.format_type(a.atttypid, a.atttypmod) AS DATA_TYPE, - col_description(c.oid, a.attnum) AS COLUMN_COMMENT - FROM pg_catalog.pg_attribute a - JOIN - pg_catalog.pg_class c ON a.attrelid = c.oid - JOIN - pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = current_schema() - AND a.attnum > 0 - AND NOT a.attisdropped \ - """ - sql2 = f" AND c.relname = '{table_name}'" if table_name is not None and table_name != "" else "" - sql = sql1 + sql2 - elif ds.type == "oracle": - sql1 = f""" - SELECT - col.COLUMN_NAME AS "COLUMN_NAME", - (CASE - WHEN col.DATA_TYPE IN ('VARCHAR2', 'CHAR', 'NVARCHAR2', 'NCHAR') - THEN col.DATA_TYPE || '(' || col.DATA_LENGTH || ')' - WHEN col.DATA_TYPE = 'NUMBER' AND col.DATA_PRECISION IS NOT NULL - THEN col.DATA_TYPE || '(' || col.DATA_PRECISION || - CASE WHEN col.DATA_SCALE > 0 THEN ',' || col.DATA_SCALE END || ')' - ELSE col.DATA_TYPE - END) AS "DATA_TYPE", - NVL(com.COMMENTS, '') AS "COLUMN_COMMENT" - FROM - DBA_TAB_COLUMNS col - LEFT JOIN - DBA_COL_COMMENTS com - ON col.OWNER = com.OWNER - AND col.TABLE_NAME = com.TABLE_NAME - AND col.COLUMN_NAME = com.COLUMN_NAME - WHERE - col.OWNER = '{conf.dbSchema}' - """ - sql2 = f" AND col.TABLE_NAME = '{table_name}'" if table_name is not None and table_name != "" else "" - sql = sql1 + sql2 - - result = session.execute(text(sql)) - res = result.fetchall() - res_list = [ColumnSchema(*item) for item in res] - return res_list - finally: - if result is not None: - result.close() - if session is not None: - session.close() - - -def exec_sql(ds: CoreDatasource | AssistantOutDsSchema, sql: str): - with get_session(ds) as session: - with session.execute(text(sql)) as result: - try: - columns = [item.lower() for item in result.keys()._keys] + db = DB.get_db(ds.type) + if db.connect_type == ConnectType.sqlalchemy: + with sessionmaker(bind=get_engine(ds))() as session: + sql: str = '' + if equals_ignore_case(ds.type, "sqlServer"): + sql = """select name + from sys.schemas""" + elif equals_ignore_case(ds.type, "pg", "excel"): + sql = """SELECT nspname + FROM pg_namespace""" + elif equals_ignore_case(ds.type, "oracle"): + sql = """select * + from all_users""" + with session.execute(text(sql)) as result: + res = result.fetchall() + res_list = [item[0] for item in res] + return res_list + else: + # extra_config_dict = get_extra_config(conf) + if equals_ignore_case(ds.type, 'dm'): + with get_driver_connection(ds) as conn, conn.cursor() as cursor: + cursor.execute("""select OBJECT_NAME + from all_objects + where object_type = 'SCH'""", timeout=conf.timeout) + res = cursor.fetchall() + res_list = [item[0] for item in res] + return res_list + elif equals_ignore_case(ds.type, 'redshift'): + with get_driver_connection(ds) as conn, conn.cursor() as cursor: + cursor.execute("""SELECT nspname + FROM pg_namespace""") + res = cursor.fetchall() + res_list = [item[0] for item in res] + return res_list + elif equals_ignore_case(ds.type, 'kingbase'): + with get_driver_connection(ds) as conn, conn.cursor() as cursor: + cursor.execute("""SELECT nspname + FROM pg_namespace""") + res = cursor.fetchall() + res_list = [item[0] for item in res] + return res_list + + +def get_tables(ds: CoreDatasource): + conf = DatasourceConf(**json.loads(aes_decrypt(ds.configuration))) if not equals_ignore_case(ds.type, + "excel") else get_engine_config() + db = DB.get_db(ds.type) + sql, sql_param = get_table_sql(ds, conf, get_version(ds)) + if db.connect_type == ConnectType.sqlalchemy: + with sessionmaker(bind=get_engine(ds))() as session: + with session.execute(text(sql), {"param": sql_param}) as result: + res = result.fetchall() + res_list = [TableSchema(*item) for item in res] + return res_list + else: + # extra_config_dict = get_extra_config(conf) + if equals_ignore_case(ds.type, 'dm'): + with get_driver_connection(ds) as conn, conn.cursor() as cursor: + cursor.execute(sql, {"param": sql_param}, timeout=conf.timeout) + res = cursor.fetchall() + res_list = [TableSchema(*item) for item in res] + return res_list + elif equals_ignore_case(ds.type, 'doris', 'starrocks'): + # ssl_args = {'ssl': {'ssl_mode': 'REQUIRE'}} if conf.ssl else {} + with get_driver_connection(ds) as conn, conn.cursor() as cursor: + cursor.execute(sql, (sql_param,)) + res = cursor.fetchall() + res_list = [TableSchema(*item) for item in res] + return res_list + elif equals_ignore_case(ds.type, 'redshift'): + with get_driver_connection(ds) as conn, conn.cursor() as cursor: + cursor.execute(sql, (sql_param,)) + res = cursor.fetchall() + res_list = [TableSchema(*item) for item in res] + return res_list + elif equals_ignore_case(ds.type, 'kingbase'): + with get_driver_connection(ds) as conn, conn.cursor() as cursor: + cursor.execute(sql.format(sql_param)) + res = cursor.fetchall() + res_list = [TableSchema(*item) for item in res] + return res_list + elif equals_ignore_case(ds.type, 'es'): + res = get_es_index(conf) + res_list = [TableSchema(*item) for item in res] + return res_list + elif equals_ignore_case(ds.type, 'hive'): + with get_driver_connection(ds) as conn, conn.cursor() as cursor: + cursor.execute(sql) + res = cursor.fetchall() + res_list = [TableSchema(*item) for item in res] + return res_list + + +def get_fields(ds: CoreDatasource, table_name: str = None): + conf = DatasourceConf(**json.loads(aes_decrypt(ds.configuration))) if not equals_ignore_case(ds.type, + "excel") else get_engine_config() + db = DB.get_db(ds.type) + sql, p1, p2 = get_field_sql(ds, conf, table_name) + if db.connect_type == ConnectType.sqlalchemy: + with get_session(ds) as session: + with session.execute(text(sql), {"param1": p1, "param2": p2}) as result: res = result.fetchall() + res_list = [ColumnSchema(*item) for item in res] + return res_list + else: + # extra_config_dict = get_extra_config(conf) + if equals_ignore_case(ds.type, 'dm'): + with get_driver_pool(ds).connection() as conn, conn.cursor() as cursor: + cursor.execute(sql, {"param1": p1, "param2": p2}, timeout=conf.timeout) + res = cursor.fetchall() + res_list = [ColumnSchema(*item) for item in res] + return res_list + elif equals_ignore_case(ds.type, 'doris', 'starrocks'): + # ssl_args = {'ssl': {'ssl_mode': 'REQUIRE'}} if conf.ssl else {} + with get_driver_pool(ds).connection() as conn, conn.cursor() as cursor: + cursor.execute(sql, (p1, p2)) + res = cursor.fetchall() + res_list = [ColumnSchema(*item) for item in res] + return res_list + elif equals_ignore_case(ds.type, 'redshift'): + with get_driver_pool(ds).connection() as conn, conn.cursor() as cursor: + cursor.execute(sql, (p1, p2)) + res = cursor.fetchall() + res_list = [ColumnSchema(*item) for item in res] + return res_list + elif equals_ignore_case(ds.type, 'kingbase'): + with get_driver_pool(ds).connection() as conn, conn.cursor() as cursor: + cursor.execute(sql.format(p1, p2)) + res = cursor.fetchall() + res_list = [ColumnSchema(*item) for item in res] + return res_list + elif equals_ignore_case(ds.type, 'es'): + res = get_es_fields(conf, table_name) + res_list = [ColumnSchema(*item) for item in res] + return res_list + elif equals_ignore_case(ds.type, 'hive'): + with get_driver_pool(ds).connection() as conn, conn.cursor() as cursor: + cursor.execute(sql) + res = cursor.fetchall() + res_list = [ColumnSchema(*item) for item in res] + return res_list + + +def convert_value(value, datetime_format='space'): + """ + 将Python值转换为JSON可序列化的类型 + + :param value: 要转换的值 + :param datetime_format: 日期时间格式 + 'iso' - 2024-01-15T14:30:45 (ISO标准,带T) + 'space' - 2024-01-15 14:30:45 (空格分隔,更常见) + 'auto' - 自动选择 + """ + if value is None: + return None + # 处理 bytes 类型(包括 BIT 字段) + if isinstance(value, bytes): + # 1. 尝试判断是否是 BIT 类型 + if len(value) <= 8: # BIT 类型通常不会很长 + try: + # 转换为整数 + int_val = int.from_bytes(value, 'big') + + # 如果是 0 或 1,返回布尔值更直观 + if int_val in (0, 1): + return bool(int_val) + else: + return int_val + except: + # 如果转换失败,尝试解码为字符串 + pass + + # 2. 尝试解码为 UTF-8 字符串 + try: + return value.decode('utf-8') + except UnicodeDecodeError: + # 3. 如果包含非打印字符,返回十六进制 + if any(b < 32 and b not in (9, 10, 13) for b in value): # 非打印字符 + return f"0x{value.hex()}" + else: + # 4. 尝试 Latin-1 解码(不会失败) + return value.decode('latin-1') + + elif isinstance(value, bytearray): + # 处理 bytearray + return convert_value(bytes(value)) + + if isinstance(value, timedelta): + # 将 timedelta 转换为秒数(整数)或字符串 + return str(value) # 或 value.total_seconds() + elif isinstance(value, Decimal): + return float(value) + # 4. 处理 datetime + elif isinstance(value, datetime): + if datetime_format == 'iso': + return value.isoformat() + elif datetime_format == 'space': + return value.strftime('%Y-%m-%d %H:%M:%S') + else: # 'auto' 或其他 + # 自动判断:没有时间部分只显示日期 + if value.hour == 0 and value.minute == 0 and value.second == 0 and value.microsecond == 0: + return value.strftime('%Y-%m-%d') + else: + return value.strftime('%Y-%m-%d %H:%M:%S') + + # 5. 处理 date + elif isinstance(value, date): + return value.isoformat() # 总是 YYYY-MM-DD + + # 6. 处理 time + elif isinstance(value, time): + return str(value) + else: + return value + + +def is_numeric_type_code(type_code, dialect_name: str) -> bool: + """ + 根据数据库方言和 type_code 判断是否为数值类型 + + Args: + type_code: cursor.description[col_idx][1] 的值 + dialect_name: SQLAlchemy dialect name (mysql/postgresql/mssql/oracle/sqlite 等) + + Returns: + bool: 是否为数值类型 + """ + dialect_name = dialect_name.lower() + + # ---------- MySQL (pymysql) ---------- + if dialect_name == 'mysql': + if isinstance(type_code, int): + return type_code in { + 1, # TINYINT + 2, # SMALLINT + 3, # INT + 4, # FLOAT + 5, # DOUBLE + 8, # BIGINT + 9, # MEDIUMINT + 16, # BIT + 246, # DECIMAL/NEWDECIMAL + } + return False + + # ---------- PostgreSQL (psycopg2) ---------- + if dialect_name == 'postgresql': + if isinstance(type_code, int): + return type_code in { + 20, # int8 + 21, # int2 + 23, # int4 + 700, # float4 + 701, # float8 + 1700, # numeric + 16, # boolean + } + return False + + # ---------- Oracle (cx_Oracle / oracledb) ---------- + if dialect_name == 'oracle': + type_str = str(type_code).upper() + return any(kw in type_str for kw in ['NUMBER', 'FLOAT', 'INTEGER', 'BINARY_FLOAT', 'BINARY_DOUBLE']) + + if dialect_name == 'clickhouse': + if isinstance(type_code, str): + upper_type = type_code.upper() + # 数值类型关键字 + numeric_prefixes = ( + 'INT', # Int8/16/32/64 + 'UINT', # UInt8/16/32/64 ✅ 加上 UINT + 'FLOAT', # Float32, Float64 + 'DECIMAL', # Decimal, Decimal32/64/128 + 'BOOL', # Bool + 'BIT', # 极少数场景 + ) + return any(upper_type.startswith(p) for p in numeric_prefixes) + + # ---------- SQL Server (pyodbc / pymssql) ---------- + if dialect_name == 'mssql': + if isinstance(type_code, int): + # SQL Server (pyodbc / ODBC) 数值类型码 + return type_code in { + 2, # smallint + 3, # int + 4, # tinyint + 5, # float / real / decimal / numeric / money / smallmoney + 6, # bit + 7, # bigint + } + + # ---------- SQLite ---------- + if dialect_name == 'sqlite': + if isinstance(type_code, int): + return type_code in {1, 2, 3, 4, 5} # INTEGER, FLOAT, NUMERIC, etc. + return False + + # ---------- 未知数据库,保守返回 False ---------- + return False + + +def exec_sql(ds: CoreDatasource | AssistantOutDsSchema, sql: str, origin_column=False): + while sql.endswith(';'): + sql = sql[:-1] + # check execute sql only contain read operations + is_safe, error_reason = check_sql_read(sql, ds) + if not is_safe: + raise ValueError(f"SQL can only contain read operations: {error_reason}") + + db = DB.get_db(ds.type) + if db.connect_type == ConnectType.sqlalchemy: + with get_session(ds) as session: + # 获取当前数据库方言 + dialect_name = session.bind.dialect.name + + with session.execute(text(sql)) as result: + try: + columns = result.keys()._keys if origin_column else [item.lower() for item in result.keys()._keys] + + fields_info = [] + + for col_idx, col_name in enumerate(columns): + is_numeric = False + try: + type_code = result.cursor.description[col_idx][1] + is_numeric = is_numeric_type_code(type_code, dialect_name) + except (IndexError, AttributeError): + pass + + fields_info.append({ + "name": col_name, + "is_numeric": is_numeric + }) + + res = result.fetchall() + result_list = [ + {str(columns[i]): convert_value(value) for i, value in enumerate(tuple_item)} for tuple_item in + res + ] + return {"fields": columns, "data": result_list, "fields_info": fields_info, + "sql": bytes.decode(base64.b64encode(bytes(sql, 'utf-8')))} + except Exception as ex: + raise ParseSQLResultError(str(ex)) + else: + conf = DatasourceConf(**json.loads(aes_decrypt(ds.configuration))) + # extra_config_dict = get_extra_config(conf) + if equals_ignore_case(ds.type, 'dm'): + with get_driver_pool(ds).connection() as conn, conn.cursor() as cursor: + try: + cursor.execute(sql, timeout=conf.timeout) + res = cursor.fetchall() + columns = [field[0] for field in cursor.description] if origin_column else [field[0].lower() for + field in + cursor.description] + fields_info = build_fields_info_from_cursor(cursor, origin_column, 'dm') + result_list = [ + {str(columns[i]): convert_value(value) for i, value in enumerate(tuple_item)} for tuple_item in + res + ] + return {"fields": columns, "data": result_list, "fields_info": fields_info, + "sql": bytes.decode(base64.b64encode(bytes(sql, 'utf-8')))} + except Exception as ex: + raise ParseSQLResultError(str(ex)) + elif equals_ignore_case(ds.type, 'doris', 'starrocks'): + with get_driver_pool(ds).connection() as conn, conn.cursor() as cursor: + try: + cursor.execute(sql) + res = cursor.fetchall() + columns = [field[0] for field in cursor.description] if origin_column else [field[0].lower() for + field in + cursor.description] + fields_info = build_fields_info_from_cursor(cursor, origin_column, 'mysql') + result_list = [ + {str(columns[i]): convert_value(value) for i, value in enumerate(tuple_item)} for tuple_item in + res + ] + return {"fields": columns, "data": result_list, "fields_info": fields_info, + "sql": bytes.decode(base64.b64encode(bytes(sql, 'utf-8')))} + except Exception as ex: + raise ParseSQLResultError(str(ex)) + elif equals_ignore_case(ds.type, 'redshift'): + with get_driver_pool(ds).connection() as conn, conn.cursor() as cursor: + try: + cursor.execute(sql) + res = cursor.fetchall() + columns = [field[0] for field in cursor.description] if origin_column else [field[0].lower() for + field in + cursor.description] + fields_info = build_fields_info_from_cursor(cursor, origin_column, 'postgresql') + result_list = [ + {str(columns[i]): convert_value(value) for i, value in enumerate(tuple_item)} for tuple_item in + res + ] + return {"fields": columns, "data": result_list, "fields_info": fields_info, + "sql": bytes.decode(base64.b64encode(bytes(sql, 'utf-8')))} + except Exception as ex: + raise ParseSQLResultError(str(ex)) + elif equals_ignore_case(ds.type, 'kingbase'): + with get_driver_pool(ds).connection() as conn, conn.cursor() as cursor: + try: + cursor.execute(sql) + res = cursor.fetchall() + columns = [field[0] for field in cursor.description] if origin_column else [field[0].lower() for + field in + cursor.description] + fields_info = build_fields_info_from_cursor(cursor, origin_column, 'postgresql') + result_list = [ + {str(columns[i]): convert_value(value) for i, value in enumerate(tuple_item)} for tuple_item in + res + ] + return {"fields": columns, "data": result_list, "fields_info": fields_info, + "sql": bytes.decode(base64.b64encode(bytes(sql, 'utf-8')))} + except Exception as ex: + raise ParseSQLResultError(str(ex)) + elif equals_ignore_case(ds.type, 'es'): + try: + res, raw_columns = get_es_data_by_http(conf, sql) + columns = [field.get('name') for field in raw_columns] if origin_column else [field.get('name').lower() + for + field in + raw_columns] + fields_info = build_fields_info_from_es(raw_columns, origin_column) result_list = [ - {str(columns[i]): float(value) if isinstance(value, Decimal) else value for i, value in - enumerate(tuple_item)} - for tuple_item in res + {str(columns[i]): convert_value(value) for i, value in enumerate(tuple_item)} for tuple_item in + res ] - return {"fields": columns, "data": result_list, + return {"fields": columns, "data": result_list, "fields_info": fields_info, "sql": bytes.decode(base64.b64encode(bytes(sql, 'utf-8')))} except Exception as ex: - raise ex + raise Exception(str(ex)) + elif equals_ignore_case(ds.type, 'hive'): + with get_driver_pool(ds).connection() as conn, conn.cursor() as cursor: + try: + # Hive uses backticks for identifiers; normalize quoted identifiers as a compatibility fallback. + hive_sql = re.sub(r'"([A-Za-z_][A-Za-z0-9_]*)"', r'`\1`', sql) + cursor.execute(hive_sql) + res = cursor.fetchall() + columns = [field[0] for field in cursor.description] if origin_column else [field[0].lower() for + field in + cursor.description] + fields_info = build_fields_info_from_cursor(cursor, origin_column, 'hive') + result_list = [ + {str(columns[i]): convert_value(value) for i, value in enumerate(tuple_item)} for tuple_item in + res + ] + return {"fields": columns, "data": result_list, "fields_info": fields_info, + "sql": bytes.decode(base64.b64encode(bytes(hive_sql, 'utf-8')))} + except Exception as ex: + raise ParseSQLResultError(str(ex)) + + +def build_fields_info_from_cursor(cursor, origin_column, db_type='postgresql'): + """ + 根据数据库游标的 description 构建字段信息列表 + + Args: + cursor: 数据库游标对象 + origin_column: 是否保留原始列名大小写 + db_type: 数据库类型,支持 'mysql', 'postgresql', 'redshift', 'kingbase', 'dm', 'hive' + + Returns: + list: 包含字段名和是否数值类型的字典列表 + """ + fields_info = [] + + for col_info in cursor.description: + col_name = col_info[0] + + if db_type in ('mysql', 'mariadb', 'doris', 'starrocks'): + # MySQL/pymysql 类型码 + is_numeric = col_info[1] in ( + 1, # TINYINT + 2, # SMALLINT + 3, # INT + 4, # FLOAT + 5, # DOUBLE + 8, # BIGINT + 9, # MEDIUMINT + 16, # BIT + 246, # DECIMAL/NEWDECIMAL + ) + elif db_type in ('postgresql', 'redshift', 'kingbase'): + # PostgreSQL/psycopg2 类型 OID + is_numeric = col_info[1] in ( + 16, # bool + 20, # int8 + 21, # int2 + 23, # int4 + 700, # float4 + 701, # float8 + 790, # money + 1700, # numeric + ) + elif db_type == 'dm': + # 达梦数据库类型码 + # 获取类型类名 + type_name = col_info[1].__name__.upper() if hasattr(col_info[1], '__name__') else str(col_info[1]).upper() + + is_numeric = type_name in { + 'INT', 'INTEGER', + 'BIGINT', 'SMALLINT', 'TINYINT', + 'NUMBER', 'NUMERIC', 'DECIMAL', + 'FLOAT', 'DOUBLE', 'REAL', + 'BIT', 'BOOLEAN', + } + elif db_type == 'hive': + # Hive 类型对象转字符串判断 + type_str = str(col_info[1]).lower() + NUMERIC_PREFIXES = ('tinyint', 'smallint', 'int', 'bigint', 'float', 'double', 'decimal', 'numeric') + is_numeric = type_str == 'boolean' or any(type_str.startswith(p) for p in NUMERIC_PREFIXES) + else: + is_numeric = False + + fields_info.append({ + "name": col_name if origin_column else col_name.lower(), + "is_numeric": is_numeric + }) + + return fields_info + + +def build_fields_info_from_es(raw_columns, origin_column): + """ + 专门为 Elasticsearch 构建字段信息 + + Args: + raw_columns: ES 返回的列信息列表 + origin_column: 是否保留原始列名大小写 + + Returns: + list: 包含字段名和是否数值类型的字典列表 + """ + fields_info = [] + + for field in raw_columns: + field_name = field.get('name') if origin_column else field.get('name').lower() + field_type = field.get('type', '').lower() + + is_numeric = field_type in ( + 'long', 'integer', 'short', 'byte', + 'double', 'float', 'half_float', 'scaled_float', + 'unsigned_long', 'boolean' + ) + + fields_info.append({ + "name": field_name, + "is_numeric": is_numeric + }) + + return fields_info + + +def get_sqlglot_dialect(ds_type: str) -> str: + """根据数据源类型获取 sqlglot dialect""" + if equals_ignore_case(ds_type, 'mysql', 'doris', 'starrocks'): + return 'mysql' + elif equals_ignore_case(ds_type, 'sqlServer'): + return 'tsql' + elif equals_ignore_case(ds_type, 'hive'): + return 'hive' + return None + + +# 通用危险函数(适用于所有数据库) +COMMON_DANGEROUS_FUNCTIONS = {'version', 'current_user', 'user', 'database'} + +# 特定数据库的危险函数 +DS_SPECIFIC_DANGEROUS_FUNCTIONS = { + 'mysql': {'LOAD_FILE', 'INTO OUTFILE', 'INTO DUMPFILE'}, + 'doris': {'LOAD_FILE', 'INTO OUTFILE', 'INTO DUMPFILE'}, + 'starrocks': {'LOAD_FILE', 'INTO OUTFILE', 'INTO DUMPFILE'}, + 'postgresql': {'pg_read_file', 'pg_write_file', 'lo_import', 'lo_export'}, + 'sqlserver': {'EXEC', 'xp_cmdshell', 'sp_executesql'}, + 'oracle': {'UTL_FILE', 'DBMS_PIPE', 'DBMS_LOCK'}, + 'hive': {'ADD FILE', 'ADD JAR'}, +} + +# 危险模式正则表达式(用于检查特殊语法) +import re + +DANGEROUS_PATTERNS = [ + r'\bINTO\s+OUTFILE\b', + r'\bINTO\s+DUMPFILE\b', + r'\bEXEC\s*\(', + r'\bCOPY\s+.*\bTO\s+PROGRAM\b', +] + + +def get_dangerous_functions(ds_type: str) -> set: + """获取危险函数(通用 + 特定数据源)""" + functions = COMMON_DANGEROUS_FUNCTIONS.copy() + ds_key = ds_type.lower() if ds_type else '' + if ds_key in DS_SPECIFIC_DANGEROUS_FUNCTIONS: + functions.update(DS_SPECIFIC_DANGEROUS_FUNCTIONS[ds_key]) + return functions + + +def check_dangerous_functions(statements: list, ds_type: str) -> bool: + """检查是否使用了危险函数,返回 True 表示安全""" + dangerous_functions = get_dangerous_functions(ds_type) + dangerous_functions_upper = {f.upper() for f in dangerous_functions} + + for stmt in statements: + if stmt: + for func in stmt.find_all(exp.Anonymous): + if func.name.upper() in dangerous_functions_upper: + return False + return True + + +def check_sql_read(sql: str, ds: CoreDatasource | AssistantOutDsSchema) -> tuple[bool, str]: + """ + 检查 SQL 是否为安全的只读查询 + 返回: (是否安全, 错误原因) + """ + try: + normalized_sql = sql.strip().lstrip("(").strip() + first_keyword = normalized_sql.split(None, 1)[0].upper() if normalized_sql else "" + + # 根据配置决定是否允许元数据查询 + if settings.SQLBOT_ALLOW_METADATA_QUERIES: + allowed_read_commands = {"SELECT", "WITH", "SHOW", "DESCRIBE", "DESC", "EXPLAIN"} + else: + allowed_read_commands = {"SELECT", "WITH"} + + denied_write_commands = { + "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "ALTER", + "TRUNCATE", "MERGE", "COPY", "REPLACE", "GRANT", "REVOKE", + "USE", "SET", "CALL" + } + + if not first_keyword: + raise ValueError("Parse SQL Error") + if first_keyword in denied_write_commands: + return False, f"Write operation '{first_keyword}' is not allowed" + + # 1. 使用正则检查特殊模式 + for pattern in DANGEROUS_PATTERNS: + if re.search(pattern, sql, re.IGNORECASE): + return False, f"SQL contains dangerous pattern: {pattern}" + + dialect = get_sqlglot_dialect(ds.type) + statements = sqlglot.parse(sql, dialect=dialect) + + if not statements: + raise ValueError("Parse SQL Error") + + # 2. 使用 sqlglot 检查函数调用 + dangerous_functions = get_dangerous_functions(ds.type) + dangerous_functions_upper = {f.upper() for f in dangerous_functions} + for stmt in statements: + if stmt: + for func in stmt.find_all(exp.Anonymous): + if func.name.upper() in dangerous_functions_upper: + return False, f"SQL contains dangerous function: {func.name}" + + # 3. 检查写操作类型 + write_types = ( + exp.Insert, exp.Update, exp.Delete, + exp.Create, exp.Drop, exp.Alter, + exp.Merge, exp.Copy + ) + + for stmt in statements: + if stmt is None: + continue + if isinstance(stmt, write_types): + return False, f"SQL contains write operation: {type(stmt).__name__}" + + if first_keyword not in allowed_read_commands: + return False, f"SQL command '{first_keyword}' is not allowed. Only SELECT and WITH are permitted" + + return True, "" + + except Exception as e: + raise ValueError(f"Parse SQL Error: {e}") + + +def checkParams(extraParams: str, illegalParams: List[str]): + kvs = extraParams.split('&') + for kv in kvs: + if kv and '=' in kv: + k, v = kv.split('=') + if k in illegalParams: + raise HTTPException(status_code=500, detail=f'Illegal Parameter: {k}') + + +import threading +from collections import OrderedDict + + +class ConnectionPoolManager: + def __init__(self, max_pools=500): + """ + init + :param max_pools: max pool + """ + self.max_pools = max_pools + self._pools = OrderedDict() # 使用有序字典实现 LRU + self._lock = threading.Lock() # 保证多线程安全 + + def get_pool(self, ds: CoreDatasource | AssistantOutDsSchema, **db_config): + """ + get connection pool(lazy load + LRU update) + """ + with self._lock: + if ds.id: + # 1. 如果连接池已存在,将其移动到字典末尾(标记为最近使用) + if ds.id in self._pools: + self._pools.move_to_end(ds.id) + print(f"[LRU] return: {ds.id}") + return self._pools[ds.id] + + # 2. 如果连接池不存在,检查是否达到上限,若达到则淘汰最久未使用的(字典头部) + if len(self._pools) >= self.max_pools: + oldest_id, oldest_pool = self._pools.popitem(last=False) + oldest_pool.close() # 安全关闭被驱逐的连接池 + print(f"[LRU] remove oldest: {oldest_id}") + + # 3. 创建新连接池并放入字典末尾 + engine = get_engine(ds, use_pool=True) + new_pool = sessionmaker(bind=engine) + self._pools[ds.id] = new_pool + print(f"[LRU] create: {ds.id}") + return new_pool + + def close_all(self): + """stop""" + with self._lock: + for pool in self._pools.values(): + pool.close() + self._pools.clear() + + +pool_manager = ConnectionPoolManager(max_pools=500) + + +class DriverConnectionPoolManager: + def __init__(self, max_pools=500): + """ + init + :param max_pools: max pool + """ + self.max_pools = max_pools + self._pools = OrderedDict() # 使用有序字典实现 LRU + self._lock = threading.Lock() # 保证多线程安全 + + def get_pool(self, ds: CoreDatasource | AssistantOutDsSchema, db_config): + """ + get connection pool(lazy load + LRU update) + """ + with self._lock: + if ds.id: + # 1. 如果连接池已存在,将其移动到字典末尾(标记为最近使用) + if ds.id in self._pools: + self._pools.move_to_end(ds.id) + print(f"[LRU] return: {ds.id}") + return self._pools[ds.id] + + # 2. 如果连接池不存在,检查是否达到上限,若达到则淘汰最久未使用的(字典头部) + if len(self._pools) >= self.max_pools: + oldest_id, oldest_pool = self._pools.popitem(last=False) + oldest_pool.close() # 安全关闭被驱逐的连接池 + print(f"[LRU] remove oldest: {oldest_id}") + + # 3. 创建新连接池并放入字典末尾 + new_pool = get_driver_connection(ds, db_config, use_pool=True) + self._pools[ds.id] = new_pool + print(f"[LRU] create: {ds.id}") + return new_pool + + def close_all(self): + """stop""" + with self._lock: + for pool in self._pools.values(): + pool.close() + self._pools.clear() + + +driver_pool_manager = DriverConnectionPoolManager(max_pools=500) diff --git a/backend/apps/db/db_sql.py b/backend/apps/db/db_sql.py new file mode 100644 index 000000000..496075378 --- /dev/null +++ b/backend/apps/db/db_sql.py @@ -0,0 +1,321 @@ +# Author: Junjun +# Date: 2025/8/20 +from apps.datasource.models.datasource import CoreDatasource, DatasourceConf +from common.utils.utils import equals_ignore_case + + +def get_version_sql(ds: CoreDatasource, conf: DatasourceConf): + if equals_ignore_case(ds.type, "mysql", "doris", "starrocks"): + return """ + SELECT VERSION() + """ + elif equals_ignore_case(ds.type, "sqlServer"): + return """ + select SERVERPROPERTY('ProductVersion') + """ + elif equals_ignore_case(ds.type, "pg", "kingbase", "excel"): + return """ + SELECT current_setting('server_version') + """ + elif equals_ignore_case(ds.type, "oracle"): + return """ + SELECT version FROM v$instance + """ + elif equals_ignore_case(ds.type, "ck"): + return """ + select version() + """ + elif equals_ignore_case(ds.type, "dm"): + return """ + SELECT * FROM v$version + """ + elif equals_ignore_case(ds.type, "redshift", "sqlite", "hive"): + return '' + + +def get_table_sql(ds: CoreDatasource, conf: DatasourceConf, db_version: str = ''): + if equals_ignore_case(ds.type, "mysql"): + return """ + SELECT + TABLE_NAME, + TABLE_COMMENT + FROM + information_schema.TABLES + WHERE + TABLE_SCHEMA = :param + """, conf.database + elif equals_ignore_case(ds.type, "sqlServer"): + return """ + SELECT + TABLE_NAME AS [TABLE_NAME], + ISNULL(ep.value, '') AS [TABLE_COMMENT] + FROM + INFORMATION_SCHEMA.TABLES t + LEFT JOIN + sys.extended_properties ep + ON ep.major_id = OBJECT_ID(t.TABLE_SCHEMA + '.' + t.TABLE_NAME) + AND ep.minor_id = 0 + AND ep.name = 'MS_Description' + WHERE + t.TABLE_TYPE IN ('BASE TABLE', 'VIEW') + AND t.TABLE_SCHEMA = :param + """, conf.dbSchema + elif equals_ignore_case(ds.type, "pg", "excel"): + return """ + SELECT c.relname AS TABLE_NAME, + COALESCE(COALESCE(d.description, obj_description(c.oid)), '') AS TABLE_COMMENT + FROM pg_class c + LEFT JOIN + pg_namespace n ON n.oid = c.relnamespace + LEFT JOIN + pg_description d ON d.objoid = c.oid AND d.objsubid = 0 + WHERE n.nspname = :param + AND c.relkind IN ('r', 'v', 'p', 'm') + AND c.relname NOT LIKE 'pg_%' + AND c.relname NOT LIKE 'sql_%' + ORDER BY c.relname \ + """, conf.dbSchema + elif equals_ignore_case(ds.type, "oracle"): + return """ + SELECT DISTINCT + t.TABLE_NAME AS "TABLE_NAME", + NVL(c.COMMENTS, '') AS "TABLE_COMMENT" + FROM ( + SELECT TABLE_NAME, 'TABLE' AS OBJECT_TYPE + FROM ALL_TABLES + WHERE OWNER = :param + UNION ALL + SELECT VIEW_NAME AS TABLE_NAME, 'VIEW' AS OBJECT_TYPE + FROM ALL_VIEWS + WHERE OWNER = :param + UNION ALL + SELECT MVIEW_NAME AS TABLE_NAME, 'MATERIALIZED VIEW' AS OBJECT_TYPE + FROM ALL_MVIEWS + WHERE OWNER = :param + ) t + LEFT JOIN ALL_TAB_COMMENTS c + ON t.TABLE_NAME = c.TABLE_NAME + AND c.TABLE_TYPE = t.OBJECT_TYPE + AND c.OWNER = :param + ORDER BY t.TABLE_NAME + """, conf.dbSchema + elif equals_ignore_case(ds.type, "ck"): + version = int(db_version.split('.')[0]) + if version < 22: + return """ + SELECT name, '' as comment + FROM system.tables + WHERE database = :param + AND engine NOT IN ('Dictionary') + ORDER BY name + """, conf.database + else: + return """ + SELECT name, comment + FROM system.tables + WHERE database = :param + AND engine NOT IN ('Dictionary') + ORDER BY name + """, conf.database + elif equals_ignore_case(ds.type, "dm"): + return """ + select table_name, comments + from all_tab_comments + where owner=:param + AND (table_type = 'TABLE' or table_type = 'VIEW') + """, conf.dbSchema + elif equals_ignore_case(ds.type, "redshift"): + return """ + SELECT + relname AS TableName, + obj_description(relfilenode::regclass, 'pg_class') AS TableDescription + FROM + pg_class + WHERE + relkind in ('r','p', 'f') + AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = %s) + """, conf.dbSchema + elif equals_ignore_case(ds.type, "doris", "starrocks"): + return """ + SELECT + TABLE_NAME, + TABLE_COMMENT + FROM + information_schema.TABLES + WHERE + TABLE_SCHEMA = %s + """, conf.database + elif equals_ignore_case(ds.type, "kingbase"): + return """ + SELECT c.relname AS TABLE_NAME, + COALESCE(COALESCE(d.description, obj_description(c.oid)), '') AS TABLE_COMMENT + FROM pg_class c + LEFT JOIN + pg_namespace n ON n.oid = c.relnamespace + LEFT JOIN + pg_description d ON d.objoid = c.oid AND d.objsubid = 0 + WHERE n.nspname = '{0}' + AND c.relkind IN ('r', 'v', 'p', 'm') + AND c.relname NOT LIKE 'pg_%' + AND c.relname NOT LIKE 'sql_%' + ORDER BY c.relname \ + """, conf.dbSchema + elif equals_ignore_case(ds.type, "es"): + return "", None + elif equals_ignore_case(ds.type, "hive"): + return """ + SHOW TABLES + """, None + + +def get_field_sql(ds: CoreDatasource, conf: DatasourceConf, table_name: str = None): + if equals_ignore_case(ds.type, "mysql"): + sql1 = """ + SELECT + COLUMN_NAME, + DATA_TYPE, + COLUMN_COMMENT + FROM + INFORMATION_SCHEMA.COLUMNS + WHERE + TABLE_SCHEMA = :param1 + """ + sql2 = " AND TABLE_NAME = :param2" if table_name is not None and table_name != "" else "" + return sql1 + sql2, conf.database, table_name + elif equals_ignore_case(ds.type, "sqlServer"): + sql1 = """ + SELECT + COLUMN_NAME AS [COLUMN_NAME], + DATA_TYPE AS [DATA_TYPE], + ISNULL(EP.value, '') AS [COLUMN_COMMENT] + FROM + INFORMATION_SCHEMA.COLUMNS C + LEFT JOIN + sys.extended_properties EP + ON EP.major_id = OBJECT_ID(C.TABLE_SCHEMA + '.' + C.TABLE_NAME) + AND EP.minor_id = C.ORDINAL_POSITION + AND EP.name = 'MS_Description' + WHERE + C.TABLE_SCHEMA = :param1 + """ + sql2 = " AND C.TABLE_NAME = :param2" if table_name is not None and table_name != "" else "" + return sql1 + sql2, conf.dbSchema, table_name + elif equals_ignore_case(ds.type, "pg", "excel"): + sql1 = """ + SELECT a.attname AS COLUMN_NAME, + pg_catalog.format_type(a.atttypid, a.atttypmod) AS DATA_TYPE, + col_description(c.oid, a.attnum) AS COLUMN_COMMENT + FROM pg_catalog.pg_attribute a + JOIN + pg_catalog.pg_class c ON a.attrelid = c.oid + JOIN + pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = :param1 + AND a.attnum > 0 + AND NOT a.attisdropped \ + """ + sql2 = " AND c.relname = :param2" if table_name is not None and table_name != "" else "" + return sql1 + sql2, conf.dbSchema, table_name + elif equals_ignore_case(ds.type, "redshift"): + sql1 = """ + SELECT a.attname AS COLUMN_NAME, + pg_catalog.format_type(a.atttypid, a.atttypmod) AS DATA_TYPE, + col_description(c.oid, a.attnum) AS COLUMN_COMMENT + FROM pg_catalog.pg_attribute a + JOIN + pg_catalog.pg_class c ON a.attrelid = c.oid + JOIN + pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = %s + AND a.attnum > 0 + AND NOT a.attisdropped \ + """ + sql2 = " AND c.relname = %s" if table_name is not None and table_name != "" else "" + return sql1 + sql2, conf.dbSchema, table_name + elif equals_ignore_case(ds.type, "oracle"): + sql1 = """ + SELECT + col.COLUMN_NAME AS "COLUMN_NAME", + (CASE + WHEN col.DATA_TYPE IN ('VARCHAR2', 'CHAR', 'NVARCHAR2', 'NCHAR') + THEN col.DATA_TYPE || '(' || col.DATA_LENGTH || ')' + WHEN col.DATA_TYPE = 'NUMBER' AND col.DATA_PRECISION IS NOT NULL + THEN col.DATA_TYPE || '(' || col.DATA_PRECISION || + CASE WHEN col.DATA_SCALE > 0 THEN ',' || col.DATA_SCALE END || ')' + ELSE col.DATA_TYPE + END) AS "DATA_TYPE", + NVL(com.COMMENTS, '') AS "COLUMN_COMMENT" + FROM + ALL_TAB_COLUMNS col + LEFT JOIN + ALL_COL_COMMENTS com + ON col.OWNER = com.OWNER + AND col.TABLE_NAME = com.TABLE_NAME + AND col.COLUMN_NAME = com.COLUMN_NAME + WHERE + col.OWNER = :param1 + """ + sql2 = " AND col.TABLE_NAME = :param2" if table_name is not None and table_name != "" else "" + return sql1 + sql2, conf.dbSchema, table_name + elif equals_ignore_case(ds.type, "ck"): + sql1 = """ + SELECT + name AS COLUMN_NAME, + type AS DATA_TYPE, + comment AS COLUMN_COMMENT + FROM system.columns + WHERE database = :param1 + """ + sql2 = " AND table = :param2" if table_name is not None and table_name != "" else "" + return sql1 + sql2, conf.database, table_name + elif equals_ignore_case(ds.type, "dm"): + sql1 = """ + SELECT + c.COLUMN_NAME AS "COLUMN_NAME", + c.DATA_TYPE AS "DATA_TYPE", + COALESCE(com.COMMENTS, '') AS "COMMENTS" + FROM + ALL_TAB_COLUMNS c + LEFT JOIN + ALL_COL_COMMENTS com + ON c.TABLE_NAME = com.TABLE_NAME + AND c.COLUMN_NAME = com.COLUMN_NAME + WHERE + c.OWNER = :param1 + """ + sql2 = " AND c.TABLE_NAME = :param2" if table_name is not None and table_name != "" else "" + return sql1 + sql2, conf.dbSchema, table_name + elif equals_ignore_case(ds.type, "doris", "starrocks"): + sql1 = """ + SELECT + COLUMN_NAME, + DATA_TYPE, + COLUMN_COMMENT + FROM + INFORMATION_SCHEMA.COLUMNS + WHERE + TABLE_SCHEMA = %s + """ + sql2 = " AND TABLE_NAME = %s" if table_name is not None and table_name != "" else "" + return sql1 + sql2, conf.database, table_name + elif equals_ignore_case(ds.type, "kingbase"): + sql1 = """ + SELECT a.attname AS COLUMN_NAME, + pg_catalog.format_type(a.atttypid, a.atttypmod) AS DATA_TYPE, + col_description(c.oid, a.attnum) AS COLUMN_COMMENT + FROM pg_catalog.pg_attribute a + JOIN + pg_catalog.pg_class c ON a.attrelid = c.oid + JOIN + pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = '{0}' + AND a.attnum > 0 + AND NOT a.attisdropped \ + """ + sql2 = " AND c.relname = '{1}'" if table_name is not None and table_name != "" else "" + return sql1 + sql2, conf.dbSchema, table_name + elif equals_ignore_case(ds.type, "es"): + return "", None, None + elif equals_ignore_case(ds.type, "hive"): + sql1 = f"DESCRIBE {table_name}" + return sql1, None, None diff --git a/backend/apps/db/es_engine.py b/backend/apps/db/es_engine.py new file mode 100644 index 000000000..9e147d63f --- /dev/null +++ b/backend/apps/db/es_engine.py @@ -0,0 +1,132 @@ +# Author: Junjun +# Date: 2025/9/9 + +import json +from base64 import b64encode + +import requests +from elasticsearch import Elasticsearch + +from apps.datasource.models.datasource import DatasourceConf +from common.error import SingleMessageError + + +def get_es_auth(conf: DatasourceConf): + username = f"{conf.username}" + password = f"{conf.password}" + + credentials = f"{username}:{password}" + encoded_credentials = b64encode(credentials.encode()).decode() + + return { + "Content-Type": "application/json", + "Authorization": f"Basic {encoded_credentials}" + } + + +def get_es_connect(conf: DatasourceConf): + es_client = Elasticsearch( + [conf.host], # ES address + basic_auth=(conf.username, conf.password), + verify_certs=False, + compatibility_mode=True, + headers=get_es_auth(conf) + ) + return es_client + + +# get tables +def get_es_index(conf: DatasourceConf): + es_client = get_es_connect(conf) + indices = es_client.cat.indices(format="json") + res = [] + if indices is not None: + for idx in indices: + index_name = idx.get('index') + desc = '' + # get mapping + mapping = es_client.indices.get_mapping(index=index_name) + mappings = mapping.get(index_name).get("mappings") + if mappings.get('_meta'): + desc = mappings.get('_meta').get('description') + res.append((index_name, desc)) + return res + + +# get fields +def get_es_fields(conf: DatasourceConf, table_name: str): + es_client = get_es_connect(conf) + index_name = table_name + mapping = es_client.indices.get_mapping(index=index_name) + properties = mapping.get(index_name).get("mappings").get("properties") + res = [] + if properties is not None: + for field, config in properties.items(): + field_type = config.get("type") + desc = '' + if config.get("_meta"): + desc = config.get("_meta").get('description') + + if field_type: + res.append((field, field_type, desc)) + else: + # object、nested... + res.append((field, ','.join(list(config.keys())), desc)) + return res + + +# def get_es_data(conf: DatasourceConf, sql: str, table_name: str): +# r = requests.post(f"{conf.host}/_sql/translate", json={"query": sql}) +# if r.json().get('error'): +# print(json.dumps(r.json())) +# +# es_client = get_es_connect(conf) +# response = es_client.search( +# index=table_name, +# body=json.dumps(r.json()) +# ) +# +# # print(response) +# fields = get_es_fields(conf, table_name) +# res = [] +# for hit in response.get('hits').get('hits'): +# item = [] +# if 'fields' in hit: +# result = hit.get('fields') # {'title': ['Python'], 'age': [30]} +# for field in fields: +# v = result.get(field[0]) +# item.append(v[0]) if v else item.append(None) +# res.append(tuple(item)) +# # print(hit['fields']['title'][0]) +# # elif '_source' in hit: +# # print(hit.get('_source')) +# return res, fields + + +def get_es_data_by_http(conf: DatasourceConf, sql: str): + url = conf.host + while url.endswith('/'): + url = url[:-1] + + host = f'{url}/_sql?format=json' + + # Security improvement: Enable SSL certificate verification + # Note: In production, always set verify=True or provide path to CA bundle + # If using self-signed certificates, provide the cert path: verify='/path/to/cert.pem' + # verify_ssl = True if not url.startswith('https://localhost') else False + + response = requests.post( + host, + data=json.dumps({"query": sql}), + headers=get_es_auth(conf), + verify=False, + timeout=30 # Add timeout to prevent hanging + ) + + # print(response.json()) + res = response.json() + if res.get('error'): + raise SingleMessageError(json.dumps(res)) + fields = res.get('columns') + result = res.get('rows') + return result, fields diff --git a/backend/apps/db/type.py b/backend/apps/db/type.py deleted file mode 100644 index 552265279..000000000 --- a/backend/apps/db/type.py +++ /dev/null @@ -1,13 +0,0 @@ -# Author: Junjun -# Date: 2025/5/22 -from typing import Dict - - -def db_type_relation() -> Dict: - return { - "mysql": "MySQL", - "sqlServer": "Microsoft SQL Server", - "pg": "PostgreSQL", - "excel": "Excel/CSV", - "oracle": "Oracle" - } diff --git a/backend/apps/mcp/mcp.py b/backend/apps/mcp/mcp.py index 19afffe79..2be45074c 100644 --- a/backend/apps/mcp/mcp.py +++ b/backend/apps/mcp/mcp.py @@ -1,28 +1,32 @@ # Author: Junjun # Date: 2025/7/1 - +import json from datetime import timedelta +from typing import Optional import jwt from fastapi import HTTPException, status, APIRouter -from fastapi.responses import StreamingResponse # from fastapi.security import OAuth2PasswordBearer from jwt.exceptions import InvalidTokenError from pydantic import ValidationError from sqlmodel import select -from apps.chat.api.chat import create_chat -from apps.chat.models.chat_model import ChatMcp, CreateChat, ChatStart, McpQuestion -from apps.chat.task.llm import LLMService -from apps.system.crud.user import authenticate +from apps.chat.api.chat import create_chat, question_answer_inner +from apps.chat.models.chat_model import ChatMcp, CreateChat, ChatStart, McpQuestion, McpAssistant, ChatQuestion, \ + ChatFinishStep, McpDs, ChatToken +from apps.datasource.crud.datasource import get_datasource_list +from apps.system.crud.user import authenticate, user_ws_options from apps.system.crud.user import get_db_user from apps.system.models.system_model import UserWsModel from apps.system.models.user import UserModel -from apps.system.schemas.system_schema import BaseUserDTO +from apps.system.schemas.system_schema import BaseUserDTO, AssistantHeader from apps.system.schemas.system_schema import UserInfoDTO +from common.audit.models.log_model import OperationType, OperationModules +from common.audit.schemas.logger_decorator import LogConfig, system_log +from common.audit.schemas.request_context import RequestContext from common.core import security from common.core.config import settings -from common.core.deps import SessionDep +from common.core.deps import SessionDep, Trans from common.core.schemas import TokenPayload, XOAuth2PasswordBearer, Token from common.core.security import create_access_token @@ -33,33 +37,8 @@ router = APIRouter(tags=["mcp"], prefix="/mcp") -# @router.post("/access_token", operation_id="access_token") -# def local_login( -# session: SessionDep, -# form_data: Annotated[OAuth2PasswordRequestForm, Depends()] -# ) -> Token: -# user = authenticate(session=session, account=form_data.username, password=form_data.password) -# if not user: -# raise HTTPException(status_code=400, detail="Incorrect account or password") -# access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) -# user_dict = user.to_dict() -# return Token(access_token=create_access_token( -# user_dict, expires_delta=access_token_expires -# )) - - -# @router.get("/ds_list", operation_id="get_datasource_list") -# async def datasource_list(session: SessionDep): -# return get_datasource_list(session=session) -# -# -# @router.get("/model_list", operation_id="get_model_list") -# async def get_model_list(session: SessionDep): -# return session.query(AiModelDetail).all() - - -@router.post("/mcp_start", operation_id="mcp_start") -async def mcp_start(session: SessionDep, chat: ChatStart): +@router.post("/access_token", operation_id="access_token") +async def access_token(session: SessionDep, chat: ChatToken): user: BaseUserDTO = authenticate(session=session, account=chat.username, password=chat.password) if not user: raise HTTPException(status_code=400, detail="Incorrect account or password") @@ -71,15 +50,14 @@ async def mcp_start(session: SessionDep, chat: ChatStart): t = Token(access_token=create_access_token( user_dict, expires_delta=access_token_expires )) - c = create_chat(session, user, CreateChat(origin=1), False) - return {"access_token": t.access_token, "chat_id": c.id} + # c = create_chat(session, user, CreateChat(origin=1), False) + return {"access_token": t.access_token} -@router.post("/mcp_question", operation_id="mcp_question") -async def mcp_question(session: SessionDep, chat: McpQuestion): +def get_user(session: SessionDep, token: str): try: payload = jwt.decode( - chat.token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] + token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] ) token_data = TokenPayload(**payload) except (InvalidTokenError, ValidationError): @@ -92,6 +70,7 @@ async def mcp_question(session: SessionDep, chat: McpQuestion): db_user: UserModel = get_db_user(session=session, user_id=token_data.id) session_user = UserInfoDTO.model_validate(db_user.model_dump()) session_user.isAdmin = session_user.id == 1 and session_user.account == 'admin' + session_user.language = 'zh-CN' if session_user.isAdmin: session_user = session_user ws_model: UserWsModel = session.exec( @@ -104,10 +83,142 @@ async def mcp_question(session: SessionDep, chat: McpQuestion): if session_user.status != 1: raise HTTPException(status_code=400, detail="Inactive user") + return session_user - mcp_chat = ChatMcp(token=chat.token, chat_id=chat.chat_id, question=chat.question) - # ask - llm_service = LLMService(session_user, mcp_chat) - llm_service.init_record() - return StreamingResponse(llm_service.run_task(False), media_type="text/event-stream") +@router.post("/mcp_start", operation_id="mcp_start") +@system_log(LogConfig( + operation_type=OperationType.CREATE, + module=OperationModules.CHAT, + result_id_expr="id", + save_on_success_only=True +)) +async def mcp_start(session: SessionDep, trans: Trans, chat: ChatStart): + res_token = None + user = None + if chat.token: + res_token = chat.token + user = get_user(session, chat.token) + else: + user = authenticate(session=session, account=chat.username, password=chat.password) + if not user: + raise HTTPException(status_code=400, detail="Incorrect account or password") + + if not user.oid or user.oid == 0: + raise HTTPException(status_code=400, detail="No associated workspace, Please contact the administrator") + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + user_dict = user.to_dict() + t = Token(access_token=create_access_token( + user_dict, expires_delta=access_token_expires + )) + res_token = t.access_token + + if chat.oid: + w_list = await user_ws_options(session, user.id, trans) + oid_list = [item.id for item in w_list] + if int(chat.oid) not in oid_list: + raise HTTPException(status_code=400, detail="The current user is not in the selected workspace") + + user.oid = int(chat.oid) + + request = RequestContext.get_request() + request.state.current_user = user + + c = create_chat(session, user, CreateChat(origin=1), False) + return {"access_token": res_token, "chat_id": c.id} + + +@router.post("/mcp_ws_list", operation_id="mcp_ws_list") +async def ws_list(session: SessionDep, trans: Trans, token: str): + session_user = get_user(session, token) + return await user_ws_options(session, session_user.id, trans) + + +@router.post("/mcp_ds_list", operation_id="mcp_datasource_list") +async def datasource_list(session: SessionDep, trans: Trans, mcp_ds: McpDs): + session_user = get_user(session, mcp_ds.token) + if mcp_ds.oid: + w_list = await user_ws_options(session, session_user.id, trans) + oid_list = [item.id for item in w_list] + if int(mcp_ds.oid) not in oid_list: + raise HTTPException(status_code=400, detail="The current user is not in the selected workspace") + + session_user.oid = int(mcp_ds.oid) + ds_list = get_datasource_list(session=session, user=session_user) + result = [] + for item in ds_list: + dic = item.__dict__ + dic.pop('embedding', None) + dic.pop('table_relation', None) + dic.pop('recommended_config', None) + dic.pop('configuration', None) + result.append(dic) + return result + + +# +# +# @router.get("/model_list", operation_id="get_model_list") +# async def get_model_list(session: SessionDep): +# return session.query(AiModelDetail).all() + + +@router.post("/mcp_question", operation_id="mcp_question") +async def mcp_question(session: SessionDep, trans: Trans, chat: McpQuestion): + session_user = get_user(session, chat.token) + lang = chat.lang + if lang in ["zh-CN", "zh-TW", "en", "ko-KR"]: + session_user.language = lang + # if chat.oid: + # w_list = await user_ws_options(session, session_user.id, trans) + # oid_list = [item.id for item in w_list] + # if int(chat.oid) not in oid_list: + # raise HTTPException(status_code=400, detail="The current user is not in the selected workspace") + # + # session_user.oid = int(chat.oid) + ds_id: Optional[int] = None + if chat.datasource_id: + if isinstance(chat.datasource_id, str): + if chat.datasource_id.strip() == "": + ds_id = None + else: + try: + ds_id = int(chat.datasource_id.strip()) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid datasource ID") + elif isinstance(chat.datasource_id, int): + ds_id = chat.datasource_id + else: + raise HTTPException(status_code=400, detail="Invalid datasource ID") + + mcp_chat = ChatMcp(token=chat.token, chat_id=chat.chat_id, question=chat.question, datasource_id=ds_id) + + return await question_answer_inner(session=session, current_user=session_user, request_question=mcp_chat, + in_chat=False, stream=chat.stream, return_img=chat.return_img) + + +# Cordys crm +@router.post("/mcp_assistant", operation_id="mcp_assistant") +async def mcp_assistant(session: SessionDep, chat: McpAssistant): + session_user = BaseUserDTO(**{ + "id": -1, "account": 'sqlbot-mcp-assistant', "oid": 1, "assistant_id": -1, "password": '', "language": "zh-CN" + }) + # session_user: UserModel = get_db_user(session=session, user_id=1) + # session_user.oid = 1 + c = create_chat(session, session_user, CreateChat(origin=1), False) + + # build assistant param + configuration = {"endpoint": chat.url} + # authorization = [{"key": "x-de-token", + # "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1aWQiOjEsIm9pZCI6MSwiZXhwIjoxNzU4NTEyMDA2fQ.3NR-pgnADLdXZtI3dXX5-LuxfGYRvYD9kkr2de7KRP0", + # "target": "header"}] + mcp_assistant_header = AssistantHeader(id=1, name='mcp_assist', domain='', type=1, + configuration=json.dumps(configuration), + certificate=chat.authorization) + + # assistant question + mcp_chat = ChatQuestion(chat_id=c.id, question=chat.question) + # ask + return await question_answer_inner(session=session, current_user=session_user, request_question=mcp_chat, + current_assistant=mcp_assistant_header, + in_chat=False, stream=chat.stream, finish_step=ChatFinishStep.QUERY_DATA) diff --git a/backend/apps/settings/api/base.py b/backend/apps/settings/api/base.py new file mode 100644 index 000000000..6de5bdf19 --- /dev/null +++ b/backend/apps/settings/api/base.py @@ -0,0 +1,40 @@ +import os +from http.client import HTTPException + +from fastapi import APIRouter +from fastapi.responses import FileResponse + +from apps.swagger.i18n import PLACEHOLDER_PREFIX +from common.core.config import settings +from common.core.file import FileRequest + +router = APIRouter(tags=["System"], prefix="/system") + +path = settings.EXCEL_PATH + + +@router.post("/download-fail-info", summary=f"{PLACEHOLDER_PREFIX}download-fail-info") +async def download_excel(req: FileRequest): + """ + 根据文件路径下载 Excel 文件 + """ + filename = req.file + file_path = os.path.join(path, filename) + + # 检查文件是否存在 + if not os.path.exists(file_path): + raise HTTPException(404, "File Not Exists") + + # 检查文件是否是 Excel 文件 + if not filename.endswith('_error.xlsx'): + raise HTTPException(400, "Only support _error.xlsx") + + # 获取文件名 + filename = os.path.basename(file_path) + + # 返回文件 + return FileResponse( + path=file_path, + filename=filename, + media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) diff --git a/backend/apps/settings/api/terminology.py b/backend/apps/settings/api/terminology.py deleted file mode 100644 index 708e6ee7c..000000000 --- a/backend/apps/settings/api/terminology.py +++ /dev/null @@ -1,68 +0,0 @@ -from fastapi import APIRouter -from sqlmodel import select -from apps.settings.models.setting_models import term_model -from apps.settings.schemas.setting_schemas import term_schema_creator -from common.core.deps import SessionDep -from common.core.pagination import Paginator -from common.core.schemas import PaginatedResponse, PaginationParams -router = APIRouter(tags=["Settings"], prefix="/settings/terminology") - - -@router.get("/pager/{pageNum}/{pageSize}", response_model=PaginatedResponse[term_model]) -async def pager( - session: SessionDep, - pageNum: int, - pageSize: int -): - pagination = PaginationParams(page=pageNum, size=pageSize) - paginator = Paginator(session) - filters = {} - return await paginator.get_paginated_response( - stmt=term_model, - pagination=pagination, - **filters) - - -@router.get("/{id}", response_model=term_model) -async def get_terminology_by_id( - session: SessionDep, - id: int -): - term = session.get(term_model, id) - return term - -@router.post("", response_model=term_model) -async def add_terminology( - session: SessionDep, - creator: term_schema_creator -): - terminology = term_model(**creator.model_dump()) - session.add(terminology) - session.commit() - return terminology - -@router.put("", response_model=term_model) -async def update_terminology( - session: SessionDep, - terminology: term_model -): - terminology.id = int(terminology.id) - term = session.exec(select(term_model).where(term_model.id == terminology.id)).first() - update_data = terminology.model_dump(exclude_unset=True) - for field, value in update_data.items(): - setattr(term, field, value) - session.add(term) - session.commit() - return terminology - -@router.delete("/{id}", response_model=term_model) -async def delete_terminology( - session: SessionDep, - id: int -): - term = session.exec(select(term_model).where(term_model.id == id)).first() - session.delete(term) - session.commit() - return { - "message": f"Terminology with ID {id} deleted successfully." - } diff --git a/backend/apps/swagger/__init__.py b/backend/apps/swagger/__init__.py new file mode 100644 index 000000000..13d7ace25 --- /dev/null +++ b/backend/apps/swagger/__init__.py @@ -0,0 +1,2 @@ +# Author: Junjun +# Date: 2025/12/11 diff --git a/backend/apps/swagger/i18n.py b/backend/apps/swagger/i18n.py new file mode 100644 index 000000000..39a6aa71e --- /dev/null +++ b/backend/apps/swagger/i18n.py @@ -0,0 +1,119 @@ +# Author: Junjun +# Date: 2025/12/11 +# i18n.py +import json +from pathlib import Path +from typing import Dict + +i18n_list = ["en", "zh"] + +# placeholder prefix(trans key prefix) +PLACEHOLDER_PREFIX = "PLACEHOLDER_" + +# default lang +DEFAULT_LANG = "en" + +LOCALES_DIR = Path(__file__).parent / "locales" +_translations_cache: Dict[str, Dict[str, str]] = {} + + +def load_translation(lang: str) -> Dict[str, str]: + """Load translations for the specified language from a JSON file""" + if lang in _translations_cache: + return _translations_cache[lang] + + file_path = LOCALES_DIR / f"{lang}.json" + if not file_path.exists(): + if lang == DEFAULT_LANG: + raise FileNotFoundError(f"Default language file not found: {file_path}") + # If the non-default language is missing, fall back to the default language + return load_translation(DEFAULT_LANG) + + try: + with open(file_path, "r", encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict): + raise ValueError(f"Translation file {file_path} must be a JSON object") + _translations_cache[lang] = data + return data + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON in {file_path}: {e}") + + +# group tags +tags_metadata = [ + { + "name": "Data Q&A", + "description": f"{PLACEHOLDER_PREFIX}data_qa" + }, + { + "name": "Datasource", + "description": f"{PLACEHOLDER_PREFIX}ds_api" + }, + {"name": "Dashboard", + "description": f"{PLACEHOLDER_PREFIX}db_api" + }, + { + "name": "system_user", + "description": f"{PLACEHOLDER_PREFIX}system_user_api" + }, + { + "name": "system_ws", + "description": f"{PLACEHOLDER_PREFIX}system_ws_api" + }, + { + "name": "system_model", + "description": f"{PLACEHOLDER_PREFIX}system_model_api" + }, + { + "name": "system_assistant", + "description": f"{PLACEHOLDER_PREFIX}system_assistant_api" + }, + { + "name": "system_embedded", + "description": f"{PLACEHOLDER_PREFIX}system_embedded_api" + }, + { + "name": "system_authentication", + "description": f"{PLACEHOLDER_PREFIX}system_authentication_api" + }, + {"name": "Table Relation", + "description": f"{PLACEHOLDER_PREFIX}tr_api" + }, + { + "name": "Data Permission", + "description": f"{PLACEHOLDER_PREFIX}per_api" + }, + { + "name": "SQL Examples", + "description": f"{PLACEHOLDER_PREFIX}data_training_api" + }, + { + "name": "Terminology", + "description": f"{PLACEHOLDER_PREFIX}terminology_api" + }, + { + "name": "CustomPrompt", + "description": f"{PLACEHOLDER_PREFIX}custom_prompt_api" + }, + { + "name": "mcp", + "description": f"{PLACEHOLDER_PREFIX}mcp_api" + }, + { + "name": "recommended problem", + "description": f"{PLACEHOLDER_PREFIX}recommended_problem_api" + }, + { + "name": "Audit", + "description": f"{PLACEHOLDER_PREFIX}audit_api" + }, + { + "name": "System_variable", + "description": f"{PLACEHOLDER_PREFIX}variable_api" + } +] + + +def get_translation(lang: str) -> Dict[str, str]: + return load_translation(lang) diff --git a/backend/apps/swagger/locales/en.json b/backend/apps/swagger/locales/en.json new file mode 100644 index 000000000..5d84258f4 --- /dev/null +++ b/backend/apps/swagger/locales/en.json @@ -0,0 +1,212 @@ +{ + "ds_api": "Datasource API", + "ds_list": "Datasource list", + "ds_list_description": "Retrieve all data sources under the current workspace", + "ds_get": "Get Datasource", + "ds_id": "Datasource ID", + "ds_check": "Datasource status check", + "ds_add": "Create Datasource", + "ds_choose_tables": "Select Tables", + "ds_update": "Edit Datasource", + "ds_delete": "Delete Datasource", + "ds_get_tables": "Query Table info from database", + "ds_get_schema": "Query Schema from database", + "ds_get_fields": "Query Fields from database", + "ds_sync_fields": "Sync Fields", + "ds_table_id": "Table ID", + "ds_table_name": "Table Name", + "ds_table_list": "Get Table List", + "ds_field_list": "Get Field List", + "ds_edit_table": "Edit Table Info", + "ds_edit_field": "Edit Field Info", + "ds_preview_data": "Preview Data", + "ds_upload_excel": "Upload Excel", + "ds_excel": "File", + "ds_parse_excel": "Parse Excel and Preview Data", + "ds_import_to_db": "Import Data to Database", + "ds_export_ds_schema": "Export Comment", + "ds_upload_ds_schema": "Upload Comment", + + "mcp_api": "MCP Tools(It is not recommended to call via API)", + + "recommended_problem_api": "Recommended Questions", + "rp_save": "Save the question", + "rp_get": "Get Recommended Questions", + "rp_base": "Obtain recommended queries for data sources", + + "audit_api": "Log Audit", + "audit_export": "Export Log Audit", + "audit_get_log_page": "Pagination for Log Audit", + "audit_get_option": "Retrieve audit configuration", + + "system": "System Management", + "system_user_api": "User APIs", + "system_user_current_user": "Get Current User", + "system_user_current_user_desc": "Retrieve information of the currently logged-in user", + "system_user_grid": "Get User List", + "user_name": "Username", + "user_account": "Account", + "user_email": "User Email", + "page_num": "Page Number", + "page_size": "Page Size", + "keyword": "Search Keyword", + "status": "Status", + "origin": "Origin", + "oid": "Workspace ID", + "grid_items": "List Data", + "grid_total": "Total Count", + "grid_total_pages": "Total Pages", + "create_time": "Creation Time", + "language": "Language", + "switch_oid_api": "Switch Workspace", + "user_detail_api": "Get User Details", + "uid": "User ID", + "user_create_api": "Create User", + "user_update_api": "Update User", + "user_del_api": "Delete User", + "user_batchdel_api": "Batch Delete Users", + "language_change": "Change Language", + "reset_pwd": "Reset Password", + "update_pwd": "Update Password", + "update_status": "Toggle Status", + "origin_pwd": "Original Password", + "new_pwd": "New Password", + + "system_ws_api": "Workspace APIs", + "ws_user_grid_api": "Get Workspace User List", + "ws_user_bind_api": "Bind User to Workspace", + "ws_user_unbind_api": "Remove User from Workspace", + "ws_user_status_api": "Toggle User Role", + "ws_all_api": "Get All Workspaces", + "ws_create_api": "Create Workspace", + "ws_update_api": "Update Workspace", + "ws_query_api": "Get Workspace Details", + "ws_del_api": "Delete Workspace", + + "weight": "Weight (1: Admin, 0: Regular User)", + + "system_model_api": "Model Configuration APIs", + "system_model_default": "Set Default Model", + "system_model_grid": "Get Model List", + "system_model_query": "Get Model Details", + "system_model_create": "Save Model", + "system_model_update": "Update Model", + "system_model_del": "Delete Model", + "enable_custom_model": "Enable Custom Model", + "custom_model": "Custom Model", + "system_model_ws_mapping": "Query Model-Workspace Authorization Relationships", + "system_model_ws_mapping_update": "Update Model-Workspace Authorization Relationships", + "system_model_ws_mapping_add": "Add Model-Workspace Authorization Relationships", + "system_model_ws_mapping_delete": "Delete Model-Workspace Authorization Relationships", + "system_model_list_by_ws": "Get Model List by Workspace", + "model_name": "Name", + "model_type": "Type", + "base_model": "Base Model", + "supplier": "Operator", + "protocol": "Protocol", + "default_model": "Is Default", + "api_domain": "Base URL", + "api_key": "API Key", + "config_list": "Parameter List", + "arg_name": "Parameter Name", + "arg_val": "Parameter Value", + "arg_show_name": "Parameter Display Name", + + "system_assistant_api": "Assistant APIs", + "assistant_picture_api": "Get Pictures", + "assistant_ui_api": "Set Appearance", + "assistant_grid_api": "Get List", + "assistant_query_api": "Get Details", + "assistant_create_api": "Create", + "assistant_update_api": "Update", + "assistant_del_api": "Delete", + "assistant_domain": "Domain", + "assistant_type": "Assistant Type (0: Basic, 1: Advanced, 4: Page)", + "assistant_configuration": "Configuration", + "assistant_description": "Description", + "assistant_enableCustomModel": "Use specified model", + "assistant_customModel": "Large Language Model", + + "system_embedded_api": "Page Embedded API", + "embedded_resetsecret_api": "Reset Secret", + + "system_authentication_api": "Authentication Settings API", + "authentication_enable_api": "Enable Authentication", + "authentication_status_api": "Get Authentication Status", + "authentication_validate_api": "Validate Authentication", + + "per_api": "Data Permission", + "per_save": "Save Permission", + "per_delete": "Delete Permission", + "per_id": "Permission ID", + "per_list": "Get Permission List", + "per_get": "Get Permission Item Info", + + "tr_api": "Table Relation", + "tr_save": "Save Table Relation", + "tr_get": "Get Table Relation", + + "data_qa": "Data Q&A", + "get_chat_list": "Get Chat List", + "get_chat": "Get Chat Details", + "get_chat_with_data": "Get Chat Details (With Data)", + "get_chart_data": "Get Chart Data", + "get_chart_data_live": "Get Chart Data Live", + "get_chart_predict_data": "Get Chart Prediction Data", + "get_record_log": "Get Chart Record Log", + "get_record_usage": "Get Chart Record Token Usage & Duration", + "rename_chat": "Rename Chat", + "delete_chat": "Delete Chat", + "start_chat": "Create Chat", + "assistant_start_chat": "Assistant Create Chat", + "ask_recommend_questions": "AI Get Recommended Questions", + "get_recommend_questions": "Query Recommended Questions", + "ask_question": "Ask Question", + "analysis_or_predict": "Analyze Data / Predict Data", + "export_chart_data": "Export Chart Data", + "analysis_or_predict_action_type": "Type, allowed values: analysis | predict", + + "download-fail-info": "Download Error Information", + + "data_training_api": "SQL Examples", + "get_dt_page": "Pagination Query for SQL Examples", + "create_or_update_dt": "Create/Update SQL Example", + "delete_dt": "Delete SQL Example", + "enable_dt": "Enable/Disable", + "export_dt": "Export SQL Examples", + "excel_template_dt": "Download Template", + "upload_excel_dt": "Import SQL Examples", + + "terminology_api": "Terminology", + "get_term_page": "Pagination Query for Terms", + "create_or_update_term": "Create/Update Term", + "delete_term": "Delete Term", + "enable_term": "Enable/Disable", + "export_term": "Export Terms", + "excel_template_term": "Download Template", + "upload_term": "Import Terms", + + "custom_prompt_api": "Custom Prompts", + "custom_prompt_page": "Pagination Query for Custom Prompts", + "create_or_update_custom_prompt": "Create/Update Custom Prompt", + "delete_custom_prompt": "Delete Custom Prompt", + "export_custom_prompt": "Export Custom Prompts", + "excel_template_custom_prompt": "Download Template", + "upload_custom_prompt": "Import Custom Prompts", + + "db_api": "Dashboard Interface", + "list_resource_api": "Load Resource Tree", + "load_resource_api": "Load Resource Tree Details", + "create_resource_api": "Create Resource", + "update_resource": "Update Resource", + "delete_resource_api": "Delete Resource", + "create_canvas_api": "Create Dashboard", + "update_canvas_api": "Update Dashboard", + "check_name_api": "Name Validation", + + "variable_api": "System Variable", + "variable_save": "Save", + "variable_delete": "Delete", + "variable_list": "List", + "variable_page": "Pager" +} \ No newline at end of file diff --git a/backend/apps/swagger/locales/zh.json b/backend/apps/swagger/locales/zh.json new file mode 100644 index 000000000..397fda5e3 --- /dev/null +++ b/backend/apps/swagger/locales/zh.json @@ -0,0 +1,212 @@ +{ + "ds_api": "数据源接口", + "ds_list": "数据源列表", + "ds_list_description": "获取当前工作空间下所有数据源", + "ds_get": "获取数据源", + "ds_id": "数据源 ID", + "ds_check": "数据源状态校验", + "ds_add": "创建数据源", + "ds_choose_tables": "选择数据表", + "ds_update": "编辑数据源", + "ds_delete": "删除数据源", + "ds_get_tables": "获取数据库的表信息", + "ds_get_schema": "获取数据库Schema", + "ds_get_fields": "获取数据库表字段", + "ds_sync_fields": "同步表字段", + "ds_table_id": "表 ID", + "ds_table_name": "表名", + "ds_table_list": "获取表列表", + "ds_field_list": "获取字段列表", + "ds_edit_table": "编辑表信息", + "ds_edit_field": "编辑字段信息", + "ds_preview_data": "预览数据", + "ds_upload_excel": "上传Excel", + "ds_excel": "文件", + "ds_parse_excel": "解析Excel并预览数据", + "ds_import_to_db": "导入数据到数据库", + "ds_export_ds_schema": "导出备注信息", + "ds_upload_ds_schema": "导入备注信息", + + "mcp_api": "MCP Tools(不建议api方式调用)", + + "recommended_problem_api": "推荐问题", + "rp_save": "保存问题", + "rp_get": "获取推荐提问", + "rp_base": "获取数据源推荐提问", + + "audit_api": "日志审计", + "audit_export": "导出日志审计", + "audit_get_log_page": "分页获取日志审计", + "audit_get_option": "获取审计配置", + + "system": "系统管理", + "system_user_api": "用户接口", + "system_user_current_user": "查询当前用户", + "system_user_current_user_desc": "查询当前登录的用户信息", + "system_user_grid": "查询用户列表", + "user_name": "用户名", + "user_account": "账号", + "user_email": "用户邮箱", + "page_num": "页码", + "page_size": "页容量", + "keyword": "搜索关键字", + "status": "状态", + "origin": "来源", + "oid": "空间ID", + "grid_items": "列表数据", + "grid_total": "总数量", + "grid_total_pages": "总页数", + "create_time": "创建时间", + "language": "语言", + "switch_oid_api": "切换工作空间", + "user_detail_api": "查询用户详情", + "uid": "用户ID", + "user_create_api": "创建用户", + "user_update_api": "修改用户", + "user_del_api": "删除用户", + "user_batchdel_api": "批量删除用户", + "language_change": "切换语言", + "reset_pwd": "重置密码", + "update_pwd": "修改密码", + "update_status": "切换状态", + "origin_pwd": "原始密码", + "new_pwd": "新密码", + + "system_ws_api": "工作空间接口", + "ws_user_grid_api": "查询工作空间下用户列表", + "ws_user_bind_api": "工作空间绑定用户", + "ws_user_unbind_api": "工作空间移除用户", + "ws_user_status_api": "切换用户角色", + "ws_all_api": "查询所有工作空间", + "ws_create_api": "创建工作空间", + "ws_update_api": "修改工作空间", + "ws_query_api": "查询工作空间详情", + "ws_del_api": "删除工作空间", + + "weight": "权重(1: 管理员, 0: 普通用户)", + + "system_model_api": "模型配置api", + "system_model_default": "设置默认模型", + "system_model_grid": "查询模型列表", + "system_model_query": "查询模型详情", + "system_model_create": "保存模型", + "system_model_update": "更新模型", + "system_model_del": "删除模型", + "enable_custom_model": "启用自定义模型", + "custom_model": "自定义模型", + "system_model_ws_mapping": "查询模型授权工作空间的关联关系", + "system_model_ws_mapping_update": "更新模型授权工作空间的关联关系", + "system_model_ws_mapping_add": "新增模型授权工作空间的关联关系", + "system_model_ws_mapping_delete": "删除模型授权工作空间的关联关系", + "system_model_list_by_ws": "根据工作空间获取模型列表", + "model_name": "名称", + "model_type": "类型", + "base_model": "基础模型", + "supplier": "运营商", + "protocol": "协议", + "default_model": "是否默认", + "api_domain": "基础 Url", + "api_key": "Api key", + "config_list": "参数列表", + "arg_name": "参数名称", + "arg_val": "参数值", + "arg_show_name": "参数展示名称", + + "system_assistant_api": "小助手api", + "assistant_picture_api": "查询图片", + "assistant_ui_api": "设置外观", + "assistant_grid_api": "查询列表", + "assistant_query_api": "查询详情", + "assistant_create_api": "创建", + "assistant_update_api": "修改", + "assistant_del_api": "删除", + "assistant_domain": "域名", + "assistant_type": "助手类型(0: 基础, 1: 高级, 4: 页面)", + "assistant_configuration": "配置", + "assistant_description": "描述", + "assistant_enableCustomModel": "使用指定大模型", + "assistant_customModel": "大语言模型", + + "system_embedded_api": "页面嵌入式api", + "embedded_resetsecret_api": "重置 Secret", + + "system_authentication_api": "认证设置api", + "authentication_enable_api": "设置开启", + "authentication_status_api": "查询状态", + "authentication_validate_api": "校验状态", + + "per_api": "数据权限", + "per_save": "保存权限", + "per_delete": "删除权限", + "per_id": "权限 ID", + "per_list": "获取权限列表", + "per_get": "获取单条权限详情", + + "tr_api": "表关联关系", + "tr_save": "保存关联关系", + "tr_get": "查询关联关系", + + "data_qa": "智能问数", + "get_chat_list": "获取对话列表", + "get_chat": "获取对话详情", + "get_chat_with_data": "获取对话详情(带数据)", + "get_chart_data": "获取图表数据", + "get_chart_data_live": "获取图表实时数据", + "get_chart_predict_data": "获取图表预测数据", + "get_record_log": "获取对话日志", + "get_record_usage": "获取对话Token使用量及耗时", + "rename_chat": "重命名对话", + "delete_chat": "删除对话", + "start_chat": "创建对话", + "assistant_start_chat": "小助手创建对话", + "ask_recommend_questions": "AI获取推荐提问", + "get_recommend_questions": "查询推荐提问", + "ask_question": "提问", + "analysis_or_predict": "分析数据/预测数据", + "export_chart_data": "导出图表数据", + "analysis_or_predict_action_type": "类型,可传入值为:analysis | predict", + + "download-fail-info": "下载错误信息", + + "data_training_api": "SQL示例", + "get_dt_page": "分页查询SQL示例", + "create_or_update_dt": "创建/更新SQL示例", + "delete_dt": "删除SQL示例", + "enable_dt": "启用/禁用", + "export_dt": "导出SQL示例", + "excel_template_dt": "下载模板", + "upload_excel_dt": "导入SQL示例", + + "terminology_api": "术语", + "get_term_page": "分页查询术语", + "create_or_update_term": "创建/更新术语", + "delete_term": "删除术语", + "enable_term": "启用/禁用", + "export_term": "导出术语", + "excel_template_term": "下载模板", + "upload_term": "导入术语", + + "custom_prompt_api": "自定义提示词", + "custom_prompt_page": "分页查询自定义提示词", + "create_or_update_custom_prompt": "创建/更新自定义提示词", + "delete_custom_prompt": "删除自定义提示词", + "export_custom_prompt": "导出自定义提示词", + "excel_template_custom_prompt": "下载模板", + "upload_custom_prompt": "导入自定义提示词", + + "db_api": "仪表板接口", + "list_resource_api": "加载资源树", + "load_resource_api": "加载资源树明细", + "create_resource_api": "创建资源", + "update_resource": "更新资源", + "delete_resource_api": "删除资源", + "create_canvas_api": "新建仪表板", + "update_canvas_api": "更新仪表板", + "check_name_api": "名称校验", + + "variable_api": "系统变量", + "variable_save": "保存变量", + "variable_delete": "删除变量", + "variable_list": "获取变量", + "variable_page": "获取变量分页" +} diff --git a/backend/apps/system/api/aimodel.py b/backend/apps/system/api/aimodel.py index 9e949af63..620adac7b 100644 --- a/backend/apps/system/api/aimodel.py +++ b/backend/apps/system/api/aimodel.py @@ -1,26 +1,35 @@ import json from typing import List, Union +from fastapi import APIRouter, Path, Query, Body from fastapi.responses import StreamingResponse +from sqlmodel import func, select, update, delete + from apps.ai_model.model_factory import LLMConfig, LLMFactory +from apps.swagger.i18n import PLACEHOLDER_PREFIX +from apps.system.crud.aimodel_manage import get_ai_model_list_by_workspace +from apps.system.models.system_model import AiModelDetail, AiModelWorkspaceMapping, AiModelBrief from apps.system.schemas.ai_model_schema import AiModelConfigItem, AiModelCreator, AiModelEditor, AiModelGridItem -from fastapi import APIRouter, Query -from sqlmodel import func, select, update - -from apps.system.models.system_model import AiModelDetail -from common.core.deps import SessionDep, Trans +from apps.system.schemas.permission import SqlbotPermission, require_permissions +from common.core.deps import SessionDep, Trans, CurrentUser +from common.utils.crypto import sqlbot_decrypt from common.utils.time import get_timestamp from common.utils.utils import SQLBotLogUtil, prepare_model_arg -router = APIRouter(tags=["system/aimodel"], prefix="/system/aimodel") +router = APIRouter(tags=["system_model"], prefix="/system/aimodel") +from common.audit.models.log_model import OperationType, OperationModules +from common.audit.schemas.logger_decorator import LogConfig, system_log + -@router.post("/status") +@router.post("/status", include_in_schema=False) +@require_permissions(permission=SqlbotPermission(role=['admin'])) async def check_llm(info: AiModelCreator, trans: Trans): async def generate(): try: - additional_params = {item.key: prepare_model_arg(item.val) for item in info.config_list if item.key and item.val} + additional_params = {item.key: prepare_model_arg(item.val) for item in info.config_list if + item.key and item.val} config = LLMConfig( - model_type="openai", + model_type="openai" if info.protocol == 1 else "vllm", model_name=info.base_model, api_key=info.api_key, api_base_url=info.api_domain, @@ -28,26 +37,34 @@ async def generate(): ) llm_instance = LLMFactory.create_llm(config) async for chunk in llm_instance.llm.astream("1+1=?"): - if chunk and chunk.content: + SQLBotLogUtil.info(chunk) + if chunk and isinstance(chunk, str): + yield json.dumps({"content": chunk}) + "\n" + if chunk and isinstance(chunk, dict) and chunk.content: yield json.dumps({"content": chunk.content}) + "\n" - + except Exception as e: SQLBotLogUtil.error(f"Error checking LLM: {e}") error_msg = trans('i18n_llm.validate_error', msg=str(e)) yield json.dumps({"error": error_msg}) + "\n" - + return StreamingResponse(generate(), media_type="application/x-ndjson") -@router.get("/default") + +@router.get("/default", include_in_schema=False) async def check_default(session: SessionDep, trans: Trans): db_model = session.exec( select(AiModelDetail).where(AiModelDetail.default_model == True) ).first() if not db_model: raise Exception(trans('i18n_llm.miss_default')) - -@router.put("/default/{id}") -async def set_default(session: SessionDep, id: int): + + +@router.put("/default/{id}", summary=f"{PLACEHOLDER_PREFIX}system_model_default", + description=f"{PLACEHOLDER_PREFIX}system_model_default") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +@system_log(LogConfig(operation_type=OperationType.UPDATE, module=OperationModules.AI_MODEL, resource_id_expr="id")) +async def set_default(session: SessionDep, id: int = Path(description="ID")): db_model = session.get(AiModelDetail, id) if not db_model: raise ValueError(f"AiModelDetail with id {id} not found") @@ -65,27 +82,49 @@ async def set_default(session: SessionDep, id: int): session.rollback() raise e -@router.get("", response_model=list[AiModelGridItem]) + +@router.get("", response_model=list[AiModelGridItem], summary=f"{PLACEHOLDER_PREFIX}system_model_grid", + description=f"{PLACEHOLDER_PREFIX}system_model_grid") +@require_permissions(permission=SqlbotPermission(role=['admin'])) async def query( session: SessionDep, - keyword: Union[str, None] = Query(default=None, max_length=255) + keyword: Union[str, None] = Query(default=None, max_length=255, description=f"{PLACEHOLDER_PREFIX}keyword") ): - statement = select(AiModelDetail.id, - AiModelDetail.name, - AiModelDetail.model_type, - AiModelDetail.base_model, - AiModelDetail.supplier, - AiModelDetail.default_model) + # 子查询:统计每个 model 绑定的 workspace 数量 + count_sub = ( + select( + AiModelWorkspaceMapping.ai_model_id, + func.count().label("ws_mapping_count") + ) + .group_by(AiModelWorkspaceMapping.ai_model_id) + .subquery() + ) + statement = ( + select( + AiModelDetail.id, + AiModelDetail.name, + AiModelDetail.model_type, + AiModelDetail.base_model, + AiModelDetail.supplier, + AiModelDetail.protocol, + AiModelDetail.default_model, + func.coalesce(count_sub.c.ws_mapping_count, 0).label("ws_mapping_count"), + ) + .outerjoin(count_sub, AiModelDetail.id == count_sub.c.ai_model_id) + ) if keyword is not None: statement = statement.where(AiModelDetail.name.like(f"%{keyword}%")) statement = statement.order_by(AiModelDetail.default_model.desc(), AiModelDetail.name, AiModelDetail.create_time) items = session.exec(statement).all() return items -@router.get("/{id}", response_model=AiModelEditor) + +@router.get("/{id}", response_model=AiModelEditor, summary=f"{PLACEHOLDER_PREFIX}system_model_query", + description=f"{PLACEHOLDER_PREFIX}system_model_query") +@require_permissions(permission=SqlbotPermission(role=['admin'])) async def get_model_by_id( session: SessionDep, - id: int + id: int = Path(description="ID") ): db_model = session.get(AiModelDetail, id) if not db_model: @@ -98,12 +137,23 @@ async def get_model_by_id( config_list = [AiModelConfigItem(**item) for item in raw] except Exception: pass + try: + if db_model.api_key: + db_model.api_key = await sqlbot_decrypt(db_model.api_key) + if db_model.api_domain: + db_model.api_domain = await sqlbot_decrypt(db_model.api_domain) + except Exception: + pass data = AiModelDetail.model_validate(db_model).model_dump(exclude_unset=True) data.pop("config", None) data["config_list"] = config_list return AiModelEditor(**data) -@router.post("") + +@router.post("", summary=f"{PLACEHOLDER_PREFIX}system_model_create", + description=f"{PLACEHOLDER_PREFIX}system_model_create") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +@system_log(LogConfig(operation_type=OperationType.CREATE, module=OperationModules.AI_MODEL, result_id_expr="id")) async def add_model( session: SessionDep, creator: AiModelCreator @@ -118,8 +168,14 @@ async def add_model( detail.default_model = True session.add(detail) session.commit() + return detail + -@router.put("") +@router.put("", summary=f"{PLACEHOLDER_PREFIX}system_model_update", + description=f"{PLACEHOLDER_PREFIX}system_model_update") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +@system_log( + LogConfig(operation_type=OperationType.UPDATE, module=OperationModules.AI_MODEL, resource_id_expr="editor.id")) async def update_model( session: SessionDep, editor: AiModelEditor @@ -129,22 +185,178 @@ async def update_model( data["config"] = json.dumps([item.model_dump(exclude_unset=True) for item in editor.config_list]) data.pop("config_list", None) db_model = session.get(AiModelDetail, id) - #update_data = AiModelDetail.model_validate(data) + # update_data = AiModelDetail.model_validate(data) db_model.sqlmodel_update(data) session.add(db_model) session.commit() -@router.delete("/{id}") + +@router.delete("/{id}", summary=f"{PLACEHOLDER_PREFIX}system_model_del", + description=f"{PLACEHOLDER_PREFIX}system_model_del") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +@system_log(LogConfig(operation_type=OperationType.DELETE, module=OperationModules.AI_MODEL, resource_id_expr="id")) async def delete_model( session: SessionDep, trans: Trans, - id: int + id: int = Path(description="ID") ): item = session.get(AiModelDetail, id) if item.default_model: - raise Exception(trans('i18n_llm.delete_default_error', key = item.name)) + raise Exception(trans('i18n_llm.delete_default_error', key=item.name)) session.delete(item) session.commit() - - \ No newline at end of file + +@router.get("/{id}/ws_mapping", response_model=List[str], summary=f"{PLACEHOLDER_PREFIX}system_model_ws_mapping", + description=f"{PLACEHOLDER_PREFIX}system_model_ws_mapping") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +async def get_model_ws_mapping_by_id( + session: SessionDep, + id: int = Path(description="ID") +): + db_model = session.get(AiModelDetail, id) + if not db_model: + raise ValueError(f"AiModelDetail with id {id} not found") + + # 根据 ai_model_id 查询关联的 workspace_id 列表 + stmt = ( + select(AiModelWorkspaceMapping.workspace_id) + .where(AiModelWorkspaceMapping.ai_model_id == id) + .distinct() + ) + ws_ids: List[int] = session.exec(stmt).all() + + return [str(ws_id) for ws_id in ws_ids] + + +@router.put("/{id}/ws_mapping", response_model=List[str], summary=f"{PLACEHOLDER_PREFIX}system_model_ws_mapping_update", + description=f"{PLACEHOLDER_PREFIX}system_model_ws_mapping_update") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +async def update_model_ws_mapping_by_id( + session: SessionDep, + id: int = Path(description="ID"), + ws_ids: List[str] = Body(description="workspace id list"), +): + if ws_ids is None: + ws_ids = [] + # 提前去重 + ws_ids = list({int(ws_id) for ws_id in ws_ids}) + + db_model = session.get(AiModelDetail, id) + if not db_model: + raise ValueError(f"AiModelDetail with id {id} not found") + + # 根据 ai_model_id 更新关联的 workspace_id 列表 + # 1. 批量删除旧映射 + session.execute( + delete(AiModelWorkspaceMapping) + .where(AiModelWorkspaceMapping.ai_model_id == id) + ) + + # 2. 插入去重后的映射关系 + for ws_id in ws_ids: + session.add( + AiModelWorkspaceMapping(ai_model_id=id, workspace_id=ws_id) + ) + + session.commit() + + return [str(ws_id) for ws_id in ws_ids] + + +# 新增映射(在已有基础上追加) +@router.post("/{id}/ws_mapping", response_model=List[str], summary=f"{PLACEHOLDER_PREFIX}system_model_ws_mapping_add", + description=f"{PLACEHOLDER_PREFIX}system_model_ws_mapping_add") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +async def add_model_ws_mapping_by_id( + session: SessionDep, + id: int = Path(description="ID"), + ws_ids: List[str] = Body(description="workspace id list"), +): + if ws_ids is None: + ws_ids = [] + ws_ids = list({int(ws_id) for ws_id in ws_ids}) + + db_model = session.get(AiModelDetail, id) + if not db_model: + raise ValueError(f"AiModelDetail with id {id} not found") + + # 查询已存在的映射,过滤掉重复的 + existing_stmt = ( + select(AiModelWorkspaceMapping.workspace_id) + .where( + AiModelWorkspaceMapping.ai_model_id == id, + AiModelWorkspaceMapping.workspace_id.in_(ws_ids), + ) + ) + existing_ws_ids = set(session.exec(existing_stmt).all()) + + # 只插入不存在的映射 + new_ws_ids = [ws_id for ws_id in ws_ids if ws_id not in existing_ws_ids] + for ws_id in new_ws_ids: + session.add( + AiModelWorkspaceMapping(ai_model_id=id, workspace_id=ws_id) + ) + + session.commit() + + # 返回完整的映射列表 + all_stmt = ( + select(AiModelWorkspaceMapping.workspace_id) + .where(AiModelWorkspaceMapping.ai_model_id == id) + .distinct() + ) + all_ws_ids: List[int] = session.exec(all_stmt).all() + + return [str(ws_id) for ws_id in all_ws_ids] + + +# 删除指定映射 +@router.delete("/{id}/ws_mapping", response_model=List[str], + summary=f"{PLACEHOLDER_PREFIX}system_model_ws_mapping_delete", + description=f"{PLACEHOLDER_PREFIX}system_model_ws_mapping_delete") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +async def delete_model_ws_mapping_by_id( + session: SessionDep, + id: int = Path(description="ID"), + ws_ids: List[str] = Body(description="workspace id list"), +): + if ws_ids is None: + ws_ids = [] + ws_ids = list({int(ws_id) for ws_id in ws_ids}) + + db_model = session.get(AiModelDetail, id) + if not db_model: + raise ValueError(f"AiModelDetail with id {id} not found") + + # 只删除指定的映射 + if ws_ids: + session.execute( + delete(AiModelWorkspaceMapping) + .where( + AiModelWorkspaceMapping.ai_model_id == id, + AiModelWorkspaceMapping.workspace_id.in_(ws_ids), + ) + ) + + session.commit() + + # 返回剩余的映射列表 + stmt = ( + select(AiModelWorkspaceMapping.workspace_id) + .where(AiModelWorkspaceMapping.ai_model_id == id) + .distinct() + ) + remaining_ws_ids: List[int] = session.exec(stmt).all() + + return [str(ws_id) for ws_id in remaining_ws_ids] + + +@router.get("/list/by_ws", response_model=List[AiModelBrief], summary=f"{PLACEHOLDER_PREFIX}system_model_list_by_ws", + description=f"{PLACEHOLDER_PREFIX}system_model_list_by_ws") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +async def get_model_by_ws( + session: SessionDep, + current_user: CurrentUser +): + return get_ai_model_list_by_workspace(session, current_user.oid, False) diff --git a/backend/apps/system/api/apikey.py b/backend/apps/system/api/apikey.py new file mode 100644 index 000000000..4887e2477 --- /dev/null +++ b/backend/apps/system/api/apikey.py @@ -0,0 +1,65 @@ + +from fastapi import APIRouter +from sqlmodel import func, select +from apps.system.crud.apikey_manage import clear_api_key_cache +from apps.system.models.system_model import ApiKeyModel +from apps.system.schemas.system_schema import ApikeyGridItem, ApikeyStatus +from common.core.deps import CurrentUser, SessionDep +from common.utils.time import get_timestamp +import secrets + +router = APIRouter(tags=["system_apikey"], prefix="/system/apikey", include_in_schema=False) +from common.audit.models.log_model import OperationType, OperationModules +from common.audit.schemas.logger_decorator import LogConfig, system_log + +@router.get("") +async def grid(session: SessionDep, current_user: CurrentUser) -> list[ApikeyGridItem]: + query = select(ApiKeyModel).where(ApiKeyModel.uid == current_user.id).order_by(ApiKeyModel.create_time.desc()) + return session.exec(query).all() + +@router.post("") +@system_log(LogConfig(operation_type=OperationType.CREATE, module=OperationModules.API_KEY,result_id_expr='result.self')) +async def create(session: SessionDep, current_user: CurrentUser): + count = session.exec(select(func.count()).select_from(ApiKeyModel).where(ApiKeyModel.uid == current_user.id)).one() + if count >= 5: + raise ValueError("Maximum of 5 API keys allowed") + access_key = secrets.token_urlsafe(16) + secret_key = secrets.token_urlsafe(32) + api_key = ApiKeyModel( + access_key=access_key, + secret_key=secret_key, + create_time=get_timestamp(), + uid=current_user.id, + status=True + ) + session.add(api_key) + session.commit() + return api_key.id + +@router.put("/status") +@system_log(LogConfig(operation_type=OperationType.UPDATE, module=OperationModules.API_KEY,resource_id_expr='id')) +async def status(session: SessionDep, current_user: CurrentUser, dto: ApikeyStatus): + api_key = session.get(ApiKeyModel, dto.id) + if not api_key: + raise ValueError("API Key not found") + if api_key.uid != current_user.id: + raise PermissionError("No permission to modify this API Key") + if dto.status == api_key.status: + return + api_key.status = dto.status + await clear_api_key_cache(api_key.access_key) + session.add(api_key) + session.commit() + +@router.delete("/{id}") +@system_log(LogConfig(operation_type=OperationType.DELETE, module=OperationModules.API_KEY,resource_id_expr='id')) +async def delete(session: SessionDep, current_user: CurrentUser, id: int): + api_key = session.get(ApiKeyModel, id) + if not api_key: + raise ValueError("API Key not found") + if api_key.uid != current_user.id: + raise PermissionError("No permission to delete this API Key") + await clear_api_key_cache(api_key.access_key) + session.delete(api_key) + session.commit() + \ No newline at end of file diff --git a/backend/apps/system/api/assistant.py b/backend/apps/system/api/assistant.py index 89b9c6607..138e534c2 100644 --- a/backend/apps/system/api/assistant.py +++ b/backend/apps/system/api/assistant.py @@ -1,70 +1,307 @@ -from datetime import timedelta -from typing import Optional -from fastapi import APIRouter, FastAPI, Query, Request, Response -from sqlmodel import Session, select -from apps.system.crud.assistant import get_assistant_info +import json +import os +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo +from typing import List, Optional + +from fastapi import APIRouter, Form, HTTPException, Path, Query, Request, Response, UploadFile +from fastapi.responses import StreamingResponse +from sqlbot_xpack.file_utils import SQLBotFileUtils +from sqlmodel import select + +from apps.datasource.models.datasource import CoreDatasource +from apps.db.constant import DB +from apps.swagger.i18n import PLACEHOLDER_PREFIX +from apps.system.crud.assistant import AssistantOutDs, AssistantOutDsFactory, get_assistant_info +from apps.system.crud.assistant_manage import dynamic_upgrade_cors, save from apps.system.models.system_model import AssistantModel from apps.system.schemas.auth import CacheName, CacheNamespace -from apps.system.schemas.system_schema import AssistantBase, AssistantDTO, AssistantValidator -from common.core.deps import SessionDep, Trans +from apps.system.schemas.permission import SqlbotPermission, require_permissions +from apps.system.schemas.system_schema import AssistantBase, AssistantDTO, AssistantUiSchema, AssistantValidator +from common.core.config import settings +from common.core.deps import CurrentAssistant, SessionDep, Trans, CurrentUser from common.core.security import create_access_token from common.core.sqlbot_cache import clear_cache -from common.utils.time import get_timestamp -from starlette.middleware.cors import CORSMiddleware -from common.core.config import settings -from common.utils.utils import get_origin_from_referer -router = APIRouter(tags=["system/assistant"], prefix="/system/assistant") +from common.utils.utils import get_origin_from_referer, origin_match_domain +router = APIRouter(tags=["system_assistant"], prefix="/system/assistant") +from common.audit.models.log_model import OperationType, OperationModules +from common.audit.schemas.logger_decorator import LogConfig, system_log +from sqlbot_xpack.core import decrypt_embedded_sign -@router.get("/info/{id}") -async def info(request: Request, response: Response, session: SessionDep, trans: Trans, id: int) -> AssistantModel: +@router.get("/info/{id}", include_in_schema=False) +async def info(request: Request, response: Response, session: SessionDep, trans: Trans, id: int, virtual: Optional[int] = Query(None)): if not id: raise Exception('miss assistant id') db_model = await get_assistant_info(session=session, assistant_id=id) if not db_model: raise RuntimeError(f"assistant application not exist") db_model = AssistantModel.model_validate(db_model) - response.headers["Access-Control-Allow-Origin"] = db_model.domain + + # 校验 SQLBOT-EMBEDDED-SIGN 请求头 + sign_header = request.headers.get("SQLBOT-EMBEDDED-SIGN") + if not sign_header: + raise RuntimeError(trans('i18n_embedded.invalid_origin', origin='')) + + sign_data = await decrypt_embedded_sign(sign_header) + + # 校验 assistant_id 与 id 参数一致 + if str(sign_data.get("assistant_id")) != str(id): + raise RuntimeError(trans('i18n_embedded.invalid_origin', origin='')) + + # 校验 target(来源域名)是否合法 + target = sign_data.get("target", "") + if not origin_match_domain(target, db_model.domain): + raise RuntimeError(trans('i18n_embedded.invalid_origin', origin=target or '')) + + # 校验 sign_time 是否在 10 秒内 + sign_time_str = sign_data.get("sign_time", "") + sign_time = datetime.fromisoformat(sign_time_str) + now_utc = datetime.now(timezone.utc) + sign_time_utc = sign_time.astimezone(timezone.utc) + if abs((now_utc - sign_time_utc).total_seconds()) > 10: + raise RuntimeError(trans('i18n_embedded.invalid_origin', origin=target or '')) + + # 校验是否为真实浏览器请求(非自动化工具) + if sign_data.get("webdriver", False): + raise RuntimeError(trans('i18n_embedded.invalid_origin', origin=target or '')) + + # 校验 User-Agent 一致性(签名中的 navigator.userAgent 与请求头一致) + sign_user_agent = sign_data.get("user_agent", "") + request_user_agent = request.headers.get("User-Agent", "") + if sign_user_agent != request_user_agent: + raise RuntimeError(trans('i18n_embedded.invalid_origin', origin=target or '')) + + # 校验 timezone 与 sign_time 偏移一致性(防时区伪造) + tz_name = sign_data.get("timezone", "") + tz = ZoneInfo(tz_name) + sign_time_naive = sign_time.replace(tzinfo=None) + if tz.utcoffset(sign_time_naive) != sign_time.utcoffset(): + raise RuntimeError(trans('i18n_embedded.invalid_origin', origin=target or '')) + + origin = target.rstrip('/') + + response.headers["Access-Control-Allow-Origin"] = origin + + + assistant_oid = 1 + if (db_model.type == 0): + configuration = db_model.configuration + config_obj = json.loads(configuration) if configuration else {} + assistant_oid = config_obj.get('oid', 1) + + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + assistantDict = { + "id": virtual, "account": 'sqlbot-inner-assistant', "oid": assistant_oid, "assistant_id": id + } + access_token = create_access_token( + assistantDict, expires_delta=access_token_expires + ) + + result = db_model.model_dump() + result["token"] = access_token + return result + + +@router.get("/app/{appId}", include_in_schema=False) +async def getApp(request: Request, response: Response, session: SessionDep, trans: Trans, appId: str) -> AssistantModel: + if not appId: + raise Exception('miss assistant appId') + db_model = session.exec(select(AssistantModel).where(AssistantModel.app_id == appId)).first() + if not db_model: + raise RuntimeError(f"assistant application not exist") + db_model = AssistantModel.model_validate(db_model) origin = request.headers.get("origin") or get_origin_from_referer(request) + if not origin: + raise RuntimeError(trans('i18n_embedded.invalid_origin', origin=origin or '')) origin = origin.rstrip('/') - if origin != db_model.domain: - raise RuntimeError(trans('i18n_embedded.invalid_origin', origin = origin or '')) + if not origin_match_domain(origin, db_model.domain): + raise RuntimeError(trans('i18n_embedded.invalid_origin', origin=origin or '')) + + response.headers["Access-Control-Allow-Origin"] = origin return db_model -@router.get("/validator", response_model=AssistantValidator) + +""" @router.get("/validator", response_model=AssistantValidator, include_in_schema=False) async def validator(session: SessionDep, id: int, virtual: Optional[int] = Query(None)): if not id: raise Exception('miss assistant id') - + db_model = await get_assistant_info(session=session, assistant_id=id) if not db_model: return AssistantValidator() db_model = AssistantModel.model_validate(db_model) + assistant_oid = 1 + if (db_model.type == 0): + configuration = db_model.configuration + config_obj = json.loads(configuration) if configuration else {} + assistant_oid = config_obj.get('oid', 1) + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) assistantDict = { - "id": virtual, "account": 'sqlbot-inner-assistant', "oid": 1, "assistant_id": id + "id": virtual, "account": 'sqlbot-inner-assistant', "oid": assistant_oid, "assistant_id": id } access_token = create_access_token( assistantDict, expires_delta=access_token_expires ) - return AssistantValidator(True, True, True, access_token) - + return AssistantValidator(True, True, True, access_token) """ -@router.get("", response_model=list[AssistantModel]) -async def query(session: SessionDep): - list_result = session.exec(select(AssistantModel).order_by(AssistantModel.name, AssistantModel.create_time)).all() - return list_result -@router.post("") -async def add(request: Request, session: SessionDep, creator: AssistantBase): - db_model = AssistantModel.model_validate(creator) - db_model.create_time = get_timestamp() +@router.get('/picture/{file_id}', summary=f"{PLACEHOLDER_PREFIX}assistant_picture_api", description=f"{PLACEHOLDER_PREFIX}assistant_picture_api") +async def picture(file_id: str = Path(description="file_id")): + file_path = SQLBotFileUtils.get_file_path(file_id=file_id) + if not os.path.exists(file_path): + raise HTTPException(status_code=404, detail="File not found") + + if file_id.lower().endswith(".svg"): + media_type = "image/svg+xml" + else: + media_type = "image/jpeg" + + def iterfile(): + with open(file_path, mode="rb") as f: + yield from f + + return StreamingResponse(iterfile(), media_type=media_type) + + +@router.patch('/ui', summary=f"{PLACEHOLDER_PREFIX}assistant_ui_api", description=f"{PLACEHOLDER_PREFIX}assistant_ui_api") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +@system_log(LogConfig(operation_type=OperationType.UPDATE, module=OperationModules.APPLICATION, result_id_expr="id")) +async def ui(session: SessionDep, data: str = Form(), files: List[UploadFile] = []): + json_data = json.loads(data) + uiSchema = AssistantUiSchema(**json_data) + id = uiSchema.id + db_model = session.get(AssistantModel, id) + if not db_model: + raise ValueError(f"AssistantModel with id {id} not found") + configuration = db_model.configuration + config_obj = json.loads(configuration) if configuration else {} + + ui_schema_dict = uiSchema.model_dump(exclude_none=True, exclude_unset=True) + if files: + for file in files: + origin_file_name = file.filename + file_name, flag_name = SQLBotFileUtils.split_filename_and_flag(origin_file_name) + file.filename = file_name + if flag_name == 'logo' or flag_name == 'float_icon': + try: + SQLBotFileUtils.check_file(file=file, file_types=[".jpg", ".jpeg", ".png"], + limit_file_size=(10 * 1024 * 1024)) + except ValueError as e: + error_msg = str(e) + if '文件大小超过限制' in error_msg: + raise ValueError(f"文件大小超过限制(最大 10 M)") + else: + raise e + if config_obj.get(flag_name): + SQLBotFileUtils.delete_file(config_obj.get(flag_name)) + file_id = await SQLBotFileUtils.upload(file) + ui_schema_dict[flag_name] = file_id + else: + raise ValueError(f"Unsupported file flag: {flag_name}") + + for flag_name in ['logo', 'float_icon']: + file_val = config_obj.get(flag_name) + if file_val and not ui_schema_dict.get(flag_name): + config_obj[flag_name] = None + SQLBotFileUtils.delete_file(file_val) + + for attr, value in ui_schema_dict.items(): + if attr != 'id' and not attr.startswith("__"): + config_obj[attr] = value + + db_model.configuration = json.dumps(config_obj, ensure_ascii=False) session.add(db_model) session.commit() - dynamic_upgrade_cors(request=request, session=session) + await clear_ui_cache(db_model.id) + return db_model - -@router.put("") -@clear_cache(namespace=CacheNamespace.EMBEDDED_INFO, cacheName=CacheName.ASSISTANT_INFO, keyExpression="editor.id") + +@clear_cache(namespace=CacheNamespace.EMBEDDED_INFO, cacheName=CacheName.ASSISTANT_INFO, keyExpression="id") +async def clear_ui_cache(id: int): + pass + +@router.get("/ds", include_in_schema=False, response_model=list[dict]) +async def ds(session: SessionDep, current_assistant: CurrentAssistant): + if current_assistant.type == 0: + online = current_assistant.online + configuration = current_assistant.configuration + config: dict[any] = json.loads(configuration) + oid: int = int(config['oid']) + stmt = select(CoreDatasource.id, CoreDatasource.name, CoreDatasource.description, CoreDatasource.type, CoreDatasource.type_name, CoreDatasource.num).where( + CoreDatasource.oid == oid) + if not online: + public_list: list[int] = config.get('public_list') or None + if public_list: + stmt = stmt.where(CoreDatasource.id.in_(public_list)) + else: + return [] + db_ds_list = session.exec(stmt) + return [ + { + "id": ds.id, + "name": ds.name, + "description": ds.description, + "type": ds.type, + "type_name": ds.type_name, + "num": ds.num, + } + for ds in db_ds_list] + if current_assistant.type == 1: + out_ds_instance: AssistantOutDs = AssistantOutDsFactory.get_instance(current_assistant) + return [ + { + "id": str(ds.id), + "name": ds.name, + "description": ds.description or ds.comment, + "type": ds.type, + "type_name": get_db_type(ds.type), + "num": len(ds.tables) if ds.tables else 0, + } + for ds in out_ds_instance.ds_list + if get_db_type(ds.type) + ] + + return None + +def get_db_type(type): + try: + db = DB.get_db(type) + return db.db_name + except Exception: + return None + + +@router.get("", response_model=list[AssistantModel], summary=f"{PLACEHOLDER_PREFIX}assistant_grid_api", description=f"{PLACEHOLDER_PREFIX}assistant_grid_api") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +async def query(session: SessionDep, current_user: CurrentUser): + list_result = session.exec(select(AssistantModel).where(AssistantModel.oid == current_user.oid, AssistantModel.type != 4).order_by(AssistantModel.name, + AssistantModel.create_time)).all() + for model in list_result: + model.enable_custom_model = model.enable_custom_model or False + return list_result + + +@router.get("/advanced_application", response_model=list[AssistantModel], include_in_schema=False) +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +async def query_advanced_application(session: SessionDep, current_user: CurrentUser): + list_result = session.exec(select(AssistantModel).where(AssistantModel.type == 1, AssistantModel.oid == current_user.oid).order_by(AssistantModel.name, + AssistantModel.create_time)).all() + return list_result + + +@router.post("", summary=f"{PLACEHOLDER_PREFIX}assistant_create_api", description=f"{PLACEHOLDER_PREFIX}assistant_create_api") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +@system_log(LogConfig(operation_type=OperationType.CREATE, module=OperationModules.APPLICATION, result_id_expr="id")) +async def add(request: Request, session: SessionDep, current_user: CurrentUser, creator: AssistantBase): + oid = current_user.oid if creator.type != 4 else 1 + return await save(request, session, creator, oid) + + +@router.put("", summary=f"{PLACEHOLDER_PREFIX}assistant_update_api", description=f"{PLACEHOLDER_PREFIX}assistant_update_api") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +@clear_cache(namespace=CacheNamespace.EMBEDDED_INFO, cacheName=CacheName.ASSISTANT_INFO, keyExpression="editor.id") +@system_log(LogConfig(operation_type=OperationType.UPDATE, module=OperationModules.APPLICATION, resource_id_expr="editor.id")) async def update(request: Request, session: SessionDep, editor: AssistantDTO): id = editor.id db_model = session.get(AssistantModel, id) @@ -76,17 +313,21 @@ async def update(request: Request, session: SessionDep, editor: AssistantDTO): session.commit() dynamic_upgrade_cors(request=request, session=session) -@router.get("/{id}", response_model=AssistantModel) -async def get_one(session: SessionDep, id: int): + +""" @router.get("/{id}", response_model=AssistantModel, summary=f"{PLACEHOLDER_PREFIX}assistant_query_api", description=f"{PLACEHOLDER_PREFIX}assistant_query_api") +async def get_one(session: SessionDep, id: int = Path(description="ID")): db_model = await get_assistant_info(session=session, assistant_id=id) if not db_model: raise ValueError(f"AssistantModel with id {id} not found") db_model = AssistantModel.model_validate(db_model) - return db_model + return db_model """ -@router.delete("/{id}") -@clear_cache(namespace=CacheNamespace.EMBEDDED_INFO, cacheName=CacheName.ASSISTANT_INFO, keyExpression="id") -async def delete(request: Request, session: SessionDep, id: int): + +@router.delete("/{id}", summary=f"{PLACEHOLDER_PREFIX}assistant_del_api", description=f"{PLACEHOLDER_PREFIX}assistant_del_api") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +@clear_cache(namespace=CacheNamespace.EMBEDDED_INFO, cacheName=CacheName.ASSISTANT_INFO, keyExpression="id") +@system_log(LogConfig(operation_type=OperationType.DELETE, module=OperationModules.APPLICATION, resource_id_expr="id")) +async def delete(request: Request, session: SessionDep, id: int = Path(description="ID")): db_model = session.get(AssistantModel, id) if not db_model: raise ValueError(f"AssistantModel with id {id} not found") @@ -94,28 +335,3 @@ async def delete(request: Request, session: SessionDep, id: int): session.commit() dynamic_upgrade_cors(request=request, session=session) -def dynamic_upgrade_cors(request: Request, session: Session): - list_result = session.exec(select(AssistantModel).order_by(AssistantModel.create_time)).all() - seen = set() - unique_domains = [] - for item in list_result: - if item.domain: - for domain in item.domain.split(','): - domain = domain.strip() - if domain and domain not in seen: - seen.add(domain) - unique_domains.append(domain) - app: FastAPI = request.app - cors_middleware = None - for middleware in app.user_middleware: - if middleware.cls == CORSMiddleware: - cors_middleware = middleware - break - if cors_middleware: - updated_origins = list(set(settings.all_cors_origins + unique_domains)) - cors_middleware.kwargs['allow_origins'] = updated_origins - - - - - diff --git a/backend/apps/system/api/login.py b/backend/apps/system/api/login.py index 70d851468..3697cf58c 100644 --- a/backend/apps/system/api/login.py +++ b/backend/apps/system/api/login.py @@ -1,6 +1,7 @@ from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.security import OAuth2PasswordRequestForm +from apps.system.schemas.logout_schema import LogoutSchema from apps.system.schemas.system_schema import BaseUserDTO from common.core.deps import SessionDep, Trans from common.utils.crypto import sqlbot_decrypt @@ -9,9 +10,19 @@ from datetime import timedelta from common.core.config import settings from common.core.schemas import Token +from sqlbot_xpack.authentication.manage import logout as xpack_logout + +from common.audit.models.log_model import OperationType, OperationModules +from common.audit.schemas.logger_decorator import system_log, LogConfig + router = APIRouter(tags=["login"], prefix="/login") @router.post("/access-token") +@system_log(LogConfig( + operation_type=OperationType.LOGIN, + module=OperationModules.USER, + result_id_expr="id" +)) async def local_login( session: SessionDep, trans: Trans, @@ -26,8 +37,16 @@ async def local_login( raise HTTPException(status_code=400, detail=trans('i18n_login.no_associated_ws', msg = trans('i18n_concat_admin'))) if user.status != 1: raise HTTPException(status_code=400, detail=trans('i18n_login.user_disable', msg = trans('i18n_concat_admin'))) + if user.origin is not None and user.origin != 0: + raise HTTPException(status_code=400, detail=trans('i18n_login.origin_error')) access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) user_dict = user.to_dict() return Token(access_token=create_access_token( user_dict, expires_delta=access_token_expires - )) \ No newline at end of file + )) + +@router.post("/logout") +async def logout(session: SessionDep, request: Request, dto: LogoutSchema): + if dto.origin != 0: + return await xpack_logout(session, request, dto) + return None \ No newline at end of file diff --git a/backend/apps/system/api/parameter.py b/backend/apps/system/api/parameter.py new file mode 100644 index 000000000..3fe906b81 --- /dev/null +++ b/backend/apps/system/api/parameter.py @@ -0,0 +1,32 @@ +from fastapi import APIRouter, Request +from sqlbot_xpack.config.model import SysArgModel + +from apps.system.crud.parameter_manage import get_groups, get_parameter_args, save_parameter_args +from apps.system.schemas.permission import SqlbotPermission, require_permissions +from common.core.deps import SessionDep + +router = APIRouter(tags=["system/parameter"], prefix="/system/parameter", include_in_schema=False) +from common.audit.models.log_model import OperationType, OperationModules +from common.audit.schemas.logger_decorator import LogConfig, system_log + +@router.get("/login") +async def get_login_args(session: SessionDep) -> list[SysArgModel]: + return await get_groups(session, "login") + + +@router.get("") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +async def get_args(session: SessionDep) -> list[SysArgModel]: + return await get_parameter_args(session) + + +@router.post("", ) +@require_permissions(permission=SqlbotPermission(role=['admin'])) +@system_log(LogConfig(operation_type=OperationType.UPDATE, module=OperationModules.PARAMS_SETTING)) +async def save_args(session: SessionDep, request: Request): + return await save_parameter_args(session=session, request=request) + + +@router.get("/chat") +async def get_chat_args(session: SessionDep) -> list[SysArgModel]: + return await get_groups(session, "chat") diff --git a/backend/apps/system/api/user.py b/backend/apps/system/api/user.py index d6f33cda7..20b379ef7 100644 --- a/backend/apps/system/api/user.py +++ b/backend/apps/system/api/user.py @@ -1,41 +1,97 @@ from collections import defaultdict from typing import Optional -from fastapi import APIRouter, HTTPException, Query -from sqlmodel import SQLModel, func, or_, select, delete as sqlmodel_delete +from fastapi import APIRouter, File, Path, Query, UploadFile +from sqlmodel import SQLModel, or_, select, delete as sqlmodel_delete from apps.system.crud.user import check_account_exists, check_email_exists, check_email_format, check_pwd_format, get_db_user, single_delete, user_ws_options +from apps.system.crud.user_excel import batchUpload, downTemplate, download_error_file from apps.system.models.system_model import UserWsModel, WorkspaceModel from apps.system.models.user import UserModel from apps.system.schemas.auth import CacheName, CacheNamespace -from apps.system.schemas.system_schema import PwdEditor, UserCreator, UserEditor, UserGrid, UserLanguage, UserStatus, UserWs +from apps.system.schemas.permission import SqlbotPermission, require_permissions +from apps.system.schemas.system_schema import PwdEditor, UserCreator, UserEditor, UserGrid, UserInfoDTO, UserLanguage, UserStatus, UserWs +from common.audit.models.log_model import OperationType, OperationModules +from common.audit.schemas.logger_decorator import LogConfig, system_log from common.core.deps import CurrentUser, SessionDep, Trans from common.core.pagination import Paginator from common.core.schemas import PaginatedResponse, PaginationParams from common.core.security import default_md5_pwd, md5pwd, verify_md5pwd from common.core.sqlbot_cache import clear_cache +from common.core.config import settings +from apps.swagger.i18n import PLACEHOLDER_PREFIX -router = APIRouter(tags=["user"], prefix="/user") +router = APIRouter(tags=["system_user"], prefix="/user") -@router.get("/info") -async def user_info(current_user: CurrentUser): + +@router.get("/template", include_in_schema=False) +@require_permissions(permission=SqlbotPermission(role=['admin'])) +async def templateExcel(trans: Trans): + return await downTemplate(trans) + +@router.post("/batchImport", include_in_schema=False) +@require_permissions(permission=SqlbotPermission(role=['admin'])) +async def upload_excel(session: SessionDep, trans: Trans, current_user: CurrentUser, file: UploadFile = File(...)): + return await batchUpload(session, trans, file) + + +@router.get("/errorRecord/{file_id}", include_in_schema=False) +@require_permissions(permission=SqlbotPermission(role=['admin'])) +async def download_error(file_id: str): + return download_error_file(file_id) + +@router.get("/info", summary=f"{PLACEHOLDER_PREFIX}system_user_current_user", description=f"{PLACEHOLDER_PREFIX}system_user_current_user_desc") +async def user_info(current_user: CurrentUser) -> UserInfoDTO: return current_user -@router.get("/pager/{pageNum}/{pageSize}", response_model=PaginatedResponse[UserGrid]) + +@router.get("/defaultPwd", include_in_schema=False) +@require_permissions(permission=SqlbotPermission(role=['admin'])) +async def default_pwd() -> str: + return settings.DEFAULT_PWD + +@router.get("/pager/{pageNum}/{pageSize}", response_model=PaginatedResponse[UserGrid], summary=f"{PLACEHOLDER_PREFIX}system_user_grid", description=f"{PLACEHOLDER_PREFIX}system_user_grid") +@require_permissions(permission=SqlbotPermission(role=['admin'])) async def pager( session: SessionDep, - pageNum: int, - pageSize: int, - keyword: Optional[str] = Query(None, description="搜索关键字(可选)"), - status: Optional[int] = Query(None, description="状态"), - origins: Optional[list[int]] = Query(None, description="来源"), - oidlist: Optional[list[int]] = Query(None, description="空间ID集合(可选)"), + pageNum: int = Path(..., title=f"{PLACEHOLDER_PREFIX}page_num", description=f"{PLACEHOLDER_PREFIX}page_num"), + pageSize: int = Path(..., title=f"{PLACEHOLDER_PREFIX}page_size", description=f"{PLACEHOLDER_PREFIX}page_size"), + keyword: Optional[str] = Query(None, description=f"{PLACEHOLDER_PREFIX}keyword"), + status: Optional[int] = Query(None, description=f"{PLACEHOLDER_PREFIX}status"), + origins: Optional[list[int]] = Query(None, description=f"{PLACEHOLDER_PREFIX}origin"), + oidlist: Optional[list[int]] = Query(None, description=f"{PLACEHOLDER_PREFIX}oid"), + order_by: Optional[str] = Query(None, description="排序字段"), + desc: Optional[bool] = Query(False, description="是否降序"), ): pagination = PaginationParams(page=pageNum, size=pageSize) paginator = Paginator(session) - filters = {} + + # 允许排序的字段白名单(防止 SQL 注入) + SORT_COLUMNS = { + 'account': UserModel.account, + 'create_time': UserModel.create_time, + 'name': UserModel.name, + 'email': UserModel.email, + 'status': UserModel.status, + } + sort_field = SORT_COLUMNS.get(order_by, UserModel.account) + sort_clause = sort_field.desc() if desc else sort_field.asc() + + # SELECT 列必须包含 ORDER BY 列(PostgreSQL DISTINCT 约束) + select_columns = [UserModel.id, UserModel.account] + if order_by and order_by != 'account': + select_columns.append(sort_field) + + origin_stmt = ( + select(*select_columns) + .join(UserWsModel, UserModel.id == UserWsModel.uid, isouter=True) + .where(UserModel.id != 1) + .distinct() + .order_by(sort_clause) + ) - origin_stmt = select(UserModel.id).join(UserWsModel, UserModel.id == UserWsModel.uid, isouter=True).where(UserModel.id != 1).distinct() if oidlist: origin_stmt = origin_stmt.where(UserWsModel.oid.in_(oidlist)) + if origins: + origin_stmt = origin_stmt.where(UserModel.origin.in_(origins)) if status is not None: origin_stmt = origin_stmt.where(UserModel.status == status) if keyword: @@ -50,8 +106,7 @@ async def pager( user_page = await paginator.get_paginated_response( stmt=origin_stmt, - pagination=pagination, - **filters) + pagination=pagination) uid_list = [item.get('id') for item in user_page.items] if not uid_list: return user_page @@ -59,7 +114,7 @@ async def pager( select(UserModel, UserWsModel.oid.label('ws_oid')) .join(UserWsModel, UserModel.id == UserWsModel.uid, isouter=True) .where(UserModel.id.in_(uid_list)) - .order_by(UserModel.account, UserModel.create_time) + .order_by(sort_clause) ) user_workspaces = session.exec(stmt).all() merged = defaultdict(list) @@ -90,13 +145,19 @@ def format_user_dict(row) -> dict: result_dict[key] = item return result_dict -@router.get("/ws") + +@router.get("/ws", include_in_schema=False) async def ws_options(session: SessionDep, current_user: CurrentUser, trans: Trans) -> list[UserWs]: return await user_ws_options(session, current_user.id, trans) -@router.put("/ws/{oid}") +@router.put("/ws/{oid}", summary=f"{PLACEHOLDER_PREFIX}switch_oid_api", description=f"{PLACEHOLDER_PREFIX}switch_oid_api") @clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.USER_INFO, keyExpression="current_user.id") -async def ws_change(session: SessionDep, current_user: CurrentUser, trans:Trans, oid: int): +@system_log(LogConfig( + operation_type=OperationType.UPDATE, + module=OperationModules.USER, + resource_id_expr="editor.id" +)) +async def ws_change(session: SessionDep, current_user: CurrentUser, trans:Trans, oid: int = Path(description=f"{PLACEHOLDER_PREFIX}oid")): ws_list: list[UserWs] = await user_ws_options(session, current_user.id) if not any(x.id == oid for x in ws_list): db_ws = session.get(WorkspaceModel, oid) @@ -106,10 +167,10 @@ async def ws_change(session: SessionDep, current_user: CurrentUser, trans:Trans, user_model: UserModel = get_db_user(session = session, user_id = current_user.id) user_model.oid = oid session.add(user_model) - session.commit() -@router.get("/{id}", response_model=UserEditor) -async def query(session: SessionDep, trans: Trans, id: int) -> UserEditor: +@router.get("/{id}", response_model=UserEditor, summary=f"{PLACEHOLDER_PREFIX}user_detail_api", description=f"{PLACEHOLDER_PREFIX}user_detail_api") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +async def query(session: SessionDep, trans: Trans, id: int = Path(description=f"{PLACEHOLDER_PREFIX}uid")) -> UserEditor: db_user: UserModel = get_db_user(session = session, user_id = id) u_ws_options = await user_ws_options(session, id, trans) result = UserEditor.model_validate(db_user.model_dump()) @@ -117,15 +178,26 @@ async def query(session: SessionDep, trans: Trans, id: int) -> UserEditor: result.oid_list = [item.id for item in u_ws_options] return result -@router.post("") + +@router.post("", summary=f"{PLACEHOLDER_PREFIX}user_create_api", description=f"{PLACEHOLDER_PREFIX}user_create_api") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +@system_log(LogConfig( + operation_type=OperationType.CREATE, + module=OperationModules.USER, + result_id_expr="id" +)) +async def user_create(session: SessionDep, creator: UserCreator, trans: Trans): + return await create(session=session, creator=creator, trans=trans) + async def create(session: SessionDep, creator: UserCreator, trans: Trans): if check_account_exists(session=session, account=creator.account): raise Exception(trans('i18n_exist', msg = f"{trans('i18n_user.account')} [{creator.account}]")) - if check_email_exists(session=session, email=creator.email): - raise Exception(trans('i18n_exist', msg = f"{trans('i18n_user.email')} [{creator.email}]")) + """ if check_email_exists(session=session, email=creator.email): + raise Exception(trans('i18n_exist', msg = f"{trans('i18n_user.email')} [{creator.email}]")) """ if not check_email_format(creator.email): raise Exception(trans('i18n_format_invalid', key = f"{trans('i18n_user.email')} [{creator.email}]")) - data = creator.model_dump(exclude_unset=True) + #data = creator.model_dump(exclude_unset=True) + data = creator.model_dump() user_model = UserModel.model_validate(data) #user_model.create_time = get_timestamp() user_model.language = "zh-CN" @@ -143,75 +215,101 @@ async def create(session: SessionDep, creator: UserCreator, trans: Trans): session.add_all(db_model_list) user_model.oid = creator.oid_list[0] session.add(user_model) - session.commit() + return user_model + -@router.put("") +@router.put("", summary=f"{PLACEHOLDER_PREFIX}user_update_api", description=f"{PLACEHOLDER_PREFIX}user_update_api") +@require_permissions(permission=SqlbotPermission(role=['admin'])) @clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.USER_INFO, keyExpression="editor.id") +@system_log(LogConfig( + operation_type=OperationType.UPDATE, + module=OperationModules.USER, + resource_id_expr="editor.id" +)) async def update(session: SessionDep, editor: UserEditor, trans: Trans): user_model: UserModel = get_db_user(session = session, user_id = editor.id) if not user_model: raise Exception(f"User with id [{editor.id}] not found!") if editor.account != user_model.account: raise Exception(f"account cannot be changed!") - if editor.email != user_model.email and check_email_exists(session=session, email=editor.email): - raise Exception(trans('i18n_exist', msg = f"{trans('i18n_user.email')} [{editor.email}]")) + """ if editor.email != user_model.email and check_email_exists(session=session, email=editor.email): + raise Exception(trans('i18n_exist', msg = f"{trans('i18n_user.email')} [{editor.email}]")) """ if not check_email_format(editor.email): raise Exception(trans('i18n_format_invalid', key = f"{trans('i18n_user.email')} [{editor.email}]")) origin_oid: int = user_model.oid - del_stmt = sqlmodel_delete(UserWsModel).where(UserWsModel.uid == editor.id) - session.exec(del_stmt) + + uws_list_stmt = select(UserWsModel).where(UserWsModel.uid == editor.id) + uws_list = session.exec(uws_list_stmt).all() + + existing_oids = {uws.oid for uws in uws_list} + new_oid_set = set(editor.oid_list) if editor.oid_list else set() + oids_to_remove = existing_oids - new_oid_set + oids_to_add = new_oid_set - existing_oids + + if oids_to_remove: + del_stmt = sqlmodel_delete(UserWsModel).where(UserWsModel.uid == editor.id, UserWsModel.oid.in_(oids_to_remove)) + session.exec(del_stmt) data = editor.model_dump(exclude_unset=True) user_model.sqlmodel_update(data) user_model.oid = 0 if editor.oid_list: - # need to validate oid_list - db_model_list = [ - UserWsModel.model_validate({ - "oid": oid, - "uid": user_model.id, - "weight": 0 - }) - for oid in editor.oid_list - ] - session.add_all(db_model_list) user_model.oid = origin_oid if origin_oid in editor.oid_list else editor.oid_list[0] + if oids_to_add: + db_uws_model_list = [ + UserWsModel.model_validate({ + "oid": oid, + "uid": user_model.id, + "weight": 0 + }) + for oid in oids_to_add + ] + session.add_all(db_uws_model_list) session.add(user_model) - session.commit() - -@router.delete("/{id}") -async def delete(session: SessionDep, id: int): + +@router.delete("/{id}", summary=f"{PLACEHOLDER_PREFIX}user_del_api", description=f"{PLACEHOLDER_PREFIX}user_del_api") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +@system_log(LogConfig( + operation_type=OperationType.DELETE, + module=OperationModules.USER, + resource_id_expr="id" +)) +async def delete(session: SessionDep, id: int = Path(description=f"{PLACEHOLDER_PREFIX}uid")): await single_delete(session, id) -@router.delete("") +@router.delete("", summary=f"{PLACEHOLDER_PREFIX}user_batchdel_api", description=f"{PLACEHOLDER_PREFIX}user_batchdel_api") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +@system_log(LogConfig(operation_type=OperationType.DELETE,module=OperationModules.USER,resource_id_expr="id_list")) async def batch_del(session: SessionDep, id_list: list[int]): for id in id_list: await single_delete(session, id) -@router.put("/language") +@router.put("/language", summary=f"{PLACEHOLDER_PREFIX}language_change", description=f"{PLACEHOLDER_PREFIX}language_change") @clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.USER_INFO, keyExpression="current_user.id") async def langChange(session: SessionDep, current_user: CurrentUser, trans: Trans, language: UserLanguage): lang = language.language - if lang not in ["zh-CN", "en"]: + if lang not in ["zh-CN", "zh-TW", "en", "ko-KR"]: raise Exception(trans('i18n_user.language_not_support', key = lang)) db_user: UserModel = get_db_user(session=session, user_id=current_user.id) db_user.language = lang session.add(db_user) - session.commit() - -@router.patch("/pwd/{id}") + + +@router.patch("/pwd/{id}", summary=f"{PLACEHOLDER_PREFIX}reset_pwd", description=f"{PLACEHOLDER_PREFIX}reset_pwd") +@require_permissions(permission=SqlbotPermission(role=['admin'])) @clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.USER_INFO, keyExpression="id") -async def pwdReset(session: SessionDep, current_user: CurrentUser, trans: Trans, id: int): +@system_log(LogConfig(operation_type=OperationType.RESET_PWD,module=OperationModules.USER,resource_id_expr="id")) +async def pwdReset(session: SessionDep, current_user: CurrentUser, trans: Trans, id: int = Path(description=f"{PLACEHOLDER_PREFIX}uid")): if not current_user.isAdmin: raise Exception(trans('i18n_permission.no_permission', url = " patch[/user/pwd/id],", msg = trans('i18n_permission.only_admin'))) db_user: UserModel = get_db_user(session=session, user_id=id) db_user.password = default_md5_pwd() session.add(db_user) - session.commit() -@router.put("/pwd") +@router.put("/pwd", summary=f"{PLACEHOLDER_PREFIX}update_pwd", description=f"{PLACEHOLDER_PREFIX}update_pwd") @clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.USER_INFO, keyExpression="current_user.id") +@system_log(LogConfig(operation_type=OperationType.UPDATE_PWD,module=OperationModules.USER,result_id_expr="id")) async def pwdUpdate(session: SessionDep, current_user: CurrentUser, trans: Trans, editor: PwdEditor): new_pwd = editor.new_pwd if not check_pwd_format(new_pwd): @@ -221,11 +319,14 @@ async def pwdUpdate(session: SessionDep, current_user: CurrentUser, trans: Trans raise Exception(trans('i18n_error', key = trans('i18n_user.password'))) db_user.password = md5pwd(new_pwd) session.add(db_user) - session.commit() + return db_user + -@router.patch("/status") +@router.patch("/status", summary=f"{PLACEHOLDER_PREFIX}update_status", description=f"{PLACEHOLDER_PREFIX}update_status") +@require_permissions(permission=SqlbotPermission(role=['admin'])) @clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.USER_INFO, keyExpression="statusDto.id") -async def langChange(session: SessionDep, current_user: CurrentUser, trans: Trans, statusDto: UserStatus): +@system_log(LogConfig(operation_type=OperationType.UPDATE_STATUS,module=OperationModules.USER, resource_id_expr="statusDto.id")) +async def statusChange(session: SessionDep, current_user: CurrentUser, trans: Trans, statusDto: UserStatus): if not current_user.isAdmin: raise Exception(trans('i18n_permission.no_permission', url = ", ", msg = trans('i18n_permission.only_admin'))) status = statusDto.status @@ -234,4 +335,3 @@ async def langChange(session: SessionDep, current_user: CurrentUser, trans: Tran db_user: UserModel = get_db_user(session=session, user_id=statusDto.id) db_user.status = status session.add(db_user) - session.commit() \ No newline at end of file diff --git a/backend/apps/system/api/variable_api.py b/backend/apps/system/api/variable_api.py new file mode 100644 index 000000000..ca5c952f5 --- /dev/null +++ b/backend/apps/system/api/variable_api.py @@ -0,0 +1,39 @@ +# Author: Junjun +# Date: 2026/1/26 +from typing import List +from fastapi import APIRouter + +from apps.swagger.i18n import PLACEHOLDER_PREFIX +from apps.system.crud.system_variable import save, delete, list_all, list_page +from apps.system.models.system_variable_model import SystemVariable +from common.core.config import settings +from common.core.deps import SessionDep, CurrentUser, Trans +from apps.system.schemas.permission import SqlbotPermission, require_permissions + +router = APIRouter(tags=["System_variable"], prefix="/sys_variable") +path = settings.EXCEL_PATH + + +@router.post("/save", response_model=None, summary=f"{PLACEHOLDER_PREFIX}variable_save") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +async def save_variable(session: SessionDep, user: CurrentUser, trans: Trans, variable: SystemVariable): + return save(session, user, trans, variable) + + +@router.post("/delete",response_model=None, summary=f"{PLACEHOLDER_PREFIX}variable_delete") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +async def delete_variable(session: SessionDep, ids: List[int]): + return delete(session, ids) + + +@router.post("/listAll",response_model=None, summary=f"{PLACEHOLDER_PREFIX}variable_list") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +async def list_all_data(session: SessionDep, trans: Trans, variable: SystemVariable = None): + return list_all(session, trans, variable) + + +@router.post("/listPage/{pageNum}/{pageSize}",response_model=None, summary=f"{PLACEHOLDER_PREFIX}variable_page") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +async def pager(session: SessionDep, trans: Trans, pageNum: int, pageSize: int, + variable: SystemVariable = None): + return await list_page(session, trans, pageNum, pageSize, variable) diff --git a/backend/apps/system/api/workspace.py b/backend/apps/system/api/workspace.py index 0d7952893..0c909bb1c 100644 --- a/backend/apps/system/api/workspace.py +++ b/backend/apps/system/api/workspace.py @@ -1,27 +1,32 @@ from typing import Optional -from fastapi import APIRouter, HTTPException, Query -from sqlmodel import exists, or_, select, delete as sqlmodel_delete +from fastapi import APIRouter, HTTPException, Path, Query +from sqlmodel import exists, or_, select, delete as sqlmodel_delete, update as sqlmodel_update +from apps.swagger.i18n import PLACEHOLDER_PREFIX from apps.system.crud.user import clean_user_cache from apps.system.crud.workspace import reset_single_user_oid, reset_user_oid from apps.system.models.system_model import UserWsModel, WorkspaceBase, WorkspaceEditor, WorkspaceModel from apps.system.models.user import UserModel +from apps.system.schemas.permission import SqlbotPermission, require_permissions from apps.system.schemas.system_schema import UserWsBase, UserWsDTO, UserWsEditor, UserWsOption, WorkspaceUser +from common.audit.models.log_model import OperationType, OperationModules +from common.audit.schemas.logger_decorator import system_log, LogConfig from common.core.deps import CurrentUser, SessionDep, Trans from common.core.pagination import Paginator from common.core.schemas import PaginatedResponse, PaginationParams from common.utils.time import get_timestamp -router = APIRouter(tags=["system/workspace"], prefix="/system/workspace") +router = APIRouter(tags=["system_ws"], prefix="/system/workspace") -@router.get("/uws/option/pager/{pageNum}/{pageSize}", response_model=PaginatedResponse[UserWsOption]) +@router.get("/uws/option/pager/{pageNum}/{pageSize}", response_model=PaginatedResponse[UserWsOption], summary=f"{PLACEHOLDER_PREFIX}ws_user_grid_api", description=f"{PLACEHOLDER_PREFIX}ws_user_grid_api") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) async def option_pager( session: SessionDep, current_user: CurrentUser, trans: Trans, - pageNum: int, - pageSize: int, - oid: int = Query(description="空间ID"), - keyword: Optional[str] = Query(None, description="搜索关键字(可选)"), + pageNum: int = Path(description=f"{PLACEHOLDER_PREFIX}page_num"), + pageSize: int = Path(description=f"{PLACEHOLDER_PREFIX}page_size"), + oid: int = Query(description=f"{PLACEHOLDER_PREFIX}oid"), + keyword: Optional[str] = Query(None, description=f"{PLACEHOLDER_PREFIX}keyword"), ): if not current_user.isAdmin: raise Exception(trans('i18n_permission.no_permission', url = ", ", msg = trans('i18n_permission.only_admin'))) @@ -47,7 +52,8 @@ async def option_pager( pagination=pagination, ) -@router.get("/uws/option", response_model=UserWsOption | None) +@router.get("/uws/option", response_model=UserWsOption | None, include_in_schema=False) +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) async def option_user( session: SessionDep, current_user: CurrentUser, @@ -74,7 +80,9 @@ async def option_user( ) return session.exec(stmt).first() -@router.get("/uws/pager/{pageNum}/{pageSize}", response_model=PaginatedResponse[WorkspaceUser]) + +@router.get("/uws/pager/{pageNum}/{pageSize}", response_model=PaginatedResponse[WorkspaceUser], include_in_schema=False) +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) async def pager( session: SessionDep, current_user: CurrentUser, @@ -114,7 +122,10 @@ async def pager( ) -@router.post("/uws") +@router.post("/uws", summary=f"{PLACEHOLDER_PREFIX}ws_user_bind_api", description=f"{PLACEHOLDER_PREFIX}ws_user_bind_api") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +@system_log(LogConfig(operation_type=OperationType.ADD, module=OperationModules.MEMBER, resource_id_expr="creator.uid_list", + )) async def create(session: SessionDep, current_user: CurrentUser, trans: Trans, creator: UserWsDTO): if not current_user.isAdmin and current_user.weight == 0: raise Exception(trans('i18n_permission.no_permission', url = '', msg = '')) @@ -134,9 +145,14 @@ async def create(session: SessionDep, current_user: CurrentUser, trans: Trans, c await clean_user_cache(uid) session.add_all(db_model_list) - session.commit() -@router.put("/uws") +@router.put("/uws", summary=f"{PLACEHOLDER_PREFIX}ws_user_status_api", description=f"{PLACEHOLDER_PREFIX}ws_user_status_api") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +@system_log(LogConfig(operation_type=OperationType.UPDATE, module=OperationModules.MEMBER, resource_id_expr="editor.uid_list", + )) +async def uws_edit(session: SessionDep, trans: Trans, editor: UserWsEditor): + await edit(session, trans, editor) + async def edit(session: SessionDep, trans: Trans, editor: UserWsEditor): if not editor.oid or not editor.uid: raise Exception(trans('i18n_miss_args', key = '[oid, uid]')) @@ -150,9 +166,11 @@ async def edit(session: SessionDep, trans: Trans, editor: UserWsEditor): session.add(db_model) await clean_user_cache(editor.uid) - session.commit() -@router.delete("/uws") +@router.delete("/uws", summary=f"{PLACEHOLDER_PREFIX}ws_user_unbind_api", description=f"{PLACEHOLDER_PREFIX}ws_user_unbind_api") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +@system_log(LogConfig(operation_type=OperationType.DELETE, module=OperationModules.MEMBER, resource_id_expr="dto.uid_list", + )) async def delete(session: SessionDep, current_user: CurrentUser, trans: Trans, dto: UserWsBase): if not current_user.isAdmin and current_user.weight == 0: raise Exception(trans('i18n_permission.no_permission', url = '', msg = '')) @@ -167,24 +185,30 @@ async def delete(session: SessionDep, current_user: CurrentUser, trans: Trans, d await reset_single_user_oid(session, uid, oid, False) await clean_user_cache(uid) - session.commit() - -@router.get("", response_model=list[WorkspaceModel]) +@router.get("", response_model=list[WorkspaceModel], summary=f"{PLACEHOLDER_PREFIX}ws_all_api", description=f"{PLACEHOLDER_PREFIX}ws_all_api") +@require_permissions(permission=SqlbotPermission(role=['admin'])) async def query(session: SessionDep, trans: Trans): - list_result = session.exec(select(WorkspaceModel).order_by(WorkspaceModel.create_time.asc())).all() + list_result = session.exec(select(WorkspaceModel)).all() for ws in list_result: if ws.name.startswith('i18n'): - ws.name = trans(ws.name) + ws.name = trans(ws.name) + list_result.sort(key=lambda x: x.name) return list_result -@router.post("") +@router.post("", summary=f"{PLACEHOLDER_PREFIX}ws_create_api", description=f"{PLACEHOLDER_PREFIX}ws_create_api") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +@system_log(LogConfig(operation_type=OperationType.CREATE, module=OperationModules.WORKSPACE, result_id_expr="id", + )) async def add(session: SessionDep, creator: WorkspaceBase): db_model = WorkspaceModel.model_validate(creator) db_model.create_time = get_timestamp() session.add(db_model) - session.commit() + return db_model -@router.put("") +@router.put("", summary=f"{PLACEHOLDER_PREFIX}ws_update_api", description=f"{PLACEHOLDER_PREFIX}ws_update_api") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +@system_log(LogConfig(operation_type=OperationType.UPDATE, module=OperationModules.WORKSPACE, resource_id_expr="editor.id", + )) async def update(session: SessionDep, editor: WorkspaceEditor): id = editor.id db_model = session.get(WorkspaceModel, id) @@ -192,10 +216,10 @@ async def update(session: SessionDep, editor: WorkspaceEditor): raise HTTPException(f"WorkspaceModel with id {id} not found") db_model.name = editor.name session.add(db_model) - session.commit() -@router.get("/{id}", response_model=WorkspaceModel) -async def get_one(session: SessionDep, trans: Trans, id: int): +@router.get("/{id}", response_model=WorkspaceModel, summary=f"{PLACEHOLDER_PREFIX}ws_query_api", description=f"{PLACEHOLDER_PREFIX}ws_query_api") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +async def get_one(session: SessionDep, trans: Trans, id: int = Path(description=f"{PLACEHOLDER_PREFIX}oid")): db_model = session.get(WorkspaceModel, id) if not db_model: raise HTTPException(f"WorkspaceModel with id {id} not found") @@ -203,12 +227,25 @@ async def get_one(session: SessionDep, trans: Trans, id: int): db_model.name = trans(db_model.name) return db_model -@router.delete("/{id}") -async def single_delete(session: SessionDep, id: int): +@router.delete("/{id}", summary=f"{PLACEHOLDER_PREFIX}ws_del_api", description=f"{PLACEHOLDER_PREFIX}ws_del_api") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +@system_log(LogConfig(operation_type=OperationType.DELETE, module=OperationModules.WORKSPACE, resource_id_expr="id", + )) +async def single_delete(session: SessionDep, current_user: CurrentUser, id: int = Path(description=f"{PLACEHOLDER_PREFIX}oid")): + if not current_user.isAdmin: + raise HTTPException("only admin can delete workspace") + if id == 1: + raise HTTPException(f"Can not delete default workspace") db_model = session.get(WorkspaceModel, id) if not db_model: raise HTTPException(f"WorkspaceModel with id {id} not found") + if current_user.oid == id: + current_user.oid = 1 # reset to default workspace + update_stmt = sqlmodel_update(UserModel).where(UserModel.id == current_user.id).values(oid=1) + session.exec(update_stmt) + await clean_user_cache(current_user.id) + user_ws_list = session.exec(select(UserWsModel).where(UserWsModel.oid == id)).all() if user_ws_list: # clean user cache @@ -220,6 +257,5 @@ async def single_delete(session: SessionDep, id: int): session.exec(sqlmodel_delete(UserWsModel).where(UserWsModel.oid == id)) session.delete(db_model) - session.commit() diff --git a/backend/apps/system/crud/aimodel_manage.py b/backend/apps/system/crud/aimodel_manage.py new file mode 100644 index 000000000..b0fabdbd1 --- /dev/null +++ b/backend/apps/system/crud/aimodel_manage.py @@ -0,0 +1,67 @@ +from sqlmodel import Session, select, or_ + +from apps.system.models.system_model import AiModelDetail, AiModelBrief, AiModelWorkspaceMapping +from common.core.db import engine +from common.utils.crypto import sqlbot_encrypt +from common.utils.utils import SQLBotLogUtil + + +async def async_model_info(): + with Session(engine) as session: + model_list = session.exec(select(AiModelDetail)).all() + any_model_change = False + if model_list: + for model in model_list: + current_model_change = False + if model.api_domain.startswith("http"): + if model.api_key: + model.api_key = await sqlbot_encrypt(model.api_key) + if model.api_domain: + model.api_domain = await sqlbot_encrypt(model.api_domain) + any_model_change = True + current_model_change = True + if model.supplier and model.supplier == 12: + model.supplier = 15 + any_model_change = True + current_model_change = True + if current_model_change: + session.add(model) + if any_model_change: + session.commit() + SQLBotLogUtil.info("✅ 异步加密已有模型的密钥和地址完成") + + +def get_ai_model_list_by_workspace(session: Session, workspace_id: int, with_default: bool = True): + sub_stmt = ( + select(AiModelWorkspaceMapping.ai_model_id) + .where(AiModelWorkspaceMapping.workspace_id == workspace_id) + .distinct() + ) + + # 查询:关联的模型 + default_model 为 True 的模型,默认模型排第一 + base_condition = AiModelDetail.id.in_(sub_stmt) + if with_default: + where_condition = or_(base_condition, AiModelDetail.default_model == True) + else: + where_condition = base_condition + stmt = ( + select( + AiModelDetail.id, + AiModelDetail.name, + AiModelDetail.default_model, + AiModelDetail.supplier, + ) + .where(where_condition) + .order_by(AiModelDetail.default_model.desc()) + ) + rows = session.exec(stmt).all() + + return [ + AiModelBrief( + id=row[0], + name=row[1], + default_model=row[2], + supplier=row[3], + ) + for row in rows + ] diff --git a/backend/apps/system/crud/apikey_manage.py b/backend/apps/system/crud/apikey_manage.py new file mode 100644 index 000000000..ce6b43fcc --- /dev/null +++ b/backend/apps/system/crud/apikey_manage.py @@ -0,0 +1,17 @@ + +from sqlmodel import select + +from apps.system.models.system_model import ApiKeyModel +from apps.system.schemas.auth import CacheName, CacheNamespace +from common.core.deps import SessionDep +from common.core.sqlbot_cache import cache, clear_cache +from common.utils.utils import SQLBotLogUtil + +@cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.ASK_INFO, keyExpression="access_key") +async def get_api_key(session: SessionDep, access_key: str) -> ApiKeyModel | None: + query = select(ApiKeyModel).where(ApiKeyModel.access_key == access_key) + return session.exec(query).first() + +@clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.ASK_INFO, keyExpression="access_key") +async def clear_api_key_cache(access_key: str): + SQLBotLogUtil.info(f"Api key cache for [{access_key}] has been cleaned") \ No newline at end of file diff --git a/backend/apps/system/crud/assistant.py b/backend/apps/system/crud/assistant.py index 0699ec066..0c9d60083 100644 --- a/backend/apps/system/crud/assistant.py +++ b/backend/apps/system/crud/assistant.py @@ -1,21 +1,40 @@ import json +import re import urllib from typing import Optional import requests from fastapi import FastAPI -from sqlalchemy import Engine, create_engine from sqlmodel import Session, select from starlette.middleware.cors import CORSMiddleware -from apps.datasource.models.datasource import CoreDatasource, DatasourceConf +from apps.datasource.models.datasource import CoreDatasource +from apps.datasource.utils.utils import aes_encrypt from apps.system.models.system_model import AssistantModel from apps.system.schemas.auth import CacheName, CacheNamespace from apps.system.schemas.system_schema import AssistantHeader, AssistantOutDsSchema, UserInfoDTO from common.core.config import settings from common.core.db import engine from common.core.sqlbot_cache import cache -from common.utils.utils import string_to_numeric_hash +from common.utils.aes_crypto import simple_aes_decrypt +from common.utils.utils import SQLBotLogUtil, get_domain_list, string_to_numeric_hash +from common.core.deps import Trans +from common.core.response_middleware import ResponseMiddleware + + +def _update_cors_middleware_instance(app: FastAPI, updated_origins: list[str]): + """遍历 middleware 栈,找到 CORSMiddleware 实例并更新其 allow_origins。 + + 仅修改 middleware.kwargs 不会影响已构建的中间件实例, + 需要直接更新实例的 allow_origins 属性。 + """ + stack = getattr(app, 'middleware_stack', None) + while stack is not None and hasattr(stack, 'app'): + if isinstance(stack, CORSMiddleware): + stack.allow_origins = updated_origins + return + stack = stack.app + @cache(namespace=CacheNamespace.EMBEDDED_INFO, cacheName=CacheName.ASSISTANT_INFO, keyExpression="assistant_id") @@ -32,7 +51,7 @@ def get_assistant_user(*, id: int): def get_assistant_ds(session: Session, llm_service) -> list[dict]: assistant: AssistantHeader = llm_service.current_assistant type = assistant.type - if type == 0: + if type == 0 or type == 2: configuration = assistant.configuration if configuration: config: dict[any] = json.loads(configuration) @@ -40,9 +59,14 @@ def get_assistant_ds(session: Session, llm_service) -> list[dict]: stmt = select(CoreDatasource.id, CoreDatasource.name, CoreDatasource.description).where( CoreDatasource.oid == oid) if not assistant.online: - private_list: list[int] = config.get('private_list') or None + public_list: list[int] = config.get('public_list') or None + if public_list: + stmt = stmt.where(CoreDatasource.id.in_(public_list)) + else: + return [] + """ private_list: list[int] = config.get('private_list') or None if private_list: - stmt = stmt.where(~CoreDatasource.id.in_(private_list)) + stmt = stmt.where(~CoreDatasource.id.in_(private_list)) """ db_ds_list = session.exec(stmt) result_list = [ @@ -71,19 +95,29 @@ def init_dynamic_cors(app: FastAPI): unique_domains = [] for item in list_result: if item.domain: - for domain in item.domain.split(','): + for domain in get_domain_list(item.domain): domain = domain.strip() if domain and domain not in seen: seen.add(domain) unique_domains.append(domain) cors_middleware = None + response_middleware = None for middleware in app.user_middleware: - if middleware.cls == CORSMiddleware: + if not cors_middleware and middleware.cls == CORSMiddleware: cors_middleware = middleware + if not response_middleware and middleware.cls == ResponseMiddleware: + response_middleware = middleware + if cors_middleware and response_middleware: break + + updated_origins = list(set(settings.all_cors_origins + unique_domains)) if cors_middleware: - updated_origins = list(set(settings.all_cors_origins + unique_domains)) cors_middleware.kwargs['allow_origins'] = updated_origins + _update_cors_middleware_instance(app, updated_origins) + if response_middleware: + for instance in ResponseMiddleware.instances: + instance.update_allow_origins(updated_origins) + except Exception as e: return False, e @@ -92,17 +126,23 @@ class AssistantOutDs: assistant: AssistantHeader ds_list: Optional[list[AssistantOutDsSchema]] = None certificate: Optional[str] = None + request_origin: Optional[str] = None def __init__(self, assistant: AssistantHeader): self.assistant = assistant self.ds_list = None self.certificate = assistant.certificate + self.request_origin = assistant.request_origin self.get_ds_from_api() # @cache(namespace=CacheNamespace.EMBEDDED_INFO, cacheName=CacheName.ASSISTANT_DS, keyExpression="current_user.id") def get_ds_from_api(self): config: dict[any] = json.loads(self.assistant.configuration) endpoint: str = config['endpoint'] + endpoint = self.get_complete_endpoint(endpoint=endpoint) + if not endpoint: + raise Exception( + f"Failed to get datasource list from {config['endpoint']}, error: [Assistant domain or endpoint miss]") certificateList: list[any] = json.loads(self.certificate) header = {} cookies = {} @@ -114,21 +154,41 @@ def get_ds_from_api(self): cookies[item['key']] = item['value'] if item['target'] == 'param': param[item['key']] = item['value'] - res = requests.get(url=endpoint, params=param, headers=header, cookies=cookies, timeout=10) + timeout = int(config.get('timeout')) if config.get('timeout') else 10 + res = requests.get(url=endpoint, params=param, headers=header, cookies=cookies, timeout=timeout) if res.status_code == 200: result_json: dict[any] = json.loads(res.text) - if result_json.get('code') == 0: + if result_json.get('code') == 0 or result_json.get('code') == 200: temp_list = result_json.get('data', []) - self.ds_list = [ - self.convert2schema(item) + temp_ds_list = [ + self.convert2schema(item, config) for item in temp_list ] - + self.ds_list = temp_ds_list return self.ds_list else: raise Exception(f"Failed to get datasource list from {endpoint}, error: {result_json.get('message')}") else: - raise Exception(f"Failed to get datasource list from {endpoint}, status code: {res.status_code}") + SQLBotLogUtil.error(f"Failed to get datasource list from {endpoint}, response: {res}") + raise Exception(f"Failed to get datasource list from {endpoint}, response: {res}") + + def get_first_element(self, text: str): + parts = re.split(r'[,;]', text.strip()) + first_domain = parts[0].strip() + return first_domain + + def get_complete_endpoint(self, endpoint: str) -> str | None: + if endpoint.startswith("http://") or endpoint.startswith("https://"): + return endpoint + domain_text = self.assistant.domain + if not domain_text: + return None + if ',' in domain_text or ';' in domain_text: + return ( + self.request_origin.strip('/') if self.request_origin else self.get_first_element(domain_text).strip( + '/')) + endpoint + else: + return f"{domain_text}{endpoint}" def get_simple_ds_list(self): if self.ds_list: @@ -136,18 +196,28 @@ def get_simple_ds_list(self): else: raise Exception("Datasource list is not found.") - def get_db_schema(self, ds_id: int) -> str: + def get_db_schema(self, ds_id: int, question: str = '', embedding: bool = True, + table_list: list[str] = None) -> tuple[str, list]: ds = self.get_ds(ds_id) schema_str = "" db_name = ds.db_schema if ds.db_schema is not None and ds.db_schema != "" else ds.dataBase schema_str += f"【DB_ID】 {db_name}\n【Schema】\n" + tables = [] + table_name_list = [] + i = 0 for table in ds.tables: - schema_str += f"# Table: {db_name}.{table.name}" if ds.type != "mysql" else f"# Table: {table.name}" + # 如果传入了 table_list,则只处理在列表中的表 + if table_list is not None and table.name not in table_list: + continue + + i += 1 + schema_table = '' + schema_table += f"# Table: {db_name}.{table.name}" if ds.type != "mysql" and ds.type != "es" else f"# Table: {table.name}" table_comment = table.comment if table_comment == '': - schema_str += '\n[\n' + schema_table += '\n[\n' else: - schema_str += f", {table_comment}\n[\n" + schema_table += f", {table_comment}\n[\n" field_list = [] for field in table.fields: @@ -156,26 +226,53 @@ def get_db_schema(self, ds_id: int) -> str: field_list.append(f"({field.name}:{field.type})") else: field_list.append(f"({field.name}:{field.type}, {field_comment})") - schema_str += ",\n".join(field_list) - schema_str += '\n]\n' - return schema_str + schema_table += ",\n".join(field_list) + schema_table += '\n]\n' + t_obj = {"id": i, "schema_table": schema_table} + tables.append(t_obj) + table_name_list.append(table.name) - def get_ds(self, ds_id: int): + # do table embedding + # if embedding and tables and settings.TABLE_EMBEDDING_ENABLED: + # tables = get_table_embedding(tables, question) + + if tables: + for s in tables: + schema_str += s.get('schema_table') + + return schema_str, table_name_list + + def get_ds(self, ds_id: int, trans: Trans = None): if self.ds_list: for ds in self.ds_list: if ds.id == ds_id: return ds else: raise Exception("Datasource list is not found.") - raise Exception(f"Datasource with id {ds_id} not found.") + raise Exception(f"Datasource id {ds_id} is not found." if trans is None else trans( + 'i18n_data_training.datasource_id_not_found', key=ds_id)) - def convert2schema(self, ds_dict: dict) -> AssistantOutDsSchema: + def convert2schema(self, ds_dict: dict, config: dict[any]) -> AssistantOutDsSchema: id_marker: str = '' - attr_list = ['name', 'type', 'host', 'port', 'user', 'dataBase', 'schema'] - for attr in attr_list: - if attr in ds_dict: - id_marker += str(ds_dict.get(attr, '')) + '--sqlbot--' - id = string_to_numeric_hash(id_marker) + attr_list = ['name', 'type', 'host', 'port', 'user', 'dataBase', 'schema', 'mode', 'lowVersion'] + if config.get('encrypt', False): + key = config.get('aes_key', None) + iv = config.get('aes_iv', None) + aes_attrs = ['host', 'user', 'password', 'dataBase', 'db_schema', 'schema', 'mode', 'lowVersion'] + for attr in aes_attrs: + if attr in ds_dict and ds_dict[attr]: + try: + ds_dict[attr] = simple_aes_decrypt(ds_dict[attr], key, iv) + except Exception as e: + raise Exception( + f"Failed to encrypt {attr} for datasource {ds_dict.get('name')}, error: {str(e)}") + + id = ds_dict.get('id', None) + if not id: + for attr in attr_list: + if attr in ds_dict: + id_marker += str(ds_dict.get(attr, '')) + '--sqlbot--' + id = string_to_numeric_hash(id_marker) db_schema = ds_dict.get('schema', ds_dict.get('db_schema', '')) ds_dict.pop("schema", None) return AssistantOutDsSchema(**{**ds_dict, "id": id, "db_schema": db_schema}) @@ -187,36 +284,19 @@ def get_instance(assistant: AssistantHeader) -> AssistantOutDs: return AssistantOutDs(assistant) -def get_ds_engine(ds: AssistantOutDsSchema) -> Engine: - timeout: int = 30 - connect_args = {"connect_timeout": timeout} - conf = DatasourceConf( - host=ds.host, - port=ds.port, - username=ds.user, - password=ds.password, - database=ds.dataBase, - driver='', - extraJdbc=ds.extraParams, - dbSchema=ds.db_schema or '' - ) - conf.extraJdbc = '' - from apps.db.db import get_uri_from_config - uri = get_uri_from_config(ds.type, conf) - # if ds.type == "pg" and ds.db_schema: - # connect_args.update({"options": f"-c search_path={ds.db_schema}"}) - # engine = create_engine(uri, connect_args=connect_args, pool_timeout=timeout, pool_size=20, max_overflow=10) - - if ds.type == "pg" and ds.db_schema: - engine = create_engine(uri, - connect_args={"options": f"-c search_path={urllib.parse.quote(ds.db_schema)}", - "connect_timeout": timeout}, - pool_timeout=timeout) - elif ds.type == 'sqlServer': - engine = create_engine(uri, pool_timeout=timeout) - elif ds.type == 'oracle': - engine = create_engine(uri, - pool_timeout=timeout) - else: - engine = create_engine(uri, connect_args={"connect_timeout": timeout}, pool_timeout=timeout) - return engine +def get_out_ds_conf(ds: AssistantOutDsSchema, timeout: int = 30) -> str: + conf = { + "host": ds.host or '', + "port": ds.port or 0, + "username": ds.user or '', + "password": ds.password or '', + "database": ds.dataBase or '', + "driver": '', + "extraJdbc": ds.extraParams or '', + "dbSchema": ds.db_schema or '', + "timeout": timeout or 30, + "mode": ds.mode or '', + "lowVersion": ds.lowVersion or False, + } + conf["extraJdbc"] = '' + return aes_encrypt(json.dumps(conf)) diff --git a/backend/apps/system/crud/assistant_manage.py b/backend/apps/system/crud/assistant_manage.py new file mode 100644 index 000000000..f9dce3675 --- /dev/null +++ b/backend/apps/system/crud/assistant_manage.py @@ -0,0 +1,63 @@ + + +from typing import Optional +from fastapi import FastAPI, Request +from sqlmodel import Session, select +from starlette.middleware.cors import CORSMiddleware +from apps.system.schemas.system_schema import AssistantBase +from common.core.config import settings +from apps.system.models.system_model import AssistantModel +from common.utils.time import get_timestamp +from common.utils.utils import get_domain_list +from common.core.response_middleware import ResponseMiddleware + + +def _update_cors_middleware_instance(app: FastAPI, updated_origins: list[str]): + """遍历 middleware 栈,找到 CORSMiddleware 实例并更新其 allow_origins。""" + stack = getattr(app, 'middleware_stack', None) + while stack is not None and hasattr(stack, 'app'): + if isinstance(stack, CORSMiddleware): + stack.allow_origins = updated_origins + return + stack = stack.app + + + +def dynamic_upgrade_cors(request: Request, session: Session): + list_result = session.exec(select(AssistantModel).order_by(AssistantModel.create_time)).all() + seen = set() + unique_domains = [] + for item in list_result: + if item.domain: + for domain in get_domain_list(item.domain): + domain = domain.strip() + if domain and domain not in seen: + seen.add(domain) + unique_domains.append(domain) + app: FastAPI = request.app + cors_middleware = None + response_middleware = None + for middleware in app.user_middleware: + if not cors_middleware and middleware.cls == CORSMiddleware: + cors_middleware = middleware + if not response_middleware and middleware.cls == ResponseMiddleware: + response_middleware = middleware + if cors_middleware and response_middleware: + break + + updated_origins = list(set(settings.all_cors_origins + unique_domains)) + if cors_middleware: + cors_middleware.kwargs['allow_origins'] = updated_origins + _update_cors_middleware_instance(app, updated_origins) + if response_middleware: + for instance in ResponseMiddleware.instances: + instance.update_allow_origins(updated_origins) + +async def save(request: Request, session: Session, creator: AssistantBase, oid: Optional[int] = 1): + db_model = AssistantModel.model_validate(creator) + db_model.create_time = get_timestamp() + db_model.oid = oid + session.add(db_model) + session.commit() + dynamic_upgrade_cors(request=request, session=session) + return db_model \ No newline at end of file diff --git a/backend/apps/system/crud/parameter_manage.py b/backend/apps/system/crud/parameter_manage.py new file mode 100644 index 000000000..65aaf164a --- /dev/null +++ b/backend/apps/system/crud/parameter_manage.py @@ -0,0 +1,45 @@ +from fastapi import Request +from sqlbot_xpack.config.arg_manage import get_group_args, save_group_args +from sqlbot_xpack.config.model import SysArgModel +import json +from common.core.deps import SessionDep +from sqlbot_xpack.file_utils import SQLBotFileUtils + +async def get_parameter_args(session: SessionDep) -> list[SysArgModel]: + group_args = await get_group_args(session=session) + return [x for x in group_args if not x.pkey.startswith('appearance.')] + +async def get_groups(session: SessionDep, flag: str) -> list[SysArgModel]: + group_args = await get_group_args(session=session, flag=flag) + return group_args + +async def save_parameter_args(session: SessionDep, request: Request): + allow_file_mapping = { + """ "test_logo": { "types": [".jpg", ".jpeg", ".png"], "size": 5 * 1024 * 1024 } """ + } + form_data = await request.form() + files = form_data.getlist("files") + json_text = form_data.get("data") + sys_args = [ + SysArgModel(**{**item, "pkey": f"{item['pkey']}"}) + for item in json.loads(json_text) + if "pkey" in item + ] + if not sys_args: + return + file_mapping = None + if files: + file_mapping = {} + for file in files: + origin_file_name = file.filename + file_name, flag_name = SQLBotFileUtils.split_filename_and_flag(origin_file_name) + file.filename = file_name + allow_limit_obj = allow_file_mapping.get(flag_name) + if allow_limit_obj: + SQLBotFileUtils.check_file(file=file, file_types=allow_limit_obj.get("types"), limit_file_size=allow_limit_obj.get("size")) + else: + raise Exception(f'The file [{file_name}] is not allowed to be uploaded!') + file_id = await SQLBotFileUtils.upload(file) + file_mapping[f"{flag_name}"] = file_id + + await save_group_args(session=session, sys_args=sys_args, file_mapping=file_mapping) \ No newline at end of file diff --git a/backend/apps/system/crud/system_variable.py b/backend/apps/system/crud/system_variable.py new file mode 100644 index 000000000..0ead75d28 --- /dev/null +++ b/backend/apps/system/crud/system_variable.py @@ -0,0 +1,99 @@ +# Author: Junjun +# Date: 2026/1/26 +import datetime +from typing import List + +from fastapi import HTTPException +from sqlalchemy import and_ +from sqlmodel import select + +from apps.system.models.system_variable_model import SystemVariable +from common.core.deps import SessionDep, CurrentUser, Trans +from common.core.pagination import Paginator +from common.core.schemas import PaginationParams + + +def save(session: SessionDep, user: CurrentUser, trans: Trans, variable: SystemVariable): + checkName(session, trans, variable) + variable.type = 'custom' + if variable.id is None: + variable.create_time = datetime.datetime.now() + variable.create_by = user.id + session.add(variable) + session.commit() + else: + record = session.query(SystemVariable).filter(SystemVariable.id == variable.id).first() + update_data = variable.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(record, field, value) + session.add(record) + session.commit() + return True + + +def delete(session: SessionDep, ids: List[int]): + session.query(SystemVariable).filter(SystemVariable.id.in_(ids)).delete() + + +def list_all(session: SessionDep, trans: Trans, variable: SystemVariable): + if variable.name is None: + records = session.query(SystemVariable).order_by(SystemVariable.type.desc(), + SystemVariable.name.asc()).all() + else: + records = session.query(SystemVariable).filter( + and_(SystemVariable.name.ilike(f'%{variable.name}%'), SystemVariable.type != 'system')).order_by( + SystemVariable.type.desc(), SystemVariable.name.asc()).all() + + res = [] + for r in records: + data = SystemVariable(**r.__dict__) + if data.type == 'system': + data.name = trans(data.name) + res.append(data) + return res + + +async def list_page(session: SessionDep, trans: Trans, pageNum: int, pageSize: int, variable: SystemVariable): + pagination = PaginationParams(page=pageNum, size=pageSize) + paginator = Paginator(session) + filters = {} + + if variable.name is None: + stmt = select(SystemVariable).order_by(SystemVariable.type.desc(), SystemVariable.name.asc()) + else: + stmt = select(SystemVariable).where( + and_(SystemVariable.name.ilike(f'%{variable.name}%'), SystemVariable.type != 'system')).order_by( + SystemVariable.type.desc(), SystemVariable.name.asc()) + + variable_page = await paginator.get_paginated_response( + stmt=stmt, + pagination=pagination, + **filters) + + res = [] + for r in variable_page.items: + data = SystemVariable(**r) + if data.type == 'system': + data.name = trans(data.name) + res.append(data) + + return {"items": res, "page": variable_page.page, "size": variable_page.size, "total": variable_page.total, + "total_pages": variable_page.total_pages} + + +def checkName(session: SessionDep, trans: Trans, variable: SystemVariable): + if variable.id is None: + records = session.query(SystemVariable).filter(SystemVariable.name == variable.name).all() + if records and len(records) > 0: + raise HTTPException(status_code=500, detail=trans('i18n_variable.name_exist')) + else: + records = session.query(SystemVariable).filter( + and_(SystemVariable.name == variable.name, SystemVariable.id != variable.id)).all() + if records and len(records) > 0: + raise HTTPException(status_code=500, detail=trans('i18n_variable.name_exist')) + + +def checkValue(session: SessionDep, trans: Trans, values: List): + # values: [{"variableId":1,"variableValues":["a","b"]}] + + pass diff --git a/backend/apps/system/crud/user.py b/backend/apps/system/crud/user.py index 69255a583..5f9928b1b 100644 --- a/backend/apps/system/crud/user.py +++ b/backend/apps/system/crud/user.py @@ -1,21 +1,23 @@ - from typing import Optional + from sqlmodel import Session, func, select, delete as sqlmodel_delete + from apps.system.models.system_model import UserWsModel, WorkspaceModel from apps.system.schemas.auth import CacheName, CacheNamespace from apps.system.schemas.system_schema import EMAIL_REGEX, PWD_REGEX, BaseUserDTO, UserInfoDTO, UserWs from common.core.deps import SessionDep +from common.core.security import verify_md5pwd from common.core.sqlbot_cache import cache, clear_cache -from common.utils.locale import I18n +from common.utils.locale import I18n, I18nHelper from common.utils.utils import SQLBotLogUtil -from ..models.user import UserModel -from common.core.security import verify_md5pwd -import re +from ..models.user import UserModel, UserPlatformModel + def get_db_user(*, session: Session, user_id: int) -> UserModel: db_user = session.get(UserModel, user_id) return db_user + def get_user_by_account(*, session: Session, account: str) -> BaseUserDTO | None: statement = select(UserModel).where(UserModel.account == account) db_user = session.exec(statement).first() @@ -23,19 +25,22 @@ def get_user_by_account(*, session: Session, account: str) -> BaseUserDTO | None return None return BaseUserDTO.model_validate(db_user.model_dump()) + @cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.USER_INFO, keyExpression="user_id") async def get_user_info(*, session: Session, user_id: int) -> UserInfoDTO | None: - db_user: UserModel = get_db_user(session = session, user_id = user_id) + db_user: UserModel = get_db_user(session=session, user_id=user_id) if not db_user: return None userInfo = UserInfoDTO.model_validate(db_user.model_dump()) userInfo.isAdmin = userInfo.id == 1 and userInfo.account == 'admin' if userInfo.isAdmin: return userInfo - ws_model: UserWsModel = session.exec(select(UserWsModel).where(UserWsModel.uid == userInfo.id, UserWsModel.oid == userInfo.oid)).first() + ws_model: UserWsModel = session.exec( + select(UserWsModel).where(UserWsModel.uid == userInfo.id, UserWsModel.oid == userInfo.oid)).first() userInfo.weight = ws_model.weight if ws_model else -1 return userInfo + def authenticate(*, session: Session, account: str, password: str) -> BaseUserDTO | None: db_user = get_user_by_account(session=session, account=account) if not db_user: @@ -44,7 +49,8 @@ def authenticate(*, session: Session, account: str, password: str) -> BaseUserDT return None return db_user -async def user_ws_options(session: Session, uid: int, trans: Optional[I18n] = None) -> list[UserWs]: + +def user_ws_list(session: Session, uid: int, trans: Optional[I18n | I18nHelper] = None) -> list[UserWs]: if uid == 1: stmt = select(WorkspaceModel.id, WorkspaceModel.name).order_by(WorkspaceModel.name, WorkspaceModel.create_time) else: @@ -56,33 +62,46 @@ async def user_ws_options(session: Session, uid: int, trans: Optional[I18n] = No result = session.exec(stmt) if not trans: return result.all() - return [ - UserWs(id = id, name = trans(name) if name.startswith('i18n') else name) + list_result = [ + UserWs(id=id, name=trans(name) if name.startswith('i18n') else name) for id, name in result.all() ] - + if list_result: + list_result.sort(key=lambda x: x.name) + return list_result + +async def user_ws_options(session: Session, uid: int, trans: Optional[I18n | I18nHelper] = None) -> list[UserWs]: + return user_ws_list(session, uid, trans) + + @clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.USER_INFO, keyExpression="id") async def single_delete(session: SessionDep, id: int): - user_model: UserModel = get_db_user(session = session, user_id = id) + user_model: UserModel = get_db_user(session=session, user_id=id) del_stmt = sqlmodel_delete(UserWsModel).where(UserWsModel.uid == id) session.exec(del_stmt) + if user_model and user_model.origin and user_model.origin != 0: + platform_del_stmt = sqlmodel_delete(UserPlatformModel).where(UserPlatformModel.uid == id) + session.exec(platform_del_stmt) session.delete(user_model) session.commit() -@clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.USER_INFO, keyExpression="id") + +@clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.USER_INFO, keyExpression="id") async def clean_user_cache(id: int): SQLBotLogUtil.info(f"User cache for [{id}] has been cleaned") def check_account_exists(*, session: Session, account: str) -> bool: return session.exec(select(func.count()).select_from(UserModel).where(UserModel.account == account)).one() > 0 + + def check_email_exists(*, session: Session, email: str) -> bool: return session.exec(select(func.count()).select_from(UserModel).where(UserModel.email == email)).one() > 0 - def check_email_format(email: str) -> bool: return bool(EMAIL_REGEX.fullmatch(email)) + def check_pwd_format(pwd: str) -> bool: return bool(PWD_REGEX.fullmatch(pwd)) diff --git a/backend/apps/system/crud/user_excel.py b/backend/apps/system/crud/user_excel.py new file mode 100644 index 000000000..914743ec3 --- /dev/null +++ b/backend/apps/system/crud/user_excel.py @@ -0,0 +1,340 @@ + + +import asyncio +from http.client import HTTPException +import io +import sys +import tempfile +import uuid +import atexit +import threading +from fastapi.responses import StreamingResponse, FileResponse +import os +from openai import BaseModel +import pandas as pd +from apps.system.models.user import UserModel +from common.core.deps import SessionDep + + +class RowValidator: + def __init__(self, success: bool = False, row=list[str], error_info: dict = None): + self.success = success + self.row = row + self.dict_data = {} + self.error_info = error_info or {} +class CellValidator: + def __init__(self, success: bool = False, value: str | int | list = None, message: str = ""): + self.success = success + self.value = value + self.message = message + +class UploadResultDTO(BaseModel): + successCount: int + errorCount: int + dataKey: str | None = None + + +async def downTemplate(trans): + def inner(): + data = { + trans('i18n_user.account'): ['sqlbot1', 'sqlbot2'], + trans('i18n_user.name'): ['sqlbot_employee1', 'sqlbot_employee2'], + trans('i18n_user.email'): ['employee1@sqlbot.com', 'employee2@sqlbot.com'], + trans('i18n_user.workspace'): [trans('i18n_default_workspace'), trans('i18n_default_workspace')], + trans('i18n_user.role'): [trans('i18n_user.administrator'), trans('i18n_user.ordinary_member')], + trans('i18n_user.status'): [trans('i18n_user.status_enabled'), trans('i18n_user.status_disabled')], + trans('i18n_user.origin'): [trans('i18n_user.local_creation'), trans('i18n_user.local_creation')], + trans('i18n_user.platform_user_id'): [None, None], + } + df = pd.DataFrame(data) + buffer = io.BytesIO() + with pd.ExcelWriter(buffer, engine='xlsxwriter', engine_kwargs={'options': {'strings_to_numbers': False}}) as writer: + df.to_excel(writer, sheet_name='Sheet1', index=False) + + workbook = writer.book + worksheet = writer.sheets['Sheet1'] + + header_format = workbook.add_format({ + 'bold': True, + 'font_size': 12, + 'font_name': '微软雅黑', + 'align': 'center', + 'valign': 'vcenter', + 'border': 0, + 'text_wrap': False, + }) + + for i, col in enumerate(df.columns): + max_length = max( + len(str(col).encode('utf-8')) * 1.1, + (df[col].astype(str)).apply(len).max() + ) + worksheet.set_column(i, i, max_length + 12) + + worksheet.write(0, i, col, header_format) + + + worksheet.set_row(0, 30) + for row in range(1, len(df) + 1): + worksheet.set_row(row, 25) + + buffer.seek(0) + return io.BytesIO(buffer.getvalue()) + + result = await asyncio.to_thread(inner) + return StreamingResponse(result, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + +async def batchUpload(session: SessionDep, trans, file) -> UploadResultDTO: + ALLOWED_EXTENSIONS = {"xlsx", "xls"} + if not file.filename.lower().endswith(tuple(ALLOWED_EXTENSIONS)): + raise HTTPException(400, "Only support .xlsx/.xls") + + # Support FastAPI UploadFile (async read) and file-like objects. + NA_VALUES = ['', 'NA', 'N/A', 'NULL'] + df = None + # If file provides an async read (UploadFile), read bytes first + if hasattr(file, 'read') and asyncio.iscoroutinefunction(getattr(file, 'read')): + content = await file.read() + df = pd.read_excel(io.BytesIO(content), sheet_name=0, na_values=NA_VALUES) + else: + # If it's a Starlette UploadFile-like with a .file attribute, use that + if hasattr(file, 'file'): + fobj = file.file + try: + fobj.seek(0) + except Exception: + pass + df = pd.read_excel(fobj, sheet_name=0, na_values=NA_VALUES) + else: + # fallback: assume a path or file-like object + try: + file.seek(0) + except Exception: + pass + df = pd.read_excel(file, sheet_name=0, na_values=NA_VALUES) + head_list = list(df.columns) + i18n_head_list = get_i18n_head_list() + if not validate_head(trans=trans, head_i18n_list=i18n_head_list, head_list=head_list): + raise HTTPException(400, "Excel header validation failed") + success_list = [] + error_list = [] + for row in df.itertuples(): + row_validator = validate_row(trans=trans, head_i18n_list=i18n_head_list, row=row) + if row_validator.success: + success_list.append(row_validator.dict_data) + else: + error_list.append(row_validator) + error_file_id = None + if error_list: + error_file_id = generate_error_file(error_list, head_list) + result = UploadResultDTO(successCount=len(success_list), errorCount=len(error_list), dataKey=error_file_id) + if success_list: + user_po_list = [UserModel.model_validate(row) for row in success_list] + session.add_all(user_po_list) + session.commit() + return result + +def get_i18n_head_list(): + return [ + 'i18n_user.account', + 'i18n_user.name', + 'i18n_user.email', + 'i18n_user.workspace', + 'i18n_user.role', + 'i18n_user.status', + 'i18n_user.origin', + 'i18n_user.platform_user_id', + ] + +def validate_head(trans, head_i18n_list: list[str], head_list: list): + if len(head_list) != len(head_i18n_list): + return False + for i in range(len(head_i18n_list)): + if head_list[i] != trans(head_i18n_list[i]): + return False + return True + + + +def validate_row(trans, head_i18n_list: list[str], row): + validator = RowValidator(success=True, row=[], error_info={}) + for i in range(len(head_i18n_list)): + col_name = trans(head_i18n_list[i]) + row_value = getattr(row, col_name) + validator.row.append(row_value) + _attr_name = f"{head_i18n_list[i].split('.')[-1]}" + _method_name = f"validate_{_attr_name}" + cellValidator = dynamic_call(_method_name, row_value) + if not cellValidator.success: + validator.success = False + validator.error_info[i] = cellValidator.message + else: + validator.dict_data[_attr_name] = cellValidator.value + return validator + +def generate_error_file(error_list: list[RowValidator], head_list: list[str]) -> str: + # If no errors, return empty string + if not error_list: + return "" + + # Build DataFrame from error rows (only include rows that had errors) + df_rows = [err.row for err in error_list] + df = pd.DataFrame(df_rows, columns=head_list) + + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") + tmp_name = tmp.name + tmp.close() + + with pd.ExcelWriter(tmp_name, engine='xlsxwriter', engine_kwargs={'options': {'strings_to_numbers': False}}) as writer: + df.to_excel(writer, sheet_name='Errors', index=False) + + workbook = writer.book + worksheet = writer.sheets['Errors'] + + # header format similar to downTemplate + header_format = workbook.add_format({ + 'bold': True, + 'font_size': 12, + 'font_name': '微软雅黑', + 'align': 'center', + 'valign': 'vcenter', + 'border': 0, + 'text_wrap': False, + }) + + # apply header format and column widths + for i, col in enumerate(df.columns): + max_length = max( + len(str(col).encode('utf-8')) * 1.1, + (df[col].astype(str)).apply(len).max() if len(df) > 0 else 0 + ) + worksheet.set_column(i, i, max_length + 12) + worksheet.write(0, i, col, header_format) + + worksheet.set_row(0, 30) + for row_idx in range(1, len(df) + 1): + worksheet.set_row(row_idx, 25) + + red_format = workbook.add_format({'font_color': 'red'}) + + # Add comments and set red font for each erroneous cell. + # Note: pandas wrote header at row 0, data starts from row 1 in the sheet. + for sheet_row_idx, err in enumerate(error_list, start=1): + for col_idx, message in err.error_info.items(): + if message: + comment_text = str(message) + worksheet.write_comment(sheet_row_idx, col_idx, comment_text) + try: + cell_value = df.iat[sheet_row_idx - 1, col_idx] + except Exception: + cell_value = None + worksheet.write(sheet_row_idx, col_idx, cell_value, red_format) + + # register temp file in map and return an opaque file id + file_id = uuid.uuid4().hex + with _TEMP_FILE_LOCK: + _TEMP_FILE_MAP[file_id] = tmp_name + + return file_id + + +def download_error_file(file_id: str) -> FileResponse: + """Return a FileResponse for the given generated file id. + + Look up the actual temp path from the internal map. Only files + created by `generate_error_file` are allowed. + """ + if not file_id: + raise HTTPException(400, "file_id required") + + with _TEMP_FILE_LOCK: + file_path = _TEMP_FILE_MAP.get(file_id) + + if not file_path: + raise HTTPException(404, "File not found") + + # ensure file is inside tempdir + tempdir = tempfile.gettempdir() + try: + common = os.path.commonpath([tempdir, os.path.abspath(file_path)]) + except Exception: + raise HTTPException(403, "Unauthorized file access") + + if os.path.abspath(common) != os.path.abspath(tempdir): + raise HTTPException(403, "Unauthorized file access") + + if not os.path.exists(file_path): + raise HTTPException(404, "File not found") + + return FileResponse( + path=file_path, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + filename=os.path.basename(file_path), + ) + +def validate_account(value: str) -> CellValidator: + return CellValidator(True, value, None) +def validate_name(value: str) -> CellValidator: + return CellValidator(True, value, None) +def validate_email(value: str) -> CellValidator: + return CellValidator(True, value, None) +def validate_workspace(value: str) -> CellValidator: + return CellValidator(True, value, None) +def validate_role(value: str) -> CellValidator: + return CellValidator(True, value, None) +def validate_status(value: str) -> CellValidator: + if value == '已启用': return CellValidator(True, 1, None) + if value == '已禁用': return CellValidator(True, 0, None) + return CellValidator(False, None, "状态只能是已启用或已禁用") +def validate_origin(value: str) -> CellValidator: + if value == '本地创建': return CellValidator(True, 0, None) + return CellValidator(False, None, "不支持当前来源") +def validate_platform_id(value: str) -> CellValidator: + return CellValidator(True, value, None) + +_method_cache = { + 'validate_account': validate_account, + 'validate_name': validate_name, + 'validate_email': validate_email, + 'validate_workspace': validate_workspace, + 'validate_role': validate_role, + 'validate_status': validate_status, + 'validate_origin': validate_origin, + 'validate_platform_user_id': validate_platform_id, +} +_module = sys.modules[__name__] +def dynamic_call(method_name: str, *args, **kwargs): + if method_name in _method_cache: + return _method_cache[method_name](*args, **kwargs) + + if hasattr(_module, method_name): + func = getattr(_module, method_name) + _method_cache[method_name] = func + return func(*args, **kwargs) + + raise AttributeError(f"Function '{method_name}' not found") + + +# Map of file_id -> temp path for generated error files +_TEMP_FILE_MAP: dict[str, str] = {} +_TEMP_FILE_LOCK = threading.Lock() + + +def _cleanup_temp_files(): + with _TEMP_FILE_LOCK: + for fid, path in list(_TEMP_FILE_MAP.items()): + try: + if os.path.exists(path): + os.remove(path) + except Exception: + pass + _TEMP_FILE_MAP.clear() + + +atexit.register(_cleanup_temp_files) + + + + + + \ No newline at end of file diff --git a/backend/apps/system/middleware/auth.py b/backend/apps/system/middleware/auth.py index 32fa4c71a..feeb246aa 100644 --- a/backend/apps/system/middleware/auth.py +++ b/backend/apps/system/middleware/auth.py @@ -1,20 +1,25 @@ +import base64 +import json +import re from typing import Optional from fastapi import Request from fastapi.responses import JSONResponse +from starlette.responses import Response import jwt from sqlmodel import Session from starlette.middleware.base import BaseHTTPMiddleware -from apps.system.models.system_model import AssistantModel -from common.core.db import engine +from apps.system.crud.apikey_manage import get_api_key +from apps.system.models.system_model import ApiKeyModel, AssistantModel +from common.core.db import engine from apps.system.crud.assistant import get_assistant_info, get_assistant_user -from apps.system.crud.user import get_user_info +from apps.system.crud.user import get_user_by_account, get_user_info from apps.system.schemas.system_schema import AssistantHeader, UserInfoDTO from common.core import security from common.core.config import settings from common.core.schemas import TokenPayload from common.utils.locale import I18n -from common.utils.utils import SQLBotLogUtil +from common.utils.utils import SQLBotLogUtil, get_origin_from_referer, origin_match_domain from common.utils.whitelist import whiteUtils from fastapi.security.utils import get_authorization_scheme_param from common.core.deps import get_i18n @@ -28,16 +33,49 @@ def __init__(self, app): async def dispatch(self, request, call_next): if self.is_options(request) or whiteUtils.is_whitelisted(request.url.path): + # 动态处理 /system/assistant/info/{id} 的 CORS 预检 + if request.method == "OPTIONS": + origin = request.headers.get("origin", "") + if origin: + match = re.search(r'/system/assistant/info/(\d+)', request.url.path) + if match: + assistant_id = int(match.group(1)) + with Session(engine) as session: + db_model = session.get(AssistantModel, assistant_id) + if db_model and origin_match_domain(origin, db_model.domain): + return Response( + status_code=200, + headers={ + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Max-Age": "600", + }, + ) return await call_next(request) assistantTokenKey = settings.ASSISTANT_TOKEN_KEY assistantToken = request.headers.get(assistantTokenKey) + askToken = request.headers.get("X-SQLBOT-ASK-TOKEN") trans = await get_i18n(request) + if askToken: + validate_pass, data = await self.validateAskToken(askToken, trans) + if validate_pass: + request.state.current_user = data + return await call_next(request) + message = trans('i18n_permission.authenticate_invalid', msg = data) + return JSONResponse(message, status_code=401, headers={"Access-Control-Allow-Origin": "*"}) #if assistantToken and assistantToken.lower().startswith("assistant "): if assistantToken: - validator: tuple[any] = await self.validateAssistant(assistantToken) + validator: tuple[any] = await self.validateAssistant(assistantToken, trans) if validator[0]: request.state.current_user = validator[1] + if request.state.current_user and trans.lang: + request.state.current_user.language = trans.lang request.state.assistant = validator[2] + origin = request.headers.get("X-SQLBOT-HOST-ORIGIN") or get_origin_from_referer(request) + if origin and validator[2]: + request.state.assistant.request_origin = origin return await call_next(request) message = trans('i18n_permission.authenticate_invalid', msg = validator[1]) return JSONResponse(message, status_code=401, headers={"Access-Control-Allow-Origin": "*"}) @@ -55,6 +93,50 @@ async def dispatch(self, request, call_next): def is_options(self, request: Request): return request.method == "OPTIONS" + async def validateAskToken(self, askToken: Optional[str], trans: I18n): + if not askToken: + return False, f"Miss Token[X-SQLBOT-ASK-TOKEN]!" + schema, param = get_authorization_scheme_param(askToken) + if schema.lower() != "sk": + return False, f"Token schema error!" + try: + payload = jwt.decode( + param, options={"verify_signature": False, "verify_exp": False}, algorithms=[security.ALGORITHM] + ) + access_key = payload.get('access_key', None) + + if not access_key: + return False, f"Miss access_key payload error!" + with Session(engine) as session: + api_key_model = await get_api_key(session, access_key) + api_key_model = ApiKeyModel.model_validate(api_key_model) if api_key_model else None + if not api_key_model: + return False, f"Invalid access_key!" + if not api_key_model.status: + return False, f"Disabled access_key!" + payload = jwt.decode( + param, api_key_model.secret_key, algorithms=[security.ALGORITHM] + ) + uid = api_key_model.uid + session_user = await get_user_info(session = session, user_id = uid) + if not session_user: + message = trans('i18n_not_exist', msg = trans('i18n_user.account')) + raise Exception(message) + session_user = UserInfoDTO.model_validate(session_user) + if session_user.status != 1: + message = trans('i18n_login.user_disable', msg = trans('i18n_concat_admin')) + raise Exception(message) + if not session_user.oid or session_user.oid == 0: + message = trans('i18n_login.no_associated_ws', msg = trans('i18n_concat_admin')) + raise Exception(message) + return True, session_user + except Exception as e: + msg = str(e) + SQLBotLogUtil.exception(f"Token validation error: {msg}") + if 'expired' in msg: + return False, jwt.ExpiredSignatureError(trans('i18n_permission.token_expired')) + return False, e + async def validateToken(self, token: Optional[str], trans: I18n): if not token: return False, f"Miss Token[{settings.TOKEN_KEY}]!" @@ -87,14 +169,17 @@ async def validateToken(self, token: Optional[str], trans: I18n): return False, e - async def validateAssistant(self, assistantToken: Optional[str]) -> tuple[any]: + async def validateAssistant(self, assistantToken: Optional[str], trans: I18n) -> tuple[any]: if not assistantToken: return False, f"Miss Token[{settings.TOKEN_KEY}]!" schema, param = get_authorization_scheme_param(assistantToken) - if schema.lower() != "assistant": - return False, f"Token schema error!" - try: + + try: + if schema.lower() == 'embedded': + return await self.validateEmbedded(param, trans) + if schema.lower() != "assistant": + return False, f"Token schema error!" payload = jwt.decode( param, settings.SECRET_KEY, algorithms=[security.ALGORITHM] ) @@ -108,8 +193,63 @@ async def validateAssistant(self, assistantToken: Optional[str]) -> tuple[any]: assistant_info = await get_assistant_info(session=session, assistant_id=payload['assistant_id']) assistant_info = AssistantModel.model_validate(assistant_info) assistant_info = AssistantHeader.model_validate(assistant_info.model_dump(exclude_unset=True)) + session_user.oid = int(assistant_info.oid) + return True, session_user, assistant_info except Exception as e: SQLBotLogUtil.exception(f"Assistant validation error: {str(e)}") # Return False and the exception message - return False, e \ No newline at end of file + return False, e + + async def validateEmbedded(self, param: str, trans: I18n) -> tuple[any]: + try: + # WARNING: Signature verification is disabled for embedded tokens + # This is a security risk and should only be used if absolutely necessary + # Consider implementing proper signature verification with a shared secret + payload: dict = jwt.decode( + param, + options={"verify_signature": False, "verify_exp": False}, + algorithms=[security.ALGORITHM] + ) + app_key = payload.get('appId', '') + embeddedId = payload.get('embeddedId', None) + if not embeddedId: + embeddedId = xor_decrypt(app_key) + if not payload['account']: + return False, f"Miss account payload error!" + account = payload['account'] + with Session(engine) as session: + assistant_info = await get_assistant_info(session=session, assistant_id=embeddedId) + assistant_info = AssistantModel.model_validate(assistant_info) + payload = jwt.decode( + param, assistant_info.app_secret, algorithms=[security.ALGORITHM] + ) + assistant_info = AssistantHeader.model_validate(assistant_info.model_dump(exclude_unset=True)) + """ session_user = await get_user_info(session = session, user_id = token_data.id) + session_user = UserInfoDTO.model_validate(session_user) """ + session_user = get_user_by_account(session = session, account=account) + if not session_user: + message = trans('i18n_not_exist', msg = trans('i18n_user.account')) + raise Exception(message) + session_user = await get_user_info(session = session, user_id = session_user.id) + + session_user = UserInfoDTO.model_validate(session_user) + if session_user.status != 1: + message = trans('i18n_login.user_disable', msg = trans('i18n_concat_admin')) + raise Exception(message) + if not session_user.oid or session_user.oid == 0: + message = trans('i18n_login.no_associated_ws', msg = trans('i18n_concat_admin')) + raise Exception(message) + if session_user.oid: + assistant_info.oid = int(session_user.oid) + return True, session_user, assistant_info + except Exception as e: + SQLBotLogUtil.exception(f"Embedded validation error: {str(e)}") + # Return False and the exception message + return False, e + +def xor_decrypt(encrypted_str: str, key: int = 0xABCD1234) -> int: + encrypted_bytes = base64.urlsafe_b64decode(encrypted_str) + hex_str = encrypted_bytes.hex() + encrypted_num = int(hex_str, 16) + return encrypted_num ^ key \ No newline at end of file diff --git a/backend/apps/system/models/system_model.py b/backend/apps/system/models/system_model.py index c743533de..6b3602329 100644 --- a/backend/apps/system/models/system_model.py +++ b/backend/apps/system/models/system_model.py @@ -1,6 +1,8 @@ - from typing import Optional + +from pydantic import field_serializer from sqlmodel import BigInteger, Field, Text, SQLModel + from common.core.models import SnowflakeBase from common.core.schemas import BaseCreatorDTO @@ -9,48 +11,98 @@ class AiModelBase: supplier: int = Field(nullable=False) name: str = Field(max_length=255, nullable=False) model_type: int = Field(nullable=False) - base_model: str = Field(max_length = 255, nullable=False) + base_model: str = Field(max_length=255, nullable=False) default_model: bool = Field(default=False, nullable=False) + class AiModelDetail(SnowflakeBase, AiModelBase, table=True): - __tablename__ = "ai_model" - api_key: str | None = Field(max_length=255, nullable=True) - api_domain: str = Field(max_length=255, nullable=False) - protocol: int = Field(nullable=False, default = 1) - config: str = Field(sa_type = Text()) - status: int = Field(nullable=False, default = 1) - create_time: int = Field(default=0, sa_type=BigInteger()) - + __tablename__ = "ai_model" + api_key: str | None = Field(default=None, nullable=True, sa_type=Text()) + api_domain: str = Field(nullable=False, sa_type=Text()) + protocol: int = Field(nullable=False, default=1) + config: str = Field(sa_type=Text()) + status: int = Field(nullable=False, default=1) + create_time: int = Field(default=0, sa_type=BigInteger()) + + +class AiModelWorkspaceMapping(SnowflakeBase, table=True): + __tablename__ = "ai_model_workspace_mapping" + ai_model_id: int = Field(default=None, nullable=True, sa_type=BigInteger()) + workspace_id: int = Field(default=None, nullable=True, sa_type=BigInteger()) + +class AiModelBrief(SQLModel): + id: int + name: str + default_model: bool + supplier: int + + @field_serializer("id") + def id_to_str(self, v: int) -> str: + return str(v) class WorkspaceBase(SQLModel): name: str = Field(max_length=255, nullable=False) + class WorkspaceEditor(WorkspaceBase, BaseCreatorDTO): pass - + + class WorkspaceModel(SnowflakeBase, WorkspaceBase, table=True): __tablename__ = "sys_workspace" create_time: int = Field(default=0, sa_type=BigInteger()) - + + class UserWsBaseModel(SQLModel): uid: int = Field(nullable=False, sa_type=BigInteger()) oid: int = Field(nullable=False, sa_type=BigInteger()) - weight: int = Field(default=0, nullable=False) - + weight: int = Field(default=0, nullable=False) + + class UserWsModel(SnowflakeBase, UserWsBaseModel, table=True): __tablename__ = "sys_user_ws" - + class AssistantBaseModel(SQLModel): name: str = Field(max_length=255, nullable=False) type: int = Field(nullable=False, default=0) domain: str = Field(max_length=255, nullable=False) - description: Optional[str] = Field(sa_type = Text(), nullable=True) - configuration: Optional[str] = Field(sa_type = Text(), nullable=True) + description: Optional[str] = Field(sa_type=Text(), nullable=True) + configuration: Optional[str] = Field(sa_type=Text(), nullable=True) create_time: int = Field(default=0, sa_type=BigInteger()) + app_id: Optional[str] = Field(default=None, max_length=255, nullable=True) + app_secret: Optional[str] = Field(default=None, max_length=255, nullable=True) + oid: Optional[int] = Field(nullable=True, sa_type=BigInteger(), default=1) + enable_custom_model: Optional[bool] = Field(default=False, nullable=True) + custom_model: Optional[str] = Field(default=None, max_length=255, nullable=True) + class AssistantModel(SnowflakeBase, AssistantBaseModel, table=True): __tablename__ = "sys_assistant" - \ No newline at end of file + + +class AuthenticationBaseModel(SQLModel): + name: str = Field(max_length=255, nullable=False) + type: int = Field(nullable=False, default=0) + config: Optional[str] = Field(sa_type=Text(), nullable=True) + + +class AuthenticationModel(SnowflakeBase, AuthenticationBaseModel, table=True): + __tablename__ = "sys_authentication" + create_time: Optional[int] = Field(default=0, sa_type=BigInteger()) + enable: bool = Field(default=False, nullable=False) + valid: bool = Field(default=False, nullable=False) + + +class ApiKeyBaseModel(SQLModel): + access_key: str = Field(max_length=255, nullable=False) + secret_key: str = Field(max_length=255, nullable=False) + create_time: int = Field(default=0, sa_type=BigInteger()) + uid: int = Field(default=0, nullable=False, sa_type=BigInteger()) + status: bool = Field(default=True, nullable=False) + + +class ApiKeyModel(SnowflakeBase, ApiKeyBaseModel, table=True): + __tablename__ = "sys_apikey" diff --git a/backend/apps/system/models/system_variable_model.py b/backend/apps/system/models/system_variable_model.py new file mode 100644 index 000000000..896868984 --- /dev/null +++ b/backend/apps/system/models/system_variable_model.py @@ -0,0 +1,20 @@ +# Author: Junjun +# Date: 2026/1/26 + +from datetime import datetime +from typing import List + +from sqlalchemy import Column, BigInteger, Identity, DateTime +from sqlalchemy.dialects.postgresql import JSONB +from sqlmodel import SQLModel, Field + + +class SystemVariable(SQLModel, table=True): + __tablename__ = "system_variable" + id: int = Field(sa_column=Column(BigInteger, Identity(always=True), nullable=False, primary_key=True)) + name: str = Field(max_length=128, nullable=False) + var_type: str = Field(max_length=128, nullable=False) + type: str = Field(max_length=128, nullable=False) + value: List = Field(sa_column=Column(JSONB, nullable=True)) + create_time: datetime = Field(sa_column=Column(DateTime(timezone=False), nullable=True)) + create_by: int = Field(sa_column=Column(BigInteger())) \ No newline at end of file diff --git a/backend/apps/system/models/user.py b/backend/apps/system/models/user.py index 078f02d26..0004f057e 100644 --- a/backend/apps/system/models/user.py +++ b/backend/apps/system/models/user.py @@ -1,13 +1,14 @@ +from typing import List, Optional -from typing import Optional -from sqlmodel import BigInteger, SQLModel, Field +from sqlalchemy import Column, BigInteger +from sqlalchemy.dialects.postgresql import JSONB +from sqlmodel import SQLModel, Field from common.core.models import SnowflakeBase from common.core.security import default_md5_pwd from common.utils.time import get_timestamp - class BaseUserPO(SQLModel): account: str = Field(max_length=255, unique=True) oid: int = Field(nullable=False, sa_type=BigInteger(), default=0) @@ -15,9 +16,25 @@ class BaseUserPO(SQLModel): password: str = Field(default_factory=default_md5_pwd, max_length=255) email: str = Field(max_length=255) status: int = Field(default=0, nullable=False) + origin: int = Field(nullable=False, default=0) create_time: int = Field(default_factory=get_timestamp, sa_type=BigInteger(), nullable=False) language: str = Field(max_length=255, default="zh-CN") - + #system_variables: List = Field(sa_column=Column(JSONB, nullable=True)) + system_variables: Optional[List] = Field( + default=None, + sa_column=Column(JSONB, nullable=True) + ) + + class UserModel(SnowflakeBase, BaseUserPO, table=True): __tablename__ = "sys_user" - + + +class UserPlatformBase(SQLModel): + uid: int = Field(nullable=False, sa_type=BigInteger()) + origin: int = Field(nullable=False, default=0) + platform_uid: str = Field(max_length=255, nullable=False) + + +class UserPlatformModel(SnowflakeBase, UserPlatformBase, table=True): + __tablename__ = "sys_user_platform" diff --git a/backend/apps/system/schemas/ai_model_schema.py b/backend/apps/system/schemas/ai_model_schema.py index 5bfd206b8..7f523eb93 100644 --- a/backend/apps/system/schemas/ai_model_schema.py +++ b/backend/apps/system/schemas/ai_model_schema.py @@ -1,28 +1,30 @@ from typing import List -from pydantic import BaseModel +from pydantic import BaseModel, Field +from apps.swagger.i18n import PLACEHOLDER_PREFIX from common.core.schemas import BaseCreatorDTO class AiModelItem(BaseModel): - name: str - model_type: int - base_model: str - supplier: int - default_model: bool = False + name: str = Field(description=f"{PLACEHOLDER_PREFIX}model_name") + model_type: int = Field(description=f"{PLACEHOLDER_PREFIX}model_type") + base_model: str = Field(description=f"{PLACEHOLDER_PREFIX}base_model") + supplier: int = Field(description=f"{PLACEHOLDER_PREFIX}supplier") + protocol: int = Field(description=f"{PLACEHOLDER_PREFIX}protocol") + default_model: bool = Field(default=False, description=f"{PLACEHOLDER_PREFIX}default_model") class AiModelGridItem(AiModelItem, BaseCreatorDTO): - pass + ws_mapping_count: int = Field(default=0, description="workspace mapping count") class AiModelConfigItem(BaseModel): - key: str - val: object - name: str + key: str = Field(description=f"{PLACEHOLDER_PREFIX}arg_name") + val: object = Field(description=f"{PLACEHOLDER_PREFIX}arg_val") + name: str = Field(description=f"{PLACEHOLDER_PREFIX}arg_show_name") class AiModelCreator(AiModelItem): - api_domain: str - api_key: str - config_list: List[AiModelConfigItem] + api_domain: str = Field(description=f"{PLACEHOLDER_PREFIX}api_domain") + api_key: str = Field(description=f"{PLACEHOLDER_PREFIX}api_key") + config_list: List[AiModelConfigItem] = Field(description=f"{PLACEHOLDER_PREFIX}config_list") class AiModelEditor(AiModelCreator, BaseCreatorDTO): pass \ No newline at end of file diff --git a/backend/apps/system/schemas/auth.py b/backend/apps/system/schemas/auth.py index f67b15f09..de4a4dfe3 100644 --- a/backend/apps/system/schemas/auth.py +++ b/backend/apps/system/schemas/auth.py @@ -1,4 +1,5 @@ +from typing import Optional from pydantic import BaseModel from enum import Enum @@ -15,5 +16,8 @@ class CacheName(Enum): USER_INFO = "user:info" ASSISTANT_INFO = "assistant:info" ASSISTANT_DS = "assistant:ds" + ASK_INFO = "ask:info" + DS_ID_LIST = "ds:id:list" def __str__(self): - return self.value \ No newline at end of file + return self.value + diff --git a/backend/apps/system/schemas/logout_schema.py b/backend/apps/system/schemas/logout_schema.py new file mode 100644 index 000000000..b6b3d4921 --- /dev/null +++ b/backend/apps/system/schemas/logout_schema.py @@ -0,0 +1,9 @@ +from typing import Optional +from pydantic import BaseModel + + +class LogoutSchema(BaseModel): + token: Optional[str] = None + flag: Optional[str] = 'default' + origin: Optional[int] = 0 + data: Optional[str] = None \ No newline at end of file diff --git a/backend/apps/system/schemas/permission.py b/backend/apps/system/schemas/permission.py new file mode 100644 index 000000000..45c78f81d --- /dev/null +++ b/backend/apps/system/schemas/permission.py @@ -0,0 +1,138 @@ +from contextvars import ContextVar +from functools import wraps +from inspect import signature +from typing import Optional +from fastapi import HTTPException, Request +from pydantic import BaseModel +import re +from starlette.middleware.base import BaseHTTPMiddleware +from sqlmodel import Session, select +from apps.chat.models.chat_model import Chat +from apps.datasource.crud.datasource import get_ws_ds +from apps.datasource.models.datasource import CoreDatasource +from common.core.db import engine +from apps.system.schemas.system_schema import UserInfoDTO + +from common.utils.locale import I18n +i18n = I18n() + +class SqlbotPermission(BaseModel): + role: Optional[list[str]] = None + type: Optional[str] = None + keyExpression: Optional[str] = None + +async def get_ws_resource(oid, type) -> list: + with Session(engine) as session: + stmt = None + if type == 'ds' or type == 'datasource': + return await get_ws_ds(session, oid) + if type == 'chat': + stmt = select(Chat.id).where(Chat.oid == oid) + if stmt is not None: + db_list = session.exec(stmt).all() + return db_list + return [] + + +async def check_ws_permission(oid, type, resource) -> bool: + if not resource or (isinstance(resource, list) and len(resource) == 0): + return True + + resource_id_list = await get_ws_resource(oid, type) + if not resource_id_list: + return False + if isinstance(resource, list): + return set(resource).issubset(set(resource_id_list)) + return resource in resource_id_list + + +def require_permissions(permission: SqlbotPermission): + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + request = RequestContext.get_request() + + current_user: UserInfoDTO = getattr(request.state, 'current_user', None) + if not current_user: + raise HTTPException( + status_code=401, + detail="用户未认证" + ) + current_oid = current_user.oid + + trans = i18n(request) + + if current_user.isAdmin and not permission.type: + return await func(*args, **kwargs) + role_list = permission.role + keyExpression = permission.keyExpression + resource_type = permission.type + + if role_list: + if 'admin' in role_list and not current_user.isAdmin: + #raise Exception('no permission to execute, only for admin') + raise Exception(trans('i18n_permission.only_admin')) + if 'ws_admin' in role_list and current_user.weight == 0 and not current_user.isAdmin: + #raise Exception('no permission to execute, only for workspace admin') + raise Exception(trans('i18n_permission.only_ws_admin')) + if not resource_type: + return await func(*args, **kwargs) + if keyExpression: + sig = signature(func) + bound_args = sig.bind_partial(*args, **kwargs) + bound_args.apply_defaults() + + if keyExpression.startswith("args["): + if match := re.match(r"args\[(\d+)\]", keyExpression): + index = int(match.group(1)) + value = bound_args.args[index] + if await check_ws_permission(current_oid, resource_type, value): + return await func(*args, **kwargs) + #raise Exception('no permission to execute or resource do not exist!') + raise Exception(trans('i18n_permission.permission_resource_limit')) + + parts = keyExpression.split('.') + if not bound_args.arguments.get(parts[0]): + return await func(*args, **kwargs) + value = bound_args.arguments[parts[0]] + for part in parts[1:]: + value = getattr(value, part) + if await check_ws_permission(current_oid, resource_type, value): + return await func(*args, **kwargs) + raise Exception(trans('i18n_permission.permission_resource_limit')) + + return await func(*args, **kwargs) + + return wrapper + return decorator + +class RequestContext: + + _current_request: ContextVar[Request] = ContextVar("_current_request") + @classmethod + def set_request(cls, request: Request): + return cls._current_request.set(request) + + @classmethod + def get_request(cls) -> Request: + try: + return cls._current_request.get() + except LookupError: + raise RuntimeError( + "No request context found. " + "Make sure RequestContextMiddleware is installed." + ) + + @classmethod + def reset(cls, token): + cls._current_request.reset(token) + +class RequestContextMiddleware(BaseHTTPMiddleware): + + async def dispatch(self, request: Request, call_next): + token = RequestContext.set_request(request) + try: + response = await call_next(request) + return response + finally: + RequestContext.reset(token) \ No newline at end of file diff --git a/backend/apps/system/schemas/system_schema.py b/backend/apps/system/schemas/system_schema.py index 7ea29e671..db2f255b4 100644 --- a/backend/apps/system/schemas/system_schema.py +++ b/backend/apps/system/schemas/system_schema.py @@ -1,8 +1,10 @@ -from typing import Optional +import re +from typing import Optional,List + from pydantic import BaseModel, Field, field_validator +from apps.swagger.i18n import PLACEHOLDER_PREFIX from common.core.schemas import BaseCreatorDTO -import re EMAIL_REGEX = re.compile( r"^[a-zA-Z0-9]+([._-][a-zA-Z0-9]+)*@" @@ -14,106 +16,129 @@ r"(?=.*[~!@#$%^&*()_+\-={}|:\"<>?`\[\];',./])" r"[A-Za-z\d~!@#$%^&*()_+\-={}|:\"<>?`\[\];',./]{8,20}$" ) + + class UserStatus(BaseCreatorDTO): - status: int = 1 - - + status: int = Field(default=1, description=f"{PLACEHOLDER_PREFIX}status") + class UserLanguage(BaseModel): - language: str - + language: str = Field(description=f"{PLACEHOLDER_PREFIX}language") + class BaseUser(BaseModel): account: str = Field(min_length=1, max_length=100, description="用户账号") oid: int - + + class BaseUserDTO(BaseUser, BaseCreatorDTO): - language: str = Field(pattern=r"^(zh-CN|en)$", default="zh-CN", description="用户语言") + language: str = Field(pattern=r"^(zh-CN|zh-TW|en|ko-KR)$", default="zh-CN", description="用户语言") password: str status: int = 1 + origin: int = 0 + name: str + def to_dict(self): return { "id": self.id, "account": self.account, "oid": self.oid } + @field_validator("language") def validate_language(cls, lang: str) -> str: - if not re.fullmatch(r"^(zh-CN|en)$", lang): - raise ValueError("Language must be 'zh-CN' or 'en'") + if not re.fullmatch(r"^(zh-CN|zh-TW|en|ko-KR)$", lang): + raise ValueError("Language must be 'zh-CN', 'zh-TW', 'en', or 'ko-KR'") return lang + class UserCreator(BaseUser): - name: str = Field(min_length=1, max_length=100, description="用户名") - email: str = Field(min_length=1, max_length=100, description="用户邮箱") - status: int = 1 - oid_list: Optional[list[int]] = None - + name: str = Field(min_length=1, max_length=100, description=f"{PLACEHOLDER_PREFIX}user_name") + email: str = Field(min_length=1, max_length=100, description=f"{PLACEHOLDER_PREFIX}user_email") + status: int = Field(default=1, description=f"{PLACEHOLDER_PREFIX}status") + origin: Optional[int] = Field(default=0, description=f"{PLACEHOLDER_PREFIX}origin") + oid_list: Optional[list[int]] = Field(default=None, description=f"{PLACEHOLDER_PREFIX}oid") + system_variables: Optional[List] = Field(default=[]) + """ @field_validator("email") def validate_email(cls, lang: str) -> str: if not re.fullmatch(EMAIL_REGEX, lang): raise ValueError("Email format is invalid!") return lang """ + class UserEditor(UserCreator, BaseCreatorDTO): - pass + pass + class UserGrid(UserEditor): - create_time: int - language: str = "zh-CN" - #space_name: Optional[str] = None - origin: str = '' - - + create_time: int = Field(description=f"{PLACEHOLDER_PREFIX}create_time") + language: str = Field(default="zh-CN" ,description=f"{PLACEHOLDER_PREFIX}language") + # space_name: Optional[str] = None + # origin: str = '' + + class PwdEditor(BaseModel): - pwd: str - new_pwd: str - + pwd: str = Field(description=f"{PLACEHOLDER_PREFIX}origin_pwd") + new_pwd: str = Field(description=f"{PLACEHOLDER_PREFIX}new_pwd") + + class UserWsBase(BaseModel): - uid_list: list[int] - oid: Optional[int] = None + uid_list: list[int] = Field(description=f"{PLACEHOLDER_PREFIX}uid") + oid: Optional[int] = Field(default=None, description=f"{PLACEHOLDER_PREFIX}oid") + + class UserWsDTO(UserWsBase): - weight: Optional[int] = 0 + weight: Optional[int] = Field(default=0, description=f"{PLACEHOLDER_PREFIX}weight") + class UserWsEditor(BaseModel): - uid: int - oid: int - weight: int = 0 + uid: int = Field(description=f"{PLACEHOLDER_PREFIX}uid") + oid: int = Field(description=f"{PLACEHOLDER_PREFIX}oid") + weight: int = Field(default=0, description=f"{PLACEHOLDER_PREFIX}weight") + class UserInfoDTO(UserEditor): language: str = "zh-CN" weight: int = 0 isAdmin: bool = False - + class AssistantBase(BaseModel): - name: str - domain: str - type: int = 0 - configuration: Optional[str] = None - description: Optional[str] = None + name: str = Field(description=f"{PLACEHOLDER_PREFIX}model_name") + domain: str = Field(description=f"{PLACEHOLDER_PREFIX}assistant_domain") + type: int = Field(default=0, description=f"{PLACEHOLDER_PREFIX}assistant_type") # 0普通小助手 1高级 4页面嵌入 + configuration: Optional[str] = Field(default=None, description=f"{PLACEHOLDER_PREFIX}assistant_configuration") + description: Optional[str] = Field(default=None, description=f"{PLACEHOLDER_PREFIX}assistant_description") + oid: Optional[int] = Field(default=1, description=f"{PLACEHOLDER_PREFIX}oid") + enable_custom_model: Optional[bool] = Field(default=False, description=f"{PLACEHOLDER_PREFIX}enable_custom_model") + custom_model: Optional[str] = Field(description=f"{PLACEHOLDER_PREFIX}custom_model") + + class AssistantDTO(AssistantBase, BaseCreatorDTO): pass + class AssistantHeader(AssistantDTO): unique: Optional[str] = None certificate: Optional[str] = None online: bool = False - + request_origin: Optional[str] = None + class AssistantValidator(BaseModel): valid: bool = False id_match: bool = False domain_match: bool = False token: Optional[str] = None - + def __init__( - self, - valid: bool = False, - id_match: bool = False, - domain_match: bool = False, - token: Optional[str] = None, - **kwargs + self, + valid: bool = False, + id_match: bool = False, + domain_match: bool = False, + token: Optional[str] = None, + **kwargs ): super().__init__( valid=valid, @@ -122,23 +147,28 @@ def __init__( token=token, **kwargs ) - + + class WorkspaceUser(UserEditor): weight: int create_time: int - + + class UserWs(BaseCreatorDTO): - name: str - + name: str = Field(description="user_name") + + class UserWsOption(UserWs): - account: str - - + account: str = Field(description="user_account") + + class AssistantFieldSchema(BaseModel): id: Optional[int] = None name: Optional[str] = None type: Optional[str] = None comment: Optional[str] = None + + class AssistantTableSchema(BaseModel): id: Optional[int] = None name: Optional[str] = None @@ -147,6 +177,7 @@ class AssistantTableSchema(BaseModel): sql: Optional[str] = None fields: Optional[list[AssistantFieldSchema]] = None + class AssistantOutDsBase(BaseModel): id: Optional[int] = None name: str @@ -154,8 +185,9 @@ class AssistantOutDsBase(BaseModel): type_name: Optional[str] = None comment: Optional[str] = None description: Optional[str] = None - - + configuration: Optional[str] = None + + class AssistantOutDsSchema(AssistantOutDsBase): host: Optional[str] = None port: Optional[int] = None @@ -164,5 +196,31 @@ class AssistantOutDsSchema(AssistantOutDsBase): password: Optional[str] = None db_schema: Optional[str] = None extraParams: Optional[str] = None + mode: Optional[str] = None + lowVersion: Optional[bool] = False tables: Optional[list[AssistantTableSchema]] = None - \ No newline at end of file + + +class AssistantUiSchema(BaseCreatorDTO): + theme: Optional[str] = None + header_font_color: Optional[str] = None + logo: Optional[str] = None + float_icon: Optional[str] = None + float_icon_drag: Optional[bool] = False + x_type: Optional[str] = 'right' + x_val: Optional[int] = 0 + y_type: Optional[str] = 'bottom' + y_val: Optional[int] = 33 + name: Optional[str] = None + welcome: Optional[str] = None + welcome_desc: Optional[str] = None + +class ApikeyStatus(BaseModel): + id: int = Field(description=f"{PLACEHOLDER_PREFIX}id") + status: bool = Field(description=f"{PLACEHOLDER_PREFIX}status") + +class ApikeyGridItem(BaseCreatorDTO): + access_key: str = Field(description=f"Access Key") + secret_key: str = Field(description=f"Secret Key") + status: bool = Field(description=f"{PLACEHOLDER_PREFIX}status") + create_time: int = Field(description=f"{PLACEHOLDER_PREFIX}create_time") \ No newline at end of file diff --git a/backend/apps/template/generate_chart/generator.py b/backend/apps/template/generate_chart/generator.py index 0962cba70..a519b8930 100644 --- a/backend/apps/template/generate_chart/generator.py +++ b/backend/apps/template/generate_chart/generator.py @@ -4,3 +4,11 @@ def get_chart_template(): template = get_base_template() return template['template']['chart'] + +def get_base_terminology_template(): + template = get_base_template() + return template['template']['terminology'] + +def get_base_data_training_template(): + template = get_base_template() + return template['template']['data_training'] diff --git a/backend/apps/template/generate_sql/generator.py b/backend/apps/template/generate_sql/generator.py index 5d08cc9e1..07eb61972 100644 --- a/backend/apps/template/generate_sql/generator.py +++ b/backend/apps/template/generate_sql/generator.py @@ -1,6 +1,14 @@ -from apps.template.template import get_base_template +from typing import Union + +from apps.db.constant import DB +from apps.template.template import get_base_template, get_sql_template as get_base_sql_template def get_sql_template(): template = get_base_template() return template['template']['sql'] + + +def get_sql_example_template(db_type: Union[str, DB]): + template = get_base_sql_template(db_type) + return template['template'] diff --git a/backend/apps/template/template.py b/backend/apps/template/template.py index e342a84f2..1234f6f62 100644 --- a/backend/apps/template/template.py +++ b/backend/apps/template/template.py @@ -1,15 +1,64 @@ import yaml +from pathlib import Path +from functools import cache +from typing import Union -base_template = None +from apps.db.constant import DB +# 基础路径配置 +PROJECT_ROOT = Path(__file__).parent.parent.parent +TEMPLATES_DIR = PROJECT_ROOT / 'templates' +BASE_TEMPLATE_PATH = TEMPLATES_DIR / 'template.yaml' +SQL_TEMPLATES_DIR = TEMPLATES_DIR / 'sql_examples' -def load(): - with open('./template.yaml', 'r', encoding='utf-8') as f: - global base_template - base_template = yaml.load(f, Loader=yaml.SafeLoader) + +@cache +def _load_template_file(file_path: Path): + """内部函数:加载并解析YAML文件""" + try: + with open(file_path, 'r', encoding='utf-8') as f: + return yaml.safe_load(f) + except FileNotFoundError: + raise FileNotFoundError(f"Template file not found at {file_path}") + except yaml.YAMLError as e: + raise ValueError(f"Error parsing YAML file {file_path}: {e}") def get_base_template(): - if not base_template: - load() - return base_template + """获取基础模板(自动缓存)""" + return _load_template_file(BASE_TEMPLATE_PATH) + + +def get_sql_template(db_type: Union[str, DB]): + # 处理输入参数 + if isinstance(db_type, str): + # 如果是字符串,查找对应的枚举值,找不到则使用默认的 DB.pg + db_enum = DB.get_db(db_type, default_if_none=True) + elif isinstance(db_type, DB): + db_enum = db_type + else: + db_enum = DB.pg + + # 使用 template_name 作为文件名 + template_path = SQL_TEMPLATES_DIR / f"{db_enum.template_name}.yaml" + + return _load_template_file(template_path) + + +def get_all_sql_templates(): + """获取所有支持的数据库模板""" + templates = {} + for db in DB: + try: + templates[db.type] = get_sql_template(db) + except FileNotFoundError: + # 如果某个数据库的模板文件不存在,跳过 + continue + return templates + + +def reload_all_templates(): + """清空所有模板缓存""" + _load_template_file.cache_clear() + + diff --git a/backend/apps/terminology/__init__.py b/backend/apps/terminology/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/apps/terminology/api/__init__.py b/backend/apps/terminology/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/apps/terminology/api/terminology.py b/backend/apps/terminology/api/terminology.py new file mode 100644 index 000000000..979cdf41f --- /dev/null +++ b/backend/apps/terminology/api/terminology.py @@ -0,0 +1,277 @@ +import asyncio +import hashlib +import io +import os +import uuid +from http.client import HTTPException +from typing import Optional + +import pandas as pd +from fastapi import APIRouter, File, UploadFile, Query +from fastapi.responses import StreamingResponse + +from apps.chat.models.chat_model import AxisObj +from apps.swagger.i18n import PLACEHOLDER_PREFIX +from apps.system.schemas.permission import SqlbotPermission, require_permissions +from apps.terminology.curd.terminology import page_terminology, create_terminology, update_terminology, \ + delete_terminology, enable_terminology, get_all_terminology, batch_create_terminology +from apps.terminology.models.terminology_model import TerminologyInfo +from common.core.config import settings +from common.core.deps import SessionDep, CurrentUser, Trans +from common.utils.data_format import DataFormat +from common.utils.excel import get_excel_column_count +from common.audit.models.log_model import OperationType, OperationModules +from common.audit.schemas.logger_decorator import LogConfig, system_log +router = APIRouter(tags=["Terminology"], prefix="/system/terminology") + + +@router.get("/page/{current_page}/{page_size}", summary=f"{PLACEHOLDER_PREFIX}get_term_page") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +async def pager(session: SessionDep, current_user: CurrentUser, current_page: int, page_size: int, + word: Optional[str] = Query(None, description="搜索术语(可选)"), + dslist: Optional[list[int]] = Query(None, description="数据集ID集合(可选)")): + current_page, page_size, total_count, total_pages, _list = page_terminology(session, current_page, page_size, word, + current_user.oid, dslist) + + return { + "current_page": current_page, + "page_size": page_size, + "total_count": total_count, + "total_pages": total_pages, + "data": _list + } + + +@router.put("", summary=f"{PLACEHOLDER_PREFIX}create_or_update_term") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'], type='ds', keyExpression="info.datasource_ids")) +@system_log(LogConfig(operation_type=OperationType.CREATE_OR_UPDATE, module=OperationModules.TERMINOLOGY,resource_id_expr='info.id', result_id_expr="result_self")) +async def create_or_update(session: SessionDep, current_user: CurrentUser, trans: Trans, info: TerminologyInfo): + oid = current_user.oid + if info.id: + return update_terminology(session, info, oid, trans) + else: + return create_terminology(session, info, oid, trans) + + +@router.delete("", summary=f"{PLACEHOLDER_PREFIX}delete_term") +@system_log(LogConfig(operation_type=OperationType.DELETE, module=OperationModules.TERMINOLOGY,resource_id_expr='id_list')) +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +async def delete(session: SessionDep, id_list: list[int]): + delete_terminology(session, id_list) + + +@router.get("/{id}/enable/{enabled}", summary=f"{PLACEHOLDER_PREFIX}enable_term") +@system_log(LogConfig(operation_type=OperationType.UPDATE, module=OperationModules.TERMINOLOGY,resource_id_expr='id')) +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +async def enable(session: SessionDep, id: int, enabled: bool, trans: Trans): + enable_terminology(session, id, enabled, trans) + + +@router.get("/export", summary=f"{PLACEHOLDER_PREFIX}export_term") +@system_log(LogConfig(operation_type=OperationType.EXPORT, module=OperationModules.TERMINOLOGY)) +async def export_excel(session: SessionDep, trans: Trans, current_user: CurrentUser, + word: Optional[str] = Query(None, description="搜索术语(可选)")): + def inner(): + _list = get_all_terminology(session, word, oid=current_user.oid) + + data_list = [] + for obj in _list: + _data = { + "word": obj.word, + "other_words": ', '.join(obj.other_words) if obj.other_words else '', + "description": obj.description, + "all_data_sources": 'N' if obj.specific_ds else 'Y', + "datasource": ', '.join(obj.datasource_names) if obj.datasource_names and obj.specific_ds else '', + "advanced_application_name": obj.advanced_application_name or '', + } + data_list.append(_data) + + fields = [] + fields.append(AxisObj(name=trans('i18n_terminology.term_name'), value='word')) + fields.append(AxisObj(name=trans('i18n_terminology.synonyms'), value='other_words')) + fields.append(AxisObj(name=trans('i18n_terminology.term_description'), value='description')) + fields.append(AxisObj(name=trans('i18n_terminology.effective_data_sources'), value='datasource')) + fields.append(AxisObj(name=trans('i18n_terminology.all_data_sources'), value='all_data_sources')) + fields.append(AxisObj(name=trans('i18n_data_training.advanced_application'), value='advanced_application_name')) + + md_data, _fields_list = DataFormat.convert_object_array_for_pandas(fields, data_list) + + df = pd.DataFrame(md_data, columns=_fields_list) + + buffer = io.BytesIO() + + with pd.ExcelWriter(buffer, engine='xlsxwriter', + engine_kwargs={'options': {'strings_to_numbers': False}}) as writer: + df.to_excel(writer, sheet_name='Sheet1', index=False) + + buffer.seek(0) + return io.BytesIO(buffer.getvalue()) + + result = await asyncio.to_thread(inner) + return StreamingResponse(result, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + + +@router.get("/template", summary=f"{PLACEHOLDER_PREFIX}excel_template_term") +async def excel_template(trans: Trans): + def inner(): + data_list = [] + _data1 = { + "word": trans('i18n_terminology.term_name_template_example_1'), + "other_words": trans('i18n_terminology.synonyms_template_example_1'), + "description": trans('i18n_terminology.term_description_template_example_1'), + "all_data_sources": 'N', + "datasource": trans('i18n_terminology.effective_data_sources_template_example_1'), + "advanced_application_name": '', + } + data_list.append(_data1) + _data2 = { + "word": trans('i18n_terminology.term_name_template_example_2'), + "other_words": trans('i18n_terminology.synonyms_template_example_2'), + "description": trans('i18n_terminology.term_description_template_example_2'), + "all_data_sources": 'Y', + "datasource": '', + "advanced_application_name": '', + } + data_list.append(_data2) + + fields = [] + fields.append(AxisObj(name=trans('i18n_terminology.term_name_template'), value='word')) + fields.append(AxisObj(name=trans('i18n_terminology.synonyms_template'), value='other_words')) + fields.append(AxisObj(name=trans('i18n_terminology.term_description_template'), value='description')) + fields.append(AxisObj(name=trans('i18n_terminology.effective_data_sources_template'), value='datasource')) + fields.append(AxisObj(name=trans('i18n_terminology.all_data_sources_template'), value='all_data_sources')) + fields.append(AxisObj(name=trans('i18n_data_training.advanced_application'), value='advanced_application_name')) + + md_data, _fields_list = DataFormat.convert_object_array_for_pandas(fields, data_list) + + df = pd.DataFrame(md_data, columns=_fields_list) + + buffer = io.BytesIO() + + with pd.ExcelWriter(buffer, engine='xlsxwriter', + engine_kwargs={'options': {'strings_to_numbers': False}}) as writer: + df.to_excel(writer, sheet_name='Sheet1', index=False) + + buffer.seek(0) + return io.BytesIO(buffer.getvalue()) + + result = await asyncio.to_thread(inner) + return StreamingResponse(result, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + + +path = settings.EXCEL_PATH + +from sqlalchemy.orm import sessionmaker, scoped_session +from common.core.db import engine +from sqlmodel import Session + +session_maker = scoped_session(sessionmaker(bind=engine, class_=Session)) + + +@router.post("/uploadExcel", summary=f"{PLACEHOLDER_PREFIX}upload_term") +@system_log(LogConfig(operation_type=OperationType.IMPORT, module=OperationModules.TERMINOLOGY)) +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +async def upload_excel(trans: Trans, current_user: CurrentUser, file: UploadFile = File(...)): + ALLOWED_EXTENSIONS = {"xlsx", "xls"} + if not file.filename.lower().endswith(tuple(ALLOWED_EXTENSIONS)): + raise HTTPException(400, "Only support .xlsx/.xls") + + os.makedirs(path, exist_ok=True) + base_filename = f"{file.filename.split('.')[0]}_{hashlib.sha256(uuid.uuid4().bytes).hexdigest()[:10]}" + filename = f"{base_filename}.{file.filename.split('.')[1]}" + save_path = os.path.join(path, filename) + with open(save_path, "wb") as f: + f.write(await file.read()) + + oid = current_user.oid + + use_cols = [0, 1, 2, 3, 4, 5] + + def inner(): + + session = session_maker() + + sheet_names = pd.ExcelFile(save_path).sheet_names + + import_data = [] + + for sheet_name in sheet_names: + + if get_excel_column_count(save_path, sheet_name) < len(use_cols): + raise Exception(trans("i18n_excel_import.col_num_not_match")) + + df = pd.read_excel( + save_path, + sheet_name=sheet_name, + engine='calamine', + header=0, + usecols=use_cols, + dtype=str + ).fillna("") + + for index, row in df.iterrows(): + # 跳过空行 + if row.isnull().all(): + continue + + word = row[0].strip() if pd.notna(row[0]) and row[0].strip() else '' + other_words = [w.strip() for w in row[1].strip().split(',')] if pd.notna(row[1]) and row[ + 1].strip() else [] + description = row[2].strip() if pd.notna(row[2]) and row[2].strip() else '' + datasource_names = [d.strip() for d in row[3].strip().split(',')] if pd.notna(row[3]) and row[ + 3].strip() else [] + all_datasource = True if pd.notna(row[4]) and row[4].lower().strip() in ['y', 'yes', 'true'] else False + specific_ds = False if all_datasource else True + advanced_application_name = row[5].strip() if pd.notna(row[5]) and row[5].strip() else None + + import_data.append(TerminologyInfo(word=word, description=description, other_words=other_words, + datasource_names=datasource_names, specific_ds=specific_ds, + advanced_application_name=advanced_application_name)) + + res = batch_create_terminology(session, import_data, oid, trans) + + failed_records = res['failed_records'] + + error_excel_filename = None + + if len(failed_records) > 0: + data_list = [] + for obj in failed_records: + _data = { + "word": obj['data'].word, + "other_words": ', '.join(obj['data'].other_words) if obj['data'].other_words else '', + "description": obj['data'].description, + "all_data_sources": 'N' if obj['data'].specific_ds else 'Y', + "datasource": ', '.join(obj['data'].datasource_names) if obj['data'].datasource_names and obj[ + 'data'].specific_ds else '', + "advanced_application_name": obj['data'].advanced_application_name or '', + "errors": obj['errors'] + } + data_list.append(_data) + + fields = [] + fields.append(AxisObj(name=trans('i18n_terminology.term_name'), value='word')) + fields.append(AxisObj(name=trans('i18n_terminology.synonyms'), value='other_words')) + fields.append(AxisObj(name=trans('i18n_terminology.term_description'), value='description')) + fields.append(AxisObj(name=trans('i18n_terminology.effective_data_sources'), value='datasource')) + fields.append(AxisObj(name=trans('i18n_terminology.all_data_sources'), value='all_data_sources')) + fields.append(AxisObj(name=trans('i18n_data_training.advanced_application'), value='advanced_application_name')) + fields.append(AxisObj(name=trans('i18n_data_training.error_info'), value='errors')) + + md_data, _fields_list = DataFormat.convert_object_array_for_pandas(fields, data_list) + + df = pd.DataFrame(md_data, columns=_fields_list) + error_excel_filename = f"{base_filename}_error.xlsx" + save_error_path = os.path.join(path, error_excel_filename) + # 保存 DataFrame 到 Excel + df.to_excel(save_error_path, index=False) + + return { + 'success_count': res['success_count'], + 'failed_count': len(failed_records), + 'duplicate_count': res['duplicate_count'], + 'original_count': res['original_count'], + 'error_excel_filename': error_excel_filename, + } + + return await asyncio.to_thread(inner) diff --git a/backend/apps/terminology/curd/__init__.py b/backend/apps/terminology/curd/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/apps/terminology/curd/terminology.py b/backend/apps/terminology/curd/terminology.py new file mode 100644 index 000000000..7ca6128ab --- /dev/null +++ b/backend/apps/terminology/curd/terminology.py @@ -0,0 +1,977 @@ +import datetime +import logging +import traceback +from typing import List, Optional, Any +from xml.dom.minidom import parseString + +import dicttoxml +from sqlalchemy import and_, or_, select, func, delete, update, union, text, BigInteger +from sqlalchemy.orm import aliased + +from apps.ai_model.embedding import EmbeddingModelCache +from apps.datasource.models.datasource import CoreDatasource +from apps.system.models.system_model import AssistantModel +from apps.template.generate_chart.generator import get_base_terminology_template +from apps.terminology.models.terminology_model import Terminology, TerminologyInfo, TerminologyInfoResult +from common.core.config import settings +from common.core.deps import SessionDep, Trans +from common.utils.embedding_threads import run_save_terminology_embeddings + + +def get_terminology_base_query(oid: int, name: Optional[str] = None): + """ + 获取术语查询的基础查询结构 + """ + child = aliased(Terminology) + + if name and name.strip() != "": + keyword_pattern = f"%{name.strip()}%" + # 步骤1:先找到所有匹配的节点ID(无论是父节点还是子节点) + matched_ids_subquery = ( + select(Terminology.id) + .where(and_(Terminology.word.ilike(keyword_pattern), Terminology.oid == oid)) + .subquery() + ) + + # 步骤2:找到这些匹配节点的所有父节点(包括自身如果是父节点) + parent_ids_subquery = ( + select(Terminology.id) + .where( + (Terminology.id.in_(matched_ids_subquery)) | + (Terminology.id.in_( + select(Terminology.pid) + .where(Terminology.id.in_(matched_ids_subquery)) + .where(Terminology.pid.isnot(None)) + )) + ) + .where(Terminology.pid.is_(None)) # 只取父节点 + ) + else: + parent_ids_subquery = ( + select(Terminology.id) + .where(and_(Terminology.pid.is_(None), Terminology.oid == oid)) + ) + + return parent_ids_subquery, child + + +def build_terminology_query(session: SessionDep, oid: int, name: Optional[str] = None, + paginate: bool = True, current_page: int = 1, page_size: int = 10, + dslist: Optional[list[int]] = None): + """ + 构建术语查询的通用方法 + """ + parent_ids_subquery, child = get_terminology_base_query(oid, name) + + # 添加数据源筛选条件 + if dslist is not None and len(dslist) > 0: + datasource_conditions = [] + # datasource_ids 与 dslist 中的任一元素有交集 + for ds_id in dslist: + # 使用 JSONB 包含操作符,但需要确保类型正确 + datasource_conditions.append( + Terminology.datasource_ids.contains([ds_id]) + ) + + # datasource_ids 为空数组 + empty_array_condition = Terminology.datasource_ids == [] + + ds_filter_condition = or_( + *datasource_conditions, + empty_array_condition + ) + parent_ids_subquery = parent_ids_subquery.where(ds_filter_condition) + + # 计算总数 + count_stmt = select(func.count()).select_from(parent_ids_subquery.subquery()) + total_count = session.execute(count_stmt).scalar() + + if paginate: + # 分页处理 + page_size = max(10, page_size) + total_pages = (total_count + page_size - 1) // page_size + current_page = max(1, min(current_page, total_pages)) if total_pages > 0 else 1 + + paginated_parent_ids = ( + parent_ids_subquery + .order_by(Terminology.create_time.desc()) + .offset((current_page - 1) * page_size) + .limit(page_size) + .subquery() + ) + else: + # 不分页,获取所有数据 + total_pages = 1 + current_page = 1 + page_size = total_count if total_count > 0 else 1 + + paginated_parent_ids = ( + parent_ids_subquery + .order_by(Terminology.create_time.desc()) + .subquery() + ) + + # 构建公共查询部分 + children_subquery = ( + select( + child.pid, + func.jsonb_agg(child.word).filter(child.word.isnot(None)).label('other_words') + ) + .where(child.pid.isnot(None)) + .group_by(child.pid) + .subquery() + ) + + # 创建子查询来获取数据源名称 + datasource_names_subquery = ( + select( + func.jsonb_array_elements(Terminology.datasource_ids).cast(BigInteger).label('ds_id'), + Terminology.id.label('term_id') + ) + .where(Terminology.id.in_(paginated_parent_ids)) + .subquery() + ) + + stmt = ( + select( + Terminology.id, + Terminology.word, + Terminology.create_time, + Terminology.description, + Terminology.specific_ds, + Terminology.datasource_ids, + children_subquery.c.other_words, + func.jsonb_agg(CoreDatasource.name).filter(CoreDatasource.id.isnot(None)).label('datasource_names'), + Terminology.enabled, + Terminology.advanced_application, + AssistantModel.name.label('advanced_application_name'), + ) + .outerjoin( + children_subquery, + Terminology.id == children_subquery.c.pid + ) + .outerjoin( + datasource_names_subquery, + datasource_names_subquery.c.term_id == Terminology.id + ) + .outerjoin( + CoreDatasource, + CoreDatasource.id == datasource_names_subquery.c.ds_id + ) + .outerjoin(AssistantModel, + and_(Terminology.advanced_application == AssistantModel.id, AssistantModel.type == 1)) + .where(and_(Terminology.id.in_(paginated_parent_ids), Terminology.oid == oid)) + .group_by( + Terminology.id, + Terminology.word, + Terminology.create_time, + Terminology.description, + Terminology.specific_ds, + Terminology.datasource_ids, + Terminology.advanced_application, + AssistantModel.name, + children_subquery.c.other_words, + Terminology.enabled + ) + .order_by(Terminology.create_time.desc()) + ) + + return stmt, total_count, total_pages, current_page, page_size + + +def execute_terminology_query(session: SessionDep, stmt) -> List[TerminologyInfoResult]: + """ + 执行查询并返回术语信息列表 + """ + _list = [] + result = session.execute(stmt) + + for row in result: + _list.append(TerminologyInfoResult( + id=row.id, + word=row.word, + create_time=row.create_time, + description=row.description, + other_words=row.other_words if row.other_words else [], + specific_ds=row.specific_ds if row.specific_ds is not None else False, + datasource_ids=row.datasource_ids if row.datasource_ids is not None else [], + datasource_names=row.datasource_names if row.datasource_names is not None else [], + enabled=row.enabled if row.enabled is not None else False, + advanced_application=str(row.advanced_application) if row.advanced_application else None, + advanced_application_name=row.advanced_application_name, + )) + + return _list + + +def page_terminology(session: SessionDep, current_page: int = 1, page_size: int = 10, + name: Optional[str] = None, oid: Optional[int] = 1, dslist: Optional[list[int]] = None): + """ + 分页查询术语(原方法保持不变) + """ + stmt, total_count, total_pages, current_page, page_size = build_terminology_query( + session, oid, name, True, current_page, page_size, dslist + ) + _list = execute_terminology_query(session, stmt) + + return current_page, page_size, total_count, total_pages, _list + + +def get_all_terminology(session: SessionDep, name: Optional[str] = None, oid: Optional[int] = 1): + """ + 获取所有术语(不分页) + """ + stmt, total_count, total_pages, current_page, page_size = build_terminology_query( + session, oid, name, False + ) + _list = execute_terminology_query(session, stmt) + + return _list + + +def create_terminology(session: SessionDep, info: TerminologyInfo, oid: int, trans: Trans, + skip_embedding: bool = False): + """ + 创建单个术语记录 + Args: + skip_embedding: 是否跳过embedding处理(用于批量插入) + """ + # 基本验证 + if not info.word or not info.word.strip(): + raise Exception(trans("i18n_terminology.word_cannot_be_empty")) + + if not info.description or not info.description.strip(): + raise Exception(trans("i18n_terminology.description_cannot_be_empty")) + + create_time = datetime.datetime.now() + + specific_ds = info.specific_ds if info.specific_ds is not None else False + datasource_ids = info.datasource_ids if info.datasource_ids is not None else [] + + if specific_ds: + if not datasource_ids and info.advanced_application is None: + raise Exception(trans("i18n_data_training.datasource_assistant_cannot_be_none")) + + parent = Terminology( + word=info.word.strip(), + create_time=create_time, + description=info.description.strip(), + oid=oid, + specific_ds=specific_ds, + enabled=info.enabled, + datasource_ids=datasource_ids, + advanced_application=info.advanced_application, + ) + + words = [info.word.strip()] + for child_word in info.other_words: + # 先检查是否为空字符串 + if not child_word or child_word.strip() == "": + continue + + if child_word in words: + raise Exception(trans("i18n_terminology.cannot_be_repeated")) + else: + words.append(child_word.strip()) + + # 基础查询条件(word 和 oid 必须满足) + base_query = and_( + Terminology.word.in_(words), + Terminology.oid == oid, + ) + + # 构建查询 + query = session.query(Terminology).filter(base_query) + + # 作用域重复检查 + scope_conditions = [] + + if not specific_ds: + # 全部数据源:与有数据源的记录冲突,也与同为全部数据源的记录冲突 + scope_conditions.append( + and_( + Terminology.specific_ds == True, + Terminology.datasource_ids.isnot(None), + func.jsonb_array_length(Terminology.datasource_ids) > 0, + ) + ) + scope_conditions.append( + Terminology.specific_ds == False, + ) + elif specific_ds and datasource_ids: + # 指定数据源:与全部数据源冲突,也与有数据源重叠的记录冲突 + scope_conditions.append( + Terminology.specific_ds == False, + ) + ds_overlap_conditions = [ + Terminology.datasource_ids.contains([ds_id]) + for ds_id in datasource_ids + ] + scope_conditions.append( + and_( + Terminology.specific_ds == True, + Terminology.datasource_ids.isnot(None), + or_(*ds_overlap_conditions) + ) + ) + else: + # 不选数据源:仅与同为不选数据源的记录冲突 + scope_conditions.append( + and_( + Terminology.specific_ds == True, + or_( + Terminology.datasource_ids.is_(None), + func.jsonb_array_length(Terminology.datasource_ids) == 0, + ) + ) + ) + + # 高级应用重复检查:advanced_application 相同时同名即重复 + if info.advanced_application is not None: + scope_conditions.append( + Terminology.advanced_application == info.advanced_application + ) + + if scope_conditions: + query = query.where(or_(*scope_conditions)) + + # 转换为 EXISTS 查询并获取结果 + exists = session.query(query.exists()).scalar() + + if exists: + raise Exception(trans("i18n_terminology.exists_in_db")) + + session.add(parent) + session.flush() + session.refresh(parent) + + # 插入子记录(其他词) + child_list = [] + if info.other_words: + for other_word in info.other_words: + if other_word.strip() == "": + continue + child_list.append( + Terminology( + pid=parent.id, + word=other_word.strip(), + create_time=create_time, + oid=oid, + enabled=info.enabled, + specific_ds=specific_ds, + datasource_ids=datasource_ids + ) + ) + + if child_list: + session.bulk_save_objects(child_list) + session.flush() + + session.commit() + + # 处理embedding(批量插入时跳过) + if not skip_embedding: + run_save_terminology_embeddings([parent.id]) + + return parent.id + + +def batch_create_terminology(session: SessionDep, info_list: List[TerminologyInfo], oid: int, trans: Trans): + """ + 批量创建术语记录(复用单条插入逻辑) + """ + if not info_list: + return { + 'success_count': 0, + 'failed_records': [], + 'duplicate_count': 0, + 'original_count': 0, + 'deduplicated_count': 0 + } + + failed_records = [] + success_count = 0 + inserted_ids = [] + + # 第一步:数据去重(根据新的唯一性规则) + unique_records = {} + duplicate_records = [] + + for info in info_list: + # 过滤掉空的其他词 + filtered_other_words = [w.strip().lower() for w in info.other_words if w and w.strip()] + + # 根据specific_ds决定是否处理datasource_names + specific_ds = info.specific_ds if info.specific_ds is not None else False + filtered_datasource_names = [] + + if specific_ds and info.datasource_names: + # 只有当specific_ds为True时才考虑数据源名称 + filtered_datasource_names = [d.strip().lower() for d in info.datasource_names if d and d.strip()] + + # 创建唯一标识(根据新的规则) + # 1. word和other_words合并并排序(考虑顺序不同) + all_words = [info.word.strip().lower()] if info.word else [] + all_words.extend(filtered_other_words) + all_words_sorted = sorted(all_words) + + # 2. datasource_names排序(考虑顺序不同) + datasource_names_sorted = sorted(filtered_datasource_names) + + unique_key = ( + ','.join(all_words_sorted), # 合并后的所有词(排序) + ','.join(datasource_names_sorted), # 数据源名称(排序) + str(specific_ds) # specific_ds状态 + ) + + if unique_key in unique_records: + duplicate_records.append(info) + else: + unique_records[unique_key] = info + + # 将去重后的数据转换为列表 + deduplicated_list = list(unique_records.values()) + + # 预加载数据源名称到ID的映射 + datasource_name_to_id = {} + datasource_stmt = select(CoreDatasource.id, CoreDatasource.name).where(CoreDatasource.oid == oid) + datasource_result = session.execute(datasource_stmt).all() + for ds in datasource_result: + datasource_name_to_id[ds.name.strip()] = ds.id + + # 预加载高级应用名称到ID的映射 + assistant_name_to_id = {} + assistant_stmt = select(AssistantModel.id, AssistantModel.name).where( + and_(AssistantModel.oid == oid, AssistantModel.type == 1)) + assistant_result = session.execute(assistant_stmt).all() + for a in assistant_result: + assistant_name_to_id[a.name.strip()] = a.id + + # 验证和转换数据源名称 + valid_records = [] + for info in deduplicated_list: + error_messages = [] + + # 基本验证 + if not info.word or not info.word.strip(): + error_messages.append(trans("i18n_terminology.word_cannot_be_empty")) + + if not info.description or not info.description.strip(): + error_messages.append(trans("i18n_terminology.description_cannot_be_empty")) + + # 根据specific_ds决定是否验证数据源 + specific_ds = info.specific_ds if info.specific_ds is not None else False + datasource_ids = [] + advanced_application = info.advanced_application + + if specific_ds: + # specific_ds为True时需要验证数据源 + if info.datasource_names: + for ds_name in info.datasource_names: + if not ds_name or not ds_name.strip(): + continue # 跳过空的数据源名称 + + if ds_name.strip() in datasource_name_to_id: + datasource_ids.append(datasource_name_to_id[ds_name.strip()]) + else: + error_messages.append(trans("i18n_terminology.datasource_not_found").format(ds_name)) + + # 解析高级应用名称到ID + if advanced_application is None and info.advanced_application_name: + if info.advanced_application_name.strip() in assistant_name_to_id: + advanced_application = assistant_name_to_id[info.advanced_application_name.strip()] + else: + error_messages.append(trans("i18n_data_training.advanced_application_not_found").format( + info.advanced_application_name)) + + # 检查specific_ds为True时datasource_ids和advanced_application不能同时为空 + if specific_ds and not datasource_ids and advanced_application is None: + error_messages.append(trans("i18n_data_training.datasource_assistant_cannot_be_none")) + else: + # specific_ds为False时忽略数据源名称 + datasource_ids = [] + advanced_application = None + + # 检查主词和其他词是否重复(过滤空字符串) + words = [info.word.strip().lower()] + if info.other_words: + for other_word in info.other_words: + # 先检查是否为空字符串 + if not other_word or other_word.strip() == "": + continue + + word_lower = other_word.strip().lower() + if word_lower in words: + error_messages.append(trans("i18n_terminology.cannot_be_repeated")) + else: + words.append(word_lower) + + if error_messages: + failed_records.append({ + 'data': info, + 'errors': error_messages + }) + continue + + # 创建新的TerminologyInfo对象 + processed_info = TerminologyInfo( + word=info.word.strip(), + description=info.description.strip(), + other_words=[w for w in info.other_words if w and w.strip()], + datasource_ids=datasource_ids, + datasource_names=info.datasource_names, + specific_ds=specific_ds, + enabled=info.enabled if info.enabled is not None else True, + advanced_application=advanced_application, + advanced_application_name=info.advanced_application_name, + ) + + valid_records.append(processed_info) + + # 使用事务批量处理有效记录 + if valid_records: + for info in valid_records: + try: + # 直接复用create_terminology方法,跳过embedding处理 + terminology_id = create_terminology(session, info, oid, trans, skip_embedding=True) + inserted_ids.append(terminology_id) + success_count += 1 + + except Exception as e: + # 如果单条插入失败,回滚当前记录 + session.rollback() + failed_records.append({ + 'data': info, + 'errors': [str(e)] + }) + + # 批量处理embedding(只在最后执行一次) + if success_count > 0 and inserted_ids: + try: + run_save_terminology_embeddings(inserted_ids) + except Exception as e: + # 如果embedding处理失败,记录错误但不回滚数据 + print(f"Terminology embedding processing failed: {str(e)}") + # 可以选择将embedding失败的信息记录到日志或返回给调用方 + + return { + 'success_count': success_count, + 'failed_records': failed_records, + 'duplicate_count': len(duplicate_records), + 'original_count': len(info_list), + 'deduplicated_count': len(deduplicated_list) + } + + +def update_terminology(session: SessionDep, info: TerminologyInfo, oid: int, trans: Trans): + count = session.query(Terminology).filter( + Terminology.oid == oid, + Terminology.id == info.id + ).count() + if count == 0: + raise Exception(trans('i18n_terminology.terminology_not_exists')) + + specific_ds = info.specific_ds if info.specific_ds is not None else False + datasource_ids = info.datasource_ids if info.datasource_ids is not None else [] + + if specific_ds: + if not datasource_ids and info.advanced_application is None: + raise Exception(trans("i18n_data_training.datasource_assistant_cannot_be_none")) + + words = [info.word.strip()] + for child in info.other_words: + if child in words: + raise Exception(trans("i18n_terminology.cannot_be_repeated")) + else: + words.append(child.strip()) + + # 基础查询条件(word 和 oid 必须满足) + base_query = and_( + Terminology.word.in_(words), + Terminology.oid == oid, + or_( + Terminology.pid != info.id, + and_(Terminology.pid.is_(None), Terminology.id != info.id) + ), + Terminology.id != info.id + ) + + # 构建查询 + query = session.query(Terminology).filter(base_query) + + # 作用域重复检查 + scope_conditions = [] + + if not specific_ds: + # 全部数据源:与有数据源的记录冲突,也与同为全部数据源的记录冲突 + scope_conditions.append( + and_( + Terminology.specific_ds == True, + Terminology.datasource_ids.isnot(None), + func.jsonb_array_length(Terminology.datasource_ids) > 0, + ) + ) + scope_conditions.append( + Terminology.specific_ds == False, + ) + elif specific_ds and datasource_ids: + # 指定数据源:与全部数据源冲突,也与有数据源重叠的记录冲突 + scope_conditions.append( + Terminology.specific_ds == False, + ) + ds_overlap_conditions = [ + Terminology.datasource_ids.contains([ds_id]) + for ds_id in datasource_ids + ] + scope_conditions.append( + and_( + Terminology.specific_ds == True, + Terminology.datasource_ids.isnot(None), + or_(*ds_overlap_conditions) + ) + ) + else: + # 不选数据源:仅与同为不选数据源的记录冲突 + scope_conditions.append( + and_( + Terminology.specific_ds == True, + or_( + Terminology.datasource_ids.is_(None), + func.jsonb_array_length(Terminology.datasource_ids) == 0, + ) + ) + ) + + # 高级应用重复检查:advanced_application 相同时同名即重复 + if info.advanced_application is not None: + scope_conditions.append( + Terminology.advanced_application == info.advanced_application + ) + + if scope_conditions: + query = query.where(or_(*scope_conditions)) + + # 转换为 EXISTS 查询并获取结果 + exists = session.query(query.exists()).scalar() + + if exists: + raise Exception(trans("i18n_terminology.exists_in_db")) + + stmt = update(Terminology).where(and_(Terminology.id == info.id)).values( + word=info.word.strip(), + description=info.description.strip(), + specific_ds=specific_ds, + datasource_ids=datasource_ids, + enabled=info.enabled, + advanced_application=info.advanced_application, + ) + session.execute(stmt) + session.commit() + + stmt = delete(Terminology).where(and_(Terminology.pid == info.id)) + session.execute(stmt) + session.commit() + + create_time = datetime.datetime.now() + # 插入子记录(其他词) + child_list = [] + if info.other_words: + for other_word in info.other_words: + if other_word.strip() == "": + continue + child_list.append( + Terminology( + pid=info.id, + word=other_word.strip(), + create_time=create_time, + oid=oid, + enabled=info.enabled, + specific_ds=specific_ds, + datasource_ids=datasource_ids, + advanced_application=info.advanced_application, + ) + ) + + if child_list: + session.bulk_save_objects(child_list) + session.flush() + session.commit() + + # embedding + run_save_terminology_embeddings([info.id]) + + return info.id + + +def delete_terminology(session: SessionDep, ids: list[int]): + stmt = delete(Terminology).where(or_(Terminology.id.in_(ids), Terminology.pid.in_(ids))) + session.execute(stmt) + session.commit() + + +def enable_terminology(session: SessionDep, id: int, enabled: bool, trans: Trans): + count = session.query(Terminology).filter( + Terminology.id == id + ).count() + if count == 0: + raise Exception(trans('i18n_terminology.terminology_not_exists')) + + stmt = update(Terminology).where(or_(Terminology.id == id, Terminology.pid == id)).values( + enabled=enabled, + ) + session.execute(stmt) + session.commit() + + +# def run_save_embeddings(ids: List[int]): +# executor.submit(save_embeddings, ids) +# +# +# def fill_empty_embeddings(): +# executor.submit(run_fill_empty_embeddings) +# from sqlalchemy import create_engine +# from sqlalchemy.orm import sessionmaker,scoped_session +# engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) +# session_maker = scoped_session(sessionmaker(bind=engine)) + +def run_fill_empty_embeddings(session_maker): + try: + if not settings.EMBEDDING_ENABLED: + return + session = session_maker() + stmt1 = select(Terminology.id).where(and_(Terminology.embedding.is_(None), Terminology.pid.is_(None))) + stmt2 = select(Terminology.pid).where( + and_(Terminology.embedding.is_(None), Terminology.pid.isnot(None))).distinct() + combined_stmt = union(stmt1, stmt2) + results = session.execute(combined_stmt).scalars().all() + save_embeddings(session_maker, results) + except Exception: + traceback.print_exc() + finally: + session_maker.remove() + + +def save_embeddings(session_maker, ids: List[int]): + if not settings.EMBEDDING_ENABLED: + return + + if not ids or len(ids) == 0: + return + try: + session = session_maker() + _list = session.query(Terminology).filter(or_(Terminology.id.in_(ids), Terminology.pid.in_(ids))).all() + + _words_list = [item.word for item in _list] + + model = EmbeddingModelCache.get_model() + + results = model.embed_documents(_words_list) + + for index in range(len(results)): + item = results[index] + stmt = update(Terminology).where(and_(Terminology.id == _list[index].id)).values(embedding=item) + session.execute(stmt) + session.commit() + + except Exception: + traceback.print_exc() + finally: + session_maker.remove() + + +embedding_sql = f""" +SELECT id, pid, word, similarity +FROM +(SELECT id, pid, word, oid, specific_ds, datasource_ids, enabled, +( 1 - (embedding <=> :embedding_array) ) AS similarity +FROM terminology AS child +) TEMP +WHERE similarity > {settings.EMBEDDING_TERMINOLOGY_SIMILARITY} AND oid = :oid AND enabled = true +AND (specific_ds = false OR specific_ds IS NULL) +ORDER BY similarity DESC +LIMIT {settings.EMBEDDING_TERMINOLOGY_TOP_COUNT} +""" + +embedding_sql_with_datasource = f""" +SELECT id, pid, word, similarity +FROM +(SELECT id, pid, word, oid, specific_ds, datasource_ids, enabled, +( 1 - (embedding <=> :embedding_array) ) AS similarity +FROM terminology AS child +) TEMP +WHERE similarity > {settings.EMBEDDING_TERMINOLOGY_SIMILARITY} AND oid = :oid AND enabled = true +AND ( + (specific_ds = false OR specific_ds IS NULL) + OR + (specific_ds = true AND datasource_ids IS NOT NULL AND datasource_ids @> jsonb_build_array(:datasource)) +) +ORDER BY similarity DESC +LIMIT {settings.EMBEDDING_TERMINOLOGY_TOP_COUNT} +""" + +embedding_sql_with_advanced_application = f""" +SELECT id, pid, word, similarity +FROM +(SELECT id, pid, word, oid, specific_ds, advanced_application, enabled, +( 1 - (embedding <=> :embedding_array) ) AS similarity +FROM terminology AS child +) TEMP +WHERE similarity > {settings.EMBEDDING_TERMINOLOGY_SIMILARITY} AND oid = :oid AND enabled = true +AND advanced_application = :advanced_application_id +ORDER BY similarity DESC +LIMIT {settings.EMBEDDING_TERMINOLOGY_TOP_COUNT} +""" + + +def select_terminology_by_word(session: SessionDep, word: str, oid: int, datasource: int = None, + advanced_application_id: Optional[int] = None): + if word.strip() == "": + return [] + + _list: List[Terminology] = [] + + stmt = ( + select( + Terminology.id, + Terminology.pid, + Terminology.word, + ) + .where( + and_(text(":sentence ILIKE '%' || word || '%'"), Terminology.oid == oid, Terminology.enabled == True) + ) + ) + + if advanced_application_id is not None: + stmt = stmt.where(Terminology.advanced_application == advanced_application_id) + elif datasource is not None: + stmt = stmt.where( + or_( + or_(Terminology.specific_ds == False, Terminology.specific_ds.is_(None)), + and_( + Terminology.specific_ds == True, + Terminology.datasource_ids.isnot(None), + text("datasource_ids @> jsonb_build_array(:datasource)") + ) + ) + ) + else: + stmt = stmt.where(or_(Terminology.specific_ds == False, Terminology.specific_ds.is_(None))) + + # 执行查询 + params: dict[str, Any] = {'sentence': word} + if datasource is not None: + params['datasource'] = datasource + + results = session.execute(stmt, params).fetchall() + + for row in results: + _list.append(Terminology(id=row.id, word=row.word, pid=row.pid)) + + if settings.EMBEDDING_ENABLED: + with session.begin_nested(): + try: + model = EmbeddingModelCache.get_model() + + embedding = model.embed_query(word) + + if advanced_application_id is not None: + results = session.execute(text(embedding_sql_with_advanced_application), + {'embedding_array': str(embedding), 'oid': oid, + 'advanced_application_id': advanced_application_id}).fetchall() + elif datasource is not None: + results = session.execute(text(embedding_sql_with_datasource), + {'embedding_array': str(embedding), 'oid': oid, + 'datasource': datasource}).fetchall() + else: + results = session.execute(text(embedding_sql), + {'embedding_array': str(embedding), 'oid': oid}).fetchall() + + for row in results: + _list.append(Terminology(id=row.id, word=row.word, pid=row.pid)) + + except Exception: + traceback.print_exc() + session.rollback() + + _map: dict = {} + _ids: list[int] = [] + for row in _list: + if row.id in _ids or row.pid in _ids: + continue + if row.pid is not None: + _ids.append(row.pid) + else: + _ids.append(row.id) + + if len(_ids) == 0: + return [] + + t_list = session.query(Terminology.id, Terminology.pid, Terminology.word, Terminology.description).filter( + or_(Terminology.id.in_(_ids), Terminology.pid.in_(_ids))).all() + for row in t_list: + pid = str(row.pid) if row.pid is not None else str(row.id) + if _map.get(pid) is None: + _map[pid] = {'words': [], 'description': ''} + if row.pid is None: + _map[pid]['description'] = row.description + _map[pid]['words'].append(row.word) + + _results: list[dict] = [] + for key in _map.keys(): + _results.append(_map.get(key)) + + return _results + + +def get_example(): + _obj = { + 'terminologies': [ + {'words': ['GDP', '国内生产总值'], + 'description': '指在一个季度或一年,一个国家或地区的经济中所生产出的全部最终产品和劳务的价值。'}, + ] + } + return to_xml_string(_obj, 'example') + + +def to_xml_string(_dict: list[dict] | dict, root: str = 'terminologies') -> str: + item_name_func = lambda x: 'terminology' if x == 'terminologies' else 'word' if x == 'words' else 'item' + dicttoxml.LOG.setLevel(logging.ERROR) + xml = dicttoxml.dicttoxml(_dict, + cdata=['word', 'description'], + custom_root=root, + item_func=item_name_func, + xml_declaration=False, + encoding='utf-8', + attr_type=False).decode('utf-8') + pretty_xml = parseString(xml).toprettyxml() + + if pretty_xml.startswith('') + 1 + pretty_xml = pretty_xml[end_index:].lstrip() + + # 替换所有 XML 转义字符 + escape_map = { + '<': '<', + '>': '>', + '&': '&', + '"': '"', + ''': "'" + } + for escaped, original in escape_map.items(): + pretty_xml = pretty_xml.replace(escaped, original) + + return pretty_xml + + +def get_terminology_template(session: SessionDep, question: str, oid: Optional[int] = 1, + datasource: Optional[int] = None, + advanced_application_id: Optional[int] = None) -> tuple[str, list[dict]]: + if not oid: + oid = 1 + _results = select_terminology_by_word(session, question, oid, datasource, advanced_application_id) + if _results and len(_results) > 0: + terminology = to_xml_string(_results) + template = get_base_terminology_template().format(terminologies=terminology) + return template, _results + else: + return '', [] diff --git a/backend/apps/terminology/models/__init__.py b/backend/apps/terminology/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/apps/terminology/models/terminology_model.py b/backend/apps/terminology/models/terminology_model.py new file mode 100644 index 000000000..a0887eb77 --- /dev/null +++ b/backend/apps/terminology/models/terminology_model.py @@ -0,0 +1,50 @@ +from datetime import datetime +from typing import List, Optional + +from pgvector.sqlalchemy import VECTOR +from pydantic import BaseModel +from sqlalchemy import Column, Text, BigInteger, DateTime, Identity, Boolean +from sqlalchemy.dialects.postgresql import JSONB +from sqlmodel import SQLModel, Field + + +class Terminology(SQLModel, table=True): + __tablename__ = "terminology" + id: Optional[int] = Field(sa_column=Column(BigInteger, Identity(always=True), primary_key=True)) + oid: Optional[int] = Field(sa_column=Column(BigInteger, nullable=True, default=1)) + pid: Optional[int] = Field(sa_column=Column(BigInteger, nullable=True)) + create_time: Optional[datetime] = Field(sa_column=Column(DateTime(timezone=False), nullable=True)) + word: Optional[str] = Field(max_length=255) + description: Optional[str] = Field(sa_column=Column(Text, nullable=True)) + embedding: Optional[List[float]] = Field(sa_column=Column(VECTOR(), nullable=True)) + specific_ds: Optional[bool] = Field(sa_column=Column(Boolean, default=False)) + datasource_ids: Optional[list[int]] = Field(sa_column=Column(JSONB), default=[]) + enabled: Optional[bool] = Field(sa_column=Column(Boolean, default=True)) + advanced_application: Optional[int] = Field(sa_column=Column(BigInteger, nullable=True)) + + +class TerminologyInfo(BaseModel): + id: Optional[int] = None + create_time: Optional[datetime] = None + word: Optional[str] = None + description: Optional[str] = None + other_words: Optional[List[str]] = [] + specific_ds: Optional[bool] = False + datasource_ids: Optional[list[int]] = [] + datasource_names: Optional[list[str]] = [] + enabled: Optional[bool] = True + advanced_application: Optional[int] = None + advanced_application_name: Optional[str] = None + +class TerminologyInfoResult(BaseModel): + id: Optional[int] = None + create_time: Optional[datetime] = None + word: Optional[str] = None + description: Optional[str] = None + other_words: Optional[List[str]] = [] + specific_ds: Optional[bool] = False + datasource_ids: Optional[list[int]] = [] + datasource_names: Optional[list[str]] = [] + enabled: Optional[bool] = True + advanced_application: Optional[str] = None + advanced_application_name: Optional[str] = None diff --git a/backend/common/audit/__init__.py b/backend/common/audit/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/common/audit/models/__init__.py b/backend/common/audit/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/common/audit/models/log_model.py b/backend/common/audit/models/log_model.py new file mode 100644 index 000000000..bdafe6a65 --- /dev/null +++ b/backend/common/audit/models/log_model.py @@ -0,0 +1,107 @@ +from sqlmodel import Field, SQLModel,BigInteger +from datetime import datetime +from typing import Optional +from enum import Enum +from pydantic import BaseModel + +class OperationModules(str, Enum): + CHAT = "chat" # 问数 + DATASOURCE = "datasource" # 数据源 + DASHBOARD = "dashboard" # 仪表板 + MEMBER = "member" # 成员 + PERMISSION = "permission" # 权限 + RULES = "rules" # q组 + TERMINOLOGY = "terminology" # 术语 + DATA_TRAINING = "data_training" # SQL 示例库 + PROMPT_WORDS = "prompt_words" # 自定义提示词 + USER = "user" # 用户 + WORKSPACE = "workspace" # 工作空间 + AI_MODEL = "ai_model" # AI 模型 + APPLICATION = "application" # 嵌入式管理 应用 + THEME = "theme" # 外观配置 + PARAMS_SETTING = "params_setting" # 参数配置 + API_KEY = "api_key" # api key + LOG_SETTING = "log_setting" # api key + SETTING = "setting" # 设置 + SYSTEM_MANAGEMENT = "system_management" # 系统管理 + OPT_LOG = "opt_log" # 操作日志 + +class OperationStatus(str, Enum): + SUCCESS = "success" + FAILED = "failed" + +class OperationType(str, Enum): + CREATE = "create" + DELETE = "delete" + UPDATE = "update" + RESET_PWD = "reset_pwd" + UPDATE_PWD = "update_pwd" + UPDATE_STATUS = "update_status" + UPDATE_TABLE_RELATION = "update_table_relation" + EDIT = "edit" + LOGIN = "login" + VIEW = "view" + EXPORT = "export" + IMPORT = "import" + ADD = "add" + CREATE_OR_UPDATE = "create_or_update" + ANALYSIS = "analysis" + PREDICTION = "prediction" + +class SystemLogsResource(SQLModel, table=True): + __tablename__ = "sys_logs_resource" + id: Optional[int] = Field(default=None, primary_key=True) + log_id: Optional[int] = Field(default=None,sa_type=BigInteger()) + resource_id: Optional[str] = Field(default=None) + resource_name: Optional[str] = Field(default=None) + module: Optional[str] = Field(default=None) + + +class SystemLog(SQLModel, table=True): + __tablename__ = "sys_logs" + id: Optional[int] = Field(default=None, primary_key=True) + operation_type: str = Field(default=None) + operation_detail: str = Field(default=None) + user_id: Optional[int] = Field(default=None, sa_type=BigInteger()) + operation_status: str = Field(default=None) + ip_address: Optional[str] = Field(default=None) + user_agent: Optional[str] = Field(default=None) + execution_time: int = Field(default=0, description="执行时间(毫秒)", sa_type=BigInteger()) + error_message: Optional[str] = Field(default=None) + create_time: datetime = Field(default_factory=datetime.now) + module: Optional[str] = Field(default=None) + oid: Optional[int] = Field(default=None, sa_type=BigInteger()) + resource_id: Optional[str] = Field(default=None) + request_method: Optional[str] = Field(default=None) + request_path: Optional[str] = Field(default=None) + remark: Optional[str] = Field(default=None) + user_name: Optional[str] = Field(default=None) + resource_name: Optional[str] = Field(default=None) + + +class SystemLogInfo(BaseModel): + id: str = Field(default=None) + operation_type_name: str = Field(default=None) + operation_detail_info: str = Field(default=None) + user_name: str = Field(default=None) + resource_name: str = Field(default=None) + operation_status: str = Field(default=None) + ip_address: Optional[str] = Field(default=None) + create_time: datetime = Field(default_factory=datetime.now) + oid_list: str = Field(default=None) + remark: str = Field(default=None) + +class SystemLogInfoResult(BaseModel): + id: str = Field(default=None) + operation_type_name: str = Field(default=None) + operation_detail_info: str = Field(default=None) + user_name: str = Field(default=None) + resource_name: str = Field(default=None) + operation_status: str = Field(default=None) + operation_status_name: str = Field(default=None) + ip_address: Optional[str] = Field(default=None) + create_time: datetime = Field(default_factory=datetime.now) + oid_name: str = Field(default=None) + oid: str = Field(default=None) + error_message: str = Field(default=None) + remark: str = Field(default=None) diff --git a/backend/common/audit/schemas/__init__.py b/backend/common/audit/schemas/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/common/audit/schemas/log_utils.py b/backend/common/audit/schemas/log_utils.py new file mode 100644 index 000000000..254446021 --- /dev/null +++ b/backend/common/audit/schemas/log_utils.py @@ -0,0 +1,144 @@ +from sqlalchemy import select, func +from sqlalchemy.sql import Select +from sqlalchemy import String, union_all + +from apps.chat.models.chat_model import Chat +from apps.dashboard.models.dashboard_model import CoreDashboard +from apps.data_training.models.data_training_model import DataTraining +from apps.datasource.models.datasource import CoreDatasource +from apps.system.models.system_model import WorkspaceModel, AiModelDetail, ApiKeyModel +from apps.system.models.user import UserModel +from apps.terminology.models.terminology_model import Terminology +from apps.system.models.system_model import AssistantModel + +from sqlbot_xpack.permissions.models.ds_rules import DsRules +from sqlbot_xpack.custom_prompt.models.custom_prompt_model import CustomPrompt +from sqlbot_xpack.permissions.models.ds_permission import DsPermission +from sqlalchemy import literal_column + + +def build_resource_union_query() -> Select: + """ + 构建资源名称的union查询 + 返回包含id, name, module的查询 + """ + # 创建各个子查询,每个查询都包含module字段 + + # ai_model 表查询 + ai_model_query = select( + func.cast(AiModelDetail.id, String).label("id"), + AiModelDetail.name.label("name"), + literal_column("'ai_model'").label("module") + ).select_from(AiModelDetail) + + # chat 表查询(使用brief作为name) + chat_query = select( + func.cast(Chat.id, String).label("id"), + Chat.brief.label("name"), + literal_column("'chat'").label("module") + ).select_from(Chat) + + # dashboard 表查询 + dashboard_query = select( + func.cast(CoreDashboard.id, String).label("id"), + CoreDashboard.name.label("name"), + literal_column("'dashboard'").label("module") + ).select_from(CoreDashboard) + + # datasource 表查询 + datasource_query = select( + func.cast(CoreDatasource.id, String).label("id"), + CoreDatasource.name.label("name"), + literal_column("'datasource'").label("module") + ).select_from(CoreDatasource) + + # custom_prompt 表查询 + custom_prompt_query = select( + func.cast(CustomPrompt.id, String).label("id"), + CustomPrompt.name.label("name"), + literal_column("'prompt_words'").label("module") + ).select_from(CustomPrompt) + + # data_training 表查询(使用question作为name) + data_training_query = select( + func.cast(DataTraining.id, String).label("id"), + DataTraining.question.label("name"), + literal_column("'data_training'").label("module") + ).select_from(DataTraining) + + # ds_permission 表查询 + ds_permission_query = select( + func.cast(DsPermission.id, String).label("id"), + DsPermission.name.label("name"), + literal_column("'permission'").label("module") + ).select_from(DsPermission) + + # ds_rules 表查询 + ds_rules_query = select( + func.cast(DsRules.id, String).label("id"), + DsRules.name.label("name"), + literal_column("'rules'").label("module") + ).select_from(DsRules) + + # sys_user 表查询 + user_query = select( + func.cast(UserModel.id, String).label("id"), + UserModel.name.label("name"), + literal_column("'user'").label("module") + ).select_from(UserModel) + + # sys_user 表查询 + member_query = select( + func.cast(UserModel.id, String).label("id"), + UserModel.name.label("name"), + literal_column("'member'").label("module") + ).select_from(UserModel) + + # sys_workspace 表查询 + sys_workspace_query = select( + func.cast(WorkspaceModel.id, String).label("id"), + WorkspaceModel.name.label("name"), + literal_column("'workspace'").label("module") + ).select_from(WorkspaceModel) + + # terminology 表查询(使用word作为name) + terminology_query = select( + func.cast(Terminology.id, String).label("id"), + Terminology.word.label("name"), + literal_column("'terminology'").label("module") + ).select_from(Terminology) + + # sys_assistant 表查询 + sys_assistant_query = select( + func.cast(AssistantModel.id, String).label("id"), + AssistantModel.name.label("name"), + literal_column("'application'").label("module") + ).select_from(AssistantModel) + + # sys_apikey 表查询 + sys_apikey_query = select( + func.cast(ApiKeyModel.id, String).label("id"), + ApiKeyModel.access_key.label("name"), + literal_column("'api_key'").label("module") + ).select_from(ApiKeyModel) + + # 使用 union_all() 方法连接所有查询 + union_query = union_all( + ai_model_query, + chat_query, + dashboard_query, + datasource_query, + custom_prompt_query, + data_training_query, + ds_permission_query, + ds_rules_query, + user_query, + member_query, + sys_workspace_query, + terminology_query, + sys_assistant_query, + sys_apikey_query + ) + + # 返回查询,包含所有字段 + return select(union_query.c.id, union_query.c.name, union_query.c.module) diff --git a/backend/common/audit/schemas/logger_decorator.py b/backend/common/audit/schemas/logger_decorator.py new file mode 100644 index 000000000..957ff5659 --- /dev/null +++ b/backend/common/audit/schemas/logger_decorator.py @@ -0,0 +1,718 @@ +import time +import functools +import json +import inspect +from typing import Callable, Any, Optional, Dict, Union, List +from fastapi import Request, HTTPException +from datetime import datetime +from pydantic import BaseModel +from sqlmodel import Session, select +import traceback +from sqlbot_xpack.audit.curd.audit import build_resource_union_query +from common.audit.models.log_model import OperationType, OperationStatus, SystemLog, SystemLogsResource +from common.audit.schemas.request_context import RequestContext +from apps.system.crud.user import get_user_by_account +from apps.system.schemas.system_schema import UserInfoDTO, BaseUserDTO +from sqlalchemy import and_, select + +from common.core.db import engine + + +def get_resource_name_by_id_and_module(session, resource_id: Any, module: str) -> List[Dict[str, str]]: + resource_union_query = build_resource_union_query() + resource_alias = resource_union_query.alias("resource") + + # 统一处理为列表 + if not isinstance(resource_id, list): + resource_id = [resource_id] + + if not resource_id: + return [] + + # 构建查询,使用 IN 条件 + query = select( + resource_alias.c.id, + resource_alias.c.name, + resource_alias.c.module + ).where( + and_( + resource_alias.c.id.in_([str(id_) for id_ in resource_id]), + resource_alias.c.module == module + ) + ) + + results = session.execute(query).fetchall() + + return [{ + 'resource_id': str(row.id), + 'resource_name': row.name or '', + 'module': row.module or '' + } for row in results] + +class LogConfig(BaseModel): + operation_type: OperationType + operation_detail: str = None + module: Optional[str] = None + + # Extract the expression of resource ID from the parameters + resource_id_expr: Optional[str] = None + + # Extract the expression for resource ID from the returned result + result_id_expr: Optional[str] = None + + # Extract the expression for resource name or other info from the returned result + remark_expr: Optional[str] = None + + # Is it only recorded upon success + save_on_success_only: bool = False + + # Whether to ignore errors (i.e. record them as successful even when they occur) + ignore_errors: bool = False + + # Whether to extract request parameters + extract_params: bool = True + + # Delay recording (if the resource ID needs to be extracted from the result, it can be set to True) + delay_logging: bool = False + + +class SystemLogger: + @staticmethod + async def create_log( + session: Session, + operation_type: OperationType, + operation_detail: str, + user: Optional[UserInfoDTO] = None, + status: OperationStatus = OperationStatus.SUCCESS, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, + execution_time: int = 0, + error_message: Optional[str] = None, + module: Optional[str] = None, + resource_id: Any = None, + request_method: Optional[str] = None, + request_path: Optional[str] = None, + remark: Optional[str] = None + ): + try: + log = SystemLog( + operation_type=operation_type, + operation_detail=operation_detail, + user_id=user.id if user else None, + user_name=user.username if user else None, + operation_status=status, + ip_address=ip_address, + user_agent=user_agent, + execution_time=execution_time, + error_message=error_message, + module=module, + resource_id=resource_id, + request_method=request_method, + request_path=request_path, + created_at=datetime.now(), + remark=remark + ) + session.add(log) + session.commit() + return log + except Exception as e: + session.rollback() + print(f"Failed to create system log: {e}") + return None + + @staticmethod + def get_client_info(request: Request) -> Dict[str, Optional[str]]: + """Obtain client information""" + ip_address = None + user_agent = None + + if request: + # Prefer real client IP headers, then fallback to socket peer IP. + header_candidates = [ + "x-forwarded-for", + "x-real-ip", + "cf-connecting-ip", + "x-client-ip", + "x-forwarded", + "forwarded", + ] + for key in header_candidates: + value = request.headers.get(key) + if value: + if key == "x-forwarded-for": + ip_address = value.split(",")[0].strip() + elif key == "forwarded": + # RFC 7239 format, e.g. for=203.0.113.43;proto=https;by=203.0.113.1 + parts = [part.strip() for part in value.split(";")] + for part in parts: + if part.lower().startswith("for="): + ip_address = part.split("=", 1)[1].strip().strip('"') + break + else: + ip_address = value.strip() + if ip_address: + break + + if not ip_address and request.client: + ip_address = request.client.host + + # Get User Agent + user_agent = request.headers.get("user-agent") + + return { + "ip_address": ip_address, + "user_agent": user_agent + } + + @staticmethod + def extract_value_from_object(expression: str, obj: Any): + """ + Extract values from objects based on expressions + support: + -Object attribute: 'user. id' + -Dictionary key: 'data ['id'] ' + -List index: 'items [0]. id' + """ + if not expression or obj is None: + return None + + if expression == 'result_self': + return obj + + try: + # Handling point separated attribute access + parts = expression.split('.') + current = obj + + for part in parts: + if not current: + return None + + # Handle dictionary key access, such as data ['id '] + if '[' in part and ']' in part: + import re + # Extract key names, such as data ['id '] ->key='id' + match = re.search(r"\[['\"]?([^\]'\"\]]+)['\"]?\]", part) + if match: + key = match.group(1) + # Get Object Part + obj_part = part.split('[')[0] + if hasattr(current, obj_part): + current = getattr(current, obj_part) + elif isinstance(current, dict) and obj_part in current: + current = current[obj_part] + else: + return None + + # Get key value + if isinstance(current, dict) and key in current: + current = current[key] + elif hasattr(current, key): + current = getattr(current, key) + elif isinstance(current, list) and key.isdigit(): + index = int(key) + if 0 <= index < len(current): + current = current[index] + else: + return None + else: + return None + else: + return None + + # Process list indexes, such as items.0.id + elif part.isdigit() and isinstance(current, (list, tuple)): + index = int(part) + if 0 <= index < len(current): + current = current[index] + else: + return None + + # Normal attribute access + else: + if hasattr(current, part): + current = getattr(current, part) + elif isinstance(current, dict) and part in current: + current = current[part] + else: + return None + + return current if current is not None else None + + except Exception: + return None + + @staticmethod + def extract_resource_id( + expression: Optional[str], + source: Any, + source_type: str = "args" # args, kwargs, result + ): + """Extract resource IDs from different sources""" + if not expression: + return None + + try: + if source_type == "result": + # Extract directly from the result object + return SystemLogger.extract_value_from_object(expression, source) + + elif source_type == "args": + # Extract from function parameters + if isinstance(source, tuple) and len(source) > 0: + # The first element is the function itself + func_args = source[0] if isinstance(source[0], tuple) else source + + # Processing args [index] expression + if expression.startswith("args["): + import re + pattern = r"args\[(\d+)\]" + match = re.match(pattern, expression) + if match: + index = int(match.group(1)) + if index < len(func_args): + value = func_args[index] + return value if value is not None else None + + # Process attribute expressions + return SystemLogger.extract_value_from_object(expression, func_args) + elif isinstance(source, dict): + # Simple parameter name + if expression in source: + value = source[expression] + return value if value is not None else None + + # complex expression + return SystemLogger.extract_value_from_object(expression, source) + + elif source_type == "kwargs": + # Extract from keyword parameters + if isinstance(source, dict): + # Simple parameter name + if expression in source: + value = source[expression] + return value if value is not None else None + + # complex expression + return SystemLogger.extract_value_from_object(expression, source) + + return None + + except Exception: + return None + + @staticmethod + def extract_from_function_params( + expression: Optional[str], + func_args: any, + func_kwargs: dict + ): + """Extract values from function parameters""" + if not expression: + return None + + # Attempt to extract from location parameters + result = SystemLogger.extract_resource_id(expression, func_args, "args") + if result: + return result + + # Attempt to extract from keyword parameters + result = SystemLogger.extract_resource_id(expression, func_kwargs, "kwargs") + if result: + return result + + # Attempt to encapsulate parameters as objects for extraction + try: + if func_args: + # Create a dictionary containing all parameters + params_dict = {} + + # Add location parameters + for i, arg in enumerate(func_args): + params_dict[f"arg_{i}"] = arg + + # Add keyword parameters + params_dict.update(func_kwargs) + + # Attempt to extract from the dictionary + return SystemLogger.extract_resource_id(expression, params_dict, "kwargs") + except: + pass + + return None + + @staticmethod + def get_current_user(request: Optional[Request]): + """Retrieve current user information from the request""" + if not request: + return None + try: + current_user = getattr(request.state, 'current_user', None) + if current_user: + return current_user + except: + pass + + return None + + @staticmethod + def extract_request_params(request: Optional[Request]): + """Extract request parameters""" + if not request: + return None + + try: + params = {} + + # query parameters + if request.query_params: + params["query"] = dict(request.query_params) + + # path parameter + if request.path_params: + params["path"] = dict(request.path_params) + + # Head information (sensitive information not recorded) + headers = {} + for key, value in request.headers.items(): + if key.lower() not in ["authorization", "cookie", "set-cookie"]: + headers[key] = value + if headers: + params["headers"] = headers + + # Request Body - Only records Content Type and Size + content_type = request.headers.get("content-type", "") + content_length = request.headers.get("content-length") + params["body_info"] = { + "content_type": content_type, + "content_length": content_length + } + + return json.dumps(params, ensure_ascii=False, default=str) + + except Exception: + return None + + @classmethod + async def create_log_record( + cls, + config: LogConfig, + status: OperationStatus, + execution_time: int, + error_message: Optional[str] = None, + resource_id: Any = None, + resource_name: Optional[str] = None, + request: Optional[Request] = None, + remark: Optional[str] = None, + oid: int = -1, + opt_type_ref : OperationType = None, + resource_info_list : Optional[List] = None, + ) -> Optional[SystemLog]: + """Create log records""" + try: + # Obtain user information + user_info = cls.get_current_user(request) + user_id = user_info.id if user_info else -1 + user_name = user_info.name if user_info else '-1' + if config.operation_type == OperationType.LOGIN: + user_id = resource_id + user_name = resource_name + + # Obtain client information + client_info = cls.get_client_info(request) + # Get request parameters + request_params = None + if config.extract_params: + request_params = cls.extract_request_params(request) + + # Create log object + log = SystemLog( + operation_type=opt_type_ref if opt_type_ref else config.operation_type, + operation_detail=config.operation_detail, + user_id=user_id, + user_name=user_name, + oid=user_info.oid if user_info else oid, + operation_status=status, + ip_address=client_info.get("ip_address"), + user_agent=client_info.get("user_agent"), + execution_time=execution_time, + error_message=error_message, + module=config.module, + resource_id=str(resource_id), + request_method=request.method if request else None, + request_path=request.url.path if request else None, + request_params=request_params, + create_time=datetime.now(), + remark=remark + ) + + + with Session(engine) as session: + session.add(log) + session.commit() + session.refresh(log) + # 统一处理不同类型的 resource_id_info + if isinstance(resource_id, list): + resource_ids = [str(rid) for rid in resource_id] + else: + resource_ids = [str(resource_id)] + # 批量添加 SystemLogsResource + resource_entries = [] + for resource_id_details in resource_ids: + resource_entry = SystemLogsResource( + resource_id=resource_id_details, + log_id=log.id, + module=config.module + ) + resource_entries.append(resource_entry) + if resource_entries: + session.bulk_save_objects(resource_entries) + session.commit() + + if config.operation_type == OperationType.DELETE and resource_info_list is not None: + # 批量更新 SystemLogsResource 表的 resource_name + for resource_info in resource_info_list: + session.query(SystemLogsResource).filter( + SystemLogsResource.resource_id == resource_info['resource_id'], + SystemLogsResource.module == resource_info['module'], + ).update({ + SystemLogsResource.resource_name: resource_info['resource_name'] + }, synchronize_session='fetch') + session.commit() + return log + + except Exception as e: + print(f"[SystemLogger] Failed to create log: {str(traceback.format_exc())}") + return None + + +def system_log(config: Union[LogConfig, Dict]): + """ + System log annotation decorator, supports extracting resource IDs from returned results + + Usage example: + @system_log({ + "operation_type": OperationType.CREATE, + Operation_detail ":" Create User ", + "module": "user", + 'result_id_expr ':' id '# Extract the id field from the returned result + }) + """ + # If a dictionary is passed in, convert it to a LogConfig object + if isinstance(config, dict): + config = LogConfig(**config) + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + async def async_wrapper(*args, **kwargs): + start_time = time.time() + status = OperationStatus.SUCCESS + error_message = None + request = None + resource_id = None + resource_name = None + remark = None + oid = -1 + opt_type_ref = None + resource_info_list = None + result = None + + try: + # Get current request + request = RequestContext.get_request() + func_signature = inspect.signature(func) + bound_args = func_signature.bind(*args, **kwargs) + bound_args.apply_defaults() + unified_kwargs = dict(bound_args.arguments) + + # Step 1: Attempt to extract the resource ID from the parameters + if config.resource_id_expr: + resource_id = SystemLogger.extract_from_function_params( + config.resource_id_expr, + unified_kwargs, + kwargs + ) + if config.remark_expr: + remark = SystemLogger.extract_from_function_params( + config.remark_expr, + unified_kwargs, + kwargs + ) + + if config.operation_type == OperationType.LOGIN: + input_account_dec = SystemLogger.extract_from_function_params( + "form_data.username", + args, + kwargs + ) + from common.utils.crypto import sqlbot_decrypt + input_account = await sqlbot_decrypt(input_account_dec) + with Session(engine) as session: + userInfo = get_user_by_account(session=session, account=input_account) + if userInfo is not None: + resource_id = userInfo.id + resource_name = userInfo.name + oid = userInfo.oid + else: + resource_id = -1 + oid = -1 + resource_name = input_account + if config.operation_type == OperationType.DELETE: + with Session(engine) as session: + resource_info_list = get_resource_name_by_id_and_module(session, resource_id, config.module) + + if config.operation_type == OperationType.CREATE_OR_UPDATE: + opt_type_ref = OperationType.UPDATE if resource_id is not None else OperationType.CREATE + else: + opt_type_ref = config.operation_type + # Execute the original function + result = await func(*args, **kwargs) + # Step 2: If the resource ID is configured to be extracted from the results and has not been extracted before + if config.result_id_expr and not resource_id and result: + resource_id = SystemLogger.extract_resource_id( + config.result_id_expr, + result, + "result" + ) + return result + + except Exception as e: + status = OperationStatus.FAILED + error_message = str(e) + + # If it is an HTTPException, retrieve the status code + if isinstance(e, HTTPException): + error_message = f"HTTP {e.status_code}: {e.detail}" + + # If configured to ignore errors, mark as successful + if config.ignore_errors: + status = OperationStatus.SUCCESS + + raise e + + finally: + # If configured to only record on success and the current status is failure, skip + if config.save_on_success_only and status == OperationStatus.FAILED: + return + + # Calculate execution time + execution_time = int((time.time() - start_time) * 1000) + # Asynchronous creation of log records + try: + await SystemLogger.create_log_record( + config=config, + status=status, + execution_time=execution_time, + error_message=error_message, + resource_id=resource_id, + resource_name=resource_name, + remark=remark, + request=request, + oid=oid, + opt_type_ref=opt_type_ref, + resource_info_list=resource_info_list + ) + except Exception as log_error: + print(f"[SystemLogger] Log creation failed: {log_error}") + + @functools.wraps(func) + def sync_wrapper(*args, **kwargs): + start_time = time.time() + status = OperationStatus.SUCCESS + error_message = None + request = None + resource_id = None + resource_name = None + resource_info_list = None + result = None + + try: + # Get current request + request = RequestContext.get_request() + func_signature = inspect.signature(func) + bound_args = func_signature.bind(*args, **kwargs) + bound_args.apply_defaults() + unified_kwargs = dict(bound_args.arguments) + + # Extract resource ID from parameters + if config.resource_id_expr: + resource_id = SystemLogger.extract_from_function_params( + config.resource_id_expr, + unified_kwargs, + kwargs + ) + + # Obtain client information + if config.operation_type == OperationType.DELETE: + with Session(engine) as session: + resource_info_list = get_resource_name_by_id_and_module(session, resource_id, config.module) + + # Execute the original function + result = func(*args, **kwargs) + + # Extract resource ID from the results + if config.result_id_expr and not resource_id and result: + resource_id = SystemLogger.extract_resource_id( + config.result_id_expr, + result, + "result" + ) + + return result + + except Exception as e: + status = OperationStatus.FAILED + error_message = str(e) + + if isinstance(e, HTTPException): + error_message = f"HTTP {e.status_code}: {e.detail}" + + if config.ignore_errors: + status = OperationStatus.SUCCESS + + raise e + + finally: + if config.save_on_success_only and status == OperationStatus.FAILED: + return + + execution_time = int((time.time() - start_time) * 1000) + + # In the synchronous version, we still create logs asynchronously + import asyncio + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + loop.create_task( + SystemLogger.create_log_record( + config=config, + status=status, + execution_time=execution_time, + error_message=error_message, + resource_id=resource_id, + resource_name=resource_name, + request=request, + resource_info_list=resource_info_list + ) + ) + else: + asyncio.run( + SystemLogger.create_log_record( + config=config, + status=status, + execution_time=execution_time, + error_message=error_message, + resource_id=resource_id, + request=request + ) + ) + except Exception as log_error: + print(f"[SystemLogger] Log creation failed: {log_error}") + + # Return appropriate wrapper based on function type + if inspect.iscoroutinefunction(func): + return async_wrapper + else: + return sync_wrapper + + return decorator diff --git a/backend/common/audit/schemas/request_context.py b/backend/common/audit/schemas/request_context.py new file mode 100644 index 000000000..77c80819c --- /dev/null +++ b/backend/common/audit/schemas/request_context.py @@ -0,0 +1,37 @@ +from contextvars import ContextVar + +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware + + +class RequestContext: + _current_request: ContextVar[Request] = ContextVar("_current_request") + + @classmethod + def set_request(cls, request: Request): + return cls._current_request.set(request) + + @classmethod + def get_request(cls) -> Request: + try: + return cls._current_request.get() + except LookupError: + raise RuntimeError( + "No request context found. " + "Make sure RequestContextMiddleware is installed." + ) + + @classmethod + def reset(cls, token): + cls._current_request.reset(token) + + +class RequestContextMiddlewareCommon(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + # 设置请求上下文 + token = RequestContext.set_request(request) + try: + response = await call_next(request) + return response + finally: + RequestContext.reset(token) \ No newline at end of file diff --git a/backend/common/core/config.py b/backend/common/core/config.py index ba9287eea..4b9baeaec 100644 --- a/backend/common/core/config.py +++ b/backend/common/core/config.py @@ -1,4 +1,5 @@ import secrets +import urllib.parse from typing import Annotated, Any, Literal from pydantic import ( @@ -6,6 +7,7 @@ BeforeValidator, PostgresDsn, computed_field, + field_validator ) from pydantic_core import MultiHostUrl from pydantic_settings import BaseSettings, SettingsConfigDict @@ -27,7 +29,8 @@ class Settings(BaseSettings): extra="ignore", ) PROJECT_NAME: str = "SQLBot" - API_V1_STR: str = "/api/v1" + #CONTEXT_PATH: str = "/sqlbot" + CONTEXT_PATH: str = "" SECRET_KEY: str = secrets.token_urlsafe(32) # 60 minutes * 24 hours * 8 days = 8 days ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 @@ -44,43 +47,108 @@ def all_cors_origins(self) -> list[str]: self.FRONTEND_HOST ] + @computed_field # type: ignore[prop-decorator] + @property + def API_V1_STR(self) -> str: + return self.CONTEXT_PATH + "/api/v1" + POSTGRES_SERVER: str = 'localhost' POSTGRES_PORT: int = 5432 POSTGRES_USER: str = 'root' - POSTGRES_PASSWORD: str = "123456" + POSTGRES_PASSWORD: str = "Password123@pg" POSTGRES_DB: str = "sqlbot" SQLBOT_DB_URL: str = '' - #SQLBOT_DB_URL: str = 'mysql+pymysql://root:Password123%40mysql@127.0.0.1:3306/sqlbot' - - TOKEN_KEY: str = "X-SQLBOT-TOKEN" + # SQLBOT_DB_URL: str = 'mysql+pymysql://root:Password123%40mysql@127.0.0.1:3306/sqlbot' + + TOKEN_KEY: str = "X-SQLBOT-TOKEN" DEFAULT_PWD: str = "SQLBot@123456" ASSISTANT_TOKEN_KEY: str = "X-SQLBOT-ASSISTANT-TOKEN" - + CACHE_TYPE: Literal["redis", "memory", "None"] = "memory" - CACHE_REDIS_URL: str | None = None # Redis URL, e.g., "redis://[[username]:[password]]@localhost:6379/0" - + CACHE_REDIS_URL: str | None = None # Redis URL, e.g., "redis://[[username]:[password]]@localhost:6379/0" + LOG_LEVEL: str = "INFO" # DEBUG, INFO, WARNING, ERROR LOG_DIR: str = "logs" LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s:%(lineno)d - %(message)s" SQL_DEBUG: bool = False + BASE_DIR: str = "/opt/sqlbot" + SCRIPT_DIR: str = f"{BASE_DIR}/scripts" + UPLOAD_DIR: str = "/opt/sqlbot/data/file" + SQLBOT_KEY_EXPIRED: int = 100 # License key expiration timestamp, 0 means no expiration + + SQLBOT_DOC_ENABLED: bool = True @computed_field # type: ignore[prop-decorator] @property def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn | str: if self.SQLBOT_DB_URL: return self.SQLBOT_DB_URL - return MultiHostUrl.build( - scheme="postgresql+psycopg", - username=self.POSTGRES_USER, - password=self.POSTGRES_PASSWORD, - host=self.POSTGRES_SERVER, - port=self.POSTGRES_PORT, - path=self.POSTGRES_DB, - ) + # return MultiHostUrl.build( + # scheme="postgresql+psycopg", + # username=urllib.parse.quote(self.POSTGRES_USER), + # password=urllib.parse.quote(self.POSTGRES_PASSWORD), + # host=self.POSTGRES_SERVER, + # port=self.POSTGRES_PORT, + # path=self.POSTGRES_DB, + # ) + return f"postgresql+psycopg://{urllib.parse.quote(self.POSTGRES_USER)}:{urllib.parse.quote(self.POSTGRES_PASSWORD)}@{self.POSTGRES_SERVER}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" MCP_IMAGE_PATH: str = '/opt/sqlbot/images' EXCEL_PATH: str = '/opt/sqlbot/data/excel' MCP_IMAGE_HOST: str = 'http://localhost:3000' - SERVER_IMAGE_HOST: str = '' + SERVER_IMAGE_HOST: str = 'http://YOUR_SERVE_IP:MCP_PORT/images/' + SERVER_IMAGE_TIMEOUT: int = 15 + + LOCAL_MODEL_PATH: str = '/opt/sqlbot/models' + DEFAULT_EMBEDDING_MODEL: str = 'shibing624/text2vec-base-chinese' + EMBEDDING_ENABLED: bool = True + EMBEDDING_DEFAULT_SIMILARITY: float = 0.4 + EMBEDDING_TERMINOLOGY_SIMILARITY: float = EMBEDDING_DEFAULT_SIMILARITY + EMBEDDING_DATA_TRAINING_SIMILARITY: float = EMBEDDING_DEFAULT_SIMILARITY + EMBEDDING_DEFAULT_TOP_COUNT: int = 5 + EMBEDDING_TERMINOLOGY_TOP_COUNT: int = EMBEDDING_DEFAULT_TOP_COUNT + EMBEDDING_DATA_TRAINING_TOP_COUNT: int = EMBEDDING_DEFAULT_TOP_COUNT + + # 是否启用SQL查询行数限制,默认值,可被参数配置覆盖 + GENERATE_SQL_QUERY_LIMIT_ENABLED: bool = True + GENERATE_SQL_QUERY_HISTORY_ROUND_COUNT: int = 3 + + # 安全配置:是否允许元数据查询(SHOW/DESCRIBE/DESC/EXPLAIN) + # 默认关闭,防止通过元数据查询泄露数据库结构 + SQLBOT_ALLOW_METADATA_QUERIES: bool = False + + PARSE_REASONING_BLOCK_ENABLED: bool = True + DEFAULT_REASONING_CONTENT_START: str = '' + DEFAULT_REASONING_CONTENT_END: str = '' + + PG_POOL_SIZE: int = 20 + PG_MAX_OVERFLOW: int = 30 + PG_POOL_RECYCLE: int = 3600 + PG_POOL_PRE_PING: bool = True + + TABLE_EMBEDDING_ENABLED: bool = True + TABLE_EMBEDDING_COUNT: int = 10 + DS_EMBEDDING_COUNT: int = 10 + + ORACLE_CLIENT_PATH: str = '/opt/sqlbot/db_client/oracle_instant_client' + + @field_validator('SQL_DEBUG', + 'EMBEDDING_ENABLED', + 'GENERATE_SQL_QUERY_LIMIT_ENABLED', + 'PARSE_REASONING_BLOCK_ENABLED', + 'PG_POOL_PRE_PING', + 'TABLE_EMBEDDING_ENABLED', + mode='before') + @classmethod + def lowercase_bool(cls, v: Any) -> Any: + """将字符串形式的布尔值转换为Python布尔值""" + if isinstance(v, str): + v_lower = v.lower().strip() + if v_lower == 'true': + return True + elif v_lower == 'false': + return False + return v + settings = Settings() # type: ignore diff --git a/backend/common/core/db.py b/backend/common/core/db.py index 37eff29b7..dca6dd216 100644 --- a/backend/common/core/db.py +++ b/backend/common/core/db.py @@ -1,14 +1,25 @@ from sqlmodel import Session, create_engine, SQLModel - from common.core.config import settings +engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI), + pool_size=settings.PG_POOL_SIZE, + max_overflow=settings.PG_MAX_OVERFLOW, + pool_recycle=settings.PG_POOL_RECYCLE, + pool_pre_ping=settings.PG_POOL_PRE_PING) -engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) def get_session(): with Session(engine) as session: - yield session + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + def init_db(): SQLModel.metadata.create_all(engine) diff --git a/backend/common/core/file.py b/backend/common/core/file.py new file mode 100644 index 000000000..d68acbdb8 --- /dev/null +++ b/backend/common/core/file.py @@ -0,0 +1,4 @@ +from pydantic import BaseModel + +class FileRequest(BaseModel): + file: str diff --git a/backend/common/core/pagination.py b/backend/common/core/pagination.py index ab7911479..f4a884453 100644 --- a/backend/common/core/pagination.py +++ b/backend/common/core/pagination.py @@ -14,6 +14,8 @@ def _process_result_row(self, row: Row) -> Dict[str, Any]: result_dict = {} if isinstance(row, int): return {'id': row} + if isinstance(row, SQLModel) and not hasattr(row, '_fields'): + return row.model_dump() for item, key in zip(row, row._fields): if isinstance(item, SQLModel): result_dict.update(item.model_dump()) diff --git a/backend/common/core/response_middleware.py b/backend/common/core/response_middleware.py index 6842893ec..91aa61783 100644 --- a/backend/common/core/response_middleware.py +++ b/backend/common/core/response_middleware.py @@ -1,18 +1,47 @@ import json -from starlette.middleware.base import BaseHTTPMiddleware -from starlette.responses import JSONResponse +from typing import Optional + from starlette.exceptions import HTTPException +from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request +from starlette.responses import JSONResponse + from common.core.config import settings from common.utils.utils import SQLBotLogUtil + + class ResponseMiddleware(BaseHTTPMiddleware): - def __init__(self, app): + instances = [] + + def __init__(self, app, allow_origins: Optional[list[str]] = None): super().__init__(app) + self.allow_origins = allow_origins or ["'self'"] + ResponseMiddleware.instances.append(self) + def update_allow_origins(self, new_allow_origins: Optional[list[str]] = None): + if not new_allow_origins: + return + self.allow_origins = list(set(self.allow_origins + new_allow_origins)) + + async def dispatch(self, request, call_next): response = await call_next(request) - - if isinstance(response, JSONResponse) or request.url.path == f"{settings.API_V1_STR}/openapi.json": + + direct_paths = [ + f"{settings.API_V1_STR}/mcp/mcp_question", + f"{settings.API_V1_STR}/mcp/mcp_assistant", + f"{settings.CONTEXT_PATH}/openapi.json", + f"{settings.CONTEXT_PATH}/docs", + f"{settings.CONTEXT_PATH}/redoc" + ] + + route = request.scope.get("route") + # 获取定义的路径模式,例如 '/items/{item_id}' + path_pattern = '' if not route else route.path_format + + if (isinstance(response, JSONResponse) + or request.url.path == f"{settings.CONTEXT_PATH}/openapi.json" + or path_pattern in direct_paths): return response if response.status_code != 200: return response @@ -21,9 +50,9 @@ async def dispatch(self, request, call_next): body = b"" async for chunk in response.body_iterator: body += chunk - + raw_data = json.loads(body.decode()) - + if isinstance(raw_data, dict) and all(k in raw_data for k in ["code", "data", "msg"]): return JSONResponse( content=raw_data, @@ -33,13 +62,13 @@ async def dispatch(self, request, call_next): if k.lower() not in ("content-length", "content-type") } ) - + wrapped_data = { "code": 0, "data": raw_data, "msg": None } - + return JSONResponse( content=wrapped_data, status_code=response.status_code, @@ -58,7 +87,13 @@ async def dispatch(self, request, call_next): if k.lower() not in ("content-length", "content-type") } ) - + content_type = response.headers.get("content-type", "") + static_content_types = ["text/html", "javascript", "typescript", "css"] + if any(ct in content_type for ct in static_content_types): + if self.allow_origins: + frame_ancestors_value = " ".join(self.allow_origins) + response.headers["Content-Security-Policy"] = f"frame-ancestors {frame_ancestors_value};" + return response @@ -72,7 +107,6 @@ async def http_exception_handler(request: Request, exc: HTTPException): headers={"Access-Control-Allow-Origin": "*"} ) - @staticmethod async def global_exception_handler(request: Request, exc: Exception): SQLBotLogUtil.error(f"Unhandled Exception: {str(exc)}", exc_info=True) @@ -81,4 +115,3 @@ async def global_exception_handler(request: Request, exc: Exception): content=str(exc), headers={"Access-Control-Allow-Origin": "*"} ) - diff --git a/backend/common/core/schemas.py b/backend/common/core/schemas.py index 6d9d75fac..04c3d5eaa 100644 --- a/backend/common/core/schemas.py +++ b/backend/common/core/schemas.py @@ -1,8 +1,9 @@ from fastapi import HTTPException, Request from fastapi.security import OAuth2PasswordBearer -from pydantic import BaseModel +from pydantic import BaseModel, Field from sqlmodel import SQLModel from starlette.status import HTTP_403_FORBIDDEN, HTTP_401_UNAUTHORIZED +from apps.swagger.i18n import PLACEHOLDER_PREFIX from common.core.config import settings from fastapi.security.utils import get_authorization_scheme_param from typing import Generic, TypeVar, Optional @@ -14,6 +15,7 @@ class TokenPayload(BaseModel): class Token(SQLModel): access_token: str token_type: str = "bearer" + platform_info: Optional[dict] = None class XOAuth2PasswordBearer(OAuth2PasswordBearer): async def __call__(self, request: Request) -> Optional[str]: @@ -45,15 +47,15 @@ class PaginationParams(BaseModel): desc: bool = False class PaginatedResponse(BaseModel, Generic[T]): - items: list[T] - total: int - page: int - size: int - total_pages: int + items: list[T] = Field(description=f"{PLACEHOLDER_PREFIX}grid_items") + total: int = Field(description=f"{PLACEHOLDER_PREFIX}grid_total") + page: int = Field(description=f"{PLACEHOLDER_PREFIX}page_num") + size: int = Field(description=f"{PLACEHOLDER_PREFIX}page_size") + total_pages: int = Field(description=f"{PLACEHOLDER_PREFIX}grid_total_pages") class BaseCreatorDTO(BaseModel): - id: int + id: int = Field(description="ID") class Config: json_encoders = { int: lambda v: str(v) if isinstance(v, int) and v > (2**53 - 1) else v diff --git a/backend/common/core/security_config.py b/backend/common/core/security_config.py new file mode 100644 index 000000000..2b8d38a15 --- /dev/null +++ b/backend/common/core/security_config.py @@ -0,0 +1,160 @@ +""" +Security Configuration Module +Centralized security settings and best practices for the SQLBot application +""" + +from pydantic import BaseModel, Field +from typing import Optional + + +class SecurityConfig(BaseModel): + """Security configuration settings""" + + # SSL/TLS Settings + verify_ssl_certificates: bool = Field( + default=True, + description="Enable SSL certificate verification for external requests" + ) + + ssl_cert_path: Optional[str] = Field( + default=None, + description="Path to custom CA bundle for SSL verification" + ) + + # JWT Settings + jwt_verify_signature: bool = Field( + default=True, + description="Enable JWT signature verification" + ) + + jwt_verify_expiration: bool = Field( + default=True, + description="Enable JWT expiration verification" + ) + + # Request Timeout Settings + default_request_timeout: int = Field( + default=30, + description="Default timeout for HTTP requests in seconds" + ) + + database_connection_timeout: int = Field( + default=10, + description="Default timeout for database connections in seconds" + ) + + # Password Security + min_password_length: int = Field( + default=8, + description="Minimum password length" + ) + + require_password_uppercase: bool = Field( + default=True, + description="Require at least one uppercase letter in passwords" + ) + + require_password_lowercase: bool = Field( + default=True, + description="Require at least one lowercase letter in passwords" + ) + + require_password_digit: bool = Field( + default=True, + description="Require at least one digit in passwords" + ) + + require_password_special: bool = Field( + default=True, + description="Require at least one special character in passwords" + ) + + # Rate Limiting + enable_rate_limiting: bool = Field( + default=True, + description="Enable rate limiting for API endpoints" + ) + + rate_limit_per_minute: int = Field( + default=60, + description="Maximum requests per minute per user" + ) + + # SQL Injection Prevention + use_parameterized_queries: bool = Field( + default=True, + description="Always use parameterized queries to prevent SQL injection" + ) + + # XSS Prevention + sanitize_html_input: bool = Field( + default=True, + description="Sanitize HTML input to prevent XSS attacks" + ) + + # CSRF Protection + enable_csrf_protection: bool = Field( + default=True, + description="Enable CSRF protection for state-changing requests" + ) + + # Logging and Monitoring + log_security_events: bool = Field( + default=True, + description="Log security-related events" + ) + + log_failed_auth_attempts: bool = Field( + default=True, + description="Log failed authentication attempts" + ) + + max_failed_auth_attempts: int = Field( + default=5, + description="Maximum failed authentication attempts before account lockout" + ) + + account_lockout_duration_minutes: int = Field( + default=15, + description="Duration of account lockout in minutes" + ) + + +# Default security configuration +DEFAULT_SECURITY_CONFIG = SecurityConfig() + + +def get_security_config() -> SecurityConfig: + """Get the current security configuration""" + return DEFAULT_SECURITY_CONFIG + + +def validate_password_strength(password: str, config: SecurityConfig = DEFAULT_SECURITY_CONFIG) -> tuple[bool, str]: + """ + Validate password strength based on security configuration + + Args: + password: The password to validate + config: Security configuration to use + + Returns: + Tuple of (is_valid, error_message) + """ + if len(password) < config.min_password_length: + return False, f"Password must be at least {config.min_password_length} characters long" + + if config.require_password_uppercase and not any(c.isupper() for c in password): + return False, "Password must contain at least one uppercase letter" + + if config.require_password_lowercase and not any(c.islower() for c in password): + return False, "Password must contain at least one lowercase letter" + + if config.require_password_digit and not any(c.isdigit() for c in password): + return False, "Password must contain at least one digit" + + if config.require_password_special: + special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?" + if not any(c in special_chars for c in password): + return False, "Password must contain at least one special character" + + return True, "" diff --git a/backend/common/core/sqlbot_cache.py b/backend/common/core/sqlbot_cache.py index cce07c06b..39e8afb61 100644 --- a/backend/common/core/sqlbot_cache.py +++ b/backend/common/core/sqlbot_cache.py @@ -1,3 +1,4 @@ +import re from fastapi_cache import FastAPICache from functools import partial, wraps from typing import Optional, Any, Dict, Tuple @@ -16,7 +17,7 @@ def custom_key_builder( kwargs: Dict[str, Any], cacheName: str, keyExpression: Optional[str] = None, -) -> str: +) -> str | list[str]: try: base_key = f"{namespace}:{cacheName}:" @@ -27,17 +28,22 @@ def custom_key_builder( # 支持args[0]格式 if keyExpression.startswith("args["): - import re if match := re.match(r"args\[(\d+)\]", keyExpression): index = int(match.group(1)) value = bound_args.args[index] + if isinstance(value, list): + return [f"{base_key}{v}" for v in value] return f"{base_key}{value}" # 支持属性路径格式 parts = keyExpression.split('.') + if not bound_args.arguments.get(parts[0]): + return f"{base_key}{parts[0]}" value = bound_args.arguments[parts[0]] for part in parts[1:]: value = getattr(value, part) + if isinstance(value, list): + return [f"{base_key}{v}" for v in value] return f"{base_key}{value}" # 默认使用第一个参数作为key @@ -64,7 +70,7 @@ def decorator(func): @wraps(func) async def wrapper(*args, **kwargs): - if not settings.CACHE_TYPE or settings.CACHE_TYPE.lower() == "none": + if not settings.CACHE_TYPE or settings.CACHE_TYPE.lower() == "none" or not is_cache_initialized(): return await func(*args, **kwargs) # 生成缓存键 cache_key = used_key_builder( @@ -92,7 +98,7 @@ def clear_cache( def decorator(func): @wraps(func) async def wrapper(*args, **kwargs): - if not settings.CACHE_TYPE or settings.CACHE_TYPE.lower() == "none": + if not settings.CACHE_TYPE or settings.CACHE_TYPE.lower() == "none" or not is_cache_initialized(): return await func(*args, **kwargs) cache_key = custom_key_builder( func=func, @@ -102,14 +108,16 @@ async def wrapper(*args, **kwargs): cacheName=cacheName, keyExpression=keyExpression, ) + key_list = cache_key if isinstance(cache_key, list) else [cache_key] backend = FastAPICache.get_backend() - if await backend.get(cache_key): - if settings.CACHE_TYPE.lower() == "redis": - redis = backend.redis - await redis.delete(cache_key) - else: - await backend.clear(key=cache_key) - SQLBotLogUtil.debug(f"Cache cleared: {cache_key}") + for temp_cache_key in key_list: + if await backend.get(temp_cache_key): + if settings.CACHE_TYPE.lower() == "redis": + redis = backend.redis + await redis.delete(temp_cache_key) + else: + await backend.clear(key=temp_cache_key) + SQLBotLogUtil.debug(f"Cache cleared: {temp_cache_key}") return await func(*args, **kwargs) return wrapper @@ -132,4 +140,21 @@ def init_sqlbot_cache(): SQLBotLogUtil.info(f"SQLBot 使用Redis缓存, 可使用多进程模式") else: SQLBotLogUtil.warning("SQLBot 未启用缓存, 可使用多进程模式") - \ No newline at end of file + + +def is_cache_initialized() -> bool: + # 检查必要的属性是否存在 + if not hasattr(FastAPICache, "_backend") or not hasattr(FastAPICache, "_prefix"): + return False + + # 检查属性值是否为 None + if FastAPICache._backend is None or FastAPICache._prefix is None: + return False + + # 尝试获取后端确认 + try: + backend = FastAPICache.get_backend() + return backend is not None + except (AssertionError, AttributeError, Exception) as e: + SQLBotLogUtil.debug(f"缓存初始化检查失败: {str(e)}") + return False \ No newline at end of file diff --git a/backend/common/error.py b/backend/common/error.py index ec1d34201..09882e2e0 100644 --- a/backend/common/error.py +++ b/backend/common/error.py @@ -4,4 +4,16 @@ def __init__(self, message): self.message = message def __str__(self): - return self.message \ No newline at end of file + return self.message + + +class SQLBotDBConnectionError(Exception): + pass + + +class SQLBotDBError(Exception): + pass + + +class ParseSQLResultError(Exception): + pass diff --git a/backend/common/utils/aes_crypto.py b/backend/common/utils/aes_crypto.py new file mode 100644 index 000000000..e75ab5a60 --- /dev/null +++ b/backend/common/utils/aes_crypto.py @@ -0,0 +1,16 @@ +from typing import Optional +from common.core.config import settings +from sqlbot_xpack.aes_utils import SecureEncryption + +simple_aes_iv_text = 'sqlbot_em_aes_iv' +def sqlbot_aes_encrypt(text: str, key: Optional[str] = None) -> str: + return SecureEncryption.encrypt_to_single_string(text, key or settings.SECRET_KEY) + +def sqlbot_aes_decrypt(text: str, key: Optional[str] = None) -> str: + return SecureEncryption.decrypt_from_single_string(text, key or settings.SECRET_KEY) + +def simple_aes_encrypt(text: str, key: Optional[str] = None, ivtext: Optional[str] = None) -> str: + return SecureEncryption.simple_aes_encrypt(text, key or settings.SECRET_KEY[:32], ivtext or simple_aes_iv_text) + +def simple_aes_decrypt(text: str, key: Optional[str] = None, ivtext: Optional[str] = None) -> str: + return SecureEncryption.simple_aes_decrypt(text, key or settings.SECRET_KEY[:32], ivtext or simple_aes_iv_text) \ No newline at end of file diff --git a/backend/common/utils/command_utils.py b/backend/common/utils/command_utils.py new file mode 100644 index 000000000..7f1b7d805 --- /dev/null +++ b/backend/common/utils/command_utils.py @@ -0,0 +1,99 @@ +import re +from typing import Optional, Tuple + +from apps.chat.models.chat_model import QuickCommand + + +def parse_quick_command(input_str: str) -> Tuple[Optional[QuickCommand], str, Optional[int], Optional[str]]: + """ + 解析字符串中的快速命令 + + Args: + input_str: 输入字符串 + + Returns: + Tuple[Optional[QuickCommand], str, Optional[int], Optional[str]]: + (命令枚举, 去除命令的字符串, 数字参数, 警告信息) + 如果解析成功: (命令, 文本, 数字, None) + 如果解析失败: (None, 原字符串, None, 警告信息) + """ + + # 获取所有命令值 + command_values = [cmd.value for cmd in QuickCommand] + + # 1. 检查字符串中是否包含任何命令 + found_commands = [] + for cmd_value in command_values: + # 使用正则表达式查找独立的命令(前后是单词边界或空格) + pattern = r'(? 1: + return None, input_str, None, f"错误: 字符串中包含多个命令: {', '.join(found_commands)}" + + # 此时只有一个命令 + command_str = found_commands[0] + + # 3. 构建完整匹配模式,匹配命令及其后的可选数字 + # 模式: 命令 + 可选的空格 + 可选的数字 + full_pattern = r'(? 0: + return None, input_str, None, f"错误: 命令与前面的文本没有用空格分隔" + + # 6. 获取命令枚举 + command = None + for cmd in QuickCommand: + if cmd.value == command_part: + command = cmd + break + + if not command: + return None, input_str, None, f"错误: 未识别的命令: {command_part}" + + # 7. 提取去除命令和数字后的文本 + # 获取命令前的文本 + text_before_command = input_str[:match.start()].rstrip() + + # 8. 处理数字参数 + record_id = None + if number_part: + try: + record_id = int(number_part) + except ValueError: + return None, input_str, None, f"错误: 数字参数格式不正确: {number_part}" + + return command, text_before_command, record_id, None + + diff --git a/backend/common/utils/crypto.py b/backend/common/utils/crypto.py index ce5d7b95b..dcd1a13cc 100644 --- a/backend/common/utils/crypto.py +++ b/backend/common/utils/crypto.py @@ -1,4 +1,7 @@ -from sqlbot_xpack.core import sqlbot_decrypt as xpack_sqlbot_decrypt +from sqlbot_xpack.core import sqlbot_decrypt as xpack_sqlbot_decrypt, sqlbot_encrypt as xpack_sqlbot_encrypt async def sqlbot_decrypt(text: str) -> str: - return await xpack_sqlbot_decrypt(text) \ No newline at end of file + return await xpack_sqlbot_decrypt(text) + +async def sqlbot_encrypt(text: str) -> str: + return await xpack_sqlbot_encrypt(text) \ No newline at end of file diff --git a/backend/common/utils/data_format.py b/backend/common/utils/data_format.py new file mode 100644 index 000000000..56a83f866 --- /dev/null +++ b/backend/common/utils/data_format.py @@ -0,0 +1,182 @@ +from decimal import Decimal + +import pandas as pd + +from apps.chat.models.chat_model import AxisObj + + +class DataFormat: + @staticmethod + def safe_convert_to_string(df): + df_copy = df.copy() + + for col in df_copy.columns: + # 使用map避免ambiguous truth value问题 + df_copy[col] = df_copy[col].map( + # 关键:在数字字符串前添加零宽空格,阻止pandas的自动格式化 + lambda x: "" if pd.isna(x) else "\u200b" + str(x) + ) + + return df_copy + + @staticmethod + def normalize_qualified_sql_column_keys(row: dict) -> dict: + """Add unqualified keys for names like ``alias.column`` (Hive/MySQL return shape). + + Chart bindings use the bare column name (``table_name``) while drivers may return + ``_u2.table_name``. Only adds ``short`` when absent to avoid clobbering real duplicates. + """ + if not row: + return row + out = dict(row) + for k, v in row.items(): + ks = str(k) + if "." not in ks: + continue + short = ks.rsplit(".", 1)[-1] + if short not in out: + out[short] = v + return out + + @staticmethod + def normalize_qualified_sql_column_keys_in_object_array(obj_array: list) -> list: + if not obj_array: + return obj_array + return [ + DataFormat.normalize_qualified_sql_column_keys(obj) if isinstance(obj, dict) else obj + for obj in obj_array + ] + + @staticmethod + def convert_large_numbers_in_object_array(obj_array, int_threshold=1e15, float_threshold=1e10): + """处理对象数组,将每个对象中的大数字转换为字符串""" + + def format_float_without_scientific(value): + """格式化浮点数,避免科学记数法""" + if value == 0: + return "0" + formatted = str(Decimal(str(value))) + if '.' in formatted: + formatted = formatted.rstrip('0').rstrip('.') + return formatted + + def process_object(obj): + """处理单个对象""" + if not isinstance(obj, dict): + return obj + + processed_obj = {} + for key, value in obj.items(): + if isinstance(value, (int, float)): + # 只转换大数字 + if isinstance(value, int) and abs(value) >= int_threshold: + processed_obj[key] = str(value) + elif isinstance(value, float) and (abs(value) >= float_threshold or abs(value) < 1e-6): + processed_obj[key] = format_float_without_scientific(value) + else: + processed_obj[key] = value + elif isinstance(value, dict): + # 处理嵌套对象 + processed_obj[key] = process_object(value) + elif isinstance(value, list): + # 处理对象中的数组 + processed_obj[key] = [process_item(item) for item in value] + else: + processed_obj[key] = value + return processed_obj + + def process_item(item): + """处理数组中的项目""" + if isinstance(item, dict): + return process_object(item) + return item + + return [process_item(obj) for obj in obj_array] + + @staticmethod + def convert_object_array_for_pandas(column_list: list, data_list: list): + _fields_list = [] + for field_idx, field in enumerate(column_list): + _fields_list.append(field.name) + + md_data = [] + for inner_data in data_list: + _row = [] + for field_idx, field in enumerate(column_list): + value = inner_data.get(field.value) + _row.append(value) + md_data.append(_row) + return md_data, _fields_list + + @staticmethod + def convert_data_fields_for_pandas(chart: dict, fields: list, data: list): + _fields = {} + if chart.get('columns'): + for _column in chart.get('columns'): + if _column: + _fields[_column.get('value')] = _column.get('name') + if chart.get('axis'): + if chart.get('axis').get('x'): + _fields[chart.get('axis').get('x').get('value')] = chart.get('axis').get('x').get('name') + if chart.get('axis').get('y'): + # _fields[chart.get('axis').get('y').get('value')] = chart.get('axis').get('y').get('name') + y_axis = chart.get('axis').get('y') + if isinstance(y_axis, list): + # y轴是数组的情况(多指标字段) + for y_item in y_axis: + if isinstance(y_item, dict) and 'value' in y_item and 'name' in y_item: + _fields[y_item.get('value')] = y_item.get('name') + elif isinstance(y_axis, dict): + # y轴是对象的情况(单指标字段) + if 'value' in y_axis and 'name' in y_axis: + _fields[y_axis.get('value')] = y_axis.get('name') + if chart.get('axis').get('series'): + _fields[chart.get('axis').get('series').get('value')] = chart.get('axis').get('series').get( + 'name') + _column_list = [] + for field in fields: + _column_list.append( + AxisObj(name=field if not _fields.get(field) else _fields.get(field), value=field)) + + md_data, _fields_list = DataFormat.convert_object_array_for_pandas(_column_list, data) + + return md_data, _fields_list + + @staticmethod + def format_pd_data(column_list: list, data_list: list, col_formats: dict = None): + # 预处理数据并记录每列的格式类型 + # 格式类型:'text'(文本)、'number'(数字)、'default'(默认) + _fields_list = [] + + if col_formats is None: + col_formats = {} + for field_idx, field in enumerate(column_list): + _fields_list.append(field.name) + col_formats[field_idx] = 'default' # 默认不特殊处理 + + data = [] + + for _data in data_list: + _row = [] + for field_idx, field in enumerate(column_list): + value = _data.get(field.value) + if value is not None: + # 检查是否为数字且需要特殊处理 + if isinstance(value, (int, float)): + # 整数且超过15位 → 转字符串并标记为文本列 + if isinstance(value, int) and len(str(abs(value))) > 15: + value = str(value) + col_formats[field_idx] = 'text' + # 小数且超过15位有效数字 → 转字符串并标记为文本列 + elif isinstance(value, float): + decimal_str = format(value, '.16f').rstrip('0').rstrip('.') + if len(decimal_str) > 15: + value = str(value) + col_formats[field_idx] = 'text' + # 其他数字列标记为数字格式(避免科学记数法) + elif col_formats[field_idx] != 'text': + col_formats[field_idx] = 'number' + _row.append(value) + data.append(_row) + + return data, _fields_list, col_formats \ No newline at end of file diff --git a/backend/common/utils/embedding_threads.py b/backend/common/utils/embedding_threads.py new file mode 100644 index 000000000..d4a2fe6b5 --- /dev/null +++ b/backend/common/utils/embedding_threads.py @@ -0,0 +1,48 @@ +from concurrent.futures import ThreadPoolExecutor +from typing import List + +from sqlalchemy.orm import sessionmaker, scoped_session + +executor = ThreadPoolExecutor(max_workers=200) + +from common.core.db import engine + +session_maker = scoped_session(sessionmaker(bind=engine)) + + +# session = session_maker() + + +def run_save_terminology_embeddings(ids: List[int]): + from apps.terminology.curd.terminology import save_embeddings + executor.submit(save_embeddings, session_maker, ids) + + +def fill_empty_terminology_embeddings(): + from apps.terminology.curd.terminology import run_fill_empty_embeddings + executor.submit(run_fill_empty_embeddings, session_maker) + + +def run_save_data_training_embeddings(ids: List[int]): + from apps.data_training.curd.data_training import save_embeddings + executor.submit(save_embeddings, session_maker, ids) + + +def fill_empty_data_training_embeddings(): + from apps.data_training.curd.data_training import run_fill_empty_embeddings + executor.submit(run_fill_empty_embeddings, session_maker) + + +def run_save_table_embeddings(ids: List[int]): + from apps.datasource.crud.table import save_table_embedding + executor.submit(save_table_embedding, session_maker, ids) + + +def run_save_ds_embeddings(ids: List[int]): + from apps.datasource.crud.table import save_ds_embedding + executor.submit(save_ds_embedding, session_maker, ids) + + +def fill_empty_table_and_ds_embeddings(): + from apps.datasource.crud.table import run_fill_empty_table_and_ds_embedding + executor.submit(run_fill_empty_table_and_ds_embedding, session_maker) diff --git a/backend/common/utils/excel.py b/backend/common/utils/excel.py new file mode 100644 index 000000000..659428ab8 --- /dev/null +++ b/backend/common/utils/excel.py @@ -0,0 +1,12 @@ +import pandas as pd + +def get_excel_column_count(file_path, sheet_name): + """获取Excel文件的列数""" + df_temp = pd.read_excel( + file_path, + sheet_name=sheet_name, + engine='calamine', + header=0, + nrows=0 + ) + return len(df_temp.columns) \ No newline at end of file diff --git a/backend/common/utils/http_utils.py b/backend/common/utils/http_utils.py new file mode 100644 index 000000000..61f944fa0 --- /dev/null +++ b/backend/common/utils/http_utils.py @@ -0,0 +1,31 @@ +from typing import Tuple +import requests +from urllib.parse import urlparse +from requests.exceptions import RequestException, Timeout + +def verify_url(url: str, timeout: int = 5) -> Tuple[bool, str]: + try: + parsed = urlparse(url) + if not all([parsed.scheme, parsed.netloc]): + return False, "无效的 URL 格式" + + if parsed.scheme not in ['http', 'https']: + return False, "URL 必须以 http 或 https 开头" + + response = requests.get( + url, + timeout=timeout, + verify=False # 忽略 SSL 证书验证 + ) + + if response.status_code < 400: + return True, "URL 可达" + else: + return False, f"服务器返回错误状态码: {response.status_code}" + + except Timeout: + return False, f"连接超时 (>{timeout}秒)" + except RequestException as e: + return False, f"连接失败: {str(e)}" + except Exception as e: + return False, f"验证过程发生错误: {str(e)}" \ No newline at end of file diff --git a/backend/common/utils/locale.py b/backend/common/utils/locale.py index 67a0d97cf..14b87bd5f 100644 --- a/backend/common/utils/locale.py +++ b/backend/common/utils/locale.py @@ -1,8 +1,10 @@ -from pathlib import Path import json -from typing import Dict, Optional, Any +from pathlib import Path +from typing import Dict, Any + from fastapi import Request + class I18n: def __init__(self, locale_dir: str = "locales"): self.locale_dir = Path(locale_dir) @@ -18,40 +20,45 @@ def load_translations(self): with open(lang_file, 'r', encoding='utf-8') as f: self.translations[lang_file.stem.lower()] = json.load(f) - def get_language(self, request: Request) -> str: - accept_language = request.headers.get('accept-language', 'en') - primary_lang = accept_language.split(',')[0].lower() - + def get_language(self, request: Request = None, lang: str = None) -> str: + primary_lang: str | None = None + if lang is not None: + primary_lang = lang.lower() + elif request is not None: + accept_language = request.headers.get('accept-language', 'en') + primary_lang = accept_language.split(',')[0].lower() + return primary_lang if primary_lang in self.translations else 'zh-cn' - def __call__(self, request: Request) -> 'I18nHelper': - return I18nHelper(self, request) + def __call__(self, request: Request = None, lang: str = None) -> 'I18nHelper': + return I18nHelper(self, request, lang) + class I18nHelper: - def __init__(self, i18n: I18n, request: Request): + def __init__(self, i18n: I18n, request: Request = None, lang: str = None): self.i18n = i18n self.request = request - self.lang = i18n.get_language(request) + self.lang = i18n.get_language(request, lang) def _get_nested_translation(self, data: Dict[str, Any], key_path: str) -> str: keys = key_path.split('.') current = data - + for key in keys: if isinstance(current, dict) and key in current: current = current[key] else: return key_path # 如果找不到,返回原键 - + return current if isinstance(current, str) else key_path def __call__(self, arg_key: str, **kwargs) -> str: lang_data = self.i18n.translations.get(self.lang, {}) text = self._get_nested_translation(lang_data, arg_key) - + if kwargs: try: return text.format(**kwargs) except (KeyError, ValueError): return text - return text \ No newline at end of file + return text diff --git a/backend/common/utils/random.py b/backend/common/utils/random.py new file mode 100644 index 000000000..879f42252 --- /dev/null +++ b/backend/common/utils/random.py @@ -0,0 +1,6 @@ +import secrets +import string + +def get_random_string(length=16): + alphabet = string.ascii_letters + string.digits + return ''.join(secrets.choice(alphabet) for _ in range(length)) diff --git a/backend/common/utils/utils.py b/backend/common/utils/utils.py index cea2a95ec..3c3bdd0d8 100644 --- a/backend/common/utils/utils.py +++ b/backend/common/utils/utils.py @@ -6,6 +6,7 @@ from datetime import datetime, timedelta, timezone from logging.handlers import RotatingFileHandler from pathlib import Path +import re from urllib.parse import urlparse from fastapi import Request @@ -17,7 +18,6 @@ from jwt.exceptions import InvalidTokenError from common.core import security -from common.core.config import settings def generate_password_reset_token(email: str) -> str: @@ -264,3 +264,34 @@ def get_origin_from_referer(request: Request): SQLBotLogUtil.error(f"解析 Referer 出错: {e}") return referer +def origin_match_domain(origin: str, domain: str) -> bool: + if not origin or not domain: + return False + origin_normalized = origin.rstrip('/') + + for d in re.split(r'[,;]', domain): + if d.strip().rstrip('/') == origin_normalized: + return True + + return False + +def get_domain_list(domain: str) -> list[str]: + domains = [] + if not domain: + return domains + for d in re.split(r'[,;]', domain): + d_clean = d.strip().rstrip('/') + if d_clean: + domains.append(d_clean) + return domains + + +def equals_ignore_case(str1: str, *args: str) -> bool: + if str1 is None: + return None in args + for arg in args: + if arg is None: + continue + if str1.casefold() == arg.casefold(): + return True + return False \ No newline at end of file diff --git a/backend/common/utils/whitelist.py b/backend/common/utils/whitelist.py index e4bb1a31b..bdbea3420 100644 --- a/backend/common/utils/whitelist.py +++ b/backend/common/utils/whitelist.py @@ -7,7 +7,6 @@ "/", "/docs", "/login/*", - "*.json", "*.ico", "*.html", "*.js", @@ -22,14 +21,24 @@ "*.ttf", "*.eot", "*.otf", + "*.css.map", "/mcp*", "/system/license", "/system/config/key", "/images/*", "/sse", + "/system/appearance/ui", + "/system/appearance/picture/*", "/system/assistant/validator*", "/system/assistant/info/*", - "/datasource/uploadExcel" + "/system/assistant/app/*", + "/system/assistant/picture/*", + "/system/authentication/platform/status", + "/system/authentication/login/*", + "/system/authentication/sso/*", + "/system/platform/sso/*", + "/system/platform/client/*", + "/system/parameter/login" ] class WhitelistChecker: @@ -58,6 +67,13 @@ def is_whitelisted(self, path: str) -> bool: prefix = settings.API_V1_STR if path.startswith(prefix): path = path[len(prefix):] + + context_prefix = settings.CONTEXT_PATH + if context_prefix and path.startswith(context_prefix): + path = path[len(context_prefix):] + + if not path: + path = '/' if path in self.whitelist: return True @@ -74,4 +90,4 @@ def add_path(self, path: str) -> None: if "*" in path: self._compile_patterns() -whiteUtils = WhitelistChecker() \ No newline at end of file +whiteUtils = WhitelistChecker() diff --git a/backend/locales/en.json b/backend/locales/en.json index acee94159..43db335b8 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -1,40 +1,198 @@ { - "i18n_default_workspace": "Default workspace", - "i18n_ds_name_exist": "Name already exists", - "i18n_concat_admin": "Please contact administrator!", - "i18n_exist": "{msg} already exists!", - "i18n_not_exist": "{msg} not exists", - "i18n_error": "{key} error!", - "i18n_miss_args": "Missing {key} parameter!", - "i18n_format_invalid": "{key} format is incorrect!", - "i18n_login": { - "account_pwd_error": "Account or password error!", - "no_associated_ws": "No associated workspace, {msg}", - "user_disable": "Account disabled, {msg}" - }, - "i18n_user": { - "account": "Account", - "email": "Email", - "password": "Password", - "language_not_support": "System does not support [{key}] language!", - "ws_miss": "The current user is not in the workspace [{ws}]!" - }, - "i18n_ws": { - "title": "Workspace" - }, - "i18n_permission": { - "only_admin": "Only administrators can call this!", - "no_permission": "No permission to access {url}{msg}", - "authenticate_invalid": "Authenticate invalid [{msg}]", - "token_expired": "Token has expired" - }, - "i18n_llm": { - "validate_error": "Validation failed [{msg}]", - "delete_default_error": "Cannot delete default model [{key}]!", - "miss_default": "The default large language model has not been configured" - }, - "i18n_ds_invalid": "Datasource Invalid", - "i18n_embedded": { - "invalid_origin": "Domain verification failed [{origin}]" - } + "i18n_default_workspace": "Default Workspace", + "i18n_ds_name_exist": "Name already exists", + "i18n_concat_admin": "Please contact the administrator!", + "i18n_exist": "{msg} already exists!", + "i18n_name": "Name", + "i18n_not_exist": "{msg} does not exist!", + "i18n_error": "{key} error!", + "i18n_miss_args": "Missing {key} parameter!", + "i18n_format_invalid": "{key} format is incorrect!", + "i18n_login": { + "account_pwd_error": "Incorrect account or password!", + "no_associated_ws": "No associated workspace, {msg}", + "user_disable": "Account is disabled, {msg}", + "origin_error": "Invalid login method", + "prohibit_auto_create": "Automatically creating users is prohibited. Please synchronize users first", + "no_platform_user": "Account does not exist. Please sync the user first." + }, + "i18n_user": { + "account": "Account", + "email": "Email", + "password": "Password", + "language_not_support": "The system does not support [{key}] language!", + "ws_miss": "Current user is not in the workspace [{ws}]!", + "name": "Name", + "status": "User Status", + "origin": "User Source", + "workspace": "Workspace", + "role": "Role", + "platform_user_id": "External User Unique Identifier", + "administrator": "Administrator", + "ordinary_member": "Member", + "status_enabled": "Enabled", + "status_disabled": "Disabled", + "local_creation": "Local" + }, + "i18n_ws": { + "title": "Workspace" + }, + "i18n_permission": { + "only_admin": "Only administrators are allowed to call!", + "no_permission": "No permission to call {url}{msg}", + "authenticate_invalid": "Authentication invalid【{msg}】", + "token_expired": "Token expired", + "only_ws_admin": "Workspace administrator access required!", + "permission_resource_limit": "Access denied or resource not found!" + }, + "i18n_llm": { + "validate_error": "Validation failed [{msg}]", + "delete_default_error": "Cannot delete the default model [{key}]!", + "miss_default": "Default large language model has not been configured" + }, + "i18n_ds_invalid": "Datasource connection is invalid", + "i18n_ds_upload_error": "Upload Failed", + "i18n_embedded": { + "invalid_origin": "Domain name validation failed【{origin}】" + }, + "i18n_chat": { + "record_id_in_mcp": "Answer ID: " + }, + "i18n_terminology": { + "terminology_not_exists": "This terminology does not exist", + "datasource_cannot_be_none": "Datasource cannot be empty", + "cannot_be_repeated": "Terminology name and synonyms cannot be repeated", + "exists_in_db": "Terminology name and synonyms already exist", + "term_name": "Terminology Name", + "term_description": "Terminology Description", + "effective_data_sources": "Effective Data Sources", + "all_data_sources": "All Data Sources", + "synonyms": "Synonyms", + "term_name_template": "Terminology Name (Required)", + "term_description_template": "Terminology Description (Required)", + "effective_data_sources_template": "Effective Data Sources (Multiple supported, separated by \",\")", + "all_data_sources_template": "All Data Sources (Y: Apply to all data sources, N: Apply to specified data sources)", + "synonyms_template": "Synonyms (Multiple supported, separated by \",\")", + "term_name_template_example_1": "Term1", + "term_description_template_example_1": "Term1 Description", + "effective_data_sources_template_example_1": "Datasource1, Datasource2", + "synonyms_template_example_1": "Synonym1, Synonym2", + "term_name_template_example_2": "Term2", + "term_description_template_example_2": "Term2 Description", + "synonyms_template_example_2": "Synonym3", + "word_cannot_be_empty": "Term cannot be empty", + "description_cannot_be_empty": "Term description cannot be empty", + "datasource_not_found": "Datasource not found" + }, + "i18n_data_training": { + "datasource_list_is_not_found": "Data source list not found", + "datasource_id_not_found": "Data source ID: {key} not found", + "datasource_cannot_be_none": "Datasource cannot be empty", + "datasource_assistant_cannot_be_none": "Datasource or advanced application cannot both be empty", + "data_training_not_exists": "This example does not exist", + "exists_in_db": "This question already exists", + "data_training": "SQL Example Library", + "problem_description": "Problem Description", + "sample_sql": "Sample SQL", + "effective_data_sources": "Effective Data Sources", + "advanced_application": "Advanced Application", + "problem_description_template": "Problem Description (Required)", + "sample_sql_template": "Sample SQL (Required)", + "effective_data_sources_template": "Effective Data Sources", + "advanced_application_template": "Advanced Application", + "problem_description_template_example": "Query all IDs in the TEST table", + "effective_data_sources_template_example": "Effective Datasource 1", + "advanced_application_template_example": "Effective Advanced Application Name", + "error_info": "Error Information", + "question_cannot_be_empty": "Question cannot be empty", + "description_cannot_be_empty": "Sample SQL cannot be empty", + "datasource_not_found": "Datasource not found", + "advanced_application_not_found": "Advanced application not found" + }, + "i18n_custom_prompt": { + "exists_in_db": "Template name already exists", + "not_exists": "This template does not exist", + "prompt_word_name": "Prompt word name", + "prompt_word_content": "Prompt word content", + "effective_data_sources": "Effective Data Sources", + "all_data_sources": "All Data Sources", + "prompt_word_name_template": "Prompt Name (Required)", + "prompt_word_content_template": "Prompt Content (Required)", + "effective_data_sources_template": "Effective Data Sources (Multiple supported, separated by \",\")", + "all_data_sources_template": "All Data Sources (Y: Apply to all data sources, N: Apply to specified data sources)", + "prompt_word_name_template_example1": "Prompt1", + "prompt_word_content_template_example1": "Describe your prompt in detail", + "effective_data_sources_template_example1": "Datasource1, Datasource2", + "prompt_word_name_template_example2": "Prompt2", + "prompt_word_content_template_example2": "Describe your prompt in detail", + "name_cannot_be_empty": "Name cannot be empty", + "prompt_cannot_be_empty": "Prompt content cannot be empty", + "type_cannot_be_empty": "Type cannot be empty", + "datasource_not_found": "Datasource not found", + "datasource_cannot_be_none": "Datasource cannot be empty" + }, + "i18n_excel_export": { + "data_is_empty": "Form data is empty, unable to export data" + }, + "i18n_excel_import": { + "col_num_not_match": "Number of columns in Excel does not match" + }, + "i18n_authentication": { + "record_not_exist": "{msg} record does not exist. Please save it first!" + }, + "i18n_audit": { + "success": "Success", + "failed": "Failed", + "system_log": "Operation Log", + "operation_type_name": "Operation Type", + "operation_detail_info": "Operation Details", + "user_name": "Operator", + "oid_name": "Workspace", + "operation_status_name": "Operation Status", + "error_message": "Error Message", + "ip_address": "IP Address", + "create_time": "Operation Time", + "chat": "AI Q&A", + "datasource": "Datasource", + "dashboard": "Dashboard", + "member": "Member", + "permission": "Permission", + "terminology": "Terminology", + "data_training": "SQL Examples", + "prompt_words": "Prompt Words", + "user": "User", + "workspace": "Workspace", + "ai_model": "AI Model", + "application": "Application", + "theme": "Appearance Settings", + "create": "Create", + "delete": "Delete", + "update": "Update", + "edit": "Edit", + "login": "Login", + "export": "Export", + "import": "Import", + "add": "Add", + "create_or_update": "Change", + "api_key": "API Key", + "params_setting": "Parameter Configuration", + "rules": "Permission Rules", + "log_setting": "Login Authentication", + "setting": "Settings", + "system_management": "System Management", + "opt_log": "Operation Log", + "prediction": "Data prediction", + "analysis": "Data analysis", + "reset_pwd": "Reset Password", + "update_pwd": "Update Password", + "update_status": "Update Status", + "update_table_relation": "Change Table Relation" + }, + "i18n_table_not_exist": "Table not exist", + "i18n_variable": { + "name_exist": "Name exist", + "name": "Name", + "account": "Account", + "email": "Email" + } } \ No newline at end of file diff --git a/backend/locales/ko-KR.json b/backend/locales/ko-KR.json new file mode 100644 index 000000000..bccbfdbb9 --- /dev/null +++ b/backend/locales/ko-KR.json @@ -0,0 +1,198 @@ +{ + "i18n_default_workspace": "기본 작업 공간", + "i18n_ds_name_exist": "이름이 이미 존재합니다", + "i18n_concat_admin": "관리자에게 문의하세요!", + "i18n_exist": "{msg}이(가) 이미 존재합니다!", + "i18n_name": "이름", + "i18n_not_exist": "{msg}이(가) 존재하지 않습니다!", + "i18n_error": "{key} 오류!", + "i18n_miss_args": "{key} 매개변수가 누락되었습니다!", + "i18n_format_invalid": "{key} 형식이 올바르지 않습니다!", + "i18n_login": { + "account_pwd_error": "계정 또는 비밀번호가 잘못되었습니다!", + "no_associated_ws": "연결된 작업 공간이 없습니다, {msg}", + "user_disable": "계정이 비활성화되었습니다, {msg}", + "origin_error": "잘못된 로그인 방식입니다", + "prohibit_auto_create": "사용자 자동 생성이 금지되어 있습니다. 먼저 사용자를 동기화해 주세요", + "no_platform_user": "계정이 존재하지 않습니다. 먼저 사용자를 동기화하세요." + }, + "i18n_user": { + "account": "계정", + "email": "이메일", + "password": "비밀번호", + "language_not_support": "시스템이 [{key}] 언어를 지원하지 않습니다!", + "ws_miss": "현재 사용자가 [{ws}] 작업 공간에 속해 있지 않습니다!", + "name": "성명", + "status": "사용자 상태", + "origin": "사용자 출처", + "workspace": "작업 공간", + "role": "역할", + "platform_user_id": "외부 사용자 고유 식별자", + "administrator": "관리자", + "ordinary_member": "일반 멤버", + "status_enabled": "활성화됨", + "status_disabled": "비활성화됨", + "local_creation": "로컬 생성" + }, + "i18n_ws": { + "title": "작업 공간" + }, + "i18n_permission": { + "only_admin": "관리자만 호출할 수 있습니다!", + "no_permission": "{url}{msg} 호출 권한이 없습니다", + "authenticate_invalid": "인증 무효 【{msg}】", + "token_expired": "토큰이 만료됨", + "only_ws_admin": "워크스페이스 관리자만 사용 가능합니다!", + "permission_resource_limit": "권한이 없거나 리소스를 찾을 수 없습니다!" + }, + "i18n_llm": { + "validate_error": "검증 실패 [{msg}]", + "delete_default_error": "기본 모델 [{key}]을(를) 삭제할 수 없습니다!", + "miss_default": "기본 대형 언어 모델이 구성되지 않았습니다" + }, + "i18n_ds_invalid": "데이터 소스 연결이 무효합니다", + "i18n_ds_upload_error": "파일 업로드 실패", + "i18n_embedded": { + "invalid_origin": "도메인 이름 검증 실패 【{origin}】" + }, + "i18n_chat": { + "record_id_in_mcp": "응답 ID: " + }, + "i18n_terminology": { + "datasource_list_is_not_found": "데이터 소스 목록을 찾을 수 없습니다", + "datasource_id_not_found": "데이터 소스 ID: {key}를 찾을 수 없습니다", + "terminology_not_exists": "이 용어가 존재하지 않습니다", + "datasource_cannot_be_none": "데이터 소스는 비울 수 없습니다", + "cannot_be_repeated": "용어 이름과 동의어는 반복될 수 없습니다", + "exists_in_db": "용어 이름과 동의어가 이미 존재합니다", + "term_name": "용어 이름", + "term_description": "용어 설명", + "effective_data_sources": "유효 데이터 소스", + "all_data_sources": "모든 데이터 소스", + "synonyms": "동의어", + "term_name_template": "용어 이름 (필수)", + "term_description_template": "용어 설명 (필수)", + "effective_data_sources_template": "유효 데이터 소스 (여러 개 지원, \",\"로 구분)", + "all_data_sources_template": "모든 데이터 소스 (Y: 모든 데이터 소스에 적용, N: 지정된 데이터 소스에 적용)", + "synonyms_template": "동의어 (여러 개 지원, \",\"로 구분)", + "term_name_template_example_1": "용어1", + "term_description_template_example_1": "용어1 설명", + "effective_data_sources_template_example_1": "데이터소스1, 데이터소스2", + "synonyms_template_example_1": "동의어1, 동의어2", + "term_name_template_example_2": "용어2", + "term_description_template_example_2": "용어2 설명", + "synonyms_template_example_2": "동의어3", + "word_cannot_be_empty": "용어는 비울 수 없습니다", + "description_cannot_be_empty": "용어 설명은 비울 수 없습니다", + "datasource_not_found": "데이터 소스를 찾을 수 없음" + }, + "i18n_data_training": { + "datasource_cannot_be_none": "데이터 소스는 비울 수 없습니다", + "datasource_assistant_cannot_be_none": "데이터 소스와 고급 애플리케이션을 모두 비울 수 없습니다", + "data_training_not_exists": "이 예시가 존재하지 않습니다", + "exists_in_db": "이 질문이 이미 존재합니다", + "data_training": "SQL 예시 라이브러리", + "problem_description": "문제 설명", + "sample_sql": "예시 SQL", + "effective_data_sources": "유효 데이터 소스", + "advanced_application": "고급 애플리케이션", + "problem_description_template": "문제 설명 (필수)", + "sample_sql_template": "예시 SQL (필수)", + "effective_data_sources_template": "유효 데이터 소스", + "advanced_application_template": "고급 애플리케이션", + "problem_description_template_example": "TEST 테이블 내 모든 ID 조회", + "effective_data_sources_template_example": "유효 데이터소스1", + "advanced_application_template_example": "유효 고급 애플리케이션 이름", + "error_info": "오류 정보", + "question_cannot_be_empty": "질문은 비울 수 없습니다", + "description_cannot_be_empty": "예시 SQL은 비울 수 없습니다", + "datasource_not_found": "데이터 소스를 찾을 수 없음", + "advanced_application_not_found": "고급 애플리케이션을 찾을 수 없음" + }, + "i18n_custom_prompt": { + "exists_in_db": "템플릿 이름이 이미 존재합니다", + "not_exists": "이 템플릿이 존재하지 않습니다", + "prompt_word_name": "프롬프트 이름", + "prompt_word_content": "프롬프트 내용", + "effective_data_sources": "유효 데이터 소스", + "all_data_sources": "모든 데이터 소스", + "prompt_word_name_template": "프롬프트 이름 (필수)", + "prompt_word_content_template": "프롬프트 내용 (필수)", + "effective_data_sources_template": "유효 데이터 소스 (여러 개 지원, \",\"로 구분)", + "all_data_sources_template": "모든 데이터 소스 (Y: 모든 데이터 소스에 적용, N: 지정된 데이터 소스에 적용)", + "prompt_word_name_template_example1": "프롬프트1", + "prompt_word_content_template_example1": "프롬프트를 상세히 설명해 주세요", + "effective_data_sources_template_example1": "데이터소스1, 데이터소스2", + "prompt_word_name_template_example2": "프롬프트2", + "prompt_word_content_template_example2": "프롬프트를 상세히 설명해 주세요", + "name_cannot_be_empty": "이름은 비울 수 없습니다", + "prompt_cannot_be_empty": "프롬프트 내용은 비울 수 없습니다", + "type_cannot_be_empty": "유형은 비울 수 없습니다", + "datasource_not_found": "데이터 소스를 찾을 수 없음", + "datasource_cannot_be_none": "데이터 소스는 비울 수 없습니다" + }, + "i18n_excel_export": { + "data_is_empty": "폼 데이터가 비어 있어 데이터를 내보낼 수 없습니다" + }, + "i18n_excel_import": { + "col_num_not_match": "Excel 열 개수가 일치하지 않습니다" + }, + "i18n_authentication": { + "record_not_exist": "{msg} 기록이 존재하지 않습니다. 먼저 저장해 주세요!" + }, + "i18n_audit": { + "success": "성공", + "failed": "실패", + "system_log": "작업 로그", + "operation_type_name": "작업 유형", + "operation_detail_info": "작업 상세", + "user_name": "작업자", + "oid_name": "작업 공간", + "operation_status_name": "작업 상태", + "error_message": "오류 메시지", + "ip_address": "IP 주소", + "create_time": "작업 시간", + "chat": "지능형 질의", + "datasource": "데이터 소스", + "dashboard": "대시보드", + "member": "구성원", + "permission": "권한", + "terminology": "용어", + "data_training": "SQL 예제 라이브러리", + "prompt_words": "프롬프트 단어", + "user": "사용자", + "workspace": "작업 공간", + "ai_model": "AI 모델", + "application": "애플리케이션", + "theme": "외관 설정", + "create": "생성", + "delete": "삭제", + "update": "수정", + "edit": "편집", + "login": "로그인", + "export": "내보내기", + "import": "가져오기", + "add": "추가", + "create_or_update": "변경", + "api_key": "API 키", + "params_setting": "매개변수 설정", + "rules": "권한 규칙", + "log_setting": "로그인 인증", + "setting": "설정", + "system_management": "시스템 관리", + "opt_log": "작업 로그", + "prediction": "데이터 예측", + "analysis": "데이터 분석", + "reset_pwd": "비밀번호 재설정", + "update_pwd": "비밀번호 업데이트", + "update_status": "상태 업데이트", + "update_table_relation": "테이블 관계 변경" + }, + "i18n_table_not_exist": "현재 테이블이 존재하지 않습니다", + "i18n_variable": { + "name_exist": "변수 이름이 이미 존재합니다", + "name": "이름", + "account": "계정", + "email": "이메일" + } +} \ No newline at end of file diff --git a/backend/locales/zh-CN.json b/backend/locales/zh-CN.json index 77ea01aad..788e911e7 100644 --- a/backend/locales/zh-CN.json +++ b/backend/locales/zh-CN.json @@ -1,41 +1,198 @@ { - "i18n_default_workspace": "默认工作空间", - "i18n_ds_name_exist": "名称已存在", - "i18n_concat_admin": "请联系管理员!", - "i18n_exist": "{msg}已存在!", - "i18n_not_exist": "{msg}不存在!", - "i18n_error": "{key}错误!", - "i18n_miss_args": "缺失{key}参数!", - "i18n_format_invalid": "{key}格式不正确!", - "i18n_login": { - "account_pwd_error": "账号或密码错误!", - "no_associated_ws": "没有关联的工作空间,{msg}", - "user_disable": "账号已禁用,{msg}" - }, - "i18n_user": { - "account": "账号", - "email": "邮箱", - "password": "密码", - "language_not_support": "系统不支持[{key}]语言!", - "ws_miss": "当前用户不在工作空间[{ws}]中!" - }, - "i18n_ws": { - "title": "工作空间" - }, - "i18n_permission": { - "only_admin": "仅支持管理员调用!", - "no_permission": "无权调用{url}{msg}", - "authenticate_invalid": "认证无效【{msg}】", - "token_expired": "Token 已过期" - - }, - "i18n_llm": { - "validate_error": "校验失败[{msg}]", - "delete_default_error": "无法删除默认模型[{key}]!", - "miss_default": "尚未配置默认大语言模型" - }, - "i18n_ds_invalid": "数据源连接无效", - "i18n_embedded": { - "invalid_origin": "域名校验失败【{origin}】" - } + "i18n_default_workspace": "默认工作空间", + "i18n_ds_name_exist": "名称已存在", + "i18n_concat_admin": "请联系管理员!", + "i18n_exist": "{msg}已存在!", + "i18n_name": "名称", + "i18n_not_exist": "{msg}不存在!", + "i18n_error": "{key}错误!", + "i18n_miss_args": "缺失{key}参数!", + "i18n_format_invalid": "{key}格式不正确!", + "i18n_login": { + "account_pwd_error": "账号或密码错误!", + "no_associated_ws": "没有关联的工作空间,{msg}", + "user_disable": "账号已禁用,{msg}", + "origin_error": "登录方式错误", + "prohibit_auto_create": "禁止自动创建用户,请先同步用户", + "no_platform_user": "账号不存在,请先同步用户" + }, + "i18n_user": { + "account": "账号", + "email": "邮箱", + "password": "密码", + "language_not_support": "系统不支持[{key}]语言!", + "ws_miss": "当前用户不在工作空间[{ws}]中!", + "name": "姓名", + "status": "用户状态", + "origin": "用户来源", + "workspace": "工作空间", + "role": "角色", + "platform_user_id": "外部用户唯一标识", + "administrator": "管理员", + "ordinary_member": "普通成员", + "status_enabled": "已启用", + "status_disabled": "已禁用", + "local_creation": "本地创建" + }, + "i18n_ws": { + "title": "工作空间" + }, + "i18n_permission": { + "only_admin": "仅支持管理员调用!", + "no_permission": "无权调用{url}{msg}", + "authenticate_invalid": "认证无效【{msg}】", + "token_expired": "Token 已过期", + "only_ws_admin": "仅支持工作空间管理员调用!", + "permission_resource_limit": "没有操作权限或资源不存在!" + }, + "i18n_llm": { + "validate_error": "校验失败[{msg}]", + "delete_default_error": "无法删除默认模型[{key}]!", + "miss_default": "尚未配置默认大语言模型" + }, + "i18n_ds_invalid": "数据源连接无效", + "i18n_ds_upload_error": "上传文件失败", + "i18n_embedded": { + "invalid_origin": "域名校验失败【{origin}】" + }, + "i18n_chat": { + "record_id_in_mcp": "响应ID: " + }, + "i18n_terminology": { + "terminology_not_exists": "该术语不存在", + "datasource_cannot_be_none": "数据源不能为空", + "cannot_be_repeated": "术语名称,同义词不能重复", + "exists_in_db": "术语名称,同义词已存在", + "term_name": "术语名称", + "term_description": "术语描述", + "effective_data_sources": "生效数据源", + "all_data_sources": "所有数据源", + "synonyms": "同义词", + "term_name_template": "术语名称(必填)", + "term_description_template": "术语描述(必填)", + "effective_data_sources_template": "生效数据源(支持多个,用\",\"分割)", + "all_data_sources_template": "所有数据源(Y:应用到全部数据源,N:应用到指定数据源)", + "synonyms_template": "同义词(支持多个,用\",\"分割)", + "term_name_template_example_1": "术语1", + "term_description_template_example_1": "术语1描述", + "effective_data_sources_template_example_1": "生效数据源1, 生效数据源2", + "synonyms_template_example_1": "同义词1, 同义词2", + "term_name_template_example_2": "术语2", + "term_description_template_example_2": "术语2描述", + "synonyms_template_example_2": "同义词3", + "word_cannot_be_empty": "术语名称不能为空", + "description_cannot_be_empty": "术语描述不能为空", + "datasource_not_found": "找不到数据源" + }, + "i18n_data_training": { + "datasource_list_is_not_found": "数据源列表未找到", + "datasource_id_not_found": "数据源ID: {key} 未找到", + "datasource_cannot_be_none": "数据源不能为空", + "datasource_assistant_cannot_be_none": "数据源或高级应用不能都为空", + "data_training_not_exists": "该示例不存在", + "exists_in_db": "该问题已存在", + "data_training": "SQL 示例库", + "problem_description": "问题描述", + "sample_sql": "示例 SQL", + "effective_data_sources": "生效数据源", + "advanced_application": "高级应用", + "problem_description_template": "问题描述(必填)", + "sample_sql_template": "示例 SQL(必填)", + "effective_data_sources_template": "生效数据源", + "advanced_application_template": "高级应用", + "problem_description_template_example": "查询TEST表内所有ID", + "effective_data_sources_template_example": "生效数据源1", + "advanced_application_template_example": "生效高级应用名称", + "error_info": "错误信息", + "question_cannot_be_empty": "问题不能为空", + "description_cannot_be_empty": "示例 SQL 不能为空", + "datasource_not_found": "找不到数据源", + "advanced_application_not_found": "找不到高级应用" + }, + "i18n_custom_prompt": { + "exists_in_db": "模版名称已存在", + "not_exists": "该模版不存在", + "prompt_word_name": "提示词名称", + "prompt_word_content": "提示词内容", + "effective_data_sources": "生效数据源", + "all_data_sources": "所有数据源", + "prompt_word_name_template": "提示词名称(必填)", + "prompt_word_content_template": "提示词内容(必填)", + "effective_data_sources_template": "生效数据源(支持多个,用\",\"分割)", + "all_data_sources_template": "所有数据源(Y:应用到全部数据源,N:应用到指定数据源)", + "prompt_word_name_template_example1": "提示词1", + "prompt_word_content_template_example1": "详细描述你的提示词", + "effective_data_sources_template_example1": "生效数据源1, 生效数据源2", + "prompt_word_name_template_example2": "提示词2", + "prompt_word_content_template_example2": "详细描述你的提示词", + "name_cannot_be_empty": "名称不能为空", + "prompt_cannot_be_empty": "提示词内容不能为空", + "type_cannot_be_empty": "类型不能为空", + "datasource_not_found": "找不到数据源", + "datasource_cannot_be_none": "数据源不能为空" + }, + "i18n_excel_export": { + "data_is_empty": "表单数据为空,无法导出数据" + }, + "i18n_excel_import": { + "col_num_not_match": "EXCEL列数量不匹配" + }, + "i18n_authentication": { + "record_not_exist": "{msg} 记录不存在,请先保存!" + }, + "i18n_audit": { + "success": "成功", + "failed": "失败", + "system_log": "操作日志", + "operation_type_name": "操作类型", + "operation_detail_info": "操作详情", + "user_name": "操作用户", + "oid_name": "工作空间", + "operation_status_name": "操作状态", + "error_message": "错误信息", + "ip_address": "IP 地址", + "create_time": "操作时间", + "chat": "智能问数", + "datasource": "数据源", + "dashboard": "仪表板", + "member": "成员", + "permission": "权限", + "terminology": "术语", + "data_training": "SQL 示例库", + "prompt_words": "提示词", + "user": "用户", + "workspace": "工作空间", + "ai_model": "AI 模型", + "application": "应用", + "theme": "外观配置", + "create": "新建", + "delete": "删除", + "update": "更新", + "edit": "编辑", + "login": "登录", + "export": "导出", + "import": "导入", + "add": "添加", + "create_or_update": "变更", + "api_key": "API Key", + "params_setting": "参数配置", + "rules": "权限规则", + "log_setting": "登录认证", + "setting": "设置", + "system_management": "系统管理", + "opt_log": "操作日志", + "prediction": "数据预测", + "analysis": "数据分析", + "reset_pwd": "重置密码", + "update_pwd": "更新密码", + "update_status": "更新状态", + "update_table_relation": "变更表关系" + }, + "i18n_table_not_exist": "当前表不存在", + "i18n_variable": { + "name_exist": "变量名称已存在", + "name": "姓名", + "account": "账号", + "email": "邮箱" + } } \ No newline at end of file diff --git a/backend/locales/zh-TW.json b/backend/locales/zh-TW.json new file mode 100644 index 000000000..6b37aca9f --- /dev/null +++ b/backend/locales/zh-TW.json @@ -0,0 +1,198 @@ +{ + "i18n_default_workspace": "默認工作空間", + "i18n_ds_name_exist": "名稱已存在", + "i18n_concat_admin": "請聯繫管理員!", + "i18n_exist": "{msg}已存在!", + "i18n_name": "名稱", + "i18n_not_exist": "{msg}不存在!", + "i18n_error": "{key}錯誤!", + "i18n_miss_args": "缺失{key}參數!", + "i18n_format_invalid": "{key}格式不正確!", + "i18n_login": { + "account_pwd_error": "帳號或密碼錯誤!", + "no_associated_ws": "沒有關聯的工作空間,{msg}", + "user_disable": "帳號已禁用,{msg}", + "origin_error": "登入方式錯誤", + "prohibit_auto_create": "禁止自動建立用戶,請先同步用戶", + "no_platform_user": "帳號不存在,請先同步用戶" + }, + "i18n_user": { + "account": "帳號", + "email": "郵箱", + "password": "密碼", + "language_not_support": "系統不支援[{key}]語言!", + "ws_miss": "當前用戶不在工作空間[{ws}]中!", + "name": "姓名", + "status": "用戶狀態", + "origin": "用戶來源", + "workspace": "工作空間", + "role": "角色", + "platform_user_id": "外部用戶唯一標識", + "administrator": "管理員", + "ordinary_member": "普通成員", + "status_enabled": "已啟用", + "status_disabled": "已禁用", + "local_creation": "本地建立" + }, + "i18n_ws": { + "title": "工作空間" + }, + "i18n_permission": { + "only_admin": "僅支援管理員調用!", + "no_permission": "無權調用{url}{msg}", + "authenticate_invalid": "認證無效【{msg}】", + "token_expired": "Token 已過期", + "only_ws_admin": "僅支援工作空間管理員調用!", + "permission_resource_limit": "沒有操作權限或資源不存在!" + }, + "i18n_llm": { + "validate_error": "校驗失敗[{msg}]", + "delete_default_error": "無法刪除默認模型[{key}]!", + "miss_default": "尚未配置默認大語言模型" + }, + "i18n_ds_invalid": "數據源連接無效", + "i18n_ds_upload_error": "上傳文件失敗", + "i18n_embedded": { + "invalid_origin": "網域校驗失敗【{origin}】" + }, + "i18n_chat": { + "record_id_in_mcp": "響應ID: " + }, + "i18n_terminology": { + "terminology_not_exists": "該術語不存在", + "datasource_cannot_be_none": "數據源不能為空", + "cannot_be_repeated": "術語名稱,同義詞不能重複", + "exists_in_db": "術語名稱,同義詞已存在", + "term_name": "術語名稱", + "term_description": "術語描述", + "effective_data_sources": "生效數據源", + "all_data_sources": "所有數據源", + "synonyms": "同義詞", + "term_name_template": "術語名稱(必填)", + "term_description_template": "術語描述(必填)", + "effective_data_sources_template": "生效數據源(支援多個,用\",\"分割)", + "all_data_sources_template": "所有數據源(Y:應用到全部數據源,N:應用到指定數據源)", + "synonyms_template": "同義詞(支援多個,用\",\"分割)", + "term_name_template_example_1": "術語1", + "term_description_template_example_1": "術語1描述", + "effective_data_sources_template_example_1": "生效數據源1, 生效數據源2", + "synonyms_template_example_1": "同義詞1, 同義詞2", + "term_name_template_example_2": "術語2", + "term_description_template_example_2": "術語2描述", + "synonyms_template_example_2": "同義詞3", + "word_cannot_be_empty": "術語名稱不能為空", + "description_cannot_be_empty": "術語描述不能為空", + "datasource_not_found": "找不到數據源" + }, + "i18n_data_training": { + "datasource_list_is_not_found": "數據源列表未找到", + "datasource_id_not_found": "數據源ID: {key} 未找到", + "datasource_cannot_be_none": "數據源不能為空", + "datasource_assistant_cannot_be_none": "數據源或高級應用不能都為空", + "data_training_not_exists": "該範例不存在", + "exists_in_db": "該問題已存在", + "data_training": "SQL 範例庫", + "problem_description": "問題描述", + "sample_sql": "範例 SQL", + "effective_data_sources": "生效數據源", + "advanced_application": "高級應用", + "problem_description_template": "問題描述(必填)", + "sample_sql_template": "範例 SQL(必填)", + "effective_data_sources_template": "生效數據源", + "advanced_application_template": "高級應用", + "problem_description_template_example": "查詢TEST表內所有ID", + "effective_data_sources_template_example": "生效數據源1", + "advanced_application_template_example": "生效高級應用名稱", + "error_info": "錯誤訊息", + "question_cannot_be_empty": "問題不能為空", + "description_cannot_be_empty": "範例 SQL 不能為空", + "datasource_not_found": "找不到數據源", + "advanced_application_not_found": "找不到高級應用" + }, + "i18n_custom_prompt": { + "exists_in_db": "模版名稱已存在", + "not_exists": "該模版不存在", + "prompt_word_name": "提示詞名稱", + "prompt_word_content": "提示詞內容", + "effective_data_sources": "生效數據源", + "all_data_sources": "所有數據源", + "prompt_word_name_template": "提示詞名稱(必填)", + "prompt_word_content_template": "提示詞內容(必填)", + "effective_data_sources_template": "生效數據源(支援多個,用\",\"分割)", + "all_data_sources_template": "所有數據源(Y:應用到全部數據源,N:應用到指定數據源)", + "prompt_word_name_template_example1": "提示詞1", + "prompt_word_content_template_example1": "詳細描述你的提示詞", + "effective_data_sources_template_example1": "生效數據源1, 生效數據源2", + "prompt_word_name_template_example2": "提示詞2", + "prompt_word_content_template_example2": "詳細描述你的提示詞", + "name_cannot_be_empty": "名稱不能為空", + "prompt_cannot_be_empty": "提示詞內容不能為空", + "type_cannot_be_empty": "類型不能為空", + "datasource_not_found": "找不到數據源", + "datasource_cannot_be_none": "數據源不能為空" + }, + "i18n_excel_export": { + "data_is_empty": "表單數據為空,無法導出數據" + }, + "i18n_excel_import": { + "col_num_not_match": "EXCEL列數量不匹配" + }, + "i18n_authentication": { + "record_not_exist": "{msg} 記錄不存在,請先儲存!" + }, + "i18n_audit": { + "success": "成功", + "failed": "失敗", + "system_log": "操作日誌", + "operation_type_name": "操作類型", + "operation_detail_info": "操作詳情", + "user_name": "操作用戶", + "oid_name": "工作空間", + "operation_status_name": "操作狀態", + "error_message": "錯誤訊息", + "ip_address": "IP 地址", + "create_time": "操作時間", + "chat": "智能問數", + "datasource": "數據源", + "dashboard": "儀表板", + "member": "成員", + "permission": "權限", + "terminology": "術語", + "data_training": "SQL 範例庫", + "prompt_words": "提示詞", + "user": "用戶", + "workspace": "工作空間", + "ai_model": "AI 模型", + "application": "應用", + "theme": "外觀配置", + "create": "新建", + "delete": "刪除", + "update": "更新", + "edit": "編輯", + "login": "登入", + "export": "匯出", + "import": "匯入", + "add": "新增", + "create_or_update": "變更", + "api_key": "API Key", + "params_setting": "參數配置", + "rules": "權限規則", + "log_setting": "登入認證", + "setting": "設定", + "system_management": "系統管理", + "opt_log": "操作日誌", + "prediction": "數據預測", + "analysis": "數據分析", + "reset_pwd": "重置密碼", + "update_pwd": "更新密碼", + "update_status": "更新狀態", + "update_table_relation": "變更表關係" + }, + "i18n_table_not_exist": "當前表不存在", + "i18n_variable": { + "name_exist": "變數名稱已存在", + "name": "姓名", + "account": "帳號", + "email": "郵箱" + } +} \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index 7a639e5ad..a8bab9743 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,20 +1,34 @@ +import os +from typing import Dict, Any + import sqlbot_xpack from alembic.config import Config -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.concurrency import asynccontextmanager +from fastapi.openapi.utils import get_openapi +from fastapi.responses import JSONResponse from fastapi.routing import APIRoute from fastapi.staticfiles import StaticFiles from fastapi_mcp import FastApiMCP +from starlette.datastructures import MutableHeaders from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.middleware.cors import CORSMiddleware +from starlette.middleware.base import BaseHTTPMiddleware from alembic import command from apps.api import api_router +from apps.swagger.i18n import PLACEHOLDER_PREFIX, tags_metadata, i18n_list +from apps.swagger.i18n import get_translation, DEFAULT_LANG +from apps.system.crud.aimodel_manage import async_model_info from apps.system.crud.assistant import init_dynamic_cors from apps.system.middleware.auth import TokenMiddleware +from apps.system.schemas.permission import RequestContextMiddleware +from common.audit.schemas.request_context import RequestContextMiddlewareCommon from common.core.config import settings from common.core.response_middleware import ResponseMiddleware, exception_handler from common.core.sqlbot_cache import init_sqlbot_cache +from common.utils.embedding_threads import fill_empty_terminology_embeddings, fill_empty_data_training_embeddings, \ + fill_empty_table_and_ds_embeddings from common.utils.utils import SQLBotLogUtil @@ -23,13 +37,30 @@ def run_migrations(): command.upgrade(alembic_cfg, "head") +def init_terminology_embedding_data(): + fill_empty_terminology_embeddings() + + +def init_data_training_embedding_data(): + fill_empty_data_training_embeddings() + + +def init_table_and_ds_embedding(): + fill_empty_table_and_ds_embeddings() + + @asynccontextmanager async def lifespan(app: FastAPI): run_migrations() init_sqlbot_cache() init_dynamic_cors(app) + init_terminology_embedding_data() + init_data_training_embedding_data() + init_table_and_ds_embedding() SQLBotLogUtil.info("✅ SQLBot 初始化完成") await sqlbot_xpack.core.clean_xpack_cache() + await async_model_info() # 异步加密已有模型的密钥和地址 + await sqlbot_xpack.core.monitor_app(app) yield SQLBotLogUtil.info("SQLBot 应用关闭") @@ -41,14 +72,129 @@ def custom_generate_unique_id(route: APIRoute) -> str: app = FastAPI( title=settings.PROJECT_NAME, - openapi_url=f"{settings.API_V1_STR}/openapi.json", + openapi_url=f"{settings.CONTEXT_PATH}/openapi.json" if settings.SQLBOT_DOC_ENABLED else None, generate_unique_id_function=custom_generate_unique_id, - lifespan=lifespan + lifespan=lifespan, + docs_url=None, + redoc_url=None ) + +class McpClientIpForwardMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + client_host = request.client.host if request.client else None + if client_host: + headers = MutableHeaders(scope=request.scope) + if not headers.get("x-real-ip"): + headers["x-real-ip"] = client_host + if not headers.get("x-forwarded-for"): + headers["x-forwarded-for"] = client_host + if not headers.get("x-client-ip"): + headers["x-client-ip"] = client_host + return await call_next(request) + +# cache docs for different text +_openapi_cache: Dict[str, Dict[str, Any]] = {} + +# replace placeholder +def replace_placeholders_in_schema(schema: Dict[str, Any], trans: Dict[str, str]) -> None: + """ + search OpenAPI schema,replace PLACEHOLDER_xxx to text。 + """ + if isinstance(schema, dict): + for key, value in schema.items(): + if isinstance(value, str) and value.startswith(PLACEHOLDER_PREFIX): + placeholder_key = value[len(PLACEHOLDER_PREFIX):] + schema[key] = trans.get(placeholder_key, value) + else: + replace_placeholders_in_schema(value, trans) + elif isinstance(schema, list): + for item in schema: + replace_placeholders_in_schema(item, trans) + + + +# OpenAPI build +def get_language_from_request(request: Request) -> str: + # get param from query ?lang=zh + lang = request.query_params.get("lang") + if lang in i18n_list: + return lang + # get lang from Accept-Language Header + accept_lang = request.headers.get("accept-language", "") + if "zh" in accept_lang.lower(): + return "zh" + return DEFAULT_LANG + + +def generate_openapi_for_lang(lang: str) -> Dict[str, Any]: + if lang in _openapi_cache: + return _openapi_cache[lang] + + # tags metadata + trans = get_translation(lang) + localized_tags = [] + for tag in tags_metadata: + desc = tag["description"] + if desc.startswith(PLACEHOLDER_PREFIX): + key = desc[len(PLACEHOLDER_PREFIX):] + desc = trans.get(key, desc) + localized_tags.append({ + "name": tag["name"], + "description": desc + }) + + # 1. create OpenAPI + openapi_schema = get_openapi( + title="SQLBot API Document" if lang == "en" else "SQLBot API 文档", + version="1.0.0", + routes=app.routes, + tags=localized_tags + ) + + # openapi version + openapi_schema.setdefault("openapi", "3.1.0") + + # 2. get trans for lang + trans = get_translation(lang) + + # 3. replace placeholder + replace_placeholders_in_schema(openapi_schema, trans) + + # 4. cache + _openapi_cache[lang] = openapi_schema + return openapi_schema + + + +# custom /openapi.json and /docs +if settings.SQLBOT_DOC_ENABLED: + @app.get(f"{settings.CONTEXT_PATH}/openapi.json", include_in_schema=False) + async def custom_openapi(request: Request): + lang = get_language_from_request(request) + schema = generate_openapi_for_lang(lang) + return JSONResponse(schema) + + + @app.get(f"{settings.CONTEXT_PATH}/docs", include_in_schema=False) + async def custom_swagger_ui(request: Request): + lang = get_language_from_request(request) + from fastapi.openapi.docs import get_swagger_ui_html + return get_swagger_ui_html( + openapi_url=f"./openapi.json?lang={lang}", + title="SQLBot API Docs", + swagger_favicon_url="https://fastapi.tiangolo.com/img/favicon.png", + swagger_js_url="./swagger-ui-bundle.js", + swagger_css_url="./swagger-ui.css", + ) + + mcp_app = FastAPI() +mcp_app.add_middleware(McpClientIpForwardMiddleware) # mcp server, images path -mcp_app.mount("/images", StaticFiles(directory=settings.MCP_IMAGE_PATH), name="images") +images_path = settings.MCP_IMAGE_PATH +os.makedirs(images_path, exist_ok=True) +mcp_app.mount("/images", StaticFiles(directory=images_path), name="images") mcp = FastApiMCP( app, @@ -56,7 +202,8 @@ def custom_generate_unique_id(route: APIRoute) -> str: description="SQLBot MCP Server", describe_all_responses=True, describe_full_response_schema=True, - include_operations=["get_datasource_list", "get_model_list", "mcp_question", "mcp_start"] + include_operations=["mcp_datasource_list", "get_model_list", "mcp_question", "mcp_start", "mcp_assistant", "mcp_ws_list", "access_token"], + headers=["Authorization", "X-Forwarded-For", "X-Real-IP", "CF-Connecting-IP", "X-Client-IP"] ) mcp.mount(mcp_app) @@ -73,6 +220,8 @@ def custom_generate_unique_id(route: APIRoute) -> str: app.add_middleware(TokenMiddleware) app.add_middleware(ResponseMiddleware) +app.add_middleware(RequestContextMiddleware) +app.add_middleware(RequestContextMiddlewareCommon) app.include_router(api_router, prefix=settings.API_V1_STR) # Register exception handlers diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 53680a0be..1c38a592d 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,10 +1,10 @@ [project] name = "sqlbot" -version = "1.0.0" +version = "1.10.0" description = "" -requires-python = ">=3.10,<3.13" +requires-python = "==3.11.*" dependencies = [ - "fastapi[standard]<1.0.0,>=0.114.2", + "fastapi[standard]<1.0.0,>=0.115.12", "python-multipart<1.0.0,>=0.0.7", "passlib[bcrypt]<2.0.0,>=1.7.4", "tenacity<9.0.0,>=8.2.3", @@ -23,7 +23,10 @@ dependencies = [ "langchain-core>=0.3,<0.4", "langchain-openai>=0.3,<0.4", "langchain-community>=0.3,<0.4", + "langchain-huggingface>=0.2.0", + "sentence-transformers>=4.0.2", "langgraph>=0.3,<0.4", + "pgvector>=0.4.1", "dashscope>=1.14.0,<2.0.0", "pymysql (>=1.1.1,<2.0.0)", "cryptography (>=44.0.3,<45.0.0)", @@ -36,16 +39,47 @@ dependencies = [ "pyyaml (>=6.0.2,<7.0.0)", "fastapi-mcp (>=0.3.4,<0.4.0)", "tabulate>=0.9.0", - "sqlbot-xpack==0.0.3.13", + "sqlbot-xpack>=0.0.5.32,<0.0.6.0", "fastapi-cache2>=0.2.2", "sqlparse>=0.5.3", "redis>=6.2.0", "xlsxwriter>=3.2.5", "python-calamine>=0.4.0", + "xlrd>=2.0.2", + "clickhouse-sqlalchemy>=0.3.2", + "dicttoxml>=1.7.16", + "dmpython==2.5.22; platform_system != 'Darwin'", + "redshift-connector>=2.1.8", + "elasticsearch[requests] (>=7.10,<8.0)", + "ldap3>=2.9.1", + "sqlglot>=28.6.0", + "numpy==2.3.5", + "pyhive[hive_pure_sasl]>=0.7.0", + "thrift-sasl", + "dbutils>=3.1.2", ] + +[project.optional-dependencies] +cpu = [ + "torch>=2.7.0", +] +cu128 = [ + "torch>=2.7.0", +] + +[[tool.uv.index]] +name = "pytorch-cpu" +url = "https://download.pytorch.org/whl/cpu" +explicit = true + +[[tool.uv.index]] +name = "pytorch-cu128" +url = "https://download.pytorch.org/whl/cu128" +explicit = true + [[tool.uv.index]] name = "default" -url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" +url = "http://mirrors.aliyun.com/pypi/simple" default = true [[tool.uv.index]] @@ -55,8 +89,18 @@ explicit = true [tool.uv.sources] sqlbot-xpack = { index = "testpypi" } +torch = [ + { index = "pytorch-cpu", extra = "cpu" }, + { index = "pytorch-cu128", extra = "cu128" }, +] [tool.uv] +conflicts = [ + [ + { extra = "cpu" }, + { extra = "cu128" }, + ], +] dev-dependencies = [ "pytest<8.0.0,>=7.4.3", "mypy<2.0.0,>=1.8.0", @@ -88,20 +132,20 @@ exclude = ["alembic"] [tool.ruff.lint] select = [ - "E", # pycodestyle errors - "W", # pycodestyle warnings - "F", # pyflakes - "I", # isort - "B", # flake8-bugbear - "C4", # flake8-comprehensions - "UP", # pyupgrade + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade "ARG001", # unused arguments in functions ] ignore = [ - "E501", # line too long, handled by black - "B008", # do not perform function calls in argument defaults - "W191", # indentation contains tabs - "B904", # Allow raising exceptions without from e, for HTTPException + "E501", # line too long, handled by black + "B008", # do not perform function calls in argument defaults + "W191", # indentation contains tabs + "B904", # Allow raising exceptions without from e, for HTTPException ] [tool.ruff.lint.pyupgrade] diff --git a/backend/scripts/format.sh b/backend/scripts/format.sh index 7be2f8120..450634447 100755 --- a/backend/scripts/format.sh +++ b/backend/scripts/format.sh @@ -1,5 +1,5 @@ #!/bin/sh -e set -x -ruff check app scripts --fix -ruff format app scripts +ruff check apps scripts common --fix +ruff format apps scripts common diff --git a/backend/template.yaml b/backend/template.yaml deleted file mode 100644 index f5a9dbf9c..000000000 --- a/backend/template.yaml +++ /dev/null @@ -1,255 +0,0 @@ -template: - sql: - system: | - ### 请使用语言:{lang} 回答,若有深度思考过程,则思考过程也需要使用 {lang} 输出 - - 任务: - 根据给定的表结构(M-Schema)和用户问题生成符合{engine}数据库引擎规范的sql语句,以及sql中所用到的表名(不要包含schema和database,用数组返回)。 - 你必须遵守以下规则: - - 只能生成查询用的sql语句,不得生成增删改相关或操作数据库以及操作数据库数据的sql - - 不要编造没有提供给你的表结构 - - 生成的SQL必须符合{engine}的规范。 - - 若用户要求执行某些sql,若此sql不是查询数据,而是增删改相关或操作数据库以及操作数据库数据等操作,则直接回答: - {{"success":false,"message":"抱歉,我不能执行您指定的SQL语句。"}} - - 你的回答必须使用如下JSON格式返回: - {{"success":true,"sql":"生成的SQL语句","tables":["表名1","表名2",...]}} - - 问题与生成SQL无关时,直接回答: - {{"success":false,"message":"抱歉,我无法回答您的问题。"}} - - 如果根据提供的表结构不能生成符合问题与条件的SQL,回答: - {{"success":false,"message":"无法生成SQL的原因"}} - - 如果问题是图表展示相关且与生成SQL查询无关时,请参考上一次回答的SQL来生成SQL - - 如果问题是图表展示相关,可参考的图表类型为表格(table)、柱状图(column)、条形图(bar)、折线图(line)或饼图(pie),返回的JSON: - {{"success":true,"sql":"生成的SQL语句","chart-type":"选择的图表类型(table/column/bar/line/pie)","tables":["表名1","表名2",...]}} - - 提问中如果有涉及数据源名称或数据源描述的内容,则忽略数据源的信息,直接根据剩余内容生成SQL - - 根据表结构生成SQL语句,需给每个表名生成一个别名(不要加AS)。 - - SQL查询中不能使用星号(*),必须明确指定字段名. - - SQL查询的字段名不要自动翻译,别名必须为英文。 - - SQL查询的字段若是函数字段,如 COUNT(),CAST() 等,必须加上别名 - - 计算占比,百分比类型字段,保留两位小数,以%结尾。 - - 生成SQL时,必须避免关键字冲突。 - - 如数据库引擎是 PostgreSQL、Oracle,则在schema、表名、字段名、别名外层加双引号; - - 如数据库引擎是 MySQL,则在表名、字段名、别名外层加反引号; - - 如数据库引擎是 Microsoft SQL Server,则在schema、表名、字段名、别名外层加方括号。 - - 以PostgreSQL为例,查询Schema为TEST表TABLE下所有数据,则生成的SQL为: - SELECT "id" FROM "TEST"."TABLE" - - 注意在表名外双引号的位置,千万不要生成为: - SELECT "id" FROM "TEST.TABLE" - - 如果生成SQL的字段内有时间格式的字段(重要): - - 若提问中没有指定查询顺序,则默认按时间升序排序 - - 若提问是时间,且没有指定具体格式,则格式化为yyyy-MM-dd HH:mm:ss的格式 - - 若提问是日期,且没有指定具体格式,则格式化为yyyy-MM-dd的格式 - - 若提问是年月,且没有指定具体格式,则格式化为yyyy-MM的格式 - - 若提问是年,且没有指定具体格式,则格式化为yyyy的格式 - - 生成的格式化语法需要适配对应的数据库引擎。 - - 生成的SQL查询结果可以用来进行图表展示,需要注意排序字段的排序优先级,例如: - - 柱状图或折线图:适合展示在横轴的字段优先排序,若SQL包含分类字段,则分类字段次一级排序 - - ### M-Schema格式简单的解释如下: - ``` - 【DB_ID】 [Database名] - 【Schema】 - # Table: [Database名].[Table名], [表描述(若没有则为空)] - [ - ([字段名1]:[字段1的类型], [字段1的描述(这一行的逗号后都是描述,若没有则为空)]), - ([字段名2]:[字段2的类型], [字段2的描述(这一行的逗号后都是描述,若没有则为空)]), - ([字段名3]:[字段3的类型], [字段3的描述(这一行的逗号后都是描述,若没有则为空)]), - ... - ] - ``` - - ### 提供表结构如下: - {schema} - - ### 响应, 请直接返回JSON结果: - ```json - - user: | - ### 问题: - {question} - - ### 其他规则: - {rule} - chart: - system: | - ### 请使用语言:{lang} 回答,若有深度思考过程,则思考过程也需要使用 {lang} 输出 - - ### 说明: - 您的任务是通过给定的问题和SQL生成 JSON 以进行数据可视化。 - 请遵守以下规则: - - 如果需要表格,则生成的 JSON 格式应为: - {{"type":"table", "title": "标题", "columns": [{{"name":"{lang}字段名1", "value": "SQL 查询列 1(有别名用别名,去掉外层的反引号、双引号、方括号)"}}, {{"name": "{lang}字段名 2", "value": "SQL 查询列 2(有别名用别名,去掉外层的反引号、双引号、方括号)"}}]}} - 必须从 SQL 查询列中提取“columns”。 - - 如果需要柱状图,则生成的 JSON 格式应为(如果有分类则在JSON中返回series): - {{"type":"column", "title": "标题", "axis": {{"x": {{"name":"x轴的{lang}名称", "value": "SQL 查询 x 轴的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}, "y": {{"name":"y轴的{lang}名称","value": "SQL 查询 y 轴的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}, "series": {{"name":"分类的{lang}名称","value":"SQL 查询分类的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}}}}} - 必须从 SQL 查询列中提取“x”和“y”。 - - 如果需要条形图,则生成的 JSON 格式应为(如果有分类则在JSON中返回series),条形图相当于是旋转后的柱状图,因此 x 轴仍为维度轴,y 轴仍为指标轴: - {{"type":"bar", "title": "标题", "axis": {{"x": {{"name":"x轴的{lang}名称", "value": "SQL 查询 x 轴的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}, "y": {{"name":"y轴的{lang}名称","value": "SQL 查询 y 轴的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}, "series": {{"name":"分类的{lang}名称","value":"SQL 查询分类的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}}}}} - 必须从 SQL 查询列中提取“x”和“y”。 - - 如果需要折线图,则生成的 JSON 格式应为(如果有分类则在JSON中返回series): - {{"type":"line", "title": "标题", "axis": {{"x": {{"name":"x轴的{lang}名称","value": "SQL 查询 x 轴的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}, "y": {{"name":"y轴的{lang}名称","value": "SQL 查询 y 轴的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}, "series": {{"name":"分类的{lang}名称","value":"SQL 查询分类的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}}}}} - 其中“x”和“y”必须从SQL查询列中提取。 - - 如果需要饼图,则生成的 JSON 格式应为: - {{"type":"pie", "title": "标题", "axis": {{"y": {{"name":"值轴的{lang}名称","value":"SQL 查询数值的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}, "series": {{"name":"分类的{lang}名称","value":"SQL 查询分类的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}}}}} - 其中“y”和“series”必须从SQL查询列中提取。 - - 如果答案未知或者与生成JSON无关,则生成的 JSON 格式应为: - {{"type":"error", "reason": "抱歉,我无法回答您的问题。"}} - - JSON中生成的标题需要尽量精简 - - ### 示例: - 如果 SQL 为: SELECT products_sales_data.category, AVG(products_sales_data.price) AS average_price FROM products_sales_data GROUP BY products_sales_data.category; - 问题是:每个商品分类的平均价格 - 则生成的 JSON 可以是: {{"type":"table", "title": "每个商品分类的平均价格", "columns": [{{"name":"商品分类","value":"category"}}, {{"name":"平均价格","value":"average_price"}}]}} - - ### 响应, 请直接返回JSON结果: - ```json - - user: | - ### SQL: - {sql} - - ### 问题: - {question} - - ### 其他规则: - {rule} - guess: - system: | - ### 请使用语言:{lang} 回答,不需要输出深度思考过程 - - ### 说明: - 您的任务是根据给定的表结构,用户问题以及以往用户提问,推测用户接下来可能提问的1-4个问题。 - 请遵循以下规则: - - 推测的问题需要与提供的表结构相关,生成的提问例子如:["查询所有用户数据","使用饼图展示各产品类型的占比","使用折线图展示销售额趋势",...] - - 推测问题如果涉及图形展示,支持的图形类型为:表格(table)、柱状图(column)、条形图(bar)、折线图(line)或饼图(pie) - - 推测的问题不能与当前用户问题重复 - - 推测的问题必须与给出的表结构相关 - - 若有以往用户提问列表,则根据以往用户提问列表,推测用户最频繁提问的问题,加入到你生成的推测问题中 - - 忽略“重新生成”想关的问题 - - 如果用户没有提问且没有以往用户提问,则仅根据提供的表结构推测问题 - - 生成的推测问题使用JSON格式返回: - ["推测问题1", "推测问题2", "推测问题3", "推测问题4"] - - 最多返回4个你推测出的结果 - - 若无法推测,则返回空数据JSON: - [] - - 若你的给出的JSON不是{lang}的,则必须翻译为{lang} - - ### 响应, 请直接返回JSON结果: - ```json - - user: | - ### 表结构: - {schema} - - ### 当前问题: - {question} - - ### 以往提问: - {old_questions} - analysis: - system: | - ### 请使用语言:{lang} 回答,若有深度思考过程,则思考过程也需要使用 {lang} 输出 - - ### 说明: - 你是一个数据分析师,你的任务是根据给定的数据分析数据,并给出你的分析结果。 - user: | - ### 字段(字段别名): - {fields} - - ### 数据: - {data} - predict: - system: | - ### 请使用语言:{lang} 回答,若有深度思考过程,则思考过程也需要使用 {lang} 输出 - - ### 说明: - 你是一个数据分析师,你的任务是根据给定的数据进行数据预测,我将以JSON格式给你一组数据,你帮我预测之后的数据(一段可以展示趋势的数据,至少2个周期),用json数组的格式返回,返回的格式需要与传入的数据格式保持一致。 - ```json - - 无法预测或者不支持预测的数据请直接返回(不需要返回JSON格式,需要翻译为 {lang} 输出):"抱歉,该数据无法进行预测。(有原因则返回无法预测的原因)" - 如果可以预测,则不需要返回原有数据,直接返回预测的部份 - user: | - ### 字段(字段别名): - {fields} - - ### 数据: - {data} - datasource: - system: | - ### 请使用语言:{lang} 回答 - - ### 说明: - 你是一个数据分析师,你需要根据用户的提问,以及提供的数据源列表(格式为JSON数组:[{{"id": 数据源ID1,"name":"数据源名称1","description":"数据源描述1"}},{{"id": 数据源ID2,"name":"数据源名称2","description":"数据源描述2"}}]),根据名称和描述找出最符合用户提问的数据源,这个数据源后续将被用来进行数据的分析 - - ### 要求: - - 以JSON格式返回你找到的符合提问的数据源ID,格式为:{{"id": 符合要求的数据源ID}} - - 如果匹配到多个数据源,则只需要返回其中一个即可 - - 如果没有符合要求的数据源,则返回:{{"fail":"没有找到匹配的数据源"}} - - 不需要思考过程,请直接返回JSON结果 - - ### 响应, 请直接返回JSON结果: - ```json - user: | - ### 数据源列表: - {data} - - ### 问题: - {question} - permissions: - system: | - ### 请使用语言:{lang} 回答 - - ### 说明: - 提供给你一句SQL和一组表的过滤条件,从这组表的过滤条件中找出SQL中用到的表所对应的过滤条件,将用到的表所对应的过滤条件添加到提供给你的SQL中(不要替换SQL中原有的条件),生成符合{engine}数据库引擎规范的新SQL语句(如果过滤条件为空则无需处理)。 - 表的过滤条件json格式如下: - [{{"table":"表名","filter":"过滤条件"}},...] - 你必须遵守以下规则: - - 生成的SQL必须符合{engine}的规范。 - - 不要替换原来SQL中的过滤条件,将新过滤条件添加到SQL中,生成一个新的sql。 - - 如果存在冗余的过滤条件则进行去重后再生成新SQL。 - - 给过滤条件中的字段前加上表别名(如果没有表别名则加表名),如:table.field。 - - 生成SQL时,必须避免关键字冲突: - - 如数据库引擎是 PostgreSQL、Oracle,则在schema、表名、字段名、别名外层加双引号; - - 如数据库引擎是 MySQL,则在表名、字段名、别名外层加反引号; - - 如数据库引擎是 Microsoft SQL Server,则在schema、表名、字段名、别名外层加方括号。 - - 生成的SQL使用JSON格式返回: - {{"success":true,"sql":"生成的SQL语句"}} - - 如果不能生成SQL,回答: - {{"success":false,"message":"无法生成SQL的原因"}} - - ### 响应, 请直接返回JSON结果: - ```json - - user: | - ### sql: - {sql} - - ### 过滤条件: - {filter} - dynamic_sql: - system: | - ### 请使用语言:{lang} 回答 - - ### 说明: - 提供给你一句SQL和一组子查询映射表,你需要将给定的SQL查询中的表名替换为对应的子查询。请严格保持原始SQL的结构不变,只替换表引用部分,生成符合{engine}数据库引擎规范的新SQL语句。 - - 子查询映射表标记为sub_query,格式为[{{"table":"表名","query":"子查询语句"}},...] - 你必须遵守以下规则: - - 生成的SQL必须符合{engine}的规范。 - - 不要替换原来SQL中的过滤条件。 - - 完全匹配表名(注意大小写敏感)。 - - 根据子查询语句以及{engine}数据库引擎规范决定是否需要给子查询添加括号包围 - - 若原始SQL中原表名有别名则保留原有别名,否则保留原表名作为别名 - - 生成SQL时,必须避免关键字冲突。 - - 生成的SQL使用JSON格式返回: - {{"success":true,"sql":"生成的SQL语句"}} - - 如果不能生成SQL,回答: - {{"success":false,"message":"无法生成SQL的原因"}} - - ### 响应, 请直接返回JSON结果: - ```json - - user: | - ### sql: - {sql} - - ### 子查询映射表: - {sub_query} diff --git a/backend/templates/sql_examples/AWS_Redshift.yaml b/backend/templates/sql_examples/AWS_Redshift.yaml new file mode 100644 index 000000000..768bdca6b --- /dev/null +++ b/backend/templates/sql_examples/AWS_Redshift.yaml @@ -0,0 +1,88 @@ +template: + quot_rule: | + + 必须对数据库名、表名、字段名、别名外层加双引号(")。 + + 1. 点号(.)不能包含在引号内,必须写成 "schema"."table" + 2. 即使标识符不含特殊字符或非关键字,也需强制加双引号 + 3. Redshift 默认将未加引号的标识符转为小写 + + + + limit_rule: | + + 当需要限制行数时,必须使用 LIMIT 或 FETCH FIRST 来限制行数(Redshift 兼容 PostgreSQL) + + 1. 标准写法:LIMIT 100 + 2. 可选写法:FETCH FIRST 100 ROWS ONLY + + + + other_rule: | + 必须为每个表生成别名(不加AS) + {multi_table_condition} + 禁止使用星号(*),必须明确字段名 + 中文/特殊字符字段需保留原名并添加英文别名 + 函数字段必须加别名 + 百分比字段保留两位小数并以%结尾(使用ROUND+CONCAT) + 避免与Redshift关键字冲突(如USER/GROUP/ORDER等) + + basic_example: | + + + 📌 以下示例严格遵循中的 AWS Redshift 规范,展示符合要求的 SQL 写法与典型错误案例。 + ⚠️ 注意:示例中的表名、字段名均为演示虚构,实际使用时需替换为用户提供的真实标识符。 + 🔍 重点观察: + 1. 双引号包裹所有数据库对象的规范用法 + 2. 中英别名/百分比/函数等特殊字段的处理 + 3. 关键字冲突的规避方式 + + + 查询 TEST.SALES 表的前100条订单(含百分比计算) + + SELECT * FROM TEST.SALES LIMIT 100 -- 错误:未加引号、使用星号 + SELECT "订单ID", "金额" FROM "TEST"."SALES" "t1" FETCH FIRST 100 ROWS ONLY -- 错误:缺少英文别名 + SELECT COUNT("订单ID") FROM "TEST"."SALES" "t1" -- 错误:函数未加别名 + + + SELECT + "t1"."订单ID" AS "order_id", + "t1"."金额" AS "amount", + COUNT("t1"."订单ID") AS "total_orders", + CONCAT(ROUND("t1"."折扣率" * 100, 2), '%') AS "discount_percent" + FROM "TEST"."SALES" "t1" + GROUP BY "t1"."订单ID", "t1"."金额", "t1"."折扣率" + LIMIT 100 + + + + + 统计用户表 PUBLIC.USERS(含关键字字段user)的活跃占比 + + SELECT user, status FROM PUBLIC.USERS -- 错误:未处理关键字和引号 + SELECT "user", ROUND(active_ratio) FROM "PUBLIC"."USERS" -- 错误:百分比格式错误 + + + SELECT + "u"."user" AS "user_account", + CONCAT(ROUND("u"."active_ratio" * 100, 2), '%') AS "active_percent" + FROM "PUBLIC"."USERS" "u" + WHERE "u"."status" = 1 + FETCH FIRST 1000 ROWS ONLY + + + + + example_engine: AWS Redshift 1.0 + example_answer_1: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"continent\" AS \"continent_name\", \"year\" AS \"year\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" ORDER BY \"country\", \"year\"","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_1_with_limit: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"continent\" AS \"continent_name\", \"year\" AS \"year\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" ORDER BY \"country\", \"year\" LIMIT 1000 OFFSET 0","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_2: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE \"year\" = '2024' ORDER BY \"gdp\" DESC","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_2_with_limit: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE \"year\" = '2024' ORDER BY \"gdp\" DESC LIMIT 1000 OFFSET 0","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_3: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE \"year\" = '2025' AND \"country\" = '中国'","tables":["sample_country_gdp"],"chart-type":"table"} + example_answer_3_with_limit: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE \"year\" = '2025' AND \"country\" = '中国' LIMIT 1000 OFFSET 0","tables":["sample_country_gdp"],"chart-type":"table"} diff --git a/backend/templates/sql_examples/ClickHouse.yaml b/backend/templates/sql_examples/ClickHouse.yaml new file mode 100644 index 000000000..c2b56f2dd --- /dev/null +++ b/backend/templates/sql_examples/ClickHouse.yaml @@ -0,0 +1,91 @@ +template: + quot_rule: | + + 必须对数据库名、表名、字段名、别名外层加双引号(")。 + + 1. 点号(.)不能包含在引号内,必须写成 "database"."table" + 2. ClickHouse 严格区分大小写,必须通过引号保留原始大小写 + 3. 嵌套字段使用点号连接:`"json_column.field"` + + + + limit_rule: | + + 当需要限制行数时,必须使用标准的LIMIT语法 + + 1. 标准写法:LIMIT [count] + 2. 分页写法:LIMIT [count] OFFSET [start] + 3. 禁止使用原生 `topk()` 等函数替代 + + + + other_rule: | + 必须为每个表生成简短别名(如t1/t2) + {multi_table_condition} + 禁止使用星号(*),必须明确字段名 + JSON字段需用点号语法访问:`"column.field"` + 函数字段必须加别名 + 百分比显示为:`ROUND(x*100,2) || '%'` + 避免与ClickHouse关键字冲突(如`timestamp`/`default`) + + basic_example: | + + + 📌 以下示例严格遵循中的 ClickHouse 规范,展示符合要求的 SQL 写法与典型错误案例。 + ⚠️ 注意:示例中的表名、字段名均为演示虚构,实际使用时需替换为用户提供的真实标识符。 + 🔍 重点观察: + 1. 双引号包裹所有数据库对象的规范用法 + 2. 中英别名/百分比/函数等特殊字段的处理 + 3. 关键字冲突的规避方式 + + + 查询 events 表的前100条错误日志(含JSON字段) + + SELECT * FROM default.events LIMIT 100 -- 错误1:使用星号 + SELECT message FROM "default"."events" WHERE level = 'error' -- 错误2:未处理JSON字段 + SELECT "message", "extra.error_code" FROM events LIMIT 100 -- 错误3:表名未加引号 + + + SELECT + "e"."message" AS "log_content", + "e"."extra"."error_code" AS "error_id", + toDateTime("e"."timestamp") AS "log_time" + FROM "default"."events" "e" + WHERE "e"."level" = 'error' + LIMIT 100 + + + + + 统计各地区的错误率Top 5(含百分比) + + SELECT region, COUNT(*) FROM events GROUP BY region -- 错误1:使用COUNT(*) + SELECT "region", MAX("count") FROM "events" GROUP BY 1 -- 错误2:使用序号分组 + + + SELECT + "e"."region" AS "area", + COUNT(*) AS "total", + COUNTIf("e"."level" = 'error') AS "error_count", + ROUND(error_count * 100.0 / total, 2) || '%' AS "error_rate" + FROM "default"."events" "e" + GROUP BY "e"."region" + ORDER BY "error_rate" DESC + LIMIT 5 + + + + + example_engine: ClickHouse 23.3 + example_answer_1: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"continent\" AS \"continent_name\", \"year\" AS \"year\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" ORDER BY \"country\", \"year\"","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_1_with_limit: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"continent\" AS \"continent_name\", \"year\" AS \"year\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" ORDER BY \"country\", \"year\" LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_2: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE \"year\" = '2024' ORDER BY \"gdp\" DESC","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_2_with_limit: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE \"year\" = '2024' ORDER BY \"gdp\" DESC LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_3: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE \"year\" = '2025' AND \"country\" = '中国'","tables":["sample_country_gdp"],"chart-type":"table"} + example_answer_3_with_limit: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE \"year\" = '2025' AND \"country\" = '中国' LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"table"} diff --git a/backend/templates/sql_examples/DM.yaml b/backend/templates/sql_examples/DM.yaml new file mode 100644 index 000000000..df050f6e8 --- /dev/null +++ b/backend/templates/sql_examples/DM.yaml @@ -0,0 +1,89 @@ +template: + quot_rule: | + + 必须对模式名、表名、字段名、别名外层加双引号(")。 + + 1. 点号(.)不能包含在引号内,必须写成 "schema"."table" + 2. 即使标识符不含特殊字符或非关键字,也需强制加双引号 + 3. 达梦默认将未加引号的标识符转为大写 + + + + limit_rule: | + + 当需要限制行数时,必须使用达梦兼容的语法 + + 1. 标准写法:LIMIT [count] + 2. 分页写法:LIMIT [offset], [count] 或 LIMIT [count] OFFSET [start] + 3. 兼容Oracle写法:WHERE ROWNUM <= 100(需达梦7+版本) + + + + other_rule: | + 必须为每个表生成别名(不加AS) + {multi_table_condition} + 禁止使用星号(*),必须明确字段名 + 中文/特殊字符字段需保留原名并添加英文别名 + 函数字段必须加别名 + 百分比字段使用TO_CHAR(x*100,'990.99') || '%' + 避免与达梦关键字冲突(如LEVEL/ORDER/PARTITION等) + + basic_example: | + + + 📌 以下示例严格遵循中的 达梦(DM) 规范,展示符合要求的 SQL 写法与典型错误案例。 + ⚠️ 注意:示例中的表名、字段名均为演示虚构,实际使用时需替换为用户提供的真实标识符。 + 🔍 重点观察: + 1. 双引号包裹所有数据库对象的规范用法 + 2. 中英别名/百分比/函数等特殊字段的处理 + 3. 关键字冲突的规避方式 + + + 查询 TEST.ORDERS 表的前100条订单(含中文字段和百分比) + + SELECT * FROM TEST.ORDERS LIMIT 100 -- 错误:未加引号、使用星号 + SELECT "订单ID", "金额" FROM "TEST"."ORDERS" "t1" WHERE ROWNUM <= 100 -- 错误:混用ROWNUM和LIMIT + SELECT COUNT("订单ID") FROM "TEST"."ORDERS" "t1" -- 错误:函数未加别名 + + + SELECT + "t1"."订单ID" AS "order_id", + "t1"."金额" AS "amount", + COUNT("t1"."订单ID") AS "total_orders", + TO_CHAR("t1"."折扣率" * 100, '990.99') || '%' AS "discount_percent" + FROM "TEST"."ORDERS" "t1" + GROUP BY "t1"."订单ID", "t1"."金额", "t1"."折扣率" + LIMIT 100 + + + + + 统计用户表 PUBLIC.USERS(含关键字字段LEVEL)的活跃占比 + + SELECT LEVEL, status FROM PUBLIC.USERS -- 错误:未处理关键字 + SELECT "LEVEL", ROUND(active_ratio*100) FROM "PUBLIC"."USERS" -- 错误:百分比格式错误 + + + SELECT + "u"."LEVEL" AS "user_level", + TO_CHAR("u"."active_ratio" * 100, '990.99') || '%' AS "active_percent" + FROM "PUBLIC"."USERS" "u" + WHERE "u"."status" = 1 + LIMIT 0, 1000 -- 达梦兼容写法 + + + + + example_engine: DM Database 8 + example_answer_1: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"continent\" AS \"continent_name\", \"year\" AS \"year\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" ORDER BY \"country\", \"year\"","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_1_with_limit: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"continent\" AS \"continent_name\", \"year\" AS \"year\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" ORDER BY \"country\", \"year\" LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_2: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE \"year\" = '2024' ORDER BY \"gdp\" DESC","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_2_with_limit: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE \"year\" = '2024' ORDER BY \"gdp\" DESC LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_3: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE \"year\" = '2025' AND \"country\" = '中国'","tables":["sample_country_gdp"],"chart-type":"table"} + example_answer_3_with_limit: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE \"year\" = '2025' AND \"country\" = '中国' LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"table"} diff --git a/backend/templates/sql_examples/Doris.yaml b/backend/templates/sql_examples/Doris.yaml new file mode 100644 index 000000000..9e16b7593 --- /dev/null +++ b/backend/templates/sql_examples/Doris.yaml @@ -0,0 +1,91 @@ +template: + quot_rule: | + + 必须对数据库名、表名、字段名、别名外层加反引号(`)。 + + 1. 点号(.)不能包含在反引号内,必须写成 `database`.`table` + 2. 当标识符为关键字、含特殊字符或需保留大小写时必须加反引号 + 3. Doris 标识符默认不区分大小写,但建议统一使用反引号 + + + + limit_rule: | + + 当需要限制行数时,必须使用标准的LIMIT语法 + + 1. 标准写法:LIMIT [count] + 2. 分页写法:LIMIT [count] OFFSET [start] + 3. 支持 MySQL 兼容写法:LIMIT [offset], [count] + + + + other_rule: | + 必须为每个表生成别名(不加AS) + {multi_table_condition} + 禁止使用星号(*),必须明确字段名 + 中文/特殊字符字段需保留原名并添加英文别名 + 函数字段必须加别名 + 百分比显示为:CONCAT(ROUND(x*100,2),'%') + 避免与Doris关键字冲突(如`rank`/`partition`/`values`) + 分区查询需使用PARTITION语句明确指定 + + basic_example: | + + + 📌 以下示例严格遵循中的 Doris 规范,展示符合要求的 SQL 写法与典型错误案例。 + ⚠️ 注意:示例中的表名、字段名均为演示虚构,实际使用时需替换为用户提供的真实标识符。 + 🔍 重点观察: + 1. 双引号包裹所有数据库对象的规范用法 + 2. 中英别名/百分比/函数等特殊字段的处理 + 3. 关键字冲突的规避方式 + + + 查询 test.orders 表的前100条订单(含中文字段和百分比) + + SELECT * FROM test.orders LIMIT 100 -- 错误:使用星号 + SELECT `订单ID`, `金额` FROM `test`.`orders` `t1` LIMIT 100 -- 错误:缺少英文别名 + SELECT COUNT(`订单ID`) FROM `test`.`orders` `t1` -- 错误:函数未加别名 + + + SELECT + `t1`.`订单ID` AS `order_id`, + `t1`.`金额` AS `amount`, + COUNT(`t1`.`订单ID`) AS `total_orders`, + CONCAT(ROUND(`t1`.`折扣率` * 100, 2), '%') AS `discount_percent` + FROM `test`.`orders` `t1` + GROUP BY `t1`.`订单ID`, `t1`.`金额`, `t1`.`折扣率` + LIMIT 100 + + + + + 统计用户表 dw.users(含分区字段和关键字rank)的活跃占比 + + SELECT rank, status FROM dw.users -- 错误:未处理关键字 + SELECT `rank`, ROUND(active_ratio*100) FROM `dw`.`users` -- 错误:百分比格式错误 + + + SELECT + `u`.`rank` AS `user_rank`, + CONCAT(ROUND(`u`.`active_ratio` * 100, 2), '%') AS `active_percent` + FROM `dw`.`users` `u` + WHERE `u`.`status` = 1 + AND `u`.`dt` = '2024-01-01' -- Doris分区字段过滤 + LIMIT 1000 + + + + + example_engine: Apache Doris 2.0 + example_answer_1: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `continent` AS `continent_name`, `year` AS `year`, `gdp` AS `gdp_usd` FROM `Sample_Database`.`sample_country_gdp` ORDER BY `country`, `year`","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_1_with_limit: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `continent` AS `continent_name`, `year` AS `year`, `gdp` AS `gdp_usd` FROM `Sample_Database`.`sample_country_gdp` ORDER BY `country`, `year` LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_2: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `gdp` AS `gdp_usd` FROM `Sample_Database`.`sample_country_gdp` WHERE `year` = '2024' ORDER BY `gdp` DESC","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_2_with_limit: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `gdp` AS `gdp_usd` FROM `Sample_Database`.`sample_country_gdp` WHERE `year` = '2024' ORDER BY `gdp` DESC LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_3: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `gdp` AS `gdp_usd` FROM `Sample_Database`.`sample_country_gdp` WHERE `year` = '2025' AND `country` = '中国'","tables":["sample_country_gdp"],"chart-type":"table"} + example_answer_3_with_limit: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `gdp` AS `gdp_usd` FROM `Sample_Database`.`sample_country_gdp` WHERE `year` = '2025' AND `country` = '中国' LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"table"} diff --git a/backend/templates/sql_examples/Elasticsearch.yaml b/backend/templates/sql_examples/Elasticsearch.yaml new file mode 100644 index 000000000..14188c2f6 --- /dev/null +++ b/backend/templates/sql_examples/Elasticsearch.yaml @@ -0,0 +1,105 @@ +template: + quot_rule: | + + 必须对索引名(表名)、字段名、别名外层加双引号(")。 + + 1. 通配符索引需整体加引号:`"logs-*"` + 2. 嵌套字段用点号连接:`"user.name"` + 3. 时间字段必须显式转换:`CAST("@timestamp" AS TIMESTAMP)` + + + + limit_rule: | + + 当需要限制行数时,必须使用标准的LIMIT语法 + + 1. 首选:`LIMIT [count]` + 2. 分页:`LIMIT [count] OFFSET [start]` + 3. 禁止使用Elasticsearch原生`size/from`参数 + + + + other_rule: | + 必须为每个索引生成别名(不加AS) + + Elasticsearch SQL多表查询字段限定规则 + + 当使用JOIN涉及多个索引时,所有字段引用必须明确限定索引别名 + 适用于SELECT、WHERE、GROUP BY、HAVING、ORDER BY、ON等子句中的所有字段引用 + 单索引查询不需要表名前缀 + + + Elasticsearch的JOIN性能有限,仅适合小数据量关联 + 标识符引用使用双引号(") + 大多数Elasticsearch查询是单索引查询,不需要字段限定 + 嵌套字段通过点号访问,如 "customer.name",不需要表名前缀 + + + 禁止使用星号(*),必须明确字段名 + 中文/特殊字符字段需保留原名并添加英文别名 + 函数字段必须加别名 + 百分比显示为:`ROUND(x*100,2) || '%'` + 避免与Elasticsearch关键字冲突(如`score`/`type`) + + basic_example: | + + + 📌 以下示例严格遵循中的 Elasticsearch 规范,展示符合要求的 SQL 写法与典型错误案例。 + ⚠️ 注意:示例中的表名、字段名均为演示虚构,实际使用时需替换为用户提供的真实标识符。 + 🔍 重点观察: + 1. 双引号包裹所有数据库对象的规范用法 + 2. 中英别名/百分比/函数等特殊字段的处理 + 3. 关键字冲突的规避方式 + + + 查询 logs-* 索引的前100条错误日志(含时间过滤) + + SELECT * FROM logs-* WHERE level = 'error' LIMIT 100 -- 错误1:使用星号 + SELECT message FROM "logs-*" WHERE @timestamp > NOW() - 1d -- 错误2:未转换时间 + SELECT "message", "@timestamp" FROM "logs-*" LIMIT 100 -- 错误3:未处理时间字段 + + + SELECT + "l"."message" AS "log_content", + "l"."host.ip" AS "client_ip", + CAST("l"."@timestamp" AS TIMESTAMP) AS "log_time" + FROM "logs-*" "l" + WHERE "l"."level" = 'error' + AND CAST("l"."@timestamp" AS TIMESTAMP) > DATE_SUB(NOW(), INTERVAL 1 DAY) + LIMIT 100 + + + + + 统计各地区的错误日志占比(Top 5) + + SELECT region, COUNT(*) FROM "logs-*" GROUP BY region -- 错误1:使用COUNT(*) + SELECT "region", MAX("count") FROM "logs-*" GROUP BY 1 -- 错误2:使用序号分组 + + + SELECT + "l"."region" AS "area", + COUNT("l"."message") AS "error_count", + ROUND(COUNT("l"."message") * 100.0 / SUM(COUNT("l"."message")) OVER (), 2) || '%' AS "error_percent" + FROM "logs-*" "l" + WHERE "l"."level" = 'error' + GROUP BY "l"."region" + ORDER BY "error_count" DESC + LIMIT 5 + + + + + example_engine: Elasticsearch SQL 8.9 + example_answer_1: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"continent\" AS \"continent_name\", \"year\" AS \"year\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" ORDER BY \"country\", \"year\"","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_1_with_limit: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"continent\" AS \"continent_name\", \"year\" AS \"year\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" ORDER BY \"country\", \"year\" LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_2: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE \"year\" = '2024' ORDER BY \"gdp\" DESC","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_2_with_limit: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE \"year\" = '2024' ORDER BY \"gdp\" DESC LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_3: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE \"year\" = '2025' AND \"country\" = '中国'","tables":["sample_country_gdp"],"chart-type":"table"} + example_answer_3_with_limit: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE \"year\" = '2025' AND \"country\" = '中国' LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"table"} diff --git a/backend/templates/sql_examples/Hive.yaml b/backend/templates/sql_examples/Hive.yaml new file mode 100644 index 000000000..f7998b930 --- /dev/null +++ b/backend/templates/sql_examples/Hive.yaml @@ -0,0 +1,88 @@ +template: + quot_rule: | + + 必须对数据库名、表名、字段名、别名外层加反引号(`)。 + + 1. 点号(.)不能包含在引号内,必须写成 `database`.`table` + 2. 即使标识符不含特殊字符或非关键字,也需强制加反引号 + 3. 在多表关联(JOIN)/ 多子查询的 SQL 中,只要多个表 / 子查询存在 同名字段,所有引用该字段的位置,必须显式指定 表别名 + + + + limit_rule: | + + 当需要限制行数时,必须使用标准的LIMIT语法 + + + other_rule: | + 必须为每个表生成别名(不加AS) + {multi_table_condition} + 禁止使用星号(*),必须明确字段名 + 中文/特殊字符字段需保留原名并添加英文别名 + 不能用 + 拼接字符串,字符串必须使用单引号 + 分组非常严格:SELECT 里的字段必须出现在 GROUP BY 里,或者是聚合函数 + 函数字段必须加别名 + 百分比字段保留两位小数并以%结尾 + WHERE 条件中不能使用 >、<、>=、<= 等比较运算符,必须使用 = + HIVE 中没有 NOT IN 操作符,必须使用 LEFT JOIN 或 EXISTS 替代 + 判空使用 NVL()函数 + 避免与数据库关键字冲突 + + basic_example: | + + + 📌 以下示例严格遵循中的 Hive 规范,展示符合要求的 SQL 写法与典型错误案例。 + ⚠️ 注意:示例中的表名、字段名均为演示虚构,实际使用时需替换为用户提供的真实标识符。 + 🔍 重点观察: + 1. 反引号包裹所有数据库对象的规范用法 + 2. 中英别名/百分比/函数等特殊字段的处理 + 3. 关键字冲突的规避方式 + + + 查询 ods.orders 表的前100条订单(含中文字段和百分比) + + SELECT * FROM ods.orders LIMIT 100 -- 错误:未加引号、使用星号 + SELECT `订单ID`, `金额` FROM `ods`.`orders` `t1` LIMIT 100 -- 错误:缺少英文别名 + SELECT COUNT(`订单ID`) FROM `ods`.`orders` `t1` -- 错误:函数未加别名 + + + SELECT + `t1`.`订单ID` AS `order_id`, + `t1`.`金额` AS `amount`, + COUNT(`t1`.`订单ID`) AS `total_orders`, + CONCAT(CAST(ROUND(`t1`.`折扣率` * 100, 2) AS STRING), '%') AS `discount_percent` + FROM `ods`.`orders` `t1` + GROUP BY `t1`.`订单ID`, `t1`.`金额`, `t1`.`折扣率` + LIMIT 100 + + + + + 统计 dim.users(含关键字字段user)的活跃占比 + + SELECT user, status FROM dim.users -- 错误:未处理关键字和引号 + SELECT `user`, ROUND(active_ratio) FROM `dim`.`users` -- 错误:百分比格式错误 + + + SELECT + `u`.`user` AS `username`, + CONCAT(CAST(ROUND(`u`.`active_ratio` * 100, 2) AS STRING), '%') AS `active_percent` + FROM `dim`.`users` `u` + WHERE `u`.`status` = 1 + + + + + example_engine: Apache Hive 2.X + example_answer_1: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `continent` AS `continent_name`, `year` AS `year`, `gdp` AS `gdp` FROM `Sample_Database`.`sample_country_gdp` ORDER BY `country`, `year`","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_1_with_limit: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `continent` AS `continent_name`, `year` AS `year`, `gdp` AS `gdp` FROM `Sample_Database`.`sample_country_gdp` ORDER BY `country`, `year` LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_2: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `gdp` AS `gdp` FROM `Sample_Database`.`sample_country_gdp` WHERE `year` = '2024' ORDER BY `gdp` DESC","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_2_with_limit: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `gdp` AS `gdp` FROM `Sample_Database`.`sample_country_gdp` WHERE `year` = '2024' ORDER BY `gdp` DESC LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_3: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `gdp` AS `gdp` FROM `Sample_Database`.`sample_country_gdp` WHERE `year` = '2025' AND `country` = '中国'","tables":["sample_country_gdp"],"chart-type":"table"} + example_answer_3_with_limit: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `gdp` AS `gdp` FROM `Sample_Database`.`sample_country_gdp` WHERE `year` = '2025' AND `country` = '中国' LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"table"} diff --git a/backend/templates/sql_examples/Kingbase.yaml b/backend/templates/sql_examples/Kingbase.yaml new file mode 100644 index 000000000..e3fa4caa4 --- /dev/null +++ b/backend/templates/sql_examples/Kingbase.yaml @@ -0,0 +1,84 @@ +template: + quot_rule: | + + 必须对数据库名、表名、字段名、别名外层加双引号(")。 + + 1. 点号(.)不能包含在引号内,必须写成 "schema"."table" + 2. 即使标识符不含特殊字符或非关键字,也需强制加双引号 + 3. Kingbase 默认将未加引号的标识符转为小写 + + + + limit_rule: | + + 当需要限制行数时,必须使用兼容Kingbase的语法 + + 1. 标准写法:LIMIT [count] + 2. 分页写法:LIMIT [count] OFFSET [start] + 3. 兼容 Oracle 写法:WHERE ROWNUM <= 100(需开启兼容模式) + + + + other_rule: | + 必须为每个表生成别名(不加AS) + {multi_table_condition} + 禁止使用星号(*),必须明确字段名 + 中文/特殊字符字段需保留原名并添加英文别名 + 函数字段必须加别名 + 百分比显示为:TO_CHAR(x*100,'990.99') || '%' + 避免与Kingbase关键字冲突(如SYSDATE/LEVEL等) + + basic_example: | + + + 📌 以下示例严格遵循 Kingbase 语法规范(兼容 PostgreSQL) + ⚠️ 注意:示例中的表名、字段名均为演示虚构,实际使用时需替换为用户提供的真实标识符。 + 🔍 重点观察: + 1. 双引号包裹所有数据库对象 + 2. 中英别名/百分比/函数等特殊字段处理 + 3. 关键字冲突规避(如SYSDATE/LEVEL) + + + 查询 TEST.ORDERS 表的前100条订单(含中文字段) + + SELECT * FROM TEST.ORDERS LIMIT 100 -- 错误:未加引号、使用星号 + SELECT "订单ID", "金额" FROM "TEST"."ORDERS" "t1" -- 错误:缺少英文别名 + + + SELECT + "t1"."订单ID" AS "order_id", + "t1"."金额" AS "amount_usd", + TO_CHAR("t1"."折扣率" * 100, '990.99') || '%' AS "discount_percent" + FROM "TEST"."ORDERS" "t1" + LIMIT 100 + + + + 统计用户表 PUBLIC.USERS(含关键字字段LEVEL) + + SELECT LEVEL, status FROM PUBLIC.USERS -- 错误:未处理关键字 + SELECT "LEVEL", "active_ratio" FROM "PUBLIC"."USERS" -- 错误:百分比未格式化 + + + SELECT + "u"."LEVEL" AS "user_level", + TO_CHAR("u"."active_ratio" * 100, '990.99') || '%' AS "active_percent" + FROM "PUBLIC"."USERS" "u" + WHERE "u"."status" = 1 + + + + + example_engine: Kingbase V8 + example_answer_1: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"continent\" AS \"continent_name\", \"year\" AS \"year\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" ORDER BY \"country\", \"year\"","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_1_with_limit: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"continent\" AS \"continent_name\", \"year\" AS \"year\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" ORDER BY \"country\", \"year\" LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_2: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE \"year\" = '2024' ORDER BY \"gdp\" DESC","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_2_with_limit: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE \"year\" = '2024' ORDER BY \"gdp\" DESC LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_3: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE \"year\" = '2025' AND \"country\" = '中国'","tables":["sample_country_gdp"],"chart-type":"table"} + example_answer_3_with_limit: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"gdp\" AS \"gdp_usd\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE \"year\" = '2025' AND \"country\" = '中国' LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"table"} \ No newline at end of file diff --git a/backend/templates/sql_examples/Microsoft_SQL_Server.yaml b/backend/templates/sql_examples/Microsoft_SQL_Server.yaml new file mode 100644 index 000000000..31bc77c5f --- /dev/null +++ b/backend/templates/sql_examples/Microsoft_SQL_Server.yaml @@ -0,0 +1,89 @@ +template: + quot_rule: | + + 必须对数据库名、表名、字段名、别名外层加方括号([])。 + + 1. 点号(.)不能包含在方括号内,必须写成 [schema].[table] + 2. 当标识符为关键字、含特殊字符或需保留大小写时必须加方括号 + 3. SQL Server 标识符默认不区分大小写 + + + + limit_rule: | + + 当需要限制行数时,必须使用 Microsoft SQL Server 的SQL语法: + + 1. 标准写法:TOP [count] + 2. 分页写法:OFFSET [start] ROWS FETCH NEXT [count] ROWS ONLY + 3. 禁止使用 MySQL 的 LIMIT 语法 + + + + other_rule: | + 必须为每个表生成别名(不加AS) + {multi_table_condition} + 禁止使用星号(*),必须明确字段名 + 中文/特殊字符字段需保留原名并添加英文别名 + 函数字段必须加别名 + 百分比显示为:CONVERT(VARCHAR, ROUND(x*100,2)) + '%' + 避免与SQL Server关键字冲突(如[user]/[order]/[desc]) + + basic_example: | + + + 📌 以下示例严格遵循中的 Microsoft SQL Server 规范,展示符合要求的 SQL 写法与典型错误案例。 + ⚠️ 注意:示例中的表名、字段名均为演示虚构,实际使用时需替换为用户提供的真实标识符。 + 🔍 重点观察: + 1. 双引号包裹所有数据库对象的规范用法 + 2. 中英别名/百分比/函数等特殊字段的处理 + 3. 关键字冲突的规避方式 + + + 查询 Sales.Orders 表的前100条订单(含中文字段和百分比) + + SELECT * FROM Sales.Orders -- 错误:使用星号 + SELECT [订单ID], [金额] FROM [Sales].[Orders] [o] -- 错误:缺少英文别名 + SELECT COUNT([订单ID]) FROM [Sales].[Orders] [o] -- 错误:函数未加别名 + + + SELECT TOP 100 + [o].[订单ID] AS [order_id], + [o].[金额] AS [amount], + COUNT([o].[订单ID]) AS [total_orders], + CONVERT(VARCHAR, ROUND([o].[折扣率] * 100, 2)) + '%' AS [discount_percent] + FROM [Sales].[Orders] [o] + GROUP BY [o].[订单ID], [o].[金额], [o].[折扣率] + + + + + 统计用户表 dbo.Users(含关键字字段user)的活跃占比 + + SELECT user, status FROM dbo.Users -- 错误:未处理关键字 + SELECT [user], ROUND(active_ratio*100) FROM [dbo].[Users] -- 错误:百分比格式错误 + + + SELECT + [u].[user] AS [user_name], + CONVERT(VARCHAR, ROUND([u].[active_ratio] * 100, 2)) + '%' AS [active_percent] + FROM [dbo].[Users] [u] + WHERE [u].[status] = 1 + ORDER BY [u].[create_date] + OFFSET 0 ROWS FETCH NEXT 1000 ROWS ONLY + + + + + example_engine: Microsoft SQL Server 2022 + example_answer_1: | + {"success":true,"sql":"SELECT [country] AS [country_name], [continent] AS [continent_name], [year] AS [year], [gdp] AS [gdp_usd] FROM [Sample_Database].[sample_country_gdp] ORDER BY [country], [year]","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_1_with_limit: | + {"success":true,"sql":"SELECT TOP 1000 [country] AS [country_name], [continent] AS [continent_name], [year] AS [year], [gdp] AS [gdp_usd] FROM [Sample_Database].[sample_country_gdp] ORDER BY [country], [year]","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_2: | + {"success":true,"sql":"SELECT [country] AS [country_name], [gdp] AS [gdp_usd] FROM [Sample_Database].[sample_country_gdp] WHERE [year] = '2024' ORDER BY [gdp] DESC","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_2_with_limit: | + {"success":true,"sql":"SELECT TOP 1000 [country] AS [country_name], [gdp] AS [gdp_usd] FROM [Sample_Database].[sample_country_gdp] WHERE [year] = '2024' ORDER BY [gdp] DESC","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_3: | + {"success":true,"sql":"SELECT [country] AS [country_name], [gdp] AS [gdp_usd] FROM [Sample_Database].[sample_country_gdp] WHERE [year] = '2025' AND [country] = N'中国'","tables":["sample_country_gdp"],"chart-type":"table"} + example_answer_3_with_limit: | + {"success":true,"sql":"SELECT TOP 1000 [country] AS [country_name], [gdp] AS [gdp_usd] FROM [Sample_Database].[sample_country_gdp] WHERE [year] = '2025' AND [country] = N'中国'","tables":["sample_country_gdp"],"chart-type":"table"} diff --git a/backend/templates/sql_examples/MySQL.yaml b/backend/templates/sql_examples/MySQL.yaml new file mode 100644 index 000000000..36e27e818 --- /dev/null +++ b/backend/templates/sql_examples/MySQL.yaml @@ -0,0 +1,88 @@ +template: + quot_rule: | + + 必须对数据库名、表名、字段名、别名外层加反引号(`)。 + + 1. 点号(.)不能包含在反引号内,必须写成 `schema`.`table` + 2. 当标识符为关键字、含特殊字符或需保留大小写时必须加反引号 + 3. MySQL 默认不区分大小写(Linux系统区分表名大小写) + + + + limit_rule: | + + 当需要限制行数时,必须使用标准的LIMIT语法 + + 1. 标准写法:LIMIT [count] + 2. 分页写法:LIMIT [offset], [count] 或 LIMIT [count] OFFSET [start] + + + + other_rule: | + 必须为每个表生成别名(不加AS) + {multi_table_condition} + 禁止使用星号(*),必须明确字段名 + 中文/特殊字符字段需保留原名并添加英文别名 + 函数字段必须加别名 + 百分比显示为:CONCAT(ROUND(x*100,2),'%') + 避免与MySQL关键字冲突(如`order`/`group`/`desc`) + + basic_example: | + + + 📌 以下示例严格遵循中的 MySQL 规范,展示符合要求的 SQL 写法与典型错误案例。 + ⚠️ 注意:示例中的表名、字段名均为演示虚构,实际使用时需替换为用户提供的真实标识符。 + 🔍 重点观察: + 1. 双引号包裹所有数据库对象的规范用法 + 2. 中英别名/百分比/函数等特殊字段的处理 + 3. 关键字冲突的规避方式 + + + 查询 test.orders 表的前100条订单(含中文字段和百分比) + + SELECT * FROM test.orders LIMIT 100 -- 错误:使用星号 + SELECT `订单ID`, `金额` FROM `test`.`orders` `t1` LIMIT 100 -- 错误:缺少英文别名 + SELECT COUNT(`订单ID`) FROM `test`.`orders` `t1` -- 错误:函数未加别名 + + + SELECT + `t1`.`订单ID` AS `order_id`, + `t1`.`金额` AS `amount`, + COUNT(`t1`.`订单ID`) AS `total_orders`, + CONCAT(ROUND(`t1`.`折扣率` * 100, 2), '%') AS `discount_percent` + FROM `test`.`orders` `t1` + GROUP BY `t1`.`订单ID`, `t1`.`金额`, `t1`.`折扣率` + LIMIT 100 + + + + + 统计用户表 public.users(含关键字字段desc)的活跃占比 + + SELECT desc, status FROM public.users -- 错误:未处理关键字 + SELECT `desc`, ROUND(active_ratio*100) FROM `public`.`users` -- 错误:百分比格式错误 + + + SELECT + `u`.`desc` AS `description`, + CONCAT(ROUND(`u`.`active_ratio` * 100, 2), '%') AS `active_percent` + FROM `public`.`users` `u` + WHERE `u`.`status` = 1 + LIMIT 1000 + + + + + example_engine: MySQL 8.0 + example_answer_1: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `continent` AS `continent_name`, `year` AS `year`, `gdp` AS `gdp_usd` FROM `Sample_Database`.`sample_country_gdp` ORDER BY `country`, `year`","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_1_with_limit: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `continent` AS `continent_name`, `year` AS `year`, `gdp` AS `gdp_usd` FROM `Sample_Database`.`sample_country_gdp` ORDER BY `country`, `year` LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_2: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `gdp` AS `gdp_usd` FROM `Sample_Database`.`sample_country_gdp` WHERE `year` = '2024' ORDER BY `gdp` DESC","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_2_with_limit: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `gdp` AS `gdp_usd` FROM `Sample_Database`.`sample_country_gdp` WHERE `year` = '2024' ORDER BY `gdp` DESC LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_3: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `gdp` AS `gdp_usd` FROM `Sample_Database`.`sample_country_gdp` WHERE `year` = '2025' AND `country` = '中国'","tables":["sample_country_gdp"],"chart-type":"table"} + example_answer_3_with_limit: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `gdp` AS `gdp_usd` FROM `Sample_Database`.`sample_country_gdp` WHERE `year` = '2025' AND `country` = '中国' LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"table"} diff --git a/backend/templates/sql_examples/Oracle.yaml b/backend/templates/sql_examples/Oracle.yaml new file mode 100644 index 000000000..e3c52288b --- /dev/null +++ b/backend/templates/sql_examples/Oracle.yaml @@ -0,0 +1,208 @@ +template: + process_check: | + + 1. 分析用户问题,确定查询需求 + 2. 根据表结构生成基础SQL + 3. 强制检查:逐字验证SQL中使用的表名和字段名是否在中定义,且字符必须完全匹配(包括繁简体、大小写、特殊字符),不得有任何转换。如發现任何不一致,必须重新生成SQL。 + 4. 强制检查:应用数据量限制规则(默认限制或用户指定数量) + 5. 应用其他规则(引号、别名、格式化等) + 6. 最终验证:GROUP BY查询的ROWNUM位置是否正确? + 7. 强制检查:验证SQL语法是否符合规范 + 8. 确定图表类型(根据规则选择table/column/bar/line/pie) + 9. 确定对话标题 + 10. 生成JSON结果 + 11. 强制检查:JSON格式是否正确 + 12. 返回JSON结果 + + + quot_rule: | + + 必须对数据库名、表名、字段名、别名外层加双引号(")。 + + 1. 点号(.)不能包含在引号内,必须写成 "schema"."table" + 2. 即使标识符不含特殊字符或非关键字,也需强制加双引号 + 3. Oracle 对象名默认大写,如需小写必须加双引号 + + + + limit_rule: | + + Oracle版本语法适配 + + 如果db-engine版本号小于12 + 必须使用ROWNUM语法 + 如果db-engine版本号大于等于12 + 推荐使用FETCH FIRST语法 + + + + Oracle数据库 FETCH FIRST 语法规范 + 若使用 FETCH FIRST 语法,则必须遵循该规范 + + + + + + Oracle数据库ROWNUM语法规范 + 若使用ROWNUM语法,则必须遵循该规范 + + + 简单查询 + + + + 语法禁区 + + - 禁止多个WHERE子句 + - 禁止ROWNUM在GROUP BY内层(影响分组结果) + - 禁止括号不完整 + + + + + + GROUP BY查询的ROWNUM强制规范(必须严格遵守) + 所有包含GROUP BY或聚合函数的查询必须使用外层查询结构 + ROWNUM必须放在最外层查询的WHERE子句中 + + + 如果SQL包含GROUP BY、COUNT、SUM等聚合函数 + 必须使用:SELECT ... FROM (内层完整查询) WHERE ROWNUM <= N + 否则(简单查询) + 可以使用:SELECT ... FROM table WHERE conditions AND ROWNUM <= N + + + + -- 错误:ROWNUM在内层影响分组结果 + SELECT ... GROUP BY ... WHERE ROWNUM <= N + + + + -- 正确:ROWNUM在外层 + SELECT ... FROM (SELECT ... GROUP BY ...) WHERE ROWNUM <= N + + + + other_rule: | + 必须为每个表生成别名(不加AS) + {multi_table_condition} + 禁止使用星号(*),必须明确字段名 + 中文/特殊字符字段需保留原名并添加英文别名 + 函数字段必须加别名 + 百分比字段保留两位小数并以%结尾 + 避免与 Oracle 关键字冲突(如 DATE/LEVEL/ORDER 等) + + basic_example: | + + + 📌 以下示例严格遵循中的 Oracle 规范,展示符合要求的 SQL 写法与典型错误案例。 + ⚠️ 注意:示例中的表名、字段名均为演示虚构,实际使用时需替换为用户提供的真实标识符。 + 🔍 重点观察: + 1. 双引号包裹所有数据库对象的规范用法 + 2. 中英别名/百分比/函数等特殊字段的处理 + 3. 关键字冲突的规避方式 + + + 查询 TEST.ORDERS 表的前100条订单(含中文字段和百分比) + + SELECT * FROM TEST.ORDERS WHERE ROWNUM <= 100 -- 错误:未加引号、使用星号 + SELECT "订单ID", "金额" FROM "TEST"."ORDERS" "t1" WHERE ROWNUM <= 100 -- 错误:缺少英文别名 + SELECT COUNT("订单ID") FROM "TEST"."ORDERS" "t1" -- 错误:函数未加别名 + + + SELECT + "t1"."订单ID" AS "order_id", + "t1"."金额" AS "amount", + COUNT("t1"."订单ID") AS "total_orders", + TO_CHAR("t1"."折扣率" * 100, '990.99') || '%' AS "discount_percent" + FROM "TEST"."ORDERS" "t1" + GROUP BY "t1"."订单ID", "t1"."金额", "t1"."折扣率" + WHERE ROWNUM <= 100 + + + + + 统计用户表 PUBLIC.USERS(含关键字字段DATE)的活跃占比 + + SELECT DATE, status FROM PUBLIC.USERS -- 错误:未处理关键字和引号 + SELECT "DATE", ROUND(active_ratio) FROM "PUBLIC"."USERS" -- 错误:百分比格式错误 + + + SELECT + "u"."DATE" AS "create_date", + TO_CHAR("u"."active_ratio" * 100, '990.99') || '%' AS "active_percent" + FROM "PUBLIC"."USERS" "u" + WHERE "u"."status" = 1 + AND ROWNUM <= 1000 + + + SELECT + "u"."DATE" AS "create_date", + TO_CHAR("u"."active_ratio" * 100, '990.99') || '%' AS "active_percent" + FROM "PUBLIC"."USERS" "u" + WHERE "u"."status" = 1 + FETCH FIRST 1000 ROWS ONLY + + + + + 统计用户表 PUBLIC.USERS 各部门人数 + + SELECT + "u"."DEPARTMENT" AS "department_name", + count(*) AS "user_count" + FROM "PUBLIC"."USERS" "u" + WHERE "u"."status" = 1 + AND ROWNUM <= 100 -- 严重错误:影响分组结果! + GROUP BY "u"."DEPARTMENT" + ORDER BY "department_name" + + + SELECT "department_name", "user_count" FROM + SELECT + "u"."DEPARTMENT" AS "department_name", + count(*) AS "user_count" + FROM "PUBLIC"."USERS" "u" + WHERE "u"."status" = 1 + GROUP BY "u"."DEPARTMENT" + ORDER BY "department_name" + WHERE ROWNUM <= 100 -- 错误:语法错误,同级内只能有一个WHERE + + + SELECT "department_name", "user_count" FROM ( + SELECT + "u"."DEPARTMENT" AS "department_name", + count(*) AS "user_count" + FROM "PUBLIC"."USERS" "u" + WHERE "u"."status" = 1 + GROUP BY "u"."DEPARTMENT" + ORDER BY "department_name" + ) + WHERE ROWNUM <= 100 -- 正确,在外层限制数量(确保最终结果可控) + + + SELECT + "u"."DEPARTMENT" AS "department_name", + count(*) AS "user_count" + FROM "PUBLIC"."USERS" "u" + WHERE "u"."status" = 1 + GROUP BY "u"."DEPARTMENT" + ORDER BY "department_name" + FETCH FIRST 100 ROWS ONLY + + + + + example_engine: Oracle 11g + example_answer_1: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"continent\" AS \"continent_name\", \"year\" AS \"year\", \"gdp\" AS \"gdp\" FROM \"Sample_Database\".\"sample_country_gdp\" ORDER BY \"country\", \"year\"","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_1_with_limit: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"continent\" AS \"continent_name\", \"year\" AS \"year\", \"gdp\" AS \"gdp\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE ROWNUM <= 1000 ORDER BY \"country\", \"year\"","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_2: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"gdp\" AS \"gdp\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE \"year\" = '2024' ORDER BY \"gdp\" DESC","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_2_with_limit: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"gdp\" AS \"gdp\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE \"year\" = '2024' AND ROWNUM <= 1000 ORDER BY \"gdp\" DESC","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_3: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"gdp\" AS \"gdp\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE \"year\" = '2025' AND \"country\" = '中国'","tables":["sample_country_gdp"],"chart-type":"table"} + example_answer_3_with_limit: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"gdp\" AS \"gdp\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE \"year\" = '2025' AND \"country\" = '中国' AND ROWNUM <= 1000","tables":["sample_country_gdp"],"chart-type":"table"} diff --git a/backend/templates/sql_examples/PostgreSQL.yaml b/backend/templates/sql_examples/PostgreSQL.yaml new file mode 100644 index 000000000..268e8b7b5 --- /dev/null +++ b/backend/templates/sql_examples/PostgreSQL.yaml @@ -0,0 +1,82 @@ +template: + quot_rule: | + + 必须对数据库名、表名、字段名、别名外层加双引号(")。 + + 1. 点号(.)不能包含在引号内,必须写成 "schema"."table" + 2. 即使标识符不含特殊字符或非关键字,也需强制加双引号 + + + + limit_rule: | + + 当需要限制行数时,必须使用标准的LIMIT语法 + + + other_rule: | + 必须为每个表生成别名(不加AS) + {multi_table_condition} + 禁止使用星号(*),必须明确字段名 + 中文/特殊字符字段需保留原名并添加英文别名 + 函数字段必须加别名 + 百分比字段保留两位小数并以%结尾 + 避免与数据库关键字冲突 + + basic_example: | + + + 📌 以下示例严格遵循中的 PostgreSQL 规范,展示符合要求的 SQL 写法与典型错误案例。 + ⚠️ 注意:示例中的表名、字段名均为演示虚构,实际使用时需替换为用户提供的真实标识符。 + 🔍 重点观察: + 1. 双引号包裹所有数据库对象的规范用法 + 2. 中英别名/百分比/函数等特殊字段的处理 + 3. 关键字冲突的规避方式 + + + 查询 TEST.ORDERS 表的前100条订单(含中文字段和百分比) + + SELECT * FROM TEST.ORDERS LIMIT 100 -- 错误:未加引号、使用星号 + SELECT "订单ID", "金额" FROM "TEST"."ORDERS" "t1" LIMIT 100 -- 错误:缺少英文别名 + SELECT COUNT("订单ID") FROM "TEST"."ORDERS" "t1" -- 错误:函数未加别名 + + + SELECT + "t1"."订单ID" AS "order_id", + "t1"."金额" AS "amount", + COUNT("t1"."订单ID") AS "total_orders", + ROUND("t1"."折扣率" * 100, 2) || '%' AS "discount_percent" + FROM "TEST"."ORDERS" "t1" + GROUP BY "t1"."订单ID", "t1"."金额", "t1"."折扣率" + LIMIT 100 + + + + + 统计用户表 PUBLIC.USERS(含关键字字段user)的活跃占比 + + SELECT user, status FROM PUBLIC.USERS -- 错误:未处理关键字和引号 + SELECT "user", ROUND(active_ratio) FROM "PUBLIC"."USERS" -- 错误:百分比格式错误 + + + SELECT + "u"."user" AS "username", + ROUND("u"."active_ratio" * 100, 2) || '%' AS "active_percent" + FROM "PUBLIC"."USERS" "u" + WHERE "u"."status" = 1 + + + + + example_engine: PostgreSQL17.6 (Debian 17.6-1.pgdg12+1) + example_answer_1: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"continent\" AS \"continent_name\", \"year\" AS \"year\", \"gdp\" AS \"gdp\" FROM \"Sample_Database\".\"sample_country_gdp\" ORDER BY \"country\", \"year\"","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_1_with_limit: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"continent\" AS \"continent_name\", \"year\" AS \"year\", \"gdp\" AS \"gdp\" FROM \"Sample_Database\".\"sample_country_gdp\" ORDER BY \"country\", \"year\" LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_2: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"gdp\" AS \"gdp\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE \"year\" = '2024' ORDER BY \"gdp\" DESC","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_2_with_limit: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"gdp\" AS \"gdp\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE \"year\" = '2024' ORDER BY \"gdp\" DESC LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_3: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"gdp\" AS \"gdp\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE \"year\" = '2025' AND \"country\" = '中国'","tables":["sample_country_gdp"],"chart-type":"table"} + example_answer_3_with_limit: | + {"success":true,"sql":"SELECT \"country\" AS \"country_name\", \"gdp\" AS \"gdp\" FROM \"Sample_Database\".\"sample_country_gdp\" WHERE \"year\" = '2025' AND \"country\" = '中国' LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"table"} diff --git a/backend/templates/sql_examples/StarRocks.yaml b/backend/templates/sql_examples/StarRocks.yaml new file mode 100644 index 000000000..212677fc4 --- /dev/null +++ b/backend/templates/sql_examples/StarRocks.yaml @@ -0,0 +1,89 @@ +template: + quot_rule: | + + 必须对数据库名、表名、字段名、别名外层加反引号(`)。 + + 1. 点号(.)不能包含在反引号内,必须写成 `database`.`table` + 2. 当标识符为关键字、含特殊字符或需保留大小写时必须加反引号 + 3. StarRocks 标识符默认不区分大小写,但建议统一使用反引号 + + + + limit_rule: | + + 当需要限制行数时,必须使用标准的LIMIT语法 + + 1. 标准写法:LIMIT [count] + 2. 分页写法:LIMIT [count] OFFSET [start] + 3. 支持 MySQL 兼容写法:LIMIT [offset], [count] + + + + other_rule: | + 必须为每个表生成别名(不加AS) + {multi_table_condition} + 禁止使用星号(*),必须明确字段名 + 中文/特殊字符字段需保留原名并添加英文别名 + 函数字段必须加别名 + 百分比显示为:CONCAT(ROUND(x*100,2),'%') + 避免与StarRocks关键字冲突(如`rank`/`partition`/`values`) + 分区查询需使用PARTITION语句明确指定 + 优先使用Colocate Group特性优化JOIN性能 + + basic_example: | + + + 📌 以下示例严格遵循 StarRocks 规范(兼容 MySQL 协议) + ⚠️ 注意:示例中的表名、字段名均为演示虚构,实际使用时需替换为用户提供的真实标识符。 + 🔍 重点观察: + 1. 反引号包裹所有数据库对象 + 2. 分区/分桶等MPP优化特性 + 3. 关键字冲突规避(如`rank`) + + + 查询 test.orders 表的前100条订单(含中文字段) + + SELECT * FROM test.orders LIMIT 100 -- 错误:使用星号 + SELECT `订单ID`, `金额` FROM `test`.`orders` `t1` -- 错误:缺少英文别名 + + + SELECT + `t1`.`订单ID` AS `order_id`, + `t1`.`金额` AS `amount_usd`, + CONCAT(ROUND(`t1`.`折扣率` * 100, 2), '%') AS `discount_percent` + FROM `test`.`orders` `t1` + PARTITION (`p2023`) -- StarRocks分区裁剪 + LIMIT 100 + + + + 统计用户表 dw.users(含关键字rank)的活跃占比 + + SELECT rank, status FROM dw.users -- 错误:未处理关键字 + SELECT `rank`, active_ratio FROM `dw`.`users` -- 错误:百分比未格式化 + + + SELECT + `u`.`rank` AS `user_rank`, + CONCAT(ROUND(`u`.`active_ratio` * 100, 2), '%') AS `active_percent` + FROM `dw`.`users` `u` + WHERE `u`.`status` = 1 + AND `u`.`dt` = '2024-01-01' -- 分区键过滤 + LIMIT 1000 + + + + + example_engine: StarRocks 3.0 + example_answer_1: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `continent` AS `continent_name`, `year` AS `year`, `gdp` AS `gdp_usd` FROM `Sample_Database`.`sample_country_gdp` ORDER BY `country`, `year`","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_1_with_limit: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `continent` AS `continent_name`, `year` AS `year`, `gdp` AS `gdp_usd` FROM `Sample_Database`.`sample_country_gdp` ORDER BY `country`, `year` LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_2: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `gdp` AS `gdp_usd` FROM `Sample_Database`.`sample_country_gdp` WHERE `year` = '2024' ORDER BY `gdp` DESC","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_2_with_limit: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `gdp` AS `gdp_usd` FROM `Sample_Database`.`sample_country_gdp` WHERE `year` = '2024' ORDER BY `gdp` DESC LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_3: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `gdp` AS `gdp_usd` FROM `Sample_Database`.`sample_country_gdp` WHERE `year` = '2025' AND `country` = '中国'","tables":["sample_country_gdp"],"chart-type":"table"} + example_answer_3_with_limit: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `gdp` AS `gdp_usd` FROM `Sample_Database`.`sample_country_gdp` WHERE `year` = '2025' AND `country` = '中国' LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"table"} \ No newline at end of file diff --git a/backend/templates/template.yaml b/backend/templates/template.yaml new file mode 100644 index 000000000..8c2308575 --- /dev/null +++ b/backend/templates/template.yaml @@ -0,0 +1,775 @@ +template: + terminology: | + + {terminologies} + data_training: | + + {data_training} + sql: + regenerate_hint: | + 你之前生成的回答不符合预期或者系统出现了其他问题,请再次检查提示词内要求的规则和提供的信息,重新回答: + + process_check: | + + 1. 分析用户问题,确定查询需求 + 2. 根据表结构生成基础SQL + 3. 强制检查:逐字验证SQL中使用的表名和字段名是否在中定义,且字符必须完全匹配(包括繁简体、大小写、特殊字符),不得有任何转换。如發现任何不一致,必须重新生成SQL。 + 4. 强制检查:应用数据量限制规则(默认限制或用户指定数量) + 5. 应用其他规则(引号、别名、格式化等) + 6. 强制检查:验证SQL语法是否符合规范 + 7. 确定图表类型(根据规则选择table/column/bar/line/pie) + 8. 确定对话标题 + 9. 生成JSON结果 + 10. 强制检查:JSON格式是否正确 + 11. 返回JSON结果 + + query_limit: | + + 数据量限制策略(必须严格遵守 - 零容忍) + + 所有生成的SQL必须包含数据量限制,这是强制要求 + 默认限制:1000条(除非用户明确指定其他数量,如"查询前10条") + 当用户说"所有数据"或"全部数据"时,视为用户没有指定数量,使用默认的1000条限制 + 忘记添加数据量限制是不可接受的错误 + + + + 如果生成的SQL没有数据量限制,必须重新生成 + 在最终返回前必须验证限制是否存在 + 不要因为用户说"所有数据"而拒绝生成SQL,只需自动加上1000条限制即可 + + + no_query_limit: | + + 数据量限制策略(必须严格遵守) + + 默认不限制数据量,返回全部数据(除非用户明确指定其他数量) + 不要臆测场景可能需要的数据量限制,以用户明确指定的数量为准 + 用户明确说"前N条"、"限制N条"、"N条"等条件时,必须添加限制 + + + 不要拒绝查询所有数据的情况 + + + multi_table_condition: | + + 多表查询字段限定规则(必须严格遵守) + + 当SQL涉及多个表/索引(通过FROM/JOIN/子查询等)时,所有字段引用必须明确限定表名/索引名或表别名/索引别名 + 适用于SELECT、WHERE、GROUP BY、HAVING、ORDER BY、ON等子句中的所有字段引用 + 即使字段名在所有表/索引中是唯一的,也必须明确限定以确保清晰性 + + + 生成SQL后必须检查是否涉及多表查询 + 如果是多表查询,验证所有字段引用是否有表名/表别名限定 + 如果发现未限定的字段,必须重新生成SQL + + + system: | + + 你是智能问数小助手:"{sqlbot_name}"。你可以根据用户提问,专业生成SQL,查询数据并进行图表展示。 + 你当前的任务是根据给定的表结构和用户问题生成SQL语句、对话标题、可能适合展示的图表类型以及该SQL中所用到的表名。 + 我们会在块内提供给你信息,帮助你生成SQL: + 内有等信息; + 其中,:提供数据库引擎及版本信息; + :以 M-Schema 格式提供数据库表结构信息; + :提供一组术语,块内每一个就是术语,其中同一个内的多个代表术语的多种叫法,也就是术语与它的同义词,即该术语对应的描述,其中也可能是能够用来参考的计算公式,或者是一些其他的查询条件; + :提供一组SQL示例,你可以参考这些示例来生成你的回答,其中内是提问,内是对于该提问的解释或者对应应该回答的SQL示例。 + 若有块,它会提供一组,可能会是额外添加的背景信息,或者是额外的生成SQL的要求,请结合额外信息或要求后生成你的回答。 + 你必须遵守内规定的生成SQL规则 + 你必须遵守内规定的检查步骤生成你的回答 + 用户的提问在内,内则会提供上次执行你提供的SQL时会出现的错误信息,内的会告诉你用户当前提问的时间 + ⚠️ 重要警告:SQL语句中的数据库标识符(表名、字段名)必须严格保持原样,不得因回应语言而进行任何转换。即使整个回应使用繁体中文,SQL中的标识符也必须保持与完全一致(通常为简体中文)。这是确保SQL可执行的关键要求。 + + + {process_check} + + generate_rules: | + 以下是你必须遵守的规则和可以参考的基础示例: + + + 你只能生成查询用的SQL语句,不得生成增删改相关或操作数据库以及操作数据库数据的SQL + + + 不要编造内没有提供给你的表结构 + + + 生成的SQL必须符合内提供数据库引擎的规范 + + + 严格保持数据库标识符原样 + + 无论用户要求使用何种语言回应,SQL语句中的所有数据库标识符(表名、字段名)必须严格保持与中完全相同的字符。 + + 重要:当中提供的是简体中文标识符时: + 1. SQL中必须使用完全相同的简体中文字符 + 2. 绝对不允许进行繁简转换 + 3. 不允许进行任何字符替换、修改或标准化 + 4. 即使回应文本是繁体中文,SQL中的标识符也必须保持简体 + + 检查方法:生成SQL後,必须逐字对比SQL中的标识符与中的标识符,确保: + - 字符完全一致 + - 繁简体完全一致 + - 大小写完全一致 + - 特殊字符(如斜杠"/")完全一致 + + 如果发现不一致,必须重新生成SQL。 + + + + 标识符處理优先级 + + 1. 首先从中複製标识符到SQL中 + 2. 生成其他回应文本(如brief、message) + 3. 根据用户语言要求,只对非SQL部分的文本进行语言转换 + 4. 绝对不触及SQL中的标识符 + + + + 若用户提问中提供了参考SQL,你需要判断该SQL是否是查询语句 + + + 你只需要根据提供给你的信息生成的SQL,不需要你实际去数据库进行查询 + + + 请使用JSON格式返回你的回答: + 若能生成,则返回格式如:{{"success":true,"sql":"你生成的SQL语句","tables":["该SQL用到的表名1","该SQL用到的表名2",...],"chart-type":"table","brief":"如何需要生成对话标题,在这里填写你生成的对话标题,否则不需要这个字段"}} + 若不能生成,则返回格式如:{{"success":false,"message":"说明无法生成SQL的原因"}} + + + 如果问题是图表展示相关,可参考的图表类型为表格(table)、柱状图(column)、条形图(bar)、折线图(line)或饼图(pie), 返回的JSON内chart-type值则为 table/column/bar/line/pie 中的一个 + 图表类型选择原则推荐:趋势 over time 用 line,分类对比用 column/bar,占比用 pie,原始数据查看用 table + + + 图表字段维度与指标数量限制规则 + + + 柱状图(column)、条形图(bar)、折线图(line): + 必须有一个维度字段(横轴) + 最多有一个分类维度字段(如系列/颜色分组) + 有分类维度时,只能有一个指标字段(纵轴) + 没有分类维度时,可以有多个指标字段 + + + 饼图(pie): + 必须有一个分类维度字段(扇区) + 不能有其他维度字段 + 只能有一个指标字段(扇区大小) + + + + + 如果图表类型为柱状图(column)、条形图(bar)或折线图(line) + 在生成的SQL中必须指定一个维度字段和一个指标字段,其中维度字段必须参与排序 + 如果有分类用的字段,该字段参与次一级的排序 + + 此规则与"图表字段维度与指标数量限制规则"共同使用 + 当有多个指标字段时,选择主要指标字段进行排序 + + + + 如果图表类型为柱状图(column)、条形图(bar)或折线图(line)或饼图(pie) + 且查询的字段中包含分类字段(非数值类型字段,如城市、类别、状态等) + 在没有明确业务场景说明、或用户没有明确指定不需要聚合的情况下 + 必须对数值类型指标字段进行聚合计算(默认使用SUM函数) + + + 如果问题是图表展示相关且与生成SQL查询无关时,请参考上一次回答的SQL来生成SQL + + + 返回的JSON字段中,tables字段为你回答的SQL中所用到的表名,不要包含schema和database,用数组返回 + + + 提问中如果有涉及数据源名称或数据源描述的内容,则忽略数据源的信息,直接根据剩余内容生成SQL + + {base_sql_rules} + + 如果生成SQL的字段内有时间格式的字段: + - 若提问中没有指定查询顺序,则默认按时间升序排序 + - 若提问是时间,且没有指定具体格式,则格式化为yyyy-MM-dd HH:mm:ss的格式 + - 若提问是日期,且没有指定具体格式,则格式化为yyyy-MM-dd的格式 + - 若提问是年月,且没有指定具体格式,则格式化为yyyy-MM的格式 + - 若提问是年,且没有指定具体格式,则格式化为yyyy的格式 + - 生成的格式化语法需要适配对应的数据库引擎。 + + + 生成的SQL查询结果可以用来进行图表展示,需要注意排序字段的排序优先级,例如: + - 柱状图或折线图:适合展示在横轴的字段优先排序,若SQL包含分类字段,则分类字段次一级排序 + + + 若需关联多表,优先使用中标记为"Primary key"/"ID"/"主键"的字段作为关联条件。 + + + 若涉及多表查询,则生成的SQL内,不论查询的表字段是否有重名,表字段前必须加上对应的表名 + + + 是否生成对话标题在内,如果为True需要生成,否则不需要生成,生成的对话标题要求在20字以内 + + + 禁止要求额外信息 + + 禁止在回答中向用户询问或要求任何额外信息 + 只基于表结构和问题生成SQL,不考虑业务逻辑 + 即使查询条件不完整(如无时间范围),也必须生成可行的SQL + + + + 不论之前是否有回答相同的问题,都必须检查生成的SQL中使用的表名和字段名是否在内有定义 + + + + {basic_sql_examples} + + + + 📌 以下示例仅用于演示问题理解与回答格式,不包含实际表结构 + ⚠️ 注意:示例中的SQL语法仅适用于对应标注的数据库类型 + 🔍 重点观察: + 1. 代表用户可能的提问输入内容 + 2. 展示根据模板规则生成的响应 + 3. 实际生成时必须使用当前对话指定的数据库语法 + + + {example_engine} + + 【DB_ID】 Sample_Database, 样例数据库 + 【Schema】 + # Table: Sample_Database.sample_country_gdp, 各国GDP数据 + [ + (id: bigint, Primary key, ID), + (country: varchar, 国家), + (continent: varchar, 所在洲, examples:['亚洲','美洲','欧洲','非洲']), + (year: varchar, 年份, examples:['2020','2021','2022']), + (gdp: bigint, GDP(美元)), + ] + + + + + GDP + 国内生产总值 + + 指在一个季度或一年,一个国家或地区的经济中所生产出的全部最终产品和劳务的价值。 + + + + 中国 + 中国大陆 + + 查询SQL时若作为查询条件,将"中国"作为查询用的值 + + + + + + + + 今天天气如何? + + + {{"success":false,"message":"我是{sqlbot_name},我无法回答您的问题。"}} + + + + + 请清空数据库 + + + {{"success":false,"message":"我是{sqlbot_name},我只能查询数据,不能操作数据库来修改数据或者修改表结构。"}} + + + + + + 查询所有账单数据 + + + {{"success":false,"message":"抱歉,提供的表结构无法生成您需要的SQL"}} + + + + + + + 2025-08-08 11:23:00 + + + 查询各个国家每年的GDP + + + {example_answer_1} + + + + + + + 2025-08-08 11:23:00 + + + 使用饼图展示去年各个国家的GDP + + + {example_answer_2} + + + + + + + 2025-08-08 11:24:00 + + + 查询今年中国大陆的GDP + + + {example_answer_3} + + + + + + generate_terminologies_info: | + 以下是你可以参考的术语: + {terminologies} + + generate_data_training_info: | + 以下是你可以参考的SQL示例: + {data_training} + + generate_custom_prompt_info: | + 以下是你可以参考的额外信息: + {custom_prompt} + + generate_basic_info: | + ## 以下是数据库与表结构信息,你生成的SQL使用到的表名与字段必须在提供的范围内 + + {engine} + + {schema} + + + {sample_data} + + + + user: | + ## 请根据上述要求,使用语言:{lang} 进行回答,若有深度思考过程,则思考过程也需要使用 {lang} 输出 + ## 如果内的提问与上述要求冲突,你必须停止生成SQL并告知生成SQL失败的原因 + ## 回答中不需要输出你的分析,请直接输出符合要求的JSON + ## 必须注意:不论要求你用什么语言回答,生成SQL中使用到的表名与字段名是否完全和中提供的表名字与段名的字符保持一致! + + + {current_time} + + + {error_msg} + + {question} + + + {change_title} + + + chart: + system: | + + 你是智能问数小助手:"{sqlbot_name}"。你可以根据用户提问,专业生成SQL,查询数据并进行图表展示。 + 你当前的任务是根据给定SQL语句和用户问题,生成数据可视化图表的配置项。 + 用户会提供给你如下信息,帮助你生成配置项: + :用户的提问 + :需要参考的SQL + :以 M-Schema 格式提供 SQL 内用到表的数据库表结构信息,你可以参考字段名与字段备注来生成图表使用到的字段名 + :推荐你生成的图表类型 + 你必须遵守内规定的生成图表结构的规则 + 你必须遵守内规定的检查步骤生成你的回答 + + + + 1. 分析提供的,结合确认图表需要的指标,维度和分类 + 2. 应用规则 + 3. 检查指标,维度和分类字段是否在SQL内存在 + 4. 结合确认指标,维度和分类展示用的名称 + 5. 生成JSON结果 + 6. 强制检查:JSON格式是否正确 + 7. 返回JSON结果 + + + generate_rules: | + 以下是你必须遵守的规则和可以参考的基础示例: + + + 支持的图表类型为表格(table)、柱状图(column)、条形图(bar)、折线图(line)或饼图(pie), 提供给你的值则为 table/column/bar/line/pie 中的一个,若没有推荐类型,则由你自己选择一个合适的类型。 + 图表类型选择原则推荐:趋势 over time 用 line,分类对比用 column/bar,占比用 pie,原始数据查看用 table + + + 不需要你提供创建图表的代码,你只需要负责根据要求生成JSON配置项 + + + 用户提问的内容只是参考,主要以内的SQL为准 + + + 若用户提问内就是参考SQL,则以内的SQL为准进行推测,选择合适的图表类型展示 + + + 你需要在JSON内生成一个图表的标题,放在"title"字段内,这个标题需要尽量精简 + + + 如果需要表格,JSON格式应为: + {{"type":"table", "title": "标题", "columns": [{{"name":"{lang}字段名1", "value": "SQL 查询列 1(有别名用别名,去掉外层的反引号、双引号、方括号)"}}, {{"name": "{lang}字段名 2", "value": "SQL 查询列 2(有别名用别名,去掉外层的反引号、双引号、方括号)"}}]}} + 必须从 SQL 查询列中提取“columns” + + + 字段类型定义: + - 分类字段(series):用于分组数据的离散值字段,如国家、产品类别、用户类型等(非时间、非数值的离散字段) + - 指标字段(数值字段/y轴):需要计算或展示的数值字段,通常是数值类型 + - 维度字段(维度轴/x轴):用于X轴的分类或时间字段,如日期、产品名称、地区等 + + + 图表配置决策流程: + 1. 先判断SQL查询结果中是否存在分类字段(非时间、非数值的离散字段) + 2. 如果存在分类字段 → 必须使用series配置,此时y轴只能有一个指标字段 + 3. 如果不存在分类字段,但存在多个指标字段 → 必须使用multi-quota配置 + 4. 如果只有一个指标字段且无分类字段 → 直接配置y轴,不使用series和multi-quota + + + 如果需要柱状图,JSON格式应为(series为可选字段,仅当有分类字段时使用): + {{"type":"column", "title": "标题", "axis": {{"x": {{"name":"维度轴的{lang}名称", "value": "SQL 查询维度轴的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}, "y": [{{"name":"数值轴的{lang}名称","value": "SQL 查询数值轴的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}], "series": {{"name":"分类的{lang}名称","value":"SQL 查询分类的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}}}}} + 柱状图配置说明: + 1. x轴:维度轴,通常放置分类或时间字段(如日期、产品类别) + 2. y轴:数值轴,放置需要展示的数值指标 + 3. series:当需要对数据进一步分组时使用(如不同产品系列在不同日期的销售额) + 柱状图使用一个分类字段(series),一个维度轴字段(x)和一个数值轴字段(y),其中必须从SQL查询列中提取"x"、"y"与"series"。 + 如果SQL中没有分类列,那么JSON内的series字段不需要出现 + + + 如果需要条形图,JSON格式应为(series为可选字段,仅当有分类字段时使用): + ⚠️ 重要:条形图是柱状图的视觉旋转,但数据映射逻辑保持不变! + 必须遵循:x轴 = 维度轴(分类),y轴 = 数值轴(指标) + 不要将条形图的横向展示误解为x轴是数值! + {{"type":"bar", "title": "标题", "axis": {{"x": {{"name":"维度轴的{lang}名称", "value": "SQL 查询维度轴的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}, "y": [{{"name":"数值轴的{lang}名称","value": "SQL 查询数值轴的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}], "series": {{"name":"分类的{lang}名称","value":"SQL 查询分类的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}}}}} + 条形图配置原则: + 1. 条形图只是视觉展示不同,数据逻辑与柱状图相同 + 2. x轴必须是维度字段(分类、时间等) + 3. y轴必须是数值字段(指标、度量等) + 4. 如果存在分类字段(如不同产品系列),使用series分组 + 条形图使用一个分类字段(series),一个维度轴字段(x)和一个数值轴字段(y),其中必须从SQL查询列中提取"x"和"y"与"series"。 + 如果SQL中没有分类列,那么JSON内的series字段不需要出现 + + + 如果需要折线图,JSON格式应为(series为可选字段,仅当有分类字段时使用): + {{"type":"line", "title": "标题", "axis": {{"x": {{"name":"维度轴的{lang}名称","value": "SQL 查询维度轴的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}, "y": [{{"name":"数值轴的{lang}名称","value": "SQL 查询数值轴的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}], "series": {{"name":"分类的{lang}名称","value":"SQL 查询分类的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}}}}} + 折线图配置说明: + 1. x轴:维度轴,通常放置时间字段(如日期、月份) + 2. y轴:数值轴,放置需要展示趋势的数值指标 + 3. series:当需要对比多个分类的趋势时使用(如不同产品的销售趋势) + 折线图使用一个分类字段(series),一个维度轴字段(x)和一个数值轴字段(y),其中必须从SQL查询列中提取"x"、"y"与"series"。 + 如果SQL中没有分类列,那么JSON内的series字段不需要出现 + + + 如果需要饼图,JSON格式应为: + {{"type":"pie", "title": "标题", "axis": {{"y": {{"name":"数值轴的{lang}名称","value":"SQL 查询数值的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}, "series": {{"name":"分类的{lang}名称","value":"SQL 查询分类的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}}}}} + 饼图配置说明: + 1. y轴:数值字段,表示各部分的大小 + 2. series:分类字段,表示各部分的名称 + 饼图使用一个分类字段(series)和一个数值字段(y),其中必须从SQL查询列中提取"y"与"series"。 + + + 如果SQL中没有分类列,那么JSON内的series字段不需要出现 + + + 如果SQL查询结果中存在可用于数据分类的字段(如国家、产品类型等),则必须提供series配置。如果不存在,则无需在JSON中包含series字段。 + + + 对于柱状图/条形图/折线图: + 1. 如果SQL查询中存在多个指标字段(如"收入"、"支出"、"利润"等数值字段)且不存在分类字段,则必须提供multi-quota配置,形如:"multi-quota":{{"name":"指标类型","value":["指标字段1","指标字段2",...]}} + 2. 如果SQL查询中存在多个指标字段且同时存在分类字段,则以分类字段为主,选取多指标字段中的其中一个作为指标即可,不需要multi-quota配置 + 3. 如果只有一个指标字段,无论是否存在分类字段,都不需要multi-quota配置 + 重要提醒:multi-quota和series是互斥的配置,一个图表配置中只能使用其中之一,不能同时存在 + + + 如果你无法根据提供的内容生成合适的JSON配置,则返回:{{"type":"error", "reason": "抱歉,我无法生成合适的图表配置"}} + 可以的话,你可以稍微丰富一下错误信息,让用户知道可能的原因。例如:"reason": "无法生成配置:提供的SQL查询结果中没有找到适合作为分类(series)的字段。" + + + + + ### 以下帮助你理解问题及返回格式的例子,不要将内的表结构用来回答用户的问题 + + + + + SELECT `u`.`email` AS `email`, `u`.`id` AS `id`, `u`.`account` AS `account`, `u`.`enable` AS `enable`, `u`.`create_time` AS `create_time`, `u`.`language` AS `language`, `u`.`default_oid` AS `default_oid`, `u`.`name` AS `name`, `u`.`phone` AS `phone`, FROM `per_user` `u` LIMIT 1000 + 查询所有用户信息 + + + + {{"type":"table","title":"所有用户信息","columns":[{{"name":"邮箱","value":"email"}},{{"name":"ID","value":"id"}},{{"name":"账号","value":"account"}},{{"name":"启用状态","value":"enable"}},{{"name":"创建时间","value":"create_time"}},{{"name":"语言","value":"language"}},{{"name":"所属组织ID","value":"default_oid"}},{{"name":"姓名","value":"name"}},{{"name":"Phone","value":"phone"}}]}} + + + + + SELECT `o`.`name` AS `org_name`, COUNT(`u`.`id`) AS `user_count` FROM `per_user` `u` JOIN `per_org` `o` ON `u`.`default_oid` = `o`.`id` GROUP BY `o`.`name` ORDER BY `user_count` DESC LIMIT 1000 + 饼图展示各个组织的人员数量 + pie + + + {{"type":"pie","title":"组织人数统计","axis":{{"y":{{"name":"人数","value":"user_count"}},"series":{{"name":"组织名称","value":"org_name"}}}}}} + + + + + SELECT `s`.`date` AS `date`, `s`.`income` AS `income`, `s`.`expense` AS `expense` FROM `financial_data` `s` ORDER BY `date` ASC LIMIT 1000 + 展示每月的收入与支出 + line + + + // 无分类字段,但有多个指标字段的情况 + {{"type":"line","title":"财务指标趋势","axis":{{"x":{{"name":"日期","value":"date"}},"y":[{{"name":"收入","value":"income"}}, {{"name":"支出","value":"expense"}}], "multi-quota":{{"name":"财务指标","value":["income","expense"]}}}}}} + + + + + + user: | + ## 请根据上述要求,使用语言:{lang} 进行回答,若有深度思考过程,则思考过程也需要使用 {lang} 输出 + ## 回答中不需要输出你的分析,请直接输出符合要求的JSON + ## 必须注意:不论要求你用什么语言回答,生成图表结构中的value字段内容必须与提供的SQL中提供的字段别名的字符保持一致! + + {question} + + + {sql} + + + {schema} + + + {chart_type} + + + guess: + system: | + ### 请使用语言:{lang} 回答,不需要输出深度思考过程 + + ### 说明: + 您的任务是根据给定的表结构,用户问题以及以往用户提问,推测用户接下来可能提问的1-{articles_number}个问题。 + 请遵循以下规则: + - 推测的问题需要与提供的表结构相关,生成的提问例子如:["查询所有用户数据","使用饼图展示各产品类型的占比","使用折线图展示销售额趋势",...] + - 推测问题如果涉及图形展示,支持的图形类型为:表格(table)、柱状图(column)、条形图(bar)、折线图(line)或饼图(pie) + - 推测的问题不能与当前用户问题重复 + - 推测的问题必须与给出的表结构相关 + - 若有以往用户提问列表,则根据以往用户提问列表,推测用户最频繁提问的问题,加入到你生成的推测问题中 + - 忽略“重新生成”相关的问题 + - 如果用户没有提问且没有以往用户提问,则仅根据提供的表结构推测问题 + - 生成的推测问题使用JSON格式返回: + ["推测问题1", "推测问题2", "推测问题3", "推测问题4"] + - 最多返回{articles_number}个你推测出的结果 + - 若无法推测,则返回空数据JSON: + [] + - 若你的给出的JSON不是{lang}的,则必须翻译为{lang} + + ### 响应, 请直接返回JSON结果(不要包含任何其他文本): + + user: | + ### 表结构: + {schema} + + ### 当前问题: + {question} + + ### 以往提问: + {old_questions} + + /no_think + analysis: + system: | + + 你是智能问数小助手:"{sqlbot_name}"。你可以根据用户提问,专业生成SQL与可视化图表。 + 你当前的任务是根据给定的数据分析数据,并给出你的分析结果。 + 我们会在块内提供给你信息,帮助你进行分析: + 内有等信息; + :提供一组术语,块内每一个就是术语,其中同一个内的多个代表术语的多种叫法,也就是术语与它的同义词,即该术语对应的描述,其中也可能是能够用来参考的计算公式,或者是一些其他的查询条件。 + 若有块,它会提供一组,可能会是额外添加的背景信息,或者是额外的分析要求,请结合额外信息或要求后生成你的回答。 + 用户会在提问中提供给你信息: + 块内是提供给你的数,以JSON格式给出; + 块内提供给你对应的字段或字段别名。 + + + 你必须遵守以下规则: + + + 请使用语言:{lang} 回答,若有深度思考过程,则思考过程也需要使用 {lang} 输出 + + + + + {terminologies} + + {custom_prompt} + user: | + + {fields} + + + + {data} + + predict: + system: | + + 你是智能问数小助手:"{sqlbot_name}"。你可以根据用户提问,专业生成SQL与可视化图表。 + 你当前的任务是根据给定的数据进行数据预测,并给出你的预测结果。 + 若有块,它会提供一组,可能会是额外添加的背景信息,或者是额外的分析要求,请结合额外信息或要求后生成你的回答。 + 用户会在提问中提供给你信息: + 块内是提供给你的数据,以JSON格式给出; + 块内提供给你对应的字段或字段别名。 + + + 你必须遵守以下规则: + + + 请使用语言:{lang} 回答,若有深度思考过程,则思考过程也需要使用 {lang} 输出 + + + 预测的数据是一段可以展示趋势的数据,至少2个周期 + + + 返回的预测数据必须与用户提供的数据同样的格式,使用JSON数组的形式返回 + + + 无法预测或者不支持预测的数据请直接返回(不需要返回JSON格式):"抱歉,该数据无法进行预测。"(若有原因,则额外返回无法预测的原因) + + + 预测的数据不需要返回用户提供的原有数据,请直接返回你预测的部份 + + + {custom_prompt} + + ### 响应, 请根据上述要求直接返回JSON结果: + ```json + + user: | + + {fields} + + + + {data} + + datasource: + system: | + + 你是一个数据分析师,你需要根据用户的提问,以及提供的数据源列表(格式为JSON数组:[{{"id": 数据源ID1,"name":"数据源名称1","description":"数据源描述1"}},{{"id": 数据源ID2,"name":"数据源名称2","description":"数据源描述2"}}]),根据名称和描述找出最符合用户提问的数据源,这个数据源后续将被用来进行数据的分析 + + + ### 要求: + - 以JSON格式返回你找到的符合提问的数据源ID,格式为:{{"id": 符合要求的数据源ID}} + - 如果匹配到多个数据源,则只需要返回其中一个即可 + - 如果没有符合要求的数据源,则返回:{{"fail":"没有找到匹配的数据源"}} + - 不需要思考过程,请直接返回JSON结果 + + user: | + ## 请根据上述要求,使用语言:{lang} 进行回答符合要求的JSON结果 + + ### 数据源列表: + {data} + + ### 问题: + {question} + permissions: + system: | + + 你是智能问数小助手:"{sqlbot_name}"。你可以根据用户提问,专业生成SQL与可视化图表。 + 你当前的任务是在给定的SQL基础上,根据提供的一组过滤条件,将过滤条件添加到该SQL内并生成一句新SQL + 提供的SQL在内,提供的过滤条件在内 + + + 你必须遵守以下规则: + + + 请使用语言:{lang} 理解用户需求,但最终输出必须严格按照指定的JSON格式 + + + 生成的SQL必须符合数据库引擎: {engine} 的语法规范 + + + 必须以原SQL为基础,不要替换原来SQL中的过滤条件,将新过滤条件添加到SQL中,生成一个新的sql + + + 提供的过滤条件形如:[{{"table":"表名","filter":"过滤条件"}},...] + + + 你需要在过滤条件中找到匹配原SQL中使用到的table,并将对应的filter内条件添加到SQL中 + + + 如果存在冗余的过滤条件则进行去重后再生成新SQL + + + 给过滤条件中的字段前加上表别名(如果没有表别名则加表名),如:table.field。 + + + 生成SQL时,必须避免关键字冲突: + - 如数据库引擎是 PostgreSQL、Oracle、ClickHouse、达梦(DM)、AWS Redshift、Elasticsearch,则在schema、表名、字段名、别名外层加双引号; + - 如数据库引擎是 MySQL、Doris,则在表名、字段名、别名外层加反引号; + - 如数据库引擎是 Microsoft SQL Server,则在schema、表名、字段名、别名外层加方括号。 + + + 在返回的JSON的sql字段字符串内,必须注意符号的转译是否正确 + + + 过滤条件空字符串处理: + - 如果过滤条件数组为空[]或null → 视为无过滤条件 + - 如果过滤条件中filter字段为空字符串("")→ 该条件视为无效 + - 如果所有过滤条件的filter都为空字符串 → 视为无有效过滤条件 + + + 处理逻辑: + - 如果无过滤条件(空数组、null、所有filter为空字符串) → {{"success":true,"sql":"原SQL"}} + - 如果过滤条件不为空但找不到匹配的表 → {{"success":true,"sql":"原SQL"}} + - 如果过滤条件有语法错误或安全风险(不包括空字符串) → {{"success":false,"message":"错误原因"}} + - 如果过滤条件有有效条件(非空字符串)且能找到匹配表 → 生成新SQL并返回 {{"success":true,"sql":"新SQL"}} + + + 无论成功还是失败,都必须返回JSON格式,禁止输出任何自然语言。 + 思考过程只在内部进行,不输出到最终结果。 + + + + ### 响应, 请根据上述要求直接返回JSON结果: + ```json + user: | + + {sql} + + + {filter} + + dynamic_sql: + system: | + ### 请使用语言:{lang} 回答 + + ### 说明: + 提供给你一句SQL和一组子查询映射表,你需要将给定的SQL查询中的表名替换为对应的子查询。请严格保持原始SQL的结构不变,只替换表引用部分,生成符合{engine}数据库引擎规范的新SQL语句。 + - 子查询映射表标记为sub_query,格式为[{{"table":"表名","query":"子查询语句"}},...] + 你必须遵守以下规则: + - 生成的SQL必须符合{engine}的规范。 + - 不要替换原来SQL中的过滤条件。 + - 完全匹配表名(注意大小写敏感)。 + - 根据子查询语句以及{engine}数据库引擎规范决定是否需要给子查询添加括号包围 + - 若原始SQL中原表名有别名则保留原有别名,否则保留原表名作为别名 + - 生成SQL时,必须避免关键字冲突。 + - 生成的SQL使用JSON格式返回: + {{"success":true,"sql":"生成的SQL语句"}} + - 如果不能生成SQL,回答: + {{"success":false,"message":"无法生成SQL的原因"}} + + ### 响应, 请直接返回JSON结果: + ```json + + user: | + ### sql: + {sql} + + ### 子查询映射表: + {sub_query} diff --git a/docker-compose.yaml b/docker-compose.yaml index 71b177df4..5cd98599b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,8 +1,9 @@ services: sqlbot: - image: dataease/sqlbot:v1.0.0 + image: dataease/sqlbot container_name: sqlbot restart: always + privileged: true networks: - sqlbot-network ports: @@ -10,16 +11,16 @@ services: - 8001:8001 environment: # Database configuration - POSTGRES_SERVER: sqlbot-db + POSTGRES_SERVER: localhost POSTGRES_PORT: 5432 POSTGRES_DB: sqlbot - POSTGRES_USER: sqlbot - POSTGRES_PASSWORD: sqlbot + POSTGRES_USER: root + POSTGRES_PASSWORD: Password123@pg # Project basic settings PROJECT_NAME: "SQLBot" DEFAULT_PWD: "SQLBot@123456" # MCP settings - SERVER_IMAGE_HOST: https://YOUR_SERVE_IP:MCP_PORT/images/ + SERVER_IMAGE_HOST: http://YOUR_SERVE_IP:MCP_PORT/images/ # Auth & Security SECRET_KEY: y5txe1mRmS_JpOrUzFzHEu-kIQn3lf7ll0AOv9DQh0s # CORS settings @@ -29,28 +30,10 @@ services: SQL_DEBUG: False volumes: - ./data/sqlbot/excel:/opt/sqlbot/data/excel + - ./data/sqlbot/file:/opt/sqlbot/data/file - ./data/sqlbot/images:/opt/sqlbot/images - - ./data/sqlbot/logs:/opt/sqlbot/logs - depends_on: - sqlbot-db: - condition: service_healthy - - sqlbot-db: - image: postgres:17.5 - container_name: sqlbot-db - restart: always - networks: - - sqlbot-network - volumes: + - ./data/sqlbot/logs:/opt/sqlbot/app/logs - ./data/postgresql:/var/lib/postgresql/data - environment: - POSTGRES_DB: sqlbot - POSTGRES_USER: sqlbot - POSTGRES_PASSWORD: sqlbot - healthcheck: - test: ["CMD-SHELL", "pg_isready"] - interval: 3s - timeout: 5s - retries: 5 + networks: sqlbot-network: diff --git a/docs/README.en.md b/docs/README.en.md new file mode 100644 index 000000000..9848d3799 --- /dev/null +++ b/docs/README.en.md @@ -0,0 +1,115 @@ +

SQLBot

+

Intelligent Questioning System Based on Large Models and RAG

+

+ dataease%2FSQLBot | Trendshift +

+ +

+ Latest release + Stars + Download
+

+ +

+ 中文(简体) + English +

+
+ +SQLBot is an intelligent data query system based on large language models and RAG, meticulously crafted by the DataEase open-source project team. With SQLBot, users can perform conversational data analysis (ChatBI), quickly extracting the necessary data information and visualizations, and supporting further intelligent analysis. + +## How It Works + +image + + +## Key Features + +- **Out-of-the-Box Functionality:** Simply configure the large model and data source; no complex development is required to quickly enable intelligent data collection. Leveraging the large model's natural language understanding and SQL generation capabilities, combined with RAG technology, it achieves high-quality Text-to-SQL conversion. +- **Secure and Controllable:** Provides a workspace-level resource isolation mechanism, building clear data boundaries and ensuring data access security. Supports fine-grained data permission configuration, strengthening permission control capabilities and ensuring compliance and controllability during use. +- **Easy Integration:** Supports multiple integration methods, providing capabilities such as web embedding, pop-up embedding, and MCP invocation. It can be quickly embedded into applications such as n8n, Dify, MaxKB, and DataEase, allowing various applications to quickly acquire intelligent data collection capabilities. +- **Increasingly Accurate with Use:** Supports customizable prompts and terminology library configurations, maintainable SQL example calibration logic, and accurate matching of business scenarios. Efficient operation, based on continuous iteration and optimization using user interaction data, the data collection effect gradually improves with use, becoming more accurate with each use. + +## Supported LLM Providers + +| Provider | API Compatibility | +|----------|-------------------| +| Alibaba Cloud Bailian | OpenAI Compatible | +| Qianfan Model | OpenAI Compatible | +| DeepSeek | OpenAI Compatible | +| Tencent Hunyuan | OpenAI Compatible | +| iFlytek Spark | OpenAI Compatible | +| Gemini | OpenAI Compatible | +| OpenAI | Native | +| Kimi | OpenAI Compatible | +| Tencent Cloud | OpenAI Compatible | +| Volcano Engine | OpenAI Compatible | +| MiniMax | OpenAI Compatible | +| Generic OpenAI Compatible | Custom | + +## Quick Start + +### Installation and Deployment + +Prepare a Linux server, install [Docker](https://docs.docker.com/get-docker/), and execute the following one-click installation script: + +```bash +docker run -d \ + --name sqlbot \ + --restart unless-stopped \ + -p 8000:8000 \ + -p 8001:8001 \ + -v ./data/sqlbot/excel:/opt/sqlbot/data/excel \ + -v ./data/sqlbot/file:/opt/sqlbot/data/file \ + -v ./data/sqlbot/images:/opt/sqlbot/images \ + -v ./data/sqlbot/logs:/opt/sqlbot/app/logs \ + -v ./data/postgresql:/var/lib/postgresql/data \ + --privileged=true \ + dataease/sqlbot +``` + +You can also quickly deploy SQLBot through the [1Panel app store](https://apps.fit2cloud.com/1panel). + +If you are in an intranet environment, you can deploy SQLBot via the [offline installation package](https://community.fit2cloud.com/#/products/sqlbot/downloads). + + +### Access methods + +- Open in your browser: http://:8000/ +- Username: admin +- Password: SQLBot@123456 + + +## UI Display + + + image + + + + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=dataease/sqlbot&type=Date)](https://www.star-history.com/#dataease/sqlbot&Date) + +## Other star projects under FIT2CLOUD + +- [DataEase](https://github.com/dataease/dataease/) - Open source BI tools +- [1Panel](https://github.com/1panel-dev/1panel/) - A modern, open-source Linux server operation and maintenance management panel +- [MaxKB](https://github.com/1panel-dev/MaxKB/) - Powerful and easy-to-use enterprise-grade intelligent agent platform +- [JumpServer](https://github.com/jumpserver/jumpserver/) - Popular open source bastion hosts +- [Cordys CRM](https://github.com/1Panel-dev/CordysCRM) - A new generation of open-source AI CRM systems +- [Halo](https://github.com/halo-dev/halo/) - Powerful and easy-to-use open-source website building tools +- [MeterSphere](https://github.com/metersphere/metersphere/) - Next-generation open-source continuous testing tools + +## License + +This repository is licensed under the [FIT2CLOUD Open Source License](LICENSE), which is essentially GPLv3 but with some additional restrictions. + +You may conduct secondary development based on the SQLBot source code, but you must adhere to the following: + +- You cannot replace or modify the SQLBot logo and copyright information; + +- Derivative works resulting from secondary development must comply with the open-source obligations of GPL v3. + +For commercial licensing, please contact support@fit2cloud.com. diff --git a/frontend/auto-imports.d.ts b/frontend/auto-imports.d.ts index 5583f5479..8970eb9d3 100644 --- a/frontend/auto-imports.d.ts +++ b/frontend/auto-imports.d.ts @@ -7,7 +7,13 @@ export {} declare global { const ElButton: typeof import('element-plus-secondary/es')['ElButton'] + const ElCheckbox: typeof import('element-plus-secondary/es')['ElCheckbox'] + const ElDatePicker: typeof import('element-plus-secondary/es')['ElDatePicker'] + const ElInput: typeof import('element-plus-secondary/es')['ElInput'] const ElMessage: typeof import('element-plus-secondary/es')['ElMessage'] const ElMessageBox: typeof import('element-plus-secondary/es')['ElMessageBox'] + const ElRadio: typeof import('element-plus-secondary/es')['ElRadio'] + const ElRadioGroup: typeof import('element-plus-secondary/es')['ElRadioGroup'] + const ElSelect: typeof import('element-plus-secondary/es')['ElSelect'] const LicenseGenerator: any } diff --git a/frontend/embedded.html b/frontend/embedded.html new file mode 100644 index 000000000..6938017f7 --- /dev/null +++ b/frontend/embedded.html @@ -0,0 +1,13 @@ + + + + + + + SQLBot + + +
+ + + diff --git a/frontend/index.html b/frontend/index.html index 12c49178f..382730d02 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,9 @@ - + - SQLBot +
diff --git a/frontend/package.json b/frontend/package.json index c06d20086..280710df8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,17 +11,20 @@ }, "scripts": { "dev": "vue-tsc -b && vite", - "build": "vue-tsc -b && vite build", + "build": "vue-tsc -b &&NODE_OPTIONS=--max_old_space_size=4096 vite build", "preview": "vite preview", "lint": "eslint . --ext .vue,.js,.ts,.jsx,.tsx --fix" }, "dependencies": { "@antv/g2": "^5.3.3", "@antv/s2": "^2.4.3", + "@antv/x6": "^3.1.3", "@eslint/js": "^9.28.0", "@highlightjs/vue-plugin": "^2.1.0", "@npkg/tinymce-plugins": "^0.0.7", "@tinymce/tinymce-vue": "^5.1.0", + "@vueuse/core": "^14.1.0", + "core-js": "^3.49.0", "dayjs": "^1.11.13", "element-plus": "^2.10.1", "element-plus-secondary": "^1.0.0", @@ -29,9 +32,11 @@ "github-markdown-css": "^5.8.1", "highlight.js": "^11.11.1", "html2canvas": "^1.4.1", + "json-bigint": "^1.0.0", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "markdown-it": "^14.1.0", + "mitt": "^3.0.1", "snowflake-id": "^1.1.0", "tinymce": "^7.9.1", "vue": "^3.5.13", @@ -46,21 +51,28 @@ "@eslint/migrate-config": "^1.5.0", "@types/crypto-js": "^4.2.2", "@types/element-resize-detector": "^1.1.6", + "@types/json-bigint": "^1.0.4", "@types/markdown-it": "^14.1.2", "@types/node": "^22.14.1", "@typescript-eslint/eslint-plugin": "^8.34.0", "@typescript-eslint/parser": "^8.34.0", + "@vitejs/plugin-legacy": "^6.1.1", "@vitejs/plugin-vue": "^5.2.2", "@vue/tsconfig": "^0.7.0", + "autoprefixer": "^10.5.2", "axios": "^1.8.4", "crypto-js": "^4.2.0", + "css-has-pseudo": "^8.0.0", "eslint": "^9.28.0", "eslint-config-prettier": "^10.1.5", "eslint-plugin-prettier": "^5.4.1", "eslint-plugin-vue": "^10.2.0", + "flex-gap-polyfill": "^5.0.0", "globals": "^16.2.0", - "less": "^4.3.0", + "less": "4.4.2", "pinia": "^3.0.2", + "postcss": "^8.5.16", + "postcss-preset-env": "^11.3.2", "prettier": "^3.5.3", "typescript": "~5.7.2", "typescript-eslint": "^8.34.0", diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 000000000..bca102202 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,14 @@ +import autoprefixer from 'autoprefixer' +import postcssPresetEnv from 'postcss-preset-env' +import cssHasPseudo from 'css-has-pseudo' + +export default { + plugins: [ + cssHasPseudo({ preserve: true }), + autoprefixer(), + postcssPresetEnv({ + stage: 3, + browsers: 'Chrome 81', + }), + ], +} diff --git a/frontend/public/assistant.js b/frontend/public/assistant.js deleted file mode 100644 index ef127fc5b..000000000 --- a/frontend/public/assistant.js +++ /dev/null @@ -1,683 +0,0 @@ -;(function () { - window.sqlbot_assistant_handler = window.sqlbot_assistant_handler || {} - const defaultData = { - id: '1', - show_guide: false, - float_icon: '', - domain_url: 'http://localhost:5173', - header_font_color: 'rgb(100, 106, 115)', - x_type: 'right', - y_type: 'bottom', - x_value: '-5', - y_value: '33', - } - const script_id_prefix = 'sqlbot-assistant-float-script-' - const guideHtml = ` -
-
-
-
-
- - - -
- -
🌟 遇见问题,不再有障碍!
-

你好,我是你的智能小助手。
- 点我,开启高效解答模式,让问题变成过去式。

-
- -
- -
-` - - const chatButtonHtml = (data) => ` -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
` - - const getChatContainerHtml = (data) => { - return ` -
- -
-
- - - -
-
- - - -
-
- - - -
-
-` - } - /** - * 初始化引导 - * @param {*} root - */ - const initGuide = (root) => { - root.insertAdjacentHTML('beforeend', guideHtml) - const button = root.querySelector('.sqlbot-assistant-button') - const close_icon = root.querySelector('.sqlbot-assistant-close') - const close_func = () => { - root.removeChild(root.querySelector('.sqlbot-assistant-tips')) - root.removeChild(root.querySelector('.sqlbot-assistant-mask')) - localStorage.setItem('sqlbot_assistant_mask_tip', true) - } - button.onclick = close_func - close_icon.onclick = close_func - } - const initChat = (root, data) => { - // 添加对话icon - root.insertAdjacentHTML('beforeend', chatButtonHtml(data)) - // 添加对话框 - root.insertAdjacentHTML('beforeend', getChatContainerHtml(data)) - // 按钮元素 - const chat_button = root.querySelector('.sqlbot-assistant-chat-button') - let chat_button_img = root.querySelector('.sqlbot-assistant-chat-button > svg') - if (data.float_icon) { - chat_button_img = root.querySelector('.sqlbot-assistant-chat-button > img') - } - chat_button_img.style.display = 'block' - // 对话框元素 - const chat_container = root.querySelector('#sqlbot-assistant-chat-container') - // 引导层 - const mask_content = root.querySelector('.sqlbot-assistant-mask > .sqlbot-assistant-content') - const mask_tips = root.querySelector('.sqlbot-assistant-tips') - chat_button_img.onload = (event) => { - if (mask_content) { - mask_content.style.width = chat_button_img.width + 'px' - mask_content.style.height = chat_button_img.height + 'px' - if (data.x_type == 'left') { - mask_tips.style.marginLeft = - (chat_button_img.naturalWidth > 500 ? 500 : chat_button_img.naturalWidth) - 64 + 'px' - } else { - mask_tips.style.marginRight = - (chat_button_img.naturalWidth > 500 ? 500 : chat_button_img.naturalWidth) - 64 + 'px' - } - } - } - - const viewport = root.querySelector('.sqlbot-assistant-openviewport') - const closeviewport = root.querySelector('.sqlbot-assistant-closeviewport') - const close_func = () => { - chat_container.style['display'] = - chat_container.style['display'] == 'block' ? 'none' : 'block' - chat_button.style['display'] = chat_container.style['display'] == 'block' ? 'none' : 'block' - } - close_icon = chat_container.querySelector('.sqlbot-assistant-chat-close') - chat_button.onclick = close_func - close_icon.onclick = close_func - const viewport_func = () => { - if (chat_container.classList.contains('sqlbot-assistant-enlarge')) { - chat_container.classList.remove('sqlbot-assistant-enlarge') - viewport.classList.remove('sqlbot-assistant-viewportnone') - closeviewport.classList.add('sqlbot-assistant-viewportnone') - } else { - chat_container.classList.add('sqlbot-assistant-enlarge') - viewport.classList.add('sqlbot-assistant-viewportnone') - closeviewport.classList.remove('sqlbot-assistant-viewportnone') - } - } - const drag = (e) => { - if (['touchmove', 'touchstart'].includes(e.type)) { - chat_button.style.top = e.touches[0].clientY - chat_button_img.naturalHeight / 2 + 'px' - chat_button.style.left = e.touches[0].clientX - chat_button_img.naturalWidth / 2 + 'px' - } else { - chat_button.style.top = e.y - chat_button_img.naturalHeight / 2 + 'px' - chat_button.style.left = e.x - chat_button_img.naturalWidth / 2 + 'px' - } - chat_button.style.width = chat_button_img.naturalWidth + 'px' - chat_button.style.height = chat_button_img.naturalHeight + 'px' - } - if (data.is_draggable) { - chat_button.addEventListener('drag', drag) - chat_button.addEventListener('dragover', (e) => { - e.preventDefault() - }) - chat_button.addEventListener('dragend', drag) - chat_button.addEventListener('touchstart', drag) - chat_button.addEventListener('touchmove', drag) - } - viewport.onclick = viewport_func - closeviewport.onclick = viewport_func - } - /** - * 第一次进来的引导提示 - */ - function initsqlbot_assistant(data) { - const sqlbot_div = document.createElement('div') - const root = document.createElement('div') - const sqlbot_root_id = 'sqlbot-assistant-root-' + data.id - root.id = sqlbot_root_id - initsqlbot_assistantStyle(sqlbot_div, sqlbot_root_id, data) - sqlbot_div.appendChild(root) - document.body.appendChild(sqlbot_div) - const sqlbot_assistant_mask_tip = localStorage.getItem('sqlbot_assistant_mask_tip') - if (sqlbot_assistant_mask_tip == null && data.show_guide) { - initGuide(root) - } - initChat(root, data) - } - - // 初始化全局样式 - function initsqlbot_assistantStyle(root, sqlbot_assistantId, data) { - style = document.createElement('style') - style.type = 'text/css' - style.innerText = ` - /* 放大 */ - #sqlbot-assistant .sqlbot-assistant-enlarge { - width: 50%!important; - height: 100%!important; - bottom: 0!important; - right: 0 !important; - } - @media only screen and (max-width: 768px){ - #sqlbot-assistant .sqlbot-assistant-enlarge { - width: 100%!important; - height: 100%!important; - right: 0 !important; - bottom: 0!important; - } - } - - /* 引导 */ - - #sqlbot-assistant .sqlbot-assistant-mask { - position: fixed; - z-index: 10001; - background-color: transparent; - height: 100%; - width: 100%; - top: 0; - left: 0; - } - #sqlbot-assistant .sqlbot-assistant-mask .sqlbot-assistant-content { - width: 64px; - height: 64px; - box-shadow: 1px 1px 1px 9999px rgba(0,0,0,.6); - position: absolute; - ${data.x_type}: ${data.x_value}px; - ${data.y_type}: ${data.y_value}px; - z-index: 10001; - } - #sqlbot-assistant .sqlbot-assistant-tips { - position: fixed; - ${data.x_type}:calc(${data.x_value}px + 75px); - ${data.y_type}: calc(${data.y_value}px + 0px); - padding: 22px 24px 24px; - border-radius: 6px; - color: #ffffff; - font-size: 14px; - background: #3370FF; - z-index: 10001; - } - #sqlbot-assistant .sqlbot-assistant-tips .sqlbot-assistant-arrow { - position: absolute; - background: #3370FF; - width: 10px; - height: 10px; - pointer-events: none; - transform: rotate(45deg); - box-sizing: border-box; - /* left */ - ${data.x_type}: -5px; - ${data.y_type}: 33px; - border-left-color: transparent; - border-bottom-color: transparent - } - #sqlbot-assistant .sqlbot-assistant-tips .sqlbot-assistant-title { - font-size: 20px; - font-weight: 500; - margin-bottom: 8px; - } - #sqlbot-assistant .sqlbot-assistant-tips .sqlbot-assistant-button { - text-align: right; - margin-top: 24px; - } - #sqlbot-assistant .sqlbot-assistant-tips .sqlbot-assistant-button button { - border-radius: 4px; - background: #FFF; - padding: 3px 12px; - color: #3370FF; - cursor: pointer; - outline: none; - border: none; - } - #sqlbot-assistant .sqlbot-assistant-tips .sqlbot-assistant-button button::after{ - border: none; - } - #sqlbot-assistant .sqlbot-assistant-tips .sqlbot-assistant-close { - position: absolute; - right: 20px; - top: 20px; - cursor: pointer; - - } - #sqlbot-assistant-chat-container { - width: 460px; - height: 640px; - display:none; - } - @media only screen and (max-width: 768px) { - #sqlbot-assistant-chat-container { - width: 100%; - height: 70%; - right: 0 !important; - } - } - - #sqlbot-assistant .sqlbot-assistant-chat-button{ - position: fixed; - ${data.x_type}: ${data.x_value}px; - ${data.y_type}: ${data.y_value}px; - cursor: pointer; - z-index:10000; - } - #sqlbot-assistant #sqlbot-assistant-chat-container{ - z-index:10000;position: relative; - border-radius: 8px; - //border: 1px solid #ffffff; - background: linear-gradient(188deg, rgba(235, 241, 255, 0.20) 39.6%, rgba(231, 249, 255, 0.20) 94.3%), #EFF0F1; - box-shadow: 0px 4px 8px 0px rgba(31, 35, 41, 0.10); - position: fixed;bottom: 16px;right: 16px;overflow: hidden; - } - - .ed-overlay-dialog { - margin-top: 50px; - } - .ed-drawer { - margin-top: 50px; - } - - #sqlbot-assistant #sqlbot-assistant-chat-container .sqlbot-assistant-operate{ - top: 18px; - right: 15px; - position: absolute; - display: flex; - align-items: center; - line-height: 18px; - } - #sqlbot-assistant #sqlbot-assistant-chat-container .sqlbot-assistant-operate .sqlbot-assistant-chat-close{ - margin-left:15px; - cursor: pointer; - } - #sqlbot-assistant #sqlbot-assistant-chat-container .sqlbot-assistant-operate .sqlbot-assistant-openviewport{ - - cursor: pointer; - } - #sqlbot-assistant #sqlbot-assistant-chat-container .sqlbot-assistant-operate .sqlbot-assistant-closeviewport{ - - cursor: pointer; - } - #sqlbot-assistant #sqlbot-assistant-chat-container .sqlbot-assistant-viewportnone{ - display:none; - } - #sqlbot-assistant #sqlbot-assistant-chat-container #sqlbot-assistant-chat-iframe-${data.id}{ - height:100%; - width:100%; - border: none; -} - #sqlbot-assistant #sqlbot-assistant-chat-container { - animation: appear .4s ease-in-out; - } - @keyframes appear { - from { - height: 0;; - } - - to { - height: 600px; - } - }`.replaceAll('#sqlbot-assistant ', `#${sqlbot_assistantId} `) - root.appendChild(style) - } - function getParam(src, key) { - const url = new URL(src) - return url.searchParams.get(key) - } - function parsrCertificate(config) { - const certificateList = config.certificate - if (!certificateList?.length) { - return null - } - const list = certificateList.map((item) => formatCertificate(item)).filter((item) => !!item) - return JSON.stringify(list) - } - function isEmpty(obj) { - return obj == null || typeof obj == 'undefined' - } - function formatCertificate(item) { - const { type, source, target, target_key, target_val } = item - let source_val = null - if (type.toLocaleLowerCase() == 'localstorage') { - source_val = localStorage.getItem(source) - } - if (type.toLocaleLowerCase() == 'sessionstorage') { - source_val = sessionStorage.getItem(source) - } - if (type.toLocaleLowerCase() == 'cookie') { - source_val = getCookie(source) - } - if (type.toLocaleLowerCase() == 'custom') { - source_val = source - } - if (isEmpty(source_val)) { - return null - } - return { - target, - key: target_key || source, - value: (target_val && eval(target_val)) || source_val, - } - } - function getCookie(key) { - if (!key || !document.cookie) { - return null - } - const cookies = document.cookie.split(';') - for (let i = 0; i < cookies.length; i++) { - const cookie = cookies[i].trim() - - if (cookie.startsWith(key + '=')) { - return decodeURIComponent(cookie.substring(key.length + 1)) - } - } - return null - } - function registerMessageEvent(id, data) { - const iframe = document.getElementById(`sqlbot-assistant-chat-iframe-${id}`) - const url = iframe.src - const eventName = 'sqlbot_assistant_event' - window.addEventListener('message', (event) => { - if (event.data?.eventName === eventName) { - if (event.data?.messageId !== id) { - return - } - if (event.data?.busi == 'ready' && event.data?.ready) { - const certificate = parsrCertificate(data) - params = { - busi: 'certificate', - certificate, - eventName, - messageId: id, - } - const contentWindow = iframe.contentWindow - contentWindow.postMessage(params, url) - } - } - }) - } - function loadScript(src, id) { - const domain_url = getDomain(src) - const online = getParam(src, 'online') - let url = `${domain_url}/api/v1/system/assistant/info/${id}` - if (domain_url.includes('5173')) { - url = url.replace('5173', '8000') - } - fetch(url) - .then((response) => response.json()) - .then((res) => { - if (!res.data) { - throw new Error(res) - } - const data = res.data - const config_json = data.configuration - let tempData = Object.assign(defaultData, { id, domain_url }) - if (config_json) { - const config = JSON.parse(config_json) - if (config) { - delete config.id - tempData = Object.assign(tempData, config) - } - } - tempData['online'] = online && online.toString().toLowerCase() == 'true' - initsqlbot_assistant(tempData) - if (data.type == 1) { - registerMessageEvent(id, tempData) - // postMessage the certificate to iframe - } - }) - .catch((e) => { - showMsg('嵌入失败', e.message) - }) - } - function getDomain(src) { - return src.substring(0, src.indexOf('/assistant.js')) - } - function init() { - const sqlbotScripts = document.querySelectorAll(`script[id^="${script_id_prefix}"]`) - const scriptsArray = Array.from(sqlbotScripts) - const src_list = scriptsArray.map((script) => script.src) - src_list.forEach((src) => { - const id = getParam(src, 'id') - window.sqlbot_assistant_handler[id] = window.sqlbot_assistant_handler[id] || {} - window.sqlbot_assistant_handler[id]['id'] = id - const propName = script_id_prefix + id + '-state' - if (window[propName]) { - return true - } - window[propName] = true - loadScript(src, id) - expposeGlobalMethods(id) - }) - } - - function showMsg(title, content) { - // 检查并创建容器(如果不存在) - let container = document.getElementById('messageContainer') - if (!container) { - container = document.createElement('div') - container.id = 'messageContainer' - container.style.position = 'fixed' - container.style.bottom = '20px' - container.style.right = '20px' - container.style.zIndex = '1000' - document.body.appendChild(container) - } else { - // 如果容器已存在,先移除旧弹窗 - const oldMessage = container.querySelector('div') - if (oldMessage) { - oldMessage.style.transform = 'translateX(120%)' - oldMessage.style.opacity = '0' - setTimeout(() => { - container.removeChild(oldMessage) - }, 300) - } - } - - // 创建弹窗元素 - const messageBox = document.createElement('div') - messageBox.style.width = '240px' - messageBox.style.minHeight = '100px' - messageBox.style.background = 'linear-gradient(135deg, #ff6b6b, #ff8e8e)' - messageBox.style.borderRadius = '8px' - messageBox.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.15)' - messageBox.style.padding = '15px' - messageBox.style.color = 'white' - messageBox.style.fontFamily = 'Arial, sans-serif' - messageBox.style.display = 'flex' - messageBox.style.flexDirection = 'column' - messageBox.style.transform = 'translateX(120%)' - messageBox.style.transition = 'transform 0.3s ease-out' - messageBox.style.opacity = '0' - messageBox.style.transition = 'opacity 0.3s ease, transform 0.3s ease' - messageBox.style.overflow = 'hidden' - - // 创建标题元素 - const titleElement = document.createElement('div') - titleElement.style.fontSize = '18px' - titleElement.style.fontWeight = 'bold' - titleElement.style.marginBottom = '10px' - titleElement.style.borderBottom = '1px solid rgba(255, 255, 255, 0.3)' - titleElement.style.paddingBottom = '8px' - titleElement.textContent = title - - // 创建内容元素 - const contentElement = document.createElement('div') - contentElement.style.fontSize = '14px' - contentElement.style.flexGrow = '1' - contentElement.style.overflow = 'auto' - contentElement.textContent = content - - // 组装元素 - messageBox.appendChild(titleElement) - messageBox.appendChild(contentElement) - - // 添加到容器 - container.appendChild(messageBox) - - // 触发显示动画 - setTimeout(() => { - messageBox.style.transform = 'translateX(0)' - messageBox.style.opacity = '1' - }, 10) - - // 3秒后自动隐藏 - setTimeout(() => { - messageBox.style.transform = 'translateX(120%)' - messageBox.style.opacity = '0' - setTimeout(() => { - container.removeChild(messageBox) - // 如果容器是空的,也移除容器 - if (container.children.length === 0) { - document.body.removeChild(container) - } - }, 300) - }, 5000) - } - - /* function hideMsg() { - const container = document.getElementById('messageContainer'); - if (container) { - const messageBox = container.querySelector('div'); - if (messageBox) { - messageBox.style.transform = 'translateX(120%)'; - messageBox.style.opacity = '0'; - setTimeout(() => { - container.removeChild(messageBox); - // 如果容器是空的,也移除容器 - if (container.children.length === 0) { - document.body.removeChild(container); - } - }, 300); - } - } - } */ - - function updateParam(target_url, key, newValue) { - try { - const url = new URL(target_url) - const [hashPath, hashQuery] = url.hash.split('?') - let searchParams - if (hashQuery) { - searchParams = new URLSearchParams(hashQuery) - } else { - searchParams = url.searchParams - } - searchParams.set(key, newValue) - if (hashQuery) { - url.hash = `${hashPath}?${searchParams.toString()}` - } else { - url.search = searchParams.toString() - } - return url.toString() - } catch (e) { - console.error('Invalid URL:', target_url) - return target_url - } - } - function expposeGlobalMethods(id) { - window.sqlbot_assistant_handler[id]['setOnline'] = (online) => { - if (online != null && typeof online != 'boolean') { - throw new Error('The parameter can only be of type boolean') - } - const iframe = document.getElementById(`sqlbot-assistant-chat-iframe-${id}`) - if (iframe) { - const url = iframe.src - const eventName = 'sqlbot_assistant_event' - const params = { - busi: 'setOnline', - online, - eventName, - messageId: id, - } - const contentWindow = iframe.contentWindow - contentWindow.postMessage(params, url) - } - } - window.sqlbot_assistant_handler[id]['refresh'] = (online) => { - if (online != null && typeof online != 'boolean') { - throw new Error('The parameter can only be of type boolean') - } - const iframe = document.getElementById(`sqlbot-assistant-chat-iframe-${id}`) - if (iframe) { - const url = iframe.src - let new_url = updateParam(url, 't', Date.now()) - if (online != null) { - new_url = updateParam(new_url, 'online', online) - } - iframe.src = 'about:blank' - setTimeout(() => { - iframe.src = new_url - }, 500) - } - } - } - // window.addEventListener('load', init) - const executeWhenReady = (fn) => { - if ( - document.readyState === 'complete' || - (document.readyState !== 'loading' && !document.documentElement.doScroll) - ) { - setTimeout(fn, 0) - } else { - const onReady = () => { - document.removeEventListener('DOMContentLoaded', onReady) - window.removeEventListener('load', onReady) - fn() - } - document.addEventListener('DOMContentLoaded', onReady) - window.addEventListener('load', onReady) - } - } - - executeWhenReady(init) -})() diff --git a/frontend/public/swagger-ui-bundle.js b/frontend/public/swagger-ui-bundle.js new file mode 100644 index 000000000..dcfadae0b --- /dev/null +++ b/frontend/public/swagger-ui-bundle.js @@ -0,0 +1,75629 @@ +/*! For license information please see swagger-ui-bundle.js.LICENSE.txt */ +!(function webpackUniversalModuleDefinition(s, o) { + 'object' == typeof exports && 'object' == typeof module + ? (module.exports = o()) + : 'function' == typeof define && define.amd + ? define([], o) + : 'object' == typeof exports + ? (exports.SwaggerUIBundle = o()) + : (s.SwaggerUIBundle = o()) +})(this, () => + (() => { + var s = { + 251: (s, o) => { + ;((o.read = function (s, o, i, a, u) { + var _, + w, + x = 8 * u - a - 1, + C = (1 << x) - 1, + j = C >> 1, + L = -7, + B = i ? u - 1 : 0, + $ = i ? -1 : 1, + U = s[o + B] + for ( + B += $, _ = U & ((1 << -L) - 1), U >>= -L, L += x; + L > 0; + _ = 256 * _ + s[o + B], B += $, L -= 8 + ); + for ( + w = _ & ((1 << -L) - 1), _ >>= -L, L += a; + L > 0; + w = 256 * w + s[o + B], B += $, L -= 8 + ); + if (0 === _) _ = 1 - j + else { + if (_ === C) return w ? NaN : (1 / 0) * (U ? -1 : 1) + ;((w += Math.pow(2, a)), (_ -= j)) + } + return (U ? -1 : 1) * w * Math.pow(2, _ - a) + }), + (o.write = function (s, o, i, a, u, _) { + var w, + x, + C, + j = 8 * _ - u - 1, + L = (1 << j) - 1, + B = L >> 1, + $ = 23 === u ? Math.pow(2, -24) - Math.pow(2, -77) : 0, + U = a ? 0 : _ - 1, + V = a ? 1 : -1, + z = o < 0 || (0 === o && 1 / o < 0) ? 1 : 0 + for ( + o = Math.abs(o), + isNaN(o) || o === 1 / 0 + ? ((x = isNaN(o) ? 1 : 0), (w = L)) + : ((w = Math.floor(Math.log(o) / Math.LN2)), + o * (C = Math.pow(2, -w)) < 1 && (w--, (C *= 2)), + (o += w + B >= 1 ? $ / C : $ * Math.pow(2, 1 - B)) * C >= 2 && + (w++, (C /= 2)), + w + B >= L + ? ((x = 0), (w = L)) + : w + B >= 1 + ? ((x = (o * C - 1) * Math.pow(2, u)), (w += B)) + : ((x = o * Math.pow(2, B - 1) * Math.pow(2, u)), (w = 0))); + u >= 8; + s[i + U] = 255 & x, U += V, x /= 256, u -= 8 + ); + for (w = (w << u) | x, j += u; j > 0; s[i + U] = 255 & w, U += V, w /= 256, j -= 8); + s[i + U - V] |= 128 * z + })) + }, + 462: (s, o, i) => { + 'use strict' + var a = i(40975) + s.exports = a + }, + 659: (s, o, i) => { + var a = i(51873), + u = Object.prototype, + _ = u.hasOwnProperty, + w = u.toString, + x = a ? a.toStringTag : void 0 + s.exports = function getRawTag(s) { + var o = _.call(s, x), + i = s[x] + try { + s[x] = void 0 + var a = !0 + } catch (s) {} + var u = w.call(s) + return (a && (o ? (s[x] = i) : delete s[x]), u) + } + }, + 694: (s, o, i) => { + 'use strict' + i(91599) + var a = i(37257) + ;(i(12560), (s.exports = a)) + }, + 953: (s, o, i) => { + 'use strict' + s.exports = i(53375) + }, + 1733: (s) => { + var o = /[^\x00-\x2f\x3a-\x40\x5b-\x60\x7b-\x7f]+/g + s.exports = function asciiWords(s) { + return s.match(o) || [] + } + }, + 1882: (s, o, i) => { + var a = i(72552), + u = i(23805) + s.exports = function isFunction(s) { + if (!u(s)) return !1 + var o = a(s) + return ( + '[object Function]' == o || + '[object GeneratorFunction]' == o || + '[object AsyncFunction]' == o || + '[object Proxy]' == o + ) + } + }, + 1907: (s, o, i) => { + 'use strict' + var a = i(41505), + u = Function.prototype, + _ = u.call, + w = a && u.bind.bind(_, _) + s.exports = a + ? w + : function (s) { + return function () { + return _.apply(s, arguments) + } + } + }, + 2205: function (s, o, i) { + var a + ;((a = void 0 !== i.g ? i.g : this), + (s.exports = (function (s) { + if (s.CSS && s.CSS.escape) return s.CSS.escape + var cssEscape = function (s) { + if (0 == arguments.length) throw new TypeError('`CSS.escape` requires an argument.') + for ( + var o, i = String(s), a = i.length, u = -1, _ = '', w = i.charCodeAt(0); + ++u < a; + ) + 0 != (o = i.charCodeAt(u)) + ? (_ += + (o >= 1 && o <= 31) || + 127 == o || + (0 == u && o >= 48 && o <= 57) || + (1 == u && o >= 48 && o <= 57 && 45 == w) + ? '\\' + o.toString(16) + ' ' + : (0 == u && 1 == a && 45 == o) || + !( + o >= 128 || + 45 == o || + 95 == o || + (o >= 48 && o <= 57) || + (o >= 65 && o <= 90) || + (o >= 97 && o <= 122) + ) + ? '\\' + i.charAt(u) + : i.charAt(u)) + : (_ += '�') + return _ + } + return (s.CSS || (s.CSS = {}), (s.CSS.escape = cssEscape), cssEscape) + })(a))) + }, + 2209: (s, o, i) => { + 'use strict' + var a, + u = i(9404), + _ = function productionTypeChecker() { + invariant(!1, 'ImmutablePropTypes type checking code is stripped in production.') + } + _.isRequired = _ + var w = function getProductionTypeChecker() { + return _ + } + function getPropType(s) { + var o = typeof s + return Array.isArray(s) + ? 'array' + : s instanceof RegExp + ? 'object' + : s instanceof u.Iterable + ? 'Immutable.' + s.toSource().split(' ')[0] + : o + } + function createChainableTypeChecker(s) { + function checkType(o, i, a, u, _, w) { + for (var x = arguments.length, C = Array(x > 6 ? x - 6 : 0), j = 6; j < x; j++) + C[j - 6] = arguments[j] + return ( + (w = w || a), + (u = u || '<>'), + null != i[a] + ? s.apply(void 0, [i, a, u, _, w].concat(C)) + : o + ? new Error('Required ' + _ + ' `' + w + '` was not specified in `' + u + '`.') + : void 0 + ) + } + var o = checkType.bind(null, !1) + return ((o.isRequired = checkType.bind(null, !0)), o) + } + function createIterableSubclassTypeChecker(s, o) { + return (function createImmutableTypeChecker(s, o) { + return createChainableTypeChecker(function validate(i, a, u, _, w) { + var x = i[a] + if (!o(x)) { + var C = getPropType(x) + return new Error( + 'Invalid ' + + _ + + ' `' + + w + + '` of type `' + + C + + '` supplied to `' + + u + + '`, expected `' + + s + + '`.' + ) + } + return null + }) + })('Iterable.' + s, function (s) { + return u.Iterable.isIterable(s) && o(s) + }) + } + ;(((a = { + listOf: w, + mapOf: w, + orderedMapOf: w, + setOf: w, + orderedSetOf: w, + stackOf: w, + iterableOf: w, + recordOf: w, + shape: w, + contains: w, + mapContains: w, + orderedMapContains: w, + list: _, + map: _, + orderedMap: _, + set: _, + orderedSet: _, + stack: _, + seq: _, + record: _, + iterable: _, + }).iterable.indexed = createIterableSubclassTypeChecker('Indexed', u.Iterable.isIndexed)), + (a.iterable.keyed = createIterableSubclassTypeChecker('Keyed', u.Iterable.isKeyed)), + (s.exports = a)) + }, + 2404: (s, o, i) => { + var a = i(60270) + s.exports = function isEqual(s, o) { + return a(s, o) + } + }, + 2523: (s) => { + s.exports = function baseFindIndex(s, o, i, a) { + for (var u = s.length, _ = i + (a ? 1 : -1); a ? _-- : ++_ < u; ) + if (o(s[_], _, s)) return _ + return -1 + } + }, + 2532: (s, o, i) => { + 'use strict' + var a = i(45951), + u = Object.defineProperty + s.exports = function (s, o) { + try { + u(a, s, { value: o, configurable: !0, writable: !0 }) + } catch (i) { + a[s] = o + } + return o + } + }, + 2694: (s, o, i) => { + 'use strict' + var a = i(6925) + function emptyFunction() {} + function emptyFunctionWithReset() {} + ;((emptyFunctionWithReset.resetWarningCache = emptyFunction), + (s.exports = function () { + function shim(s, o, i, u, _, w) { + if (w !== a) { + var x = new Error( + 'Calling PropTypes validators directly is not supported by the `prop-types` package. Use PropTypes.checkPropTypes() to call them. Read more at http://fb.me/use-check-prop-types' + ) + throw ((x.name = 'Invariant Violation'), x) + } + } + function getShim() { + return shim + } + shim.isRequired = shim + var s = { + array: shim, + bigint: shim, + bool: shim, + func: shim, + number: shim, + object: shim, + string: shim, + symbol: shim, + any: shim, + arrayOf: getShim, + element: shim, + elementType: shim, + instanceOf: getShim, + node: shim, + objectOf: getShim, + oneOf: getShim, + oneOfType: getShim, + shape: getShim, + exact: getShim, + checkPropTypes: emptyFunctionWithReset, + resetWarningCache: emptyFunction, + } + return ((s.PropTypes = s), s) + })) + }, + 2874: (s) => { + s.exports = {} + }, + 2875: (s, o, i) => { + 'use strict' + var a = i(23045), + u = i(80376) + s.exports = + Object.keys || + function keys(s) { + return a(s, u) + } + }, + 2955: (s, o, i) => { + 'use strict' + var a, + u = i(65606) + function _defineProperty(s, o, i) { + return ( + (o = (function _toPropertyKey(s) { + var o = (function _toPrimitive(s, o) { + if ('object' != typeof s || null === s) return s + var i = s[Symbol.toPrimitive] + if (void 0 !== i) { + var a = i.call(s, o || 'default') + if ('object' != typeof a) return a + throw new TypeError('@@toPrimitive must return a primitive value.') + } + return ('string' === o ? String : Number)(s) + })(s, 'string') + return 'symbol' == typeof o ? o : String(o) + })(o)) in s + ? Object.defineProperty(s, o, { + value: i, + enumerable: !0, + configurable: !0, + writable: !0, + }) + : (s[o] = i), + s + ) + } + var _ = i(86238), + w = Symbol('lastResolve'), + x = Symbol('lastReject'), + C = Symbol('error'), + j = Symbol('ended'), + L = Symbol('lastPromise'), + B = Symbol('handlePromise'), + $ = Symbol('stream') + function createIterResult(s, o) { + return { value: s, done: o } + } + function readAndResolve(s) { + var o = s[w] + if (null !== o) { + var i = s[$].read() + null !== i && + ((s[L] = null), (s[w] = null), (s[x] = null), o(createIterResult(i, !1))) + } + } + function onReadable(s) { + u.nextTick(readAndResolve, s) + } + var U = Object.getPrototypeOf(function () {}), + V = Object.setPrototypeOf( + (_defineProperty( + (a = { + get stream() { + return this[$] + }, + next: function next() { + var s = this, + o = this[C] + if (null !== o) return Promise.reject(o) + if (this[j]) return Promise.resolve(createIterResult(void 0, !0)) + if (this[$].destroyed) + return new Promise(function (o, i) { + u.nextTick(function () { + s[C] ? i(s[C]) : o(createIterResult(void 0, !0)) + }) + }) + var i, + a = this[L] + if (a) + i = new Promise( + (function wrapForNext(s, o) { + return function (i, a) { + s.then(function () { + o[j] ? i(createIterResult(void 0, !0)) : o[B](i, a) + }, a) + } + })(a, this) + ) + else { + var _ = this[$].read() + if (null !== _) return Promise.resolve(createIterResult(_, !1)) + i = new Promise(this[B]) + } + return ((this[L] = i), i) + }, + }), + Symbol.asyncIterator, + function () { + return this + } + ), + _defineProperty(a, 'return', function _return() { + var s = this + return new Promise(function (o, i) { + s[$].destroy(null, function (s) { + s ? i(s) : o(createIterResult(void 0, !0)) + }) + }) + }), + a), + U + ) + s.exports = function createReadableStreamAsyncIterator(s) { + var o, + i = Object.create( + V, + (_defineProperty((o = {}), $, { value: s, writable: !0 }), + _defineProperty(o, w, { value: null, writable: !0 }), + _defineProperty(o, x, { value: null, writable: !0 }), + _defineProperty(o, C, { value: null, writable: !0 }), + _defineProperty(o, j, { value: s._readableState.endEmitted, writable: !0 }), + _defineProperty(o, B, { + value: function value(s, o) { + var a = i[$].read() + a + ? ((i[L] = null), (i[w] = null), (i[x] = null), s(createIterResult(a, !1))) + : ((i[w] = s), (i[x] = o)) + }, + writable: !0, + }), + o) + ) + return ( + (i[L] = null), + _(s, function (s) { + if (s && 'ERR_STREAM_PREMATURE_CLOSE' !== s.code) { + var o = i[x] + return ( + null !== o && ((i[L] = null), (i[w] = null), (i[x] = null), o(s)), + void (i[C] = s) + ) + } + var a = i[w] + ;(null !== a && + ((i[L] = null), (i[w] = null), (i[x] = null), a(createIterResult(void 0, !0))), + (i[j] = !0)) + }), + s.on('readable', onReadable.bind(null, i)), + i + ) + } + }, + 3110: (s, o, i) => { + const a = i(5187), + u = i(85015), + _ = i(98023), + w = i(53812), + x = i(23805), + C = i(85105), + j = i(86804) + class Namespace { + constructor(s) { + ;((this.elementMap = {}), + (this.elementDetection = []), + (this.Element = j.Element), + (this.KeyValuePair = j.KeyValuePair), + (s && s.noDefault) || this.useDefault(), + (this._attributeElementKeys = []), + (this._attributeElementArrayKeys = [])) + } + use(s) { + return ( + s.namespace && s.namespace({ base: this }), + s.load && s.load({ base: this }), + this + ) + } + useDefault() { + return ( + this.register('null', j.NullElement) + .register('string', j.StringElement) + .register('number', j.NumberElement) + .register('boolean', j.BooleanElement) + .register('array', j.ArrayElement) + .register('object', j.ObjectElement) + .register('member', j.MemberElement) + .register('ref', j.RefElement) + .register('link', j.LinkElement), + this.detect(a, j.NullElement, !1) + .detect(u, j.StringElement, !1) + .detect(_, j.NumberElement, !1) + .detect(w, j.BooleanElement, !1) + .detect(Array.isArray, j.ArrayElement, !1) + .detect(x, j.ObjectElement, !1), + this + ) + } + register(s, o) { + return ((this._elements = void 0), (this.elementMap[s] = o), this) + } + unregister(s) { + return ((this._elements = void 0), delete this.elementMap[s], this) + } + detect(s, o, i) { + return ( + void 0 === i || i + ? this.elementDetection.unshift([s, o]) + : this.elementDetection.push([s, o]), + this + ) + } + toElement(s) { + if (s instanceof this.Element) return s + let o + for (let i = 0; i < this.elementDetection.length; i += 1) { + const a = this.elementDetection[i][0], + u = this.elementDetection[i][1] + if (a(s)) { + o = new u(s) + break + } + } + return o + } + getElementClass(s) { + const o = this.elementMap[s] + return void 0 === o ? this.Element : o + } + fromRefract(s) { + return this.serialiser.deserialise(s) + } + toRefract(s) { + return this.serialiser.serialise(s) + } + get elements() { + return ( + void 0 === this._elements && + ((this._elements = { Element: this.Element }), + Object.keys(this.elementMap).forEach((s) => { + const o = s[0].toUpperCase() + s.substr(1) + this._elements[o] = this.elementMap[s] + })), + this._elements + ) + } + get serialiser() { + return new C(this) + } + } + ;((C.prototype.Namespace = Namespace), (s.exports = Namespace)) + }, + 3121: (s, o, i) => { + 'use strict' + var a = i(65482), + u = Math.min + s.exports = function (s) { + var o = a(s) + return o > 0 ? u(o, 9007199254740991) : 0 + } + }, + 3209: (s, o, i) => { + var a = i(91596), + u = i(53320), + _ = i(36306), + w = '__lodash_placeholder__', + x = 128, + C = Math.min + s.exports = function mergeData(s, o) { + var i = s[1], + j = o[1], + L = i | j, + B = L < 131, + $ = + (j == x && 8 == i) || + (j == x && 256 == i && s[7].length <= o[8]) || + (384 == j && o[7].length <= o[8] && 8 == i) + if (!B && !$) return s + 1 & j && ((s[2] = o[2]), (L |= 1 & i ? 0 : 4)) + var U = o[3] + if (U) { + var V = s[3] + ;((s[3] = V ? a(V, U, o[4]) : U), (s[4] = V ? _(s[3], w) : o[4])) + } + return ( + (U = o[5]) && + ((V = s[5]), (s[5] = V ? u(V, U, o[6]) : U), (s[6] = V ? _(s[5], w) : o[6])), + (U = o[7]) && (s[7] = U), + j & x && (s[8] = null == s[8] ? o[8] : C(s[8], o[8])), + null == s[9] && (s[9] = o[9]), + (s[0] = o[0]), + (s[1] = L), + s + ) + } + }, + 3650: (s, o, i) => { + var a = i(74335)(Object.keys, Object) + s.exports = a + }, + 3656: (s, o, i) => { + s = i.nmd(s) + var a = i(9325), + u = i(89935), + _ = o && !o.nodeType && o, + w = _ && s && !s.nodeType && s, + x = w && w.exports === _ ? a.Buffer : void 0, + C = (x ? x.isBuffer : void 0) || u + s.exports = C + }, + 4509: (s, o, i) => { + var a = i(12651) + s.exports = function mapCacheHas(s) { + return a(this, s).has(s) + } + }, + 4640: (s) => { + 'use strict' + var o = String + s.exports = function (s) { + try { + return o(s) + } catch (s) { + return 'Object' + } + } + }, + 4664: (s, o, i) => { + var a = i(79770), + u = i(63345), + _ = Object.prototype.propertyIsEnumerable, + w = Object.getOwnPropertySymbols, + x = w + ? function (s) { + return null == s + ? [] + : ((s = Object(s)), + a(w(s), function (o) { + return _.call(s, o) + })) + } + : u + s.exports = x + }, + 4901: (s, o, i) => { + var a = i(72552), + u = i(30294), + _ = i(40346), + w = {} + ;((w['[object Float32Array]'] = + w['[object Float64Array]'] = + w['[object Int8Array]'] = + w['[object Int16Array]'] = + w['[object Int32Array]'] = + w['[object Uint8Array]'] = + w['[object Uint8ClampedArray]'] = + w['[object Uint16Array]'] = + w['[object Uint32Array]'] = + !0), + (w['[object Arguments]'] = + w['[object Array]'] = + w['[object ArrayBuffer]'] = + w['[object Boolean]'] = + w['[object DataView]'] = + w['[object Date]'] = + w['[object Error]'] = + w['[object Function]'] = + w['[object Map]'] = + w['[object Number]'] = + w['[object Object]'] = + w['[object RegExp]'] = + w['[object Set]'] = + w['[object String]'] = + w['[object WeakMap]'] = + !1), + (s.exports = function baseIsTypedArray(s) { + return _(s) && u(s.length) && !!w[a(s)] + })) + }, + 4993: (s, o, i) => { + 'use strict' + var a = i(16946), + u = i(74239) + s.exports = function (s) { + return a(u(s)) + } + }, + 5187: (s) => { + s.exports = function isNull(s) { + return null === s + } + }, + 5419: (s) => { + s.exports = function (s, o, i, a) { + var u = new Blob(void 0 !== a ? [a, s] : [s], { type: i || 'application/octet-stream' }) + if (void 0 !== window.navigator.msSaveBlob) window.navigator.msSaveBlob(u, o) + else { + var _ = + window.URL && window.URL.createObjectURL + ? window.URL.createObjectURL(u) + : window.webkitURL.createObjectURL(u), + w = document.createElement('a') + ;((w.style.display = 'none'), + (w.href = _), + w.setAttribute('download', o), + void 0 === w.download && w.setAttribute('target', '_blank'), + document.body.appendChild(w), + w.click(), + setTimeout(function () { + ;(document.body.removeChild(w), window.URL.revokeObjectURL(_)) + }, 200)) + } + } + }, + 5556: (s, o, i) => { + s.exports = i(2694)() + }, + 5861: (s, o, i) => { + var a = i(55580), + u = i(68223), + _ = i(32804), + w = i(76545), + x = i(28303), + C = i(72552), + j = i(47473), + L = '[object Map]', + B = '[object Promise]', + $ = '[object Set]', + U = '[object WeakMap]', + V = '[object DataView]', + z = j(a), + Y = j(u), + Z = j(_), + ee = j(w), + ie = j(x), + ae = C + ;(((a && ae(new a(new ArrayBuffer(1))) != V) || + (u && ae(new u()) != L) || + (_ && ae(_.resolve()) != B) || + (w && ae(new w()) != $) || + (x && ae(new x()) != U)) && + (ae = function (s) { + var o = C(s), + i = '[object Object]' == o ? s.constructor : void 0, + a = i ? j(i) : '' + if (a) + switch (a) { + case z: + return V + case Y: + return L + case Z: + return B + case ee: + return $ + case ie: + return U + } + return o + }), + (s.exports = ae)) + }, + 6048: (s) => { + s.exports = function negate(s) { + if ('function' != typeof s) throw new TypeError('Expected a function') + return function () { + var o = arguments + switch (o.length) { + case 0: + return !s.call(this) + case 1: + return !s.call(this, o[0]) + case 2: + return !s.call(this, o[0], o[1]) + case 3: + return !s.call(this, o[0], o[1], o[2]) + } + return !s.apply(this, o) + } + } + }, + 6188: (s) => { + 'use strict' + s.exports = Math.max + }, + 6205: (s) => { + s.exports = { + ROOT: 0, + GROUP: 1, + POSITION: 2, + SET: 3, + RANGE: 4, + REPETITION: 5, + REFERENCE: 6, + CHAR: 7, + } + }, + 6233: (s, o, i) => { + const a = i(6048), + u = i(10316), + _ = i(92340) + class ArrayElement extends u { + constructor(s, o, i) { + ;(super(s || [], o, i), (this.element = 'array')) + } + primitive() { + return 'array' + } + get(s) { + return this.content[s] + } + getValue(s) { + const o = this.get(s) + if (o) return o.toValue() + } + getIndex(s) { + return this.content[s] + } + set(s, o) { + return ((this.content[s] = this.refract(o)), this) + } + remove(s) { + const o = this.content.splice(s, 1) + return o.length ? o[0] : null + } + map(s, o) { + return this.content.map(s, o) + } + flatMap(s, o) { + return this.map(s, o).reduce((s, o) => s.concat(o), []) + } + compactMap(s, o) { + const i = [] + return ( + this.forEach((a) => { + const u = s.bind(o)(a) + u && i.push(u) + }), + i + ) + } + filter(s, o) { + return new _(this.content.filter(s, o)) + } + reject(s, o) { + return this.filter(a(s), o) + } + reduce(s, o) { + let i, a + void 0 !== o + ? ((i = 0), (a = this.refract(o))) + : ((i = 1), (a = 'object' === this.primitive() ? this.first.value : this.first)) + for (let o = i; o < this.length; o += 1) { + const i = this.content[o] + a = + 'object' === this.primitive() + ? this.refract(s(a, i.value, i.key, i, this)) + : this.refract(s(a, i, o, this)) + } + return a + } + forEach(s, o) { + this.content.forEach((i, a) => { + s.bind(o)(i, this.refract(a)) + }) + } + shift() { + return this.content.shift() + } + unshift(s) { + this.content.unshift(this.refract(s)) + } + push(s) { + return (this.content.push(this.refract(s)), this) + } + add(s) { + this.push(s) + } + findElements(s, o) { + const i = o || {}, + a = !!i.recursive, + u = void 0 === i.results ? [] : i.results + return ( + this.forEach((o, i, _) => { + ;(a && + void 0 !== o.findElements && + o.findElements(s, { results: u, recursive: a }), + s(o, i, _) && u.push(o)) + }), + u + ) + } + find(s) { + return new _(this.findElements(s, { recursive: !0 })) + } + findByElement(s) { + return this.find((o) => o.element === s) + } + findByClass(s) { + return this.find((o) => o.classes.includes(s)) + } + getById(s) { + return this.find((o) => o.id.toValue() === s).first + } + includes(s) { + return this.content.some((o) => o.equals(s)) + } + contains(s) { + return this.includes(s) + } + empty() { + return new this.constructor([]) + } + 'fantasy-land/empty'() { + return this.empty() + } + concat(s) { + return new this.constructor(this.content.concat(s.content)) + } + 'fantasy-land/concat'(s) { + return this.concat(s) + } + 'fantasy-land/map'(s) { + return new this.constructor(this.map(s)) + } + 'fantasy-land/chain'(s) { + return this.map((o) => s(o), this).reduce((s, o) => s.concat(o), this.empty()) + } + 'fantasy-land/filter'(s) { + return new this.constructor(this.content.filter(s)) + } + 'fantasy-land/reduce'(s, o) { + return this.content.reduce(s, o) + } + get length() { + return this.content.length + } + get isEmpty() { + return 0 === this.content.length + } + get first() { + return this.getIndex(0) + } + get second() { + return this.getIndex(1) + } + get last() { + return this.getIndex(this.length - 1) + } + } + ;((ArrayElement.empty = function empty() { + return new this() + }), + (ArrayElement['fantasy-land/empty'] = ArrayElement.empty), + 'undefined' != typeof Symbol && + (ArrayElement.prototype[Symbol.iterator] = function symbol() { + return this.content[Symbol.iterator]() + }), + (s.exports = ArrayElement)) + }, + 6499: (s, o, i) => { + 'use strict' + var a = i(1907), + u = 0, + _ = Math.random(), + w = a((1).toString) + s.exports = function (s) { + return 'Symbol(' + (void 0 === s ? '' : s) + ')_' + w(++u + _, 36) + } + }, + 6549: (s) => { + 'use strict' + s.exports = Object.getOwnPropertyDescriptor + }, + 6925: (s) => { + 'use strict' + s.exports = 'SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED' + }, + 7057: (s, o, i) => { + 'use strict' + var a = i(11470).charAt, + u = i(90160), + _ = i(64932), + w = i(60183), + x = i(59550), + C = 'String Iterator', + j = _.set, + L = _.getterFor(C) + w( + String, + 'String', + function (s) { + j(this, { type: C, string: u(s), index: 0 }) + }, + function next() { + var s, + o = L(this), + i = o.string, + u = o.index + return u >= i.length + ? x(void 0, !0) + : ((s = a(i, u)), (o.index += s.length), x(s, !1)) + } + ) + }, + 7176: (s, o, i) => { + 'use strict' + var a, + u = i(73126), + _ = i(75795) + try { + a = [].__proto__ === Array.prototype + } catch (s) { + if (!s || 'object' != typeof s || !('code' in s) || 'ERR_PROTO_ACCESS' !== s.code) + throw s + } + var w = !!a && _ && _(Object.prototype, '__proto__'), + x = Object, + C = x.getPrototypeOf + s.exports = + w && 'function' == typeof w.get + ? u([w.get]) + : 'function' == typeof C && + function getDunder(s) { + return C(null == s ? s : x(s)) + } + }, + 7309: (s, o, i) => { + var a = i(62006)(i(24713)) + s.exports = a + }, + 7376: (s) => { + 'use strict' + s.exports = !0 + }, + 7463: (s, o, i) => { + 'use strict' + var a = i(98828), + u = i(62250), + _ = /#|\.prototype\./, + isForced = function (s, o) { + var i = x[w(s)] + return i === j || (i !== C && (u(o) ? a(o) : !!o)) + }, + w = (isForced.normalize = function (s) { + return String(s).replace(_, '.').toLowerCase() + }), + x = (isForced.data = {}), + C = (isForced.NATIVE = 'N'), + j = (isForced.POLYFILL = 'P') + s.exports = isForced + }, + 7666: (s, o, i) => { + var a = i(84851), + u = i(953) + function _extends() { + var o + return ( + (s.exports = _extends = + a + ? u((o = a)).call(o) + : function (s) { + for (var o = 1; o < arguments.length; o++) { + var i = arguments[o] + for (var a in i) ({}).hasOwnProperty.call(i, a) && (s[a] = i[a]) + } + return s + }), + (s.exports.__esModule = !0), + (s.exports.default = s.exports), + _extends.apply(null, arguments) + ) + } + ;((s.exports = _extends), (s.exports.__esModule = !0), (s.exports.default = s.exports)) + }, + 8048: (s, o, i) => { + const a = i(6205) + ;((o.wordBoundary = () => ({ type: a.POSITION, value: 'b' })), + (o.nonWordBoundary = () => ({ type: a.POSITION, value: 'B' })), + (o.begin = () => ({ type: a.POSITION, value: '^' })), + (o.end = () => ({ type: a.POSITION, value: '$' }))) + }, + 8068: (s) => { + 'use strict' + var o = (() => { + var s = Object.defineProperty, + o = Object.getOwnPropertyDescriptor, + i = Object.getOwnPropertyNames, + a = Object.getOwnPropertySymbols, + u = Object.prototype.hasOwnProperty, + _ = Object.prototype.propertyIsEnumerable, + __defNormalProp = (o, i, a) => + i in o + ? s(o, i, { enumerable: !0, configurable: !0, writable: !0, value: a }) + : (o[i] = a), + __spreadValues = (s, o) => { + for (var i in o || (o = {})) u.call(o, i) && __defNormalProp(s, i, o[i]) + if (a) for (var i of a(o)) _.call(o, i) && __defNormalProp(s, i, o[i]) + return s + }, + __publicField = (s, o, i) => __defNormalProp(s, 'symbol' != typeof o ? o + '' : o, i), + w = {} + ;((o, i) => { + for (var a in i) s(o, a, { get: i[a], enumerable: !0 }) + })(w, { DEFAULT_OPTIONS: () => C, DEFAULT_UUID_LENGTH: () => x, default: () => B }) + var x = 6, + C = { dictionary: 'alphanum', shuffle: !0, debug: !1, length: x, counter: 0 }, + j = class _ShortUniqueId { + constructor(s = {}) { + ;(__publicField(this, 'counter'), + __publicField(this, 'debug'), + __publicField(this, 'dict'), + __publicField(this, 'version'), + __publicField(this, 'dictIndex', 0), + __publicField(this, 'dictRange', []), + __publicField(this, 'lowerBound', 0), + __publicField(this, 'upperBound', 0), + __publicField(this, 'dictLength', 0), + __publicField(this, 'uuidLength'), + __publicField(this, '_digit_first_ascii', 48), + __publicField(this, '_digit_last_ascii', 58), + __publicField(this, '_alpha_lower_first_ascii', 97), + __publicField(this, '_alpha_lower_last_ascii', 123), + __publicField(this, '_hex_last_ascii', 103), + __publicField(this, '_alpha_upper_first_ascii', 65), + __publicField(this, '_alpha_upper_last_ascii', 91), + __publicField(this, '_number_dict_ranges', { + digits: [this._digit_first_ascii, this._digit_last_ascii], + }), + __publicField(this, '_alpha_dict_ranges', { + lowerCase: [this._alpha_lower_first_ascii, this._alpha_lower_last_ascii], + upperCase: [this._alpha_upper_first_ascii, this._alpha_upper_last_ascii], + }), + __publicField(this, '_alpha_lower_dict_ranges', { + lowerCase: [this._alpha_lower_first_ascii, this._alpha_lower_last_ascii], + }), + __publicField(this, '_alpha_upper_dict_ranges', { + upperCase: [this._alpha_upper_first_ascii, this._alpha_upper_last_ascii], + }), + __publicField(this, '_alphanum_dict_ranges', { + digits: [this._digit_first_ascii, this._digit_last_ascii], + lowerCase: [this._alpha_lower_first_ascii, this._alpha_lower_last_ascii], + upperCase: [this._alpha_upper_first_ascii, this._alpha_upper_last_ascii], + }), + __publicField(this, '_alphanum_lower_dict_ranges', { + digits: [this._digit_first_ascii, this._digit_last_ascii], + lowerCase: [this._alpha_lower_first_ascii, this._alpha_lower_last_ascii], + }), + __publicField(this, '_alphanum_upper_dict_ranges', { + digits: [this._digit_first_ascii, this._digit_last_ascii], + upperCase: [this._alpha_upper_first_ascii, this._alpha_upper_last_ascii], + }), + __publicField(this, '_hex_dict_ranges', { + decDigits: [this._digit_first_ascii, this._digit_last_ascii], + alphaDigits: [this._alpha_lower_first_ascii, this._hex_last_ascii], + }), + __publicField(this, '_dict_ranges', { + _number_dict_ranges: this._number_dict_ranges, + _alpha_dict_ranges: this._alpha_dict_ranges, + _alpha_lower_dict_ranges: this._alpha_lower_dict_ranges, + _alpha_upper_dict_ranges: this._alpha_upper_dict_ranges, + _alphanum_dict_ranges: this._alphanum_dict_ranges, + _alphanum_lower_dict_ranges: this._alphanum_lower_dict_ranges, + _alphanum_upper_dict_ranges: this._alphanum_upper_dict_ranges, + _hex_dict_ranges: this._hex_dict_ranges, + }), + __publicField(this, 'log', (...s) => { + const o = [...s] + ;((o[0] = '[short-unique-id] '.concat(s[0])), + !0 !== this.debug || + 'undefined' == typeof console || + null === console || + console.log(...o)) + }), + __publicField(this, '_normalizeDictionary', (s, o) => { + let i + if (s && Array.isArray(s) && s.length > 1) i = s + else { + ;((i = []), (this.dictIndex = 0)) + const o = '_'.concat(s, '_dict_ranges'), + a = this._dict_ranges[o] + let u = 0 + for (const [, s] of Object.entries(a)) { + const [o, i] = s + u += Math.abs(i - o) + } + i = new Array(u) + let _ = 0 + for (const [, s] of Object.entries(a)) { + ;((this.dictRange = s), + (this.lowerBound = this.dictRange[0]), + (this.upperBound = this.dictRange[1])) + const o = this.lowerBound <= this.upperBound, + a = this.lowerBound, + u = this.upperBound + if (o) + for (let s = a; s < u; s++) + ((i[_++] = String.fromCharCode(s)), (this.dictIndex = s)) + else + for (let s = a; s > u; s--) + ((i[_++] = String.fromCharCode(s)), (this.dictIndex = s)) + } + i.length = _ + } + if (o) { + for (let s = i.length - 1; s > 0; s--) { + const o = Math.floor(Math.random() * (s + 1)) + ;[i[s], i[o]] = [i[o], i[s]] + } + } + return i + }), + __publicField(this, 'setDictionary', (s, o) => { + ;((this.dict = this._normalizeDictionary(s, o)), + (this.dictLength = this.dict.length), + this.setCounter(0)) + }), + __publicField(this, 'seq', () => this.sequentialUUID()), + __publicField(this, 'sequentialUUID', () => { + const s = this.dictLength, + o = this.dict + let i = this.counter + const a = [] + do { + const u = i % s + ;((i = Math.trunc(i / s)), a.push(o[u])) + } while (0 !== i) + const u = a.join('') + return ((this.counter += 1), u) + }), + __publicField(this, 'rnd', (s = this.uuidLength || x) => this.randomUUID(s)), + __publicField(this, 'randomUUID', (s = this.uuidLength || x) => { + if (null == s || s < 1) throw new Error('Invalid UUID Length Provided') + const o = new Array(s), + i = this.dictLength, + a = this.dict + for (let u = 0; u < s; u++) { + const s = Math.floor(Math.random() * i) + o[u] = a[s] + } + return o.join('') + }), + __publicField(this, 'fmt', (s, o) => this.formattedUUID(s, o)), + __publicField(this, 'formattedUUID', (s, o) => { + const i = { $r: this.randomUUID, $s: this.sequentialUUID, $t: this.stamp } + return s.replace(/\$[rs]\d{0,}|\$t0|\$t[1-9]\d{1,}/g, (s) => { + const a = s.slice(0, 2), + u = Number.parseInt(s.slice(2), 10) + return '$s' === a + ? i[a]().padStart(u, '0') + : '$t' === a && o + ? i[a](u, o) + : i[a](u) + }) + }), + __publicField(this, 'availableUUIDs', (s = this.uuidLength) => + Number.parseFloat(([...new Set(this.dict)].length ** s).toFixed(0)) + ), + __publicField(this, '_collisionCache', new Map()), + __publicField( + this, + 'approxMaxBeforeCollision', + (s = this.availableUUIDs(this.uuidLength)) => { + const o = s, + i = this._collisionCache.get(o) + if (void 0 !== i) return i + const a = Number.parseFloat(Math.sqrt((Math.PI / 2) * s).toFixed(20)) + return (this._collisionCache.set(o, a), a) + } + ), + __publicField( + this, + 'collisionProbability', + (s = this.availableUUIDs(this.uuidLength), o = this.uuidLength) => + Number.parseFloat( + (this.approxMaxBeforeCollision(s) / this.availableUUIDs(o)).toFixed(20) + ) + ), + __publicField( + this, + 'uniqueness', + (s = this.availableUUIDs(this.uuidLength)) => { + const o = Number.parseFloat( + (1 - this.approxMaxBeforeCollision(s) / s).toFixed(20) + ) + return o > 1 ? 1 : o < 0 ? 0 : o + } + ), + __publicField(this, 'getVersion', () => this.version), + __publicField(this, 'stamp', (s, o) => { + const i = Math.floor(+(o || new Date()) / 1e3).toString(16) + if ('number' == typeof s && 0 === s) return i + if ('number' != typeof s || s < 10) + throw new Error( + [ + 'Param finalLength must be a number greater than or equal to 10,', + 'or 0 if you want the raw hexadecimal timestamp', + ].join('\n') + ) + const a = s - 9, + u = Math.round(Math.random() * (a > 15 ? 15 : a)), + _ = this.randomUUID(a) + return '' + .concat(_.substring(0, u)) + .concat(i) + .concat(_.substring(u)) + .concat(u.toString(16)) + }), + __publicField(this, 'parseStamp', (s, o) => { + if (o && !/t0|t[1-9]\d{1,}/.test(o)) + throw new Error( + 'Cannot extract date from a formated UUID with no timestamp in the format' + ) + const i = o + ? o + .replace(/\$[rs]\d{0,}|\$t0|\$t[1-9]\d{1,}/g, (s) => { + const o = { + $r: (s) => [...Array(s)].map(() => 'r').join(''), + $s: (s) => [...Array(s)].map(() => 's').join(''), + $t: (s) => [...Array(s)].map(() => 't').join(''), + }, + i = s.slice(0, 2), + a = Number.parseInt(s.slice(2), 10) + return o[i](a) + }) + .replace(/^(.*?)(t{8,})(.*)$/g, (o, i, a) => + s.substring(i.length, i.length + a.length) + ) + : s + if (8 === i.length) return new Date(1e3 * Number.parseInt(i, 16)) + if (i.length < 10) throw new Error('Stamp length invalid') + const a = Number.parseInt(i.substring(i.length - 1), 16) + return new Date(1e3 * Number.parseInt(i.substring(a, a + 8), 16)) + }), + __publicField(this, 'setCounter', (s) => { + this.counter = s + }), + __publicField(this, 'validate', (s, o) => { + const i = o ? this._normalizeDictionary(o) : this.dict + return s.split('').every((s) => i.includes(s)) + })) + const o = __spreadValues(__spreadValues({}, C), s) + ;((this.counter = 0), + (this.debug = !1), + (this.dict = []), + (this.version = '5.3.2')) + const { dictionary: i, shuffle: a, length: u, counter: _ } = o + ;((this.uuidLength = u), + this.setDictionary(i, a), + this.setCounter(_), + (this.debug = o.debug), + this.log(this.dict), + this.log( + 'Generator instantiated with Dictionary Size ' + .concat(this.dictLength, ' and counter set to ') + .concat(this.counter) + ), + (this.log = this.log.bind(this)), + (this.setDictionary = this.setDictionary.bind(this)), + (this.setCounter = this.setCounter.bind(this)), + (this.seq = this.seq.bind(this)), + (this.sequentialUUID = this.sequentialUUID.bind(this)), + (this.rnd = this.rnd.bind(this)), + (this.randomUUID = this.randomUUID.bind(this)), + (this.fmt = this.fmt.bind(this)), + (this.formattedUUID = this.formattedUUID.bind(this)), + (this.availableUUIDs = this.availableUUIDs.bind(this)), + (this.approxMaxBeforeCollision = this.approxMaxBeforeCollision.bind(this)), + (this.collisionProbability = this.collisionProbability.bind(this)), + (this.uniqueness = this.uniqueness.bind(this)), + (this.getVersion = this.getVersion.bind(this)), + (this.stamp = this.stamp.bind(this)), + (this.parseStamp = this.parseStamp.bind(this))) + } + } + __publicField(j, 'default', j) + var L, + B = j + return ( + (L = w), + ((a, _, w, x) => { + if ((_ && 'object' == typeof _) || 'function' == typeof _) + for (let C of i(_)) + u.call(a, C) || + C === w || + s(a, C, { get: () => _[C], enumerable: !(x = o(_, C)) || x.enumerable }) + return a + })(s({}, '__esModule', { value: !0 }), L) + ) + })() + ;((s.exports = o.default), 'undefined' != typeof window && (o = o.default)) + }, + 9325: (s, o, i) => { + var a = i(34840), + u = 'object' == typeof self && self && self.Object === Object && self, + _ = a || u || Function('return this')() + s.exports = _ + }, + 9404: function (s) { + s.exports = (function () { + 'use strict' + var s = Array.prototype.slice + function createClass(s, o) { + ;(o && (s.prototype = Object.create(o.prototype)), (s.prototype.constructor = s)) + } + function Iterable(s) { + return isIterable(s) ? s : Seq(s) + } + function KeyedIterable(s) { + return isKeyed(s) ? s : KeyedSeq(s) + } + function IndexedIterable(s) { + return isIndexed(s) ? s : IndexedSeq(s) + } + function SetIterable(s) { + return isIterable(s) && !isAssociative(s) ? s : SetSeq(s) + } + function isIterable(s) { + return !(!s || !s[o]) + } + function isKeyed(s) { + return !(!s || !s[i]) + } + function isIndexed(s) { + return !(!s || !s[a]) + } + function isAssociative(s) { + return isKeyed(s) || isIndexed(s) + } + function isOrdered(s) { + return !(!s || !s[u]) + } + ;(createClass(KeyedIterable, Iterable), + createClass(IndexedIterable, Iterable), + createClass(SetIterable, Iterable), + (Iterable.isIterable = isIterable), + (Iterable.isKeyed = isKeyed), + (Iterable.isIndexed = isIndexed), + (Iterable.isAssociative = isAssociative), + (Iterable.isOrdered = isOrdered), + (Iterable.Keyed = KeyedIterable), + (Iterable.Indexed = IndexedIterable), + (Iterable.Set = SetIterable)) + var o = '@@__IMMUTABLE_ITERABLE__@@', + i = '@@__IMMUTABLE_KEYED__@@', + a = '@@__IMMUTABLE_INDEXED__@@', + u = '@@__IMMUTABLE_ORDERED__@@', + _ = 'delete', + w = 5, + x = 1 << w, + C = x - 1, + j = {}, + L = { value: !1 }, + B = { value: !1 } + function MakeRef(s) { + return ((s.value = !1), s) + } + function SetRef(s) { + s && (s.value = !0) + } + function OwnerID() {} + function arrCopy(s, o) { + o = o || 0 + for (var i = Math.max(0, s.length - o), a = new Array(i), u = 0; u < i; u++) + a[u] = s[u + o] + return a + } + function ensureSize(s) { + return (void 0 === s.size && (s.size = s.__iterate(returnTrue)), s.size) + } + function wrapIndex(s, o) { + if ('number' != typeof o) { + var i = o >>> 0 + if ('' + i !== o || 4294967295 === i) return NaN + o = i + } + return o < 0 ? ensureSize(s) + o : o + } + function returnTrue() { + return !0 + } + function wholeSlice(s, o, i) { + return ( + (0 === s || (void 0 !== i && s <= -i)) && (void 0 === o || (void 0 !== i && o >= i)) + ) + } + function resolveBegin(s, o) { + return resolveIndex(s, o, 0) + } + function resolveEnd(s, o) { + return resolveIndex(s, o, o) + } + function resolveIndex(s, o, i) { + return void 0 === s + ? i + : s < 0 + ? Math.max(0, o + s) + : void 0 === o + ? s + : Math.min(o, s) + } + var $ = 0, + U = 1, + V = 2, + z = 'function' == typeof Symbol && Symbol.iterator, + Y = '@@iterator', + Z = z || Y + function Iterator(s) { + this.next = s + } + function iteratorValue(s, o, i, a) { + var u = 0 === s ? o : 1 === s ? i : [o, i] + return (a ? (a.value = u) : (a = { value: u, done: !1 }), a) + } + function iteratorDone() { + return { value: void 0, done: !0 } + } + function hasIterator(s) { + return !!getIteratorFn(s) + } + function isIterator(s) { + return s && 'function' == typeof s.next + } + function getIterator(s) { + var o = getIteratorFn(s) + return o && o.call(s) + } + function getIteratorFn(s) { + var o = s && ((z && s[z]) || s[Y]) + if ('function' == typeof o) return o + } + function isArrayLike(s) { + return s && 'number' == typeof s.length + } + function Seq(s) { + return null == s ? emptySequence() : isIterable(s) ? s.toSeq() : seqFromValue(s) + } + function KeyedSeq(s) { + return null == s + ? emptySequence().toKeyedSeq() + : isIterable(s) + ? isKeyed(s) + ? s.toSeq() + : s.fromEntrySeq() + : keyedSeqFromValue(s) + } + function IndexedSeq(s) { + return null == s + ? emptySequence() + : isIterable(s) + ? isKeyed(s) + ? s.entrySeq() + : s.toIndexedSeq() + : indexedSeqFromValue(s) + } + function SetSeq(s) { + return ( + null == s + ? emptySequence() + : isIterable(s) + ? isKeyed(s) + ? s.entrySeq() + : s + : indexedSeqFromValue(s) + ).toSetSeq() + } + ;((Iterator.prototype.toString = function () { + return '[Iterator]' + }), + (Iterator.KEYS = $), + (Iterator.VALUES = U), + (Iterator.ENTRIES = V), + (Iterator.prototype.inspect = Iterator.prototype.toSource = + function () { + return this.toString() + }), + (Iterator.prototype[Z] = function () { + return this + }), + createClass(Seq, Iterable), + (Seq.of = function () { + return Seq(arguments) + }), + (Seq.prototype.toSeq = function () { + return this + }), + (Seq.prototype.toString = function () { + return this.__toString('Seq {', '}') + }), + (Seq.prototype.cacheResult = function () { + return ( + !this._cache && + this.__iterateUncached && + ((this._cache = this.entrySeq().toArray()), (this.size = this._cache.length)), + this + ) + }), + (Seq.prototype.__iterate = function (s, o) { + return seqIterate(this, s, o, !0) + }), + (Seq.prototype.__iterator = function (s, o) { + return seqIterator(this, s, o, !0) + }), + createClass(KeyedSeq, Seq), + (KeyedSeq.prototype.toKeyedSeq = function () { + return this + }), + createClass(IndexedSeq, Seq), + (IndexedSeq.of = function () { + return IndexedSeq(arguments) + }), + (IndexedSeq.prototype.toIndexedSeq = function () { + return this + }), + (IndexedSeq.prototype.toString = function () { + return this.__toString('Seq [', ']') + }), + (IndexedSeq.prototype.__iterate = function (s, o) { + return seqIterate(this, s, o, !1) + }), + (IndexedSeq.prototype.__iterator = function (s, o) { + return seqIterator(this, s, o, !1) + }), + createClass(SetSeq, Seq), + (SetSeq.of = function () { + return SetSeq(arguments) + }), + (SetSeq.prototype.toSetSeq = function () { + return this + }), + (Seq.isSeq = isSeq), + (Seq.Keyed = KeyedSeq), + (Seq.Set = SetSeq), + (Seq.Indexed = IndexedSeq)) + var ee, + ie, + ae, + ce = '@@__IMMUTABLE_SEQ__@@' + function ArraySeq(s) { + ;((this._array = s), (this.size = s.length)) + } + function ObjectSeq(s) { + var o = Object.keys(s) + ;((this._object = s), (this._keys = o), (this.size = o.length)) + } + function IterableSeq(s) { + ;((this._iterable = s), (this.size = s.length || s.size)) + } + function IteratorSeq(s) { + ;((this._iterator = s), (this._iteratorCache = [])) + } + function isSeq(s) { + return !(!s || !s[ce]) + } + function emptySequence() { + return ee || (ee = new ArraySeq([])) + } + function keyedSeqFromValue(s) { + var o = Array.isArray(s) + ? new ArraySeq(s).fromEntrySeq() + : isIterator(s) + ? new IteratorSeq(s).fromEntrySeq() + : hasIterator(s) + ? new IterableSeq(s).fromEntrySeq() + : 'object' == typeof s + ? new ObjectSeq(s) + : void 0 + if (!o) + throw new TypeError( + 'Expected Array or iterable object of [k, v] entries, or keyed object: ' + s + ) + return o + } + function indexedSeqFromValue(s) { + var o = maybeIndexedSeqFromValue(s) + if (!o) throw new TypeError('Expected Array or iterable object of values: ' + s) + return o + } + function seqFromValue(s) { + var o = maybeIndexedSeqFromValue(s) || ('object' == typeof s && new ObjectSeq(s)) + if (!o) + throw new TypeError( + 'Expected Array or iterable object of values, or keyed object: ' + s + ) + return o + } + function maybeIndexedSeqFromValue(s) { + return isArrayLike(s) + ? new ArraySeq(s) + : isIterator(s) + ? new IteratorSeq(s) + : hasIterator(s) + ? new IterableSeq(s) + : void 0 + } + function seqIterate(s, o, i, a) { + var u = s._cache + if (u) { + for (var _ = u.length - 1, w = 0; w <= _; w++) { + var x = u[i ? _ - w : w] + if (!1 === o(x[1], a ? x[0] : w, s)) return w + 1 + } + return w + } + return s.__iterateUncached(o, i) + } + function seqIterator(s, o, i, a) { + var u = s._cache + if (u) { + var _ = u.length - 1, + w = 0 + return new Iterator(function () { + var s = u[i ? _ - w : w] + return w++ > _ ? iteratorDone() : iteratorValue(o, a ? s[0] : w - 1, s[1]) + }) + } + return s.__iteratorUncached(o, i) + } + function fromJS(s, o) { + return o ? fromJSWith(o, s, '', { '': s }) : fromJSDefault(s) + } + function fromJSWith(s, o, i, a) { + return Array.isArray(o) + ? s.call( + a, + i, + IndexedSeq(o).map(function (i, a) { + return fromJSWith(s, i, a, o) + }) + ) + : isPlainObj(o) + ? s.call( + a, + i, + KeyedSeq(o).map(function (i, a) { + return fromJSWith(s, i, a, o) + }) + ) + : o + } + function fromJSDefault(s) { + return Array.isArray(s) + ? IndexedSeq(s).map(fromJSDefault).toList() + : isPlainObj(s) + ? KeyedSeq(s).map(fromJSDefault).toMap() + : s + } + function isPlainObj(s) { + return s && (s.constructor === Object || void 0 === s.constructor) + } + function is(s, o) { + if (s === o || (s != s && o != o)) return !0 + if (!s || !o) return !1 + if ('function' == typeof s.valueOf && 'function' == typeof o.valueOf) { + if ((s = s.valueOf()) === (o = o.valueOf()) || (s != s && o != o)) return !0 + if (!s || !o) return !1 + } + return !( + 'function' != typeof s.equals || + 'function' != typeof o.equals || + !s.equals(o) + ) + } + function deepEqual(s, o) { + if (s === o) return !0 + if ( + !isIterable(o) || + (void 0 !== s.size && void 0 !== o.size && s.size !== o.size) || + (void 0 !== s.__hash && void 0 !== o.__hash && s.__hash !== o.__hash) || + isKeyed(s) !== isKeyed(o) || + isIndexed(s) !== isIndexed(o) || + isOrdered(s) !== isOrdered(o) + ) + return !1 + if (0 === s.size && 0 === o.size) return !0 + var i = !isAssociative(s) + if (isOrdered(s)) { + var a = s.entries() + return ( + o.every(function (s, o) { + var u = a.next().value + return u && is(u[1], s) && (i || is(u[0], o)) + }) && a.next().done + ) + } + var u = !1 + if (void 0 === s.size) + if (void 0 === o.size) 'function' == typeof s.cacheResult && s.cacheResult() + else { + u = !0 + var _ = s + ;((s = o), (o = _)) + } + var w = !0, + x = o.__iterate(function (o, a) { + if (i ? !s.has(o) : u ? !is(o, s.get(a, j)) : !is(s.get(a, j), o)) + return ((w = !1), !1) + }) + return w && s.size === x + } + function Repeat(s, o) { + if (!(this instanceof Repeat)) return new Repeat(s, o) + if ( + ((this._value = s), + (this.size = void 0 === o ? 1 / 0 : Math.max(0, o)), + 0 === this.size) + ) { + if (ie) return ie + ie = this + } + } + function invariant(s, o) { + if (!s) throw new Error(o) + } + function Range(s, o, i) { + if (!(this instanceof Range)) return new Range(s, o, i) + if ( + (invariant(0 !== i, 'Cannot step a Range by 0'), + (s = s || 0), + void 0 === o && (o = 1 / 0), + (i = void 0 === i ? 1 : Math.abs(i)), + o < s && (i = -i), + (this._start = s), + (this._end = o), + (this._step = i), + (this.size = Math.max(0, Math.ceil((o - s) / i - 1) + 1)), + 0 === this.size) + ) { + if (ae) return ae + ae = this + } + } + function Collection() { + throw TypeError('Abstract') + } + function KeyedCollection() {} + function IndexedCollection() {} + function SetCollection() {} + ;((Seq.prototype[ce] = !0), + createClass(ArraySeq, IndexedSeq), + (ArraySeq.prototype.get = function (s, o) { + return this.has(s) ? this._array[wrapIndex(this, s)] : o + }), + (ArraySeq.prototype.__iterate = function (s, o) { + for (var i = this._array, a = i.length - 1, u = 0; u <= a; u++) + if (!1 === s(i[o ? a - u : u], u, this)) return u + 1 + return u + }), + (ArraySeq.prototype.__iterator = function (s, o) { + var i = this._array, + a = i.length - 1, + u = 0 + return new Iterator(function () { + return u > a ? iteratorDone() : iteratorValue(s, u, i[o ? a - u++ : u++]) + }) + }), + createClass(ObjectSeq, KeyedSeq), + (ObjectSeq.prototype.get = function (s, o) { + return void 0 === o || this.has(s) ? this._object[s] : o + }), + (ObjectSeq.prototype.has = function (s) { + return this._object.hasOwnProperty(s) + }), + (ObjectSeq.prototype.__iterate = function (s, o) { + for (var i = this._object, a = this._keys, u = a.length - 1, _ = 0; _ <= u; _++) { + var w = a[o ? u - _ : _] + if (!1 === s(i[w], w, this)) return _ + 1 + } + return _ + }), + (ObjectSeq.prototype.__iterator = function (s, o) { + var i = this._object, + a = this._keys, + u = a.length - 1, + _ = 0 + return new Iterator(function () { + var w = a[o ? u - _ : _] + return _++ > u ? iteratorDone() : iteratorValue(s, w, i[w]) + }) + }), + (ObjectSeq.prototype[u] = !0), + createClass(IterableSeq, IndexedSeq), + (IterableSeq.prototype.__iterateUncached = function (s, o) { + if (o) return this.cacheResult().__iterate(s, o) + var i = getIterator(this._iterable), + a = 0 + if (isIterator(i)) + for (var u; !(u = i.next()).done && !1 !== s(u.value, a++, this); ); + return a + }), + (IterableSeq.prototype.__iteratorUncached = function (s, o) { + if (o) return this.cacheResult().__iterator(s, o) + var i = getIterator(this._iterable) + if (!isIterator(i)) return new Iterator(iteratorDone) + var a = 0 + return new Iterator(function () { + var o = i.next() + return o.done ? o : iteratorValue(s, a++, o.value) + }) + }), + createClass(IteratorSeq, IndexedSeq), + (IteratorSeq.prototype.__iterateUncached = function (s, o) { + if (o) return this.cacheResult().__iterate(s, o) + for (var i, a = this._iterator, u = this._iteratorCache, _ = 0; _ < u.length; ) + if (!1 === s(u[_], _++, this)) return _ + for (; !(i = a.next()).done; ) { + var w = i.value + if (((u[_] = w), !1 === s(w, _++, this))) break + } + return _ + }), + (IteratorSeq.prototype.__iteratorUncached = function (s, o) { + if (o) return this.cacheResult().__iterator(s, o) + var i = this._iterator, + a = this._iteratorCache, + u = 0 + return new Iterator(function () { + if (u >= a.length) { + var o = i.next() + if (o.done) return o + a[u] = o.value + } + return iteratorValue(s, u, a[u++]) + }) + }), + createClass(Repeat, IndexedSeq), + (Repeat.prototype.toString = function () { + return 0 === this.size + ? 'Repeat []' + : 'Repeat [ ' + this._value + ' ' + this.size + ' times ]' + }), + (Repeat.prototype.get = function (s, o) { + return this.has(s) ? this._value : o + }), + (Repeat.prototype.includes = function (s) { + return is(this._value, s) + }), + (Repeat.prototype.slice = function (s, o) { + var i = this.size + return wholeSlice(s, o, i) + ? this + : new Repeat(this._value, resolveEnd(o, i) - resolveBegin(s, i)) + }), + (Repeat.prototype.reverse = function () { + return this + }), + (Repeat.prototype.indexOf = function (s) { + return is(this._value, s) ? 0 : -1 + }), + (Repeat.prototype.lastIndexOf = function (s) { + return is(this._value, s) ? this.size : -1 + }), + (Repeat.prototype.__iterate = function (s, o) { + for (var i = 0; i < this.size; i++) if (!1 === s(this._value, i, this)) return i + 1 + return i + }), + (Repeat.prototype.__iterator = function (s, o) { + var i = this, + a = 0 + return new Iterator(function () { + return a < i.size ? iteratorValue(s, a++, i._value) : iteratorDone() + }) + }), + (Repeat.prototype.equals = function (s) { + return s instanceof Repeat ? is(this._value, s._value) : deepEqual(s) + }), + createClass(Range, IndexedSeq), + (Range.prototype.toString = function () { + return 0 === this.size + ? 'Range []' + : 'Range [ ' + + this._start + + '...' + + this._end + + (1 !== this._step ? ' by ' + this._step : '') + + ' ]' + }), + (Range.prototype.get = function (s, o) { + return this.has(s) ? this._start + wrapIndex(this, s) * this._step : o + }), + (Range.prototype.includes = function (s) { + var o = (s - this._start) / this._step + return o >= 0 && o < this.size && o === Math.floor(o) + }), + (Range.prototype.slice = function (s, o) { + return wholeSlice(s, o, this.size) + ? this + : ((s = resolveBegin(s, this.size)), + (o = resolveEnd(o, this.size)) <= s + ? new Range(0, 0) + : new Range(this.get(s, this._end), this.get(o, this._end), this._step)) + }), + (Range.prototype.indexOf = function (s) { + var o = s - this._start + if (o % this._step == 0) { + var i = o / this._step + if (i >= 0 && i < this.size) return i + } + return -1 + }), + (Range.prototype.lastIndexOf = function (s) { + return this.indexOf(s) + }), + (Range.prototype.__iterate = function (s, o) { + for ( + var i = this.size - 1, + a = this._step, + u = o ? this._start + i * a : this._start, + _ = 0; + _ <= i; + _++ + ) { + if (!1 === s(u, _, this)) return _ + 1 + u += o ? -a : a + } + return _ + }), + (Range.prototype.__iterator = function (s, o) { + var i = this.size - 1, + a = this._step, + u = o ? this._start + i * a : this._start, + _ = 0 + return new Iterator(function () { + var w = u + return ((u += o ? -a : a), _ > i ? iteratorDone() : iteratorValue(s, _++, w)) + }) + }), + (Range.prototype.equals = function (s) { + return s instanceof Range + ? this._start === s._start && this._end === s._end && this._step === s._step + : deepEqual(this, s) + }), + createClass(Collection, Iterable), + createClass(KeyedCollection, Collection), + createClass(IndexedCollection, Collection), + createClass(SetCollection, Collection), + (Collection.Keyed = KeyedCollection), + (Collection.Indexed = IndexedCollection), + (Collection.Set = SetCollection)) + var le = + 'function' == typeof Math.imul && -2 === Math.imul(4294967295, 2) + ? Math.imul + : function imul(s, o) { + var i = 65535 & (s |= 0), + a = 65535 & (o |= 0) + return (i * a + ((((s >>> 16) * a + i * (o >>> 16)) << 16) >>> 0)) | 0 + } + function smi(s) { + return ((s >>> 1) & 1073741824) | (3221225471 & s) + } + function hash(s) { + if (!1 === s || null == s) return 0 + if ('function' == typeof s.valueOf && (!1 === (s = s.valueOf()) || null == s)) + return 0 + if (!0 === s) return 1 + var o = typeof s + if ('number' === o) { + if (s != s || s === 1 / 0) return 0 + var i = 0 | s + for (i !== s && (i ^= 4294967295 * s); s > 4294967295; ) i ^= s /= 4294967295 + return smi(i) + } + if ('string' === o) return s.length > Se ? cachedHashString(s) : hashString(s) + if ('function' == typeof s.hashCode) return s.hashCode() + if ('object' === o) return hashJSObj(s) + if ('function' == typeof s.toString) return hashString(s.toString()) + throw new Error('Value type ' + o + ' cannot be hashed.') + } + function cachedHashString(s) { + var o = Pe[s] + return ( + void 0 === o && + ((o = hashString(s)), xe === we && ((xe = 0), (Pe = {})), xe++, (Pe[s] = o)), + o + ) + } + function hashString(s) { + for (var o = 0, i = 0; i < s.length; i++) o = (31 * o + s.charCodeAt(i)) | 0 + return smi(o) + } + function hashJSObj(s) { + var o + if (ye && void 0 !== (o = fe.get(s))) return o + if (void 0 !== (o = s[_e])) return o + if (!de) { + if (void 0 !== (o = s.propertyIsEnumerable && s.propertyIsEnumerable[_e])) return o + if (void 0 !== (o = getIENodeHash(s))) return o + } + if (((o = ++be), 1073741824 & be && (be = 0), ye)) fe.set(s, o) + else { + if (void 0 !== pe && !1 === pe(s)) + throw new Error('Non-extensible objects are not allowed as keys.') + if (de) + Object.defineProperty(s, _e, { + enumerable: !1, + configurable: !1, + writable: !1, + value: o, + }) + else if ( + void 0 !== s.propertyIsEnumerable && + s.propertyIsEnumerable === s.constructor.prototype.propertyIsEnumerable + ) + ((s.propertyIsEnumerable = function () { + return this.constructor.prototype.propertyIsEnumerable.apply(this, arguments) + }), + (s.propertyIsEnumerable[_e] = o)) + else { + if (void 0 === s.nodeType) + throw new Error('Unable to set a non-enumerable property on object.') + s[_e] = o + } + } + return o + } + var pe = Object.isExtensible, + de = (function () { + try { + return (Object.defineProperty({}, '@', {}), !0) + } catch (s) { + return !1 + } + })() + function getIENodeHash(s) { + if (s && s.nodeType > 0) + switch (s.nodeType) { + case 1: + return s.uniqueID + case 9: + return s.documentElement && s.documentElement.uniqueID + } + } + var fe, + ye = 'function' == typeof WeakMap + ye && (fe = new WeakMap()) + var be = 0, + _e = '__immutablehash__' + 'function' == typeof Symbol && (_e = Symbol(_e)) + var Se = 16, + we = 255, + xe = 0, + Pe = {} + function assertNotInfinite(s) { + invariant(s !== 1 / 0, 'Cannot perform this action with an infinite size.') + } + function Map(s) { + return null == s + ? emptyMap() + : isMap(s) && !isOrdered(s) + ? s + : emptyMap().withMutations(function (o) { + var i = KeyedIterable(s) + ;(assertNotInfinite(i.size), + i.forEach(function (s, i) { + return o.set(i, s) + })) + }) + } + function isMap(s) { + return !(!s || !s[Re]) + } + ;(createClass(Map, KeyedCollection), + (Map.of = function () { + var o = s.call(arguments, 0) + return emptyMap().withMutations(function (s) { + for (var i = 0; i < o.length; i += 2) { + if (i + 1 >= o.length) throw new Error('Missing value for key: ' + o[i]) + s.set(o[i], o[i + 1]) + } + }) + }), + (Map.prototype.toString = function () { + return this.__toString('Map {', '}') + }), + (Map.prototype.get = function (s, o) { + return this._root ? this._root.get(0, void 0, s, o) : o + }), + (Map.prototype.set = function (s, o) { + return updateMap(this, s, o) + }), + (Map.prototype.setIn = function (s, o) { + return this.updateIn(s, j, function () { + return o + }) + }), + (Map.prototype.remove = function (s) { + return updateMap(this, s, j) + }), + (Map.prototype.deleteIn = function (s) { + return this.updateIn(s, function () { + return j + }) + }), + (Map.prototype.update = function (s, o, i) { + return 1 === arguments.length ? s(this) : this.updateIn([s], o, i) + }), + (Map.prototype.updateIn = function (s, o, i) { + i || ((i = o), (o = void 0)) + var a = updateInDeepMap(this, forceIterator(s), o, i) + return a === j ? void 0 : a + }), + (Map.prototype.clear = function () { + return 0 === this.size + ? this + : this.__ownerID + ? ((this.size = 0), + (this._root = null), + (this.__hash = void 0), + (this.__altered = !0), + this) + : emptyMap() + }), + (Map.prototype.merge = function () { + return mergeIntoMapWith(this, void 0, arguments) + }), + (Map.prototype.mergeWith = function (o) { + return mergeIntoMapWith(this, o, s.call(arguments, 1)) + }), + (Map.prototype.mergeIn = function (o) { + var i = s.call(arguments, 1) + return this.updateIn(o, emptyMap(), function (s) { + return 'function' == typeof s.merge ? s.merge.apply(s, i) : i[i.length - 1] + }) + }), + (Map.prototype.mergeDeep = function () { + return mergeIntoMapWith(this, deepMerger, arguments) + }), + (Map.prototype.mergeDeepWith = function (o) { + var i = s.call(arguments, 1) + return mergeIntoMapWith(this, deepMergerWith(o), i) + }), + (Map.prototype.mergeDeepIn = function (o) { + var i = s.call(arguments, 1) + return this.updateIn(o, emptyMap(), function (s) { + return 'function' == typeof s.mergeDeep + ? s.mergeDeep.apply(s, i) + : i[i.length - 1] + }) + }), + (Map.prototype.sort = function (s) { + return OrderedMap(sortFactory(this, s)) + }), + (Map.prototype.sortBy = function (s, o) { + return OrderedMap(sortFactory(this, o, s)) + }), + (Map.prototype.withMutations = function (s) { + var o = this.asMutable() + return (s(o), o.wasAltered() ? o.__ensureOwner(this.__ownerID) : this) + }), + (Map.prototype.asMutable = function () { + return this.__ownerID ? this : this.__ensureOwner(new OwnerID()) + }), + (Map.prototype.asImmutable = function () { + return this.__ensureOwner() + }), + (Map.prototype.wasAltered = function () { + return this.__altered + }), + (Map.prototype.__iterator = function (s, o) { + return new MapIterator(this, s, o) + }), + (Map.prototype.__iterate = function (s, o) { + var i = this, + a = 0 + return ( + this._root && + this._root.iterate(function (o) { + return (a++, s(o[1], o[0], i)) + }, o), + a + ) + }), + (Map.prototype.__ensureOwner = function (s) { + return s === this.__ownerID + ? this + : s + ? makeMap(this.size, this._root, s, this.__hash) + : ((this.__ownerID = s), (this.__altered = !1), this) + }), + (Map.isMap = isMap)) + var Te, + Re = '@@__IMMUTABLE_MAP__@@', + $e = Map.prototype + function ArrayMapNode(s, o) { + ;((this.ownerID = s), (this.entries = o)) + } + function BitmapIndexedNode(s, o, i) { + ;((this.ownerID = s), (this.bitmap = o), (this.nodes = i)) + } + function HashArrayMapNode(s, o, i) { + ;((this.ownerID = s), (this.count = o), (this.nodes = i)) + } + function HashCollisionNode(s, o, i) { + ;((this.ownerID = s), (this.keyHash = o), (this.entries = i)) + } + function ValueNode(s, o, i) { + ;((this.ownerID = s), (this.keyHash = o), (this.entry = i)) + } + function MapIterator(s, o, i) { + ;((this._type = o), + (this._reverse = i), + (this._stack = s._root && mapIteratorFrame(s._root))) + } + function mapIteratorValue(s, o) { + return iteratorValue(s, o[0], o[1]) + } + function mapIteratorFrame(s, o) { + return { node: s, index: 0, __prev: o } + } + function makeMap(s, o, i, a) { + var u = Object.create($e) + return ( + (u.size = s), + (u._root = o), + (u.__ownerID = i), + (u.__hash = a), + (u.__altered = !1), + u + ) + } + function emptyMap() { + return Te || (Te = makeMap(0)) + } + function updateMap(s, o, i) { + var a, u + if (s._root) { + var _ = MakeRef(L), + w = MakeRef(B) + if (((a = updateNode(s._root, s.__ownerID, 0, void 0, o, i, _, w)), !w.value)) + return s + u = s.size + (_.value ? (i === j ? -1 : 1) : 0) + } else { + if (i === j) return s + ;((u = 1), (a = new ArrayMapNode(s.__ownerID, [[o, i]]))) + } + return s.__ownerID + ? ((s.size = u), (s._root = a), (s.__hash = void 0), (s.__altered = !0), s) + : a + ? makeMap(u, a) + : emptyMap() + } + function updateNode(s, o, i, a, u, _, w, x) { + return s + ? s.update(o, i, a, u, _, w, x) + : _ === j + ? s + : (SetRef(x), SetRef(w), new ValueNode(o, a, [u, _])) + } + function isLeafNode(s) { + return s.constructor === ValueNode || s.constructor === HashCollisionNode + } + function mergeIntoNode(s, o, i, a, u) { + if (s.keyHash === a) return new HashCollisionNode(o, a, [s.entry, u]) + var _, + x = (0 === i ? s.keyHash : s.keyHash >>> i) & C, + j = (0 === i ? a : a >>> i) & C + return new BitmapIndexedNode( + o, + (1 << x) | (1 << j), + x === j + ? [mergeIntoNode(s, o, i + w, a, u)] + : ((_ = new ValueNode(o, a, u)), x < j ? [s, _] : [_, s]) + ) + } + function createNodes(s, o, i, a) { + s || (s = new OwnerID()) + for (var u = new ValueNode(s, hash(i), [i, a]), _ = 0; _ < o.length; _++) { + var w = o[_] + u = u.update(s, 0, void 0, w[0], w[1]) + } + return u + } + function packNodes(s, o, i, a) { + for ( + var u = 0, _ = 0, w = new Array(i), x = 0, C = 1, j = o.length; + x < j; + x++, C <<= 1 + ) { + var L = o[x] + void 0 !== L && x !== a && ((u |= C), (w[_++] = L)) + } + return new BitmapIndexedNode(s, u, w) + } + function expandNodes(s, o, i, a, u) { + for (var _ = 0, w = new Array(x), C = 0; 0 !== i; C++, i >>>= 1) + w[C] = 1 & i ? o[_++] : void 0 + return ((w[a] = u), new HashArrayMapNode(s, _ + 1, w)) + } + function mergeIntoMapWith(s, o, i) { + for (var a = [], u = 0; u < i.length; u++) { + var _ = i[u], + w = KeyedIterable(_) + ;(isIterable(_) || + (w = w.map(function (s) { + return fromJS(s) + })), + a.push(w)) + } + return mergeIntoCollectionWith(s, o, a) + } + function deepMerger(s, o, i) { + return s && s.mergeDeep && isIterable(o) ? s.mergeDeep(o) : is(s, o) ? s : o + } + function deepMergerWith(s) { + return function (o, i, a) { + if (o && o.mergeDeepWith && isIterable(i)) return o.mergeDeepWith(s, i) + var u = s(o, i, a) + return is(o, u) ? o : u + } + } + function mergeIntoCollectionWith(s, o, i) { + return 0 === + (i = i.filter(function (s) { + return 0 !== s.size + })).length + ? s + : 0 !== s.size || s.__ownerID || 1 !== i.length + ? s.withMutations(function (s) { + for ( + var a = o + ? function (i, a) { + s.update(a, j, function (s) { + return s === j ? i : o(s, i, a) + }) + } + : function (o, i) { + s.set(i, o) + }, + u = 0; + u < i.length; + u++ + ) + i[u].forEach(a) + }) + : s.constructor(i[0]) + } + function updateInDeepMap(s, o, i, a) { + var u = s === j, + _ = o.next() + if (_.done) { + var w = u ? i : s, + x = a(w) + return x === w ? s : x + } + invariant(u || (s && s.set), 'invalid keyPath') + var C = _.value, + L = u ? j : s.get(C, j), + B = updateInDeepMap(L, o, i, a) + return B === L ? s : B === j ? s.remove(C) : (u ? emptyMap() : s).set(C, B) + } + function popCount(s) { + return ( + (s = + ((s = (858993459 & (s -= (s >> 1) & 1431655765)) + ((s >> 2) & 858993459)) + + (s >> 4)) & + 252645135), + (s += s >> 8), + 127 & (s += s >> 16) + ) + } + function setIn(s, o, i, a) { + var u = a ? s : arrCopy(s) + return ((u[o] = i), u) + } + function spliceIn(s, o, i, a) { + var u = s.length + 1 + if (a && o + 1 === u) return ((s[o] = i), s) + for (var _ = new Array(u), w = 0, x = 0; x < u; x++) + x === o ? ((_[x] = i), (w = -1)) : (_[x] = s[x + w]) + return _ + } + function spliceOut(s, o, i) { + var a = s.length - 1 + if (i && o === a) return (s.pop(), s) + for (var u = new Array(a), _ = 0, w = 0; w < a; w++) + (w === o && (_ = 1), (u[w] = s[w + _])) + return u + } + ;(($e[Re] = !0), + ($e[_] = $e.remove), + ($e.removeIn = $e.deleteIn), + (ArrayMapNode.prototype.get = function (s, o, i, a) { + for (var u = this.entries, _ = 0, w = u.length; _ < w; _++) + if (is(i, u[_][0])) return u[_][1] + return a + }), + (ArrayMapNode.prototype.update = function (s, o, i, a, u, _, w) { + for ( + var x = u === j, C = this.entries, L = 0, B = C.length; + L < B && !is(a, C[L][0]); + L++ + ); + var $ = L < B + if ($ ? C[L][1] === u : x) return this + if ((SetRef(w), (x || !$) && SetRef(_), !x || 1 !== C.length)) { + if (!$ && !x && C.length >= qe) return createNodes(s, C, a, u) + var U = s && s === this.ownerID, + V = U ? C : arrCopy(C) + return ( + $ + ? x + ? L === B - 1 + ? V.pop() + : (V[L] = V.pop()) + : (V[L] = [a, u]) + : V.push([a, u]), + U ? ((this.entries = V), this) : new ArrayMapNode(s, V) + ) + } + }), + (BitmapIndexedNode.prototype.get = function (s, o, i, a) { + void 0 === o && (o = hash(i)) + var u = 1 << ((0 === s ? o : o >>> s) & C), + _ = this.bitmap + return _ & u ? this.nodes[popCount(_ & (u - 1))].get(s + w, o, i, a) : a + }), + (BitmapIndexedNode.prototype.update = function (s, o, i, a, u, _, x) { + void 0 === i && (i = hash(a)) + var L = (0 === o ? i : i >>> o) & C, + B = 1 << L, + $ = this.bitmap, + U = !!($ & B) + if (!U && u === j) return this + var V = popCount($ & (B - 1)), + z = this.nodes, + Y = U ? z[V] : void 0, + Z = updateNode(Y, s, o + w, i, a, u, _, x) + if (Z === Y) return this + if (!U && Z && z.length >= ze) return expandNodes(s, z, $, L, Z) + if (U && !Z && 2 === z.length && isLeafNode(z[1 ^ V])) return z[1 ^ V] + if (U && Z && 1 === z.length && isLeafNode(Z)) return Z + var ee = s && s === this.ownerID, + ie = U ? (Z ? $ : $ ^ B) : $ | B, + ae = U ? (Z ? setIn(z, V, Z, ee) : spliceOut(z, V, ee)) : spliceIn(z, V, Z, ee) + return ee + ? ((this.bitmap = ie), (this.nodes = ae), this) + : new BitmapIndexedNode(s, ie, ae) + }), + (HashArrayMapNode.prototype.get = function (s, o, i, a) { + void 0 === o && (o = hash(i)) + var u = (0 === s ? o : o >>> s) & C, + _ = this.nodes[u] + return _ ? _.get(s + w, o, i, a) : a + }), + (HashArrayMapNode.prototype.update = function (s, o, i, a, u, _, x) { + void 0 === i && (i = hash(a)) + var L = (0 === o ? i : i >>> o) & C, + B = u === j, + $ = this.nodes, + U = $[L] + if (B && !U) return this + var V = updateNode(U, s, o + w, i, a, u, _, x) + if (V === U) return this + var z = this.count + if (U) { + if (!V && --z < We) return packNodes(s, $, z, L) + } else z++ + var Y = s && s === this.ownerID, + Z = setIn($, L, V, Y) + return Y + ? ((this.count = z), (this.nodes = Z), this) + : new HashArrayMapNode(s, z, Z) + }), + (HashCollisionNode.prototype.get = function (s, o, i, a) { + for (var u = this.entries, _ = 0, w = u.length; _ < w; _++) + if (is(i, u[_][0])) return u[_][1] + return a + }), + (HashCollisionNode.prototype.update = function (s, o, i, a, u, _, w) { + void 0 === i && (i = hash(a)) + var x = u === j + if (i !== this.keyHash) + return x ? this : (SetRef(w), SetRef(_), mergeIntoNode(this, s, o, i, [a, u])) + for (var C = this.entries, L = 0, B = C.length; L < B && !is(a, C[L][0]); L++); + var $ = L < B + if ($ ? C[L][1] === u : x) return this + if ((SetRef(w), (x || !$) && SetRef(_), x && 2 === B)) + return new ValueNode(s, this.keyHash, C[1 ^ L]) + var U = s && s === this.ownerID, + V = U ? C : arrCopy(C) + return ( + $ + ? x + ? L === B - 1 + ? V.pop() + : (V[L] = V.pop()) + : (V[L] = [a, u]) + : V.push([a, u]), + U ? ((this.entries = V), this) : new HashCollisionNode(s, this.keyHash, V) + ) + }), + (ValueNode.prototype.get = function (s, o, i, a) { + return is(i, this.entry[0]) ? this.entry[1] : a + }), + (ValueNode.prototype.update = function (s, o, i, a, u, _, w) { + var x = u === j, + C = is(a, this.entry[0]) + return (C ? u === this.entry[1] : x) + ? this + : (SetRef(w), + x + ? void SetRef(_) + : C + ? s && s === this.ownerID + ? ((this.entry[1] = u), this) + : new ValueNode(s, this.keyHash, [a, u]) + : (SetRef(_), mergeIntoNode(this, s, o, hash(a), [a, u]))) + }), + (ArrayMapNode.prototype.iterate = HashCollisionNode.prototype.iterate = + function (s, o) { + for (var i = this.entries, a = 0, u = i.length - 1; a <= u; a++) + if (!1 === s(i[o ? u - a : a])) return !1 + }), + (BitmapIndexedNode.prototype.iterate = HashArrayMapNode.prototype.iterate = + function (s, o) { + for (var i = this.nodes, a = 0, u = i.length - 1; a <= u; a++) { + var _ = i[o ? u - a : a] + if (_ && !1 === _.iterate(s, o)) return !1 + } + }), + (ValueNode.prototype.iterate = function (s, o) { + return s(this.entry) + }), + createClass(MapIterator, Iterator), + (MapIterator.prototype.next = function () { + for (var s = this._type, o = this._stack; o; ) { + var i, + a = o.node, + u = o.index++ + if (a.entry) { + if (0 === u) return mapIteratorValue(s, a.entry) + } else if (a.entries) { + if (u <= (i = a.entries.length - 1)) + return mapIteratorValue(s, a.entries[this._reverse ? i - u : u]) + } else if (u <= (i = a.nodes.length - 1)) { + var _ = a.nodes[this._reverse ? i - u : u] + if (_) { + if (_.entry) return mapIteratorValue(s, _.entry) + o = this._stack = mapIteratorFrame(_, o) + } + continue + } + o = this._stack = this._stack.__prev + } + return iteratorDone() + })) + var qe = x / 4, + ze = x / 2, + We = x / 4 + function List(s) { + var o = emptyList() + if (null == s) return o + if (isList(s)) return s + var i = IndexedIterable(s), + a = i.size + return 0 === a + ? o + : (assertNotInfinite(a), + a > 0 && a < x + ? makeList(0, a, w, null, new VNode(i.toArray())) + : o.withMutations(function (s) { + ;(s.setSize(a), + i.forEach(function (o, i) { + return s.set(i, o) + })) + })) + } + function isList(s) { + return !(!s || !s[He]) + } + ;(createClass(List, IndexedCollection), + (List.of = function () { + return this(arguments) + }), + (List.prototype.toString = function () { + return this.__toString('List [', ']') + }), + (List.prototype.get = function (s, o) { + if ((s = wrapIndex(this, s)) >= 0 && s < this.size) { + var i = listNodeFor(this, (s += this._origin)) + return i && i.array[s & C] + } + return o + }), + (List.prototype.set = function (s, o) { + return updateList(this, s, o) + }), + (List.prototype.remove = function (s) { + return this.has(s) + ? 0 === s + ? this.shift() + : s === this.size - 1 + ? this.pop() + : this.splice(s, 1) + : this + }), + (List.prototype.insert = function (s, o) { + return this.splice(s, 0, o) + }), + (List.prototype.clear = function () { + return 0 === this.size + ? this + : this.__ownerID + ? ((this.size = this._origin = this._capacity = 0), + (this._level = w), + (this._root = this._tail = null), + (this.__hash = void 0), + (this.__altered = !0), + this) + : emptyList() + }), + (List.prototype.push = function () { + var s = arguments, + o = this.size + return this.withMutations(function (i) { + setListBounds(i, 0, o + s.length) + for (var a = 0; a < s.length; a++) i.set(o + a, s[a]) + }) + }), + (List.prototype.pop = function () { + return setListBounds(this, 0, -1) + }), + (List.prototype.unshift = function () { + var s = arguments + return this.withMutations(function (o) { + setListBounds(o, -s.length) + for (var i = 0; i < s.length; i++) o.set(i, s[i]) + }) + }), + (List.prototype.shift = function () { + return setListBounds(this, 1) + }), + (List.prototype.merge = function () { + return mergeIntoListWith(this, void 0, arguments) + }), + (List.prototype.mergeWith = function (o) { + return mergeIntoListWith(this, o, s.call(arguments, 1)) + }), + (List.prototype.mergeDeep = function () { + return mergeIntoListWith(this, deepMerger, arguments) + }), + (List.prototype.mergeDeepWith = function (o) { + var i = s.call(arguments, 1) + return mergeIntoListWith(this, deepMergerWith(o), i) + }), + (List.prototype.setSize = function (s) { + return setListBounds(this, 0, s) + }), + (List.prototype.slice = function (s, o) { + var i = this.size + return wholeSlice(s, o, i) + ? this + : setListBounds(this, resolveBegin(s, i), resolveEnd(o, i)) + }), + (List.prototype.__iterator = function (s, o) { + var i = 0, + a = iterateList(this, o) + return new Iterator(function () { + var o = a() + return o === et ? iteratorDone() : iteratorValue(s, i++, o) + }) + }), + (List.prototype.__iterate = function (s, o) { + for ( + var i, a = 0, u = iterateList(this, o); + (i = u()) !== et && !1 !== s(i, a++, this); + ); + return a + }), + (List.prototype.__ensureOwner = function (s) { + return s === this.__ownerID + ? this + : s + ? makeList( + this._origin, + this._capacity, + this._level, + this._root, + this._tail, + s, + this.__hash + ) + : ((this.__ownerID = s), this) + }), + (List.isList = isList)) + var He = '@@__IMMUTABLE_LIST__@@', + Ye = List.prototype + function VNode(s, o) { + ;((this.array = s), (this.ownerID = o)) + } + ;((Ye[He] = !0), + (Ye[_] = Ye.remove), + (Ye.setIn = $e.setIn), + (Ye.deleteIn = Ye.removeIn = $e.removeIn), + (Ye.update = $e.update), + (Ye.updateIn = $e.updateIn), + (Ye.mergeIn = $e.mergeIn), + (Ye.mergeDeepIn = $e.mergeDeepIn), + (Ye.withMutations = $e.withMutations), + (Ye.asMutable = $e.asMutable), + (Ye.asImmutable = $e.asImmutable), + (Ye.wasAltered = $e.wasAltered), + (VNode.prototype.removeBefore = function (s, o, i) { + if (i === o ? 1 << o : 0 === this.array.length) return this + var a = (i >>> o) & C + if (a >= this.array.length) return new VNode([], s) + var u, + _ = 0 === a + if (o > 0) { + var x = this.array[a] + if ((u = x && x.removeBefore(s, o - w, i)) === x && _) return this + } + if (_ && !u) return this + var j = editableVNode(this, s) + if (!_) for (var L = 0; L < a; L++) j.array[L] = void 0 + return (u && (j.array[a] = u), j) + }), + (VNode.prototype.removeAfter = function (s, o, i) { + if (i === (o ? 1 << o : 0) || 0 === this.array.length) return this + var a, + u = ((i - 1) >>> o) & C + if (u >= this.array.length) return this + if (o > 0) { + var _ = this.array[u] + if ((a = _ && _.removeAfter(s, o - w, i)) === _ && u === this.array.length - 1) + return this + } + var x = editableVNode(this, s) + return (x.array.splice(u + 1), a && (x.array[u] = a), x) + })) + var Xe, + Qe, + et = {} + function iterateList(s, o) { + var i = s._origin, + a = s._capacity, + u = getTailOffset(a), + _ = s._tail + return iterateNodeOrLeaf(s._root, s._level, 0) + function iterateNodeOrLeaf(s, o, i) { + return 0 === o ? iterateLeaf(s, i) : iterateNode(s, o, i) + } + function iterateLeaf(s, w) { + var C = w === u ? _ && _.array : s && s.array, + j = w > i ? 0 : i - w, + L = a - w + return ( + L > x && (L = x), + function () { + if (j === L) return et + var s = o ? --L : j++ + return C && C[s] + } + ) + } + function iterateNode(s, u, _) { + var C, + j = s && s.array, + L = _ > i ? 0 : (i - _) >> u, + B = 1 + ((a - _) >> u) + return ( + B > x && (B = x), + function () { + for (;;) { + if (C) { + var s = C() + if (s !== et) return s + C = null + } + if (L === B) return et + var i = o ? --B : L++ + C = iterateNodeOrLeaf(j && j[i], u - w, _ + (i << u)) + } + } + ) + } + } + function makeList(s, o, i, a, u, _, w) { + var x = Object.create(Ye) + return ( + (x.size = o - s), + (x._origin = s), + (x._capacity = o), + (x._level = i), + (x._root = a), + (x._tail = u), + (x.__ownerID = _), + (x.__hash = w), + (x.__altered = !1), + x + ) + } + function emptyList() { + return Xe || (Xe = makeList(0, 0, w)) + } + function updateList(s, o, i) { + if ((o = wrapIndex(s, o)) != o) return s + if (o >= s.size || o < 0) + return s.withMutations(function (s) { + o < 0 ? setListBounds(s, o).set(0, i) : setListBounds(s, 0, o + 1).set(o, i) + }) + o += s._origin + var a = s._tail, + u = s._root, + _ = MakeRef(B) + return ( + o >= getTailOffset(s._capacity) + ? (a = updateVNode(a, s.__ownerID, 0, o, i, _)) + : (u = updateVNode(u, s.__ownerID, s._level, o, i, _)), + _.value + ? s.__ownerID + ? ((s._root = u), (s._tail = a), (s.__hash = void 0), (s.__altered = !0), s) + : makeList(s._origin, s._capacity, s._level, u, a) + : s + ) + } + function updateVNode(s, o, i, a, u, _) { + var x, + j = (a >>> i) & C, + L = s && j < s.array.length + if (!L && void 0 === u) return s + if (i > 0) { + var B = s && s.array[j], + $ = updateVNode(B, o, i - w, a, u, _) + return $ === B ? s : (((x = editableVNode(s, o)).array[j] = $), x) + } + return L && s.array[j] === u + ? s + : (SetRef(_), + (x = editableVNode(s, o)), + void 0 === u && j === x.array.length - 1 ? x.array.pop() : (x.array[j] = u), + x) + } + function editableVNode(s, o) { + return o && s && o === s.ownerID ? s : new VNode(s ? s.array.slice() : [], o) + } + function listNodeFor(s, o) { + if (o >= getTailOffset(s._capacity)) return s._tail + if (o < 1 << (s._level + w)) { + for (var i = s._root, a = s._level; i && a > 0; ) + ((i = i.array[(o >>> a) & C]), (a -= w)) + return i + } + } + function setListBounds(s, o, i) { + ;(void 0 !== o && (o |= 0), void 0 !== i && (i |= 0)) + var a = s.__ownerID || new OwnerID(), + u = s._origin, + _ = s._capacity, + x = u + o, + j = void 0 === i ? _ : i < 0 ? _ + i : u + i + if (x === u && j === _) return s + if (x >= j) return s.clear() + for (var L = s._level, B = s._root, $ = 0; x + $ < 0; ) + ((B = new VNode(B && B.array.length ? [void 0, B] : [], a)), ($ += 1 << (L += w))) + $ && ((x += $), (u += $), (j += $), (_ += $)) + for (var U = getTailOffset(_), V = getTailOffset(j); V >= 1 << (L + w); ) + ((B = new VNode(B && B.array.length ? [B] : [], a)), (L += w)) + var z = s._tail, + Y = V < U ? listNodeFor(s, j - 1) : V > U ? new VNode([], a) : z + if (z && V > U && x < _ && z.array.length) { + for (var Z = (B = editableVNode(B, a)), ee = L; ee > w; ee -= w) { + var ie = (U >>> ee) & C + Z = Z.array[ie] = editableVNode(Z.array[ie], a) + } + Z.array[(U >>> w) & C] = z + } + if ((j < _ && (Y = Y && Y.removeAfter(a, 0, j)), x >= V)) + ((x -= V), (j -= V), (L = w), (B = null), (Y = Y && Y.removeBefore(a, 0, x))) + else if (x > u || V < U) { + for ($ = 0; B; ) { + var ae = (x >>> L) & C + if ((ae !== V >>> L) & C) break + ;(ae && ($ += (1 << L) * ae), (L -= w), (B = B.array[ae])) + } + ;(B && x > u && (B = B.removeBefore(a, L, x - $)), + B && V < U && (B = B.removeAfter(a, L, V - $)), + $ && ((x -= $), (j -= $))) + } + return s.__ownerID + ? ((s.size = j - x), + (s._origin = x), + (s._capacity = j), + (s._level = L), + (s._root = B), + (s._tail = Y), + (s.__hash = void 0), + (s.__altered = !0), + s) + : makeList(x, j, L, B, Y) + } + function mergeIntoListWith(s, o, i) { + for (var a = [], u = 0, _ = 0; _ < i.length; _++) { + var w = i[_], + x = IndexedIterable(w) + ;(x.size > u && (u = x.size), + isIterable(w) || + (x = x.map(function (s) { + return fromJS(s) + })), + a.push(x)) + } + return (u > s.size && (s = s.setSize(u)), mergeIntoCollectionWith(s, o, a)) + } + function getTailOffset(s) { + return s < x ? 0 : ((s - 1) >>> w) << w + } + function OrderedMap(s) { + return null == s + ? emptyOrderedMap() + : isOrderedMap(s) + ? s + : emptyOrderedMap().withMutations(function (o) { + var i = KeyedIterable(s) + ;(assertNotInfinite(i.size), + i.forEach(function (s, i) { + return o.set(i, s) + })) + }) + } + function isOrderedMap(s) { + return isMap(s) && isOrdered(s) + } + function makeOrderedMap(s, o, i, a) { + var u = Object.create(OrderedMap.prototype) + return ( + (u.size = s ? s.size : 0), + (u._map = s), + (u._list = o), + (u.__ownerID = i), + (u.__hash = a), + u + ) + } + function emptyOrderedMap() { + return Qe || (Qe = makeOrderedMap(emptyMap(), emptyList())) + } + function updateOrderedMap(s, o, i) { + var a, + u, + _ = s._map, + w = s._list, + C = _.get(o), + L = void 0 !== C + if (i === j) { + if (!L) return s + w.size >= x && w.size >= 2 * _.size + ? ((a = (u = w.filter(function (s, o) { + return void 0 !== s && C !== o + })) + .toKeyedSeq() + .map(function (s) { + return s[0] + }) + .flip() + .toMap()), + s.__ownerID && (a.__ownerID = u.__ownerID = s.__ownerID)) + : ((a = _.remove(o)), (u = C === w.size - 1 ? w.pop() : w.set(C, void 0))) + } else if (L) { + if (i === w.get(C)[1]) return s + ;((a = _), (u = w.set(C, [o, i]))) + } else ((a = _.set(o, w.size)), (u = w.set(w.size, [o, i]))) + return s.__ownerID + ? ((s.size = a.size), (s._map = a), (s._list = u), (s.__hash = void 0), s) + : makeOrderedMap(a, u) + } + function ToKeyedSequence(s, o) { + ;((this._iter = s), (this._useKeys = o), (this.size = s.size)) + } + function ToIndexedSequence(s) { + ;((this._iter = s), (this.size = s.size)) + } + function ToSetSequence(s) { + ;((this._iter = s), (this.size = s.size)) + } + function FromEntriesSequence(s) { + ;((this._iter = s), (this.size = s.size)) + } + function flipFactory(s) { + var o = makeSequence(s) + return ( + (o._iter = s), + (o.size = s.size), + (o.flip = function () { + return s + }), + (o.reverse = function () { + var o = s.reverse.apply(this) + return ( + (o.flip = function () { + return s.reverse() + }), + o + ) + }), + (o.has = function (o) { + return s.includes(o) + }), + (o.includes = function (o) { + return s.has(o) + }), + (o.cacheResult = cacheResultThrough), + (o.__iterateUncached = function (o, i) { + var a = this + return s.__iterate(function (s, i) { + return !1 !== o(i, s, a) + }, i) + }), + (o.__iteratorUncached = function (o, i) { + if (o === V) { + var a = s.__iterator(o, i) + return new Iterator(function () { + var s = a.next() + if (!s.done) { + var o = s.value[0] + ;((s.value[0] = s.value[1]), (s.value[1] = o)) + } + return s + }) + } + return s.__iterator(o === U ? $ : U, i) + }), + o + ) + } + function mapFactory(s, o, i) { + var a = makeSequence(s) + return ( + (a.size = s.size), + (a.has = function (o) { + return s.has(o) + }), + (a.get = function (a, u) { + var _ = s.get(a, j) + return _ === j ? u : o.call(i, _, a, s) + }), + (a.__iterateUncached = function (a, u) { + var _ = this + return s.__iterate(function (s, u, w) { + return !1 !== a(o.call(i, s, u, w), u, _) + }, u) + }), + (a.__iteratorUncached = function (a, u) { + var _ = s.__iterator(V, u) + return new Iterator(function () { + var u = _.next() + if (u.done) return u + var w = u.value, + x = w[0] + return iteratorValue(a, x, o.call(i, w[1], x, s), u) + }) + }), + a + ) + } + function reverseFactory(s, o) { + var i = makeSequence(s) + return ( + (i._iter = s), + (i.size = s.size), + (i.reverse = function () { + return s + }), + s.flip && + (i.flip = function () { + var o = flipFactory(s) + return ( + (o.reverse = function () { + return s.flip() + }), + o + ) + }), + (i.get = function (i, a) { + return s.get(o ? i : -1 - i, a) + }), + (i.has = function (i) { + return s.has(o ? i : -1 - i) + }), + (i.includes = function (o) { + return s.includes(o) + }), + (i.cacheResult = cacheResultThrough), + (i.__iterate = function (o, i) { + var a = this + return s.__iterate(function (s, i) { + return o(s, i, a) + }, !i) + }), + (i.__iterator = function (o, i) { + return s.__iterator(o, !i) + }), + i + ) + } + function filterFactory(s, o, i, a) { + var u = makeSequence(s) + return ( + a && + ((u.has = function (a) { + var u = s.get(a, j) + return u !== j && !!o.call(i, u, a, s) + }), + (u.get = function (a, u) { + var _ = s.get(a, j) + return _ !== j && o.call(i, _, a, s) ? _ : u + })), + (u.__iterateUncached = function (u, _) { + var w = this, + x = 0 + return ( + s.__iterate(function (s, _, C) { + if (o.call(i, s, _, C)) return (x++, u(s, a ? _ : x - 1, w)) + }, _), + x + ) + }), + (u.__iteratorUncached = function (u, _) { + var w = s.__iterator(V, _), + x = 0 + return new Iterator(function () { + for (;;) { + var _ = w.next() + if (_.done) return _ + var C = _.value, + j = C[0], + L = C[1] + if (o.call(i, L, j, s)) return iteratorValue(u, a ? j : x++, L, _) + } + }) + }), + u + ) + } + function countByFactory(s, o, i) { + var a = Map().asMutable() + return ( + s.__iterate(function (u, _) { + a.update(o.call(i, u, _, s), 0, function (s) { + return s + 1 + }) + }), + a.asImmutable() + ) + } + function groupByFactory(s, o, i) { + var a = isKeyed(s), + u = (isOrdered(s) ? OrderedMap() : Map()).asMutable() + s.__iterate(function (_, w) { + u.update(o.call(i, _, w, s), function (s) { + return ((s = s || []).push(a ? [w, _] : _), s) + }) + }) + var _ = iterableClass(s) + return u.map(function (o) { + return reify(s, _(o)) + }) + } + function sliceFactory(s, o, i, a) { + var u = s.size + if ( + (void 0 !== o && (o |= 0), + void 0 !== i && (i === 1 / 0 ? (i = u) : (i |= 0)), + wholeSlice(o, i, u)) + ) + return s + var _ = resolveBegin(o, u), + w = resolveEnd(i, u) + if (_ != _ || w != w) return sliceFactory(s.toSeq().cacheResult(), o, i, a) + var x, + C = w - _ + C == C && (x = C < 0 ? 0 : C) + var j = makeSequence(s) + return ( + (j.size = 0 === x ? x : (s.size && x) || void 0), + !a && + isSeq(s) && + x >= 0 && + (j.get = function (o, i) { + return (o = wrapIndex(this, o)) >= 0 && o < x ? s.get(o + _, i) : i + }), + (j.__iterateUncached = function (o, i) { + var u = this + if (0 === x) return 0 + if (i) return this.cacheResult().__iterate(o, i) + var w = 0, + C = !0, + j = 0 + return ( + s.__iterate(function (s, i) { + if (!C || !(C = w++ < _)) + return (j++, !1 !== o(s, a ? i : j - 1, u) && j !== x) + }), + j + ) + }), + (j.__iteratorUncached = function (o, i) { + if (0 !== x && i) return this.cacheResult().__iterator(o, i) + var u = 0 !== x && s.__iterator(o, i), + w = 0, + C = 0 + return new Iterator(function () { + for (; w++ < _; ) u.next() + if (++C > x) return iteratorDone() + var s = u.next() + return a || o === U + ? s + : iteratorValue(o, C - 1, o === $ ? void 0 : s.value[1], s) + }) + }), + j + ) + } + function takeWhileFactory(s, o, i) { + var a = makeSequence(s) + return ( + (a.__iterateUncached = function (a, u) { + var _ = this + if (u) return this.cacheResult().__iterate(a, u) + var w = 0 + return ( + s.__iterate(function (s, u, x) { + return o.call(i, s, u, x) && ++w && a(s, u, _) + }), + w + ) + }), + (a.__iteratorUncached = function (a, u) { + var _ = this + if (u) return this.cacheResult().__iterator(a, u) + var w = s.__iterator(V, u), + x = !0 + return new Iterator(function () { + if (!x) return iteratorDone() + var s = w.next() + if (s.done) return s + var u = s.value, + C = u[0], + j = u[1] + return o.call(i, j, C, _) + ? a === V + ? s + : iteratorValue(a, C, j, s) + : ((x = !1), iteratorDone()) + }) + }), + a + ) + } + function skipWhileFactory(s, o, i, a) { + var u = makeSequence(s) + return ( + (u.__iterateUncached = function (u, _) { + var w = this + if (_) return this.cacheResult().__iterate(u, _) + var x = !0, + C = 0 + return ( + s.__iterate(function (s, _, j) { + if (!x || !(x = o.call(i, s, _, j))) return (C++, u(s, a ? _ : C - 1, w)) + }), + C + ) + }), + (u.__iteratorUncached = function (u, _) { + var w = this + if (_) return this.cacheResult().__iterator(u, _) + var x = s.__iterator(V, _), + C = !0, + j = 0 + return new Iterator(function () { + var s, _, L + do { + if ((s = x.next()).done) + return a || u === U + ? s + : iteratorValue(u, j++, u === $ ? void 0 : s.value[1], s) + var B = s.value + ;((_ = B[0]), (L = B[1]), C && (C = o.call(i, L, _, w))) + } while (C) + return u === V ? s : iteratorValue(u, _, L, s) + }) + }), + u + ) + } + function concatFactory(s, o) { + var i = isKeyed(s), + a = [s] + .concat(o) + .map(function (s) { + return ( + isIterable(s) + ? i && (s = KeyedIterable(s)) + : (s = i + ? keyedSeqFromValue(s) + : indexedSeqFromValue(Array.isArray(s) ? s : [s])), + s + ) + }) + .filter(function (s) { + return 0 !== s.size + }) + if (0 === a.length) return s + if (1 === a.length) { + var u = a[0] + if (u === s || (i && isKeyed(u)) || (isIndexed(s) && isIndexed(u))) return u + } + var _ = new ArraySeq(a) + return ( + i ? (_ = _.toKeyedSeq()) : isIndexed(s) || (_ = _.toSetSeq()), + ((_ = _.flatten(!0)).size = a.reduce(function (s, o) { + if (void 0 !== s) { + var i = o.size + if (void 0 !== i) return s + i + } + }, 0)), + _ + ) + } + function flattenFactory(s, o, i) { + var a = makeSequence(s) + return ( + (a.__iterateUncached = function (a, u) { + var _ = 0, + w = !1 + function flatDeep(s, x) { + var C = this + s.__iterate(function (s, u) { + return ( + (!o || x < o) && isIterable(s) + ? flatDeep(s, x + 1) + : !1 === a(s, i ? u : _++, C) && (w = !0), + !w + ) + }, u) + } + return (flatDeep(s, 0), _) + }), + (a.__iteratorUncached = function (a, u) { + var _ = s.__iterator(a, u), + w = [], + x = 0 + return new Iterator(function () { + for (; _; ) { + var s = _.next() + if (!1 === s.done) { + var C = s.value + if ((a === V && (C = C[1]), (o && !(w.length < o)) || !isIterable(C))) + return i ? s : iteratorValue(a, x++, C, s) + ;(w.push(_), (_ = C.__iterator(a, u))) + } else _ = w.pop() + } + return iteratorDone() + }) + }), + a + ) + } + function flatMapFactory(s, o, i) { + var a = iterableClass(s) + return s + .toSeq() + .map(function (u, _) { + return a(o.call(i, u, _, s)) + }) + .flatten(!0) + } + function interposeFactory(s, o) { + var i = makeSequence(s) + return ( + (i.size = s.size && 2 * s.size - 1), + (i.__iterateUncached = function (i, a) { + var u = this, + _ = 0 + return ( + s.__iterate(function (s, a) { + return (!_ || !1 !== i(o, _++, u)) && !1 !== i(s, _++, u) + }, a), + _ + ) + }), + (i.__iteratorUncached = function (i, a) { + var u, + _ = s.__iterator(U, a), + w = 0 + return new Iterator(function () { + return (!u || w % 2) && (u = _.next()).done + ? u + : w % 2 + ? iteratorValue(i, w++, o) + : iteratorValue(i, w++, u.value, u) + }) + }), + i + ) + } + function sortFactory(s, o, i) { + o || (o = defaultComparator) + var a = isKeyed(s), + u = 0, + _ = s + .toSeq() + .map(function (o, a) { + return [a, o, u++, i ? i(o, a, s) : o] + }) + .toArray() + return ( + _.sort(function (s, i) { + return o(s[3], i[3]) || s[2] - i[2] + }).forEach( + a + ? function (s, o) { + _[o].length = 2 + } + : function (s, o) { + _[o] = s[1] + } + ), + a ? KeyedSeq(_) : isIndexed(s) ? IndexedSeq(_) : SetSeq(_) + ) + } + function maxFactory(s, o, i) { + if ((o || (o = defaultComparator), i)) { + var a = s + .toSeq() + .map(function (o, a) { + return [o, i(o, a, s)] + }) + .reduce(function (s, i) { + return maxCompare(o, s[1], i[1]) ? i : s + }) + return a && a[0] + } + return s.reduce(function (s, i) { + return maxCompare(o, s, i) ? i : s + }) + } + function maxCompare(s, o, i) { + var a = s(i, o) + return (0 === a && i !== o && (null == i || i != i)) || a > 0 + } + function zipWithFactory(s, o, i) { + var a = makeSequence(s) + return ( + (a.size = new ArraySeq(i) + .map(function (s) { + return s.size + }) + .min()), + (a.__iterate = function (s, o) { + for ( + var i, a = this.__iterator(U, o), u = 0; + !(i = a.next()).done && !1 !== s(i.value, u++, this); + ); + return u + }), + (a.__iteratorUncached = function (s, a) { + var u = i.map(function (s) { + return ((s = Iterable(s)), getIterator(a ? s.reverse() : s)) + }), + _ = 0, + w = !1 + return new Iterator(function () { + var i + return ( + w || + ((i = u.map(function (s) { + return s.next() + })), + (w = i.some(function (s) { + return s.done + }))), + w + ? iteratorDone() + : iteratorValue( + s, + _++, + o.apply( + null, + i.map(function (s) { + return s.value + }) + ) + ) + ) + }) + }), + a + ) + } + function reify(s, o) { + return isSeq(s) ? o : s.constructor(o) + } + function validateEntry(s) { + if (s !== Object(s)) throw new TypeError('Expected [K, V] tuple: ' + s) + } + function resolveSize(s) { + return (assertNotInfinite(s.size), ensureSize(s)) + } + function iterableClass(s) { + return isKeyed(s) ? KeyedIterable : isIndexed(s) ? IndexedIterable : SetIterable + } + function makeSequence(s) { + return Object.create( + (isKeyed(s) ? KeyedSeq : isIndexed(s) ? IndexedSeq : SetSeq).prototype + ) + } + function cacheResultThrough() { + return this._iter.cacheResult + ? (this._iter.cacheResult(), (this.size = this._iter.size), this) + : Seq.prototype.cacheResult.call(this) + } + function defaultComparator(s, o) { + return s > o ? 1 : s < o ? -1 : 0 + } + function forceIterator(s) { + var o = getIterator(s) + if (!o) { + if (!isArrayLike(s)) throw new TypeError('Expected iterable or array-like: ' + s) + o = getIterator(Iterable(s)) + } + return o + } + function Record(s, o) { + var i, + a = function Record(_) { + if (_ instanceof a) return _ + if (!(this instanceof a)) return new a(_) + if (!i) { + i = !0 + var w = Object.keys(s) + ;(setProps(u, w), + (u.size = w.length), + (u._name = o), + (u._keys = w), + (u._defaultValues = s)) + } + this._map = Map(_) + }, + u = (a.prototype = Object.create(tt)) + return ((u.constructor = a), a) + } + ;(createClass(OrderedMap, Map), + (OrderedMap.of = function () { + return this(arguments) + }), + (OrderedMap.prototype.toString = function () { + return this.__toString('OrderedMap {', '}') + }), + (OrderedMap.prototype.get = function (s, o) { + var i = this._map.get(s) + return void 0 !== i ? this._list.get(i)[1] : o + }), + (OrderedMap.prototype.clear = function () { + return 0 === this.size + ? this + : this.__ownerID + ? ((this.size = 0), this._map.clear(), this._list.clear(), this) + : emptyOrderedMap() + }), + (OrderedMap.prototype.set = function (s, o) { + return updateOrderedMap(this, s, o) + }), + (OrderedMap.prototype.remove = function (s) { + return updateOrderedMap(this, s, j) + }), + (OrderedMap.prototype.wasAltered = function () { + return this._map.wasAltered() || this._list.wasAltered() + }), + (OrderedMap.prototype.__iterate = function (s, o) { + var i = this + return this._list.__iterate(function (o) { + return o && s(o[1], o[0], i) + }, o) + }), + (OrderedMap.prototype.__iterator = function (s, o) { + return this._list.fromEntrySeq().__iterator(s, o) + }), + (OrderedMap.prototype.__ensureOwner = function (s) { + if (s === this.__ownerID) return this + var o = this._map.__ensureOwner(s), + i = this._list.__ensureOwner(s) + return s + ? makeOrderedMap(o, i, s, this.__hash) + : ((this.__ownerID = s), (this._map = o), (this._list = i), this) + }), + (OrderedMap.isOrderedMap = isOrderedMap), + (OrderedMap.prototype[u] = !0), + (OrderedMap.prototype[_] = OrderedMap.prototype.remove), + createClass(ToKeyedSequence, KeyedSeq), + (ToKeyedSequence.prototype.get = function (s, o) { + return this._iter.get(s, o) + }), + (ToKeyedSequence.prototype.has = function (s) { + return this._iter.has(s) + }), + (ToKeyedSequence.prototype.valueSeq = function () { + return this._iter.valueSeq() + }), + (ToKeyedSequence.prototype.reverse = function () { + var s = this, + o = reverseFactory(this, !0) + return ( + this._useKeys || + (o.valueSeq = function () { + return s._iter.toSeq().reverse() + }), + o + ) + }), + (ToKeyedSequence.prototype.map = function (s, o) { + var i = this, + a = mapFactory(this, s, o) + return ( + this._useKeys || + (a.valueSeq = function () { + return i._iter.toSeq().map(s, o) + }), + a + ) + }), + (ToKeyedSequence.prototype.__iterate = function (s, o) { + var i, + a = this + return this._iter.__iterate( + this._useKeys + ? function (o, i) { + return s(o, i, a) + } + : ((i = o ? resolveSize(this) : 0), + function (u) { + return s(u, o ? --i : i++, a) + }), + o + ) + }), + (ToKeyedSequence.prototype.__iterator = function (s, o) { + if (this._useKeys) return this._iter.__iterator(s, o) + var i = this._iter.__iterator(U, o), + a = o ? resolveSize(this) : 0 + return new Iterator(function () { + var u = i.next() + return u.done ? u : iteratorValue(s, o ? --a : a++, u.value, u) + }) + }), + (ToKeyedSequence.prototype[u] = !0), + createClass(ToIndexedSequence, IndexedSeq), + (ToIndexedSequence.prototype.includes = function (s) { + return this._iter.includes(s) + }), + (ToIndexedSequence.prototype.__iterate = function (s, o) { + var i = this, + a = 0 + return this._iter.__iterate(function (o) { + return s(o, a++, i) + }, o) + }), + (ToIndexedSequence.prototype.__iterator = function (s, o) { + var i = this._iter.__iterator(U, o), + a = 0 + return new Iterator(function () { + var o = i.next() + return o.done ? o : iteratorValue(s, a++, o.value, o) + }) + }), + createClass(ToSetSequence, SetSeq), + (ToSetSequence.prototype.has = function (s) { + return this._iter.includes(s) + }), + (ToSetSequence.prototype.__iterate = function (s, o) { + var i = this + return this._iter.__iterate(function (o) { + return s(o, o, i) + }, o) + }), + (ToSetSequence.prototype.__iterator = function (s, o) { + var i = this._iter.__iterator(U, o) + return new Iterator(function () { + var o = i.next() + return o.done ? o : iteratorValue(s, o.value, o.value, o) + }) + }), + createClass(FromEntriesSequence, KeyedSeq), + (FromEntriesSequence.prototype.entrySeq = function () { + return this._iter.toSeq() + }), + (FromEntriesSequence.prototype.__iterate = function (s, o) { + var i = this + return this._iter.__iterate(function (o) { + if (o) { + validateEntry(o) + var a = isIterable(o) + return s(a ? o.get(1) : o[1], a ? o.get(0) : o[0], i) + } + }, o) + }), + (FromEntriesSequence.prototype.__iterator = function (s, o) { + var i = this._iter.__iterator(U, o) + return new Iterator(function () { + for (;;) { + var o = i.next() + if (o.done) return o + var a = o.value + if (a) { + validateEntry(a) + var u = isIterable(a) + return iteratorValue(s, u ? a.get(0) : a[0], u ? a.get(1) : a[1], o) + } + } + }) + }), + (ToIndexedSequence.prototype.cacheResult = + ToKeyedSequence.prototype.cacheResult = + ToSetSequence.prototype.cacheResult = + FromEntriesSequence.prototype.cacheResult = + cacheResultThrough), + createClass(Record, KeyedCollection), + (Record.prototype.toString = function () { + return this.__toString(recordName(this) + ' {', '}') + }), + (Record.prototype.has = function (s) { + return this._defaultValues.hasOwnProperty(s) + }), + (Record.prototype.get = function (s, o) { + if (!this.has(s)) return o + var i = this._defaultValues[s] + return this._map ? this._map.get(s, i) : i + }), + (Record.prototype.clear = function () { + if (this.__ownerID) return (this._map && this._map.clear(), this) + var s = this.constructor + return s._empty || (s._empty = makeRecord(this, emptyMap())) + }), + (Record.prototype.set = function (s, o) { + if (!this.has(s)) + throw new Error('Cannot set unknown key "' + s + '" on ' + recordName(this)) + if (this._map && !this._map.has(s) && o === this._defaultValues[s]) return this + var i = this._map && this._map.set(s, o) + return this.__ownerID || i === this._map ? this : makeRecord(this, i) + }), + (Record.prototype.remove = function (s) { + if (!this.has(s)) return this + var o = this._map && this._map.remove(s) + return this.__ownerID || o === this._map ? this : makeRecord(this, o) + }), + (Record.prototype.wasAltered = function () { + return this._map.wasAltered() + }), + (Record.prototype.__iterator = function (s, o) { + var i = this + return KeyedIterable(this._defaultValues) + .map(function (s, o) { + return i.get(o) + }) + .__iterator(s, o) + }), + (Record.prototype.__iterate = function (s, o) { + var i = this + return KeyedIterable(this._defaultValues) + .map(function (s, o) { + return i.get(o) + }) + .__iterate(s, o) + }), + (Record.prototype.__ensureOwner = function (s) { + if (s === this.__ownerID) return this + var o = this._map && this._map.__ensureOwner(s) + return s ? makeRecord(this, o, s) : ((this.__ownerID = s), (this._map = o), this) + })) + var tt = Record.prototype + function makeRecord(s, o, i) { + var a = Object.create(Object.getPrototypeOf(s)) + return ((a._map = o), (a.__ownerID = i), a) + } + function recordName(s) { + return s._name || s.constructor.name || 'Record' + } + function setProps(s, o) { + try { + o.forEach(setProp.bind(void 0, s)) + } catch (s) {} + } + function setProp(s, o) { + Object.defineProperty(s, o, { + get: function () { + return this.get(o) + }, + set: function (s) { + ;(invariant(this.__ownerID, 'Cannot set on an immutable record.'), this.set(o, s)) + }, + }) + } + function Set(s) { + return null == s + ? emptySet() + : isSet(s) && !isOrdered(s) + ? s + : emptySet().withMutations(function (o) { + var i = SetIterable(s) + ;(assertNotInfinite(i.size), + i.forEach(function (s) { + return o.add(s) + })) + }) + } + function isSet(s) { + return !(!s || !s[nt]) + } + ;((tt[_] = tt.remove), + (tt.deleteIn = tt.removeIn = $e.removeIn), + (tt.merge = $e.merge), + (tt.mergeWith = $e.mergeWith), + (tt.mergeIn = $e.mergeIn), + (tt.mergeDeep = $e.mergeDeep), + (tt.mergeDeepWith = $e.mergeDeepWith), + (tt.mergeDeepIn = $e.mergeDeepIn), + (tt.setIn = $e.setIn), + (tt.update = $e.update), + (tt.updateIn = $e.updateIn), + (tt.withMutations = $e.withMutations), + (tt.asMutable = $e.asMutable), + (tt.asImmutable = $e.asImmutable), + createClass(Set, SetCollection), + (Set.of = function () { + return this(arguments) + }), + (Set.fromKeys = function (s) { + return this(KeyedIterable(s).keySeq()) + }), + (Set.prototype.toString = function () { + return this.__toString('Set {', '}') + }), + (Set.prototype.has = function (s) { + return this._map.has(s) + }), + (Set.prototype.add = function (s) { + return updateSet(this, this._map.set(s, !0)) + }), + (Set.prototype.remove = function (s) { + return updateSet(this, this._map.remove(s)) + }), + (Set.prototype.clear = function () { + return updateSet(this, this._map.clear()) + }), + (Set.prototype.union = function () { + var o = s.call(arguments, 0) + return 0 === + (o = o.filter(function (s) { + return 0 !== s.size + })).length + ? this + : 0 !== this.size || this.__ownerID || 1 !== o.length + ? this.withMutations(function (s) { + for (var i = 0; i < o.length; i++) + SetIterable(o[i]).forEach(function (o) { + return s.add(o) + }) + }) + : this.constructor(o[0]) + }), + (Set.prototype.intersect = function () { + var o = s.call(arguments, 0) + if (0 === o.length) return this + o = o.map(function (s) { + return SetIterable(s) + }) + var i = this + return this.withMutations(function (s) { + i.forEach(function (i) { + o.every(function (s) { + return s.includes(i) + }) || s.remove(i) + }) + }) + }), + (Set.prototype.subtract = function () { + var o = s.call(arguments, 0) + if (0 === o.length) return this + o = o.map(function (s) { + return SetIterable(s) + }) + var i = this + return this.withMutations(function (s) { + i.forEach(function (i) { + o.some(function (s) { + return s.includes(i) + }) && s.remove(i) + }) + }) + }), + (Set.prototype.merge = function () { + return this.union.apply(this, arguments) + }), + (Set.prototype.mergeWith = function (o) { + var i = s.call(arguments, 1) + return this.union.apply(this, i) + }), + (Set.prototype.sort = function (s) { + return OrderedSet(sortFactory(this, s)) + }), + (Set.prototype.sortBy = function (s, o) { + return OrderedSet(sortFactory(this, o, s)) + }), + (Set.prototype.wasAltered = function () { + return this._map.wasAltered() + }), + (Set.prototype.__iterate = function (s, o) { + var i = this + return this._map.__iterate(function (o, a) { + return s(a, a, i) + }, o) + }), + (Set.prototype.__iterator = function (s, o) { + return this._map + .map(function (s, o) { + return o + }) + .__iterator(s, o) + }), + (Set.prototype.__ensureOwner = function (s) { + if (s === this.__ownerID) return this + var o = this._map.__ensureOwner(s) + return s ? this.__make(o, s) : ((this.__ownerID = s), (this._map = o), this) + }), + (Set.isSet = isSet)) + var rt, + nt = '@@__IMMUTABLE_SET__@@', + st = Set.prototype + function updateSet(s, o) { + return s.__ownerID + ? ((s.size = o.size), (s._map = o), s) + : o === s._map + ? s + : 0 === o.size + ? s.__empty() + : s.__make(o) + } + function makeSet(s, o) { + var i = Object.create(st) + return ((i.size = s ? s.size : 0), (i._map = s), (i.__ownerID = o), i) + } + function emptySet() { + return rt || (rt = makeSet(emptyMap())) + } + function OrderedSet(s) { + return null == s + ? emptyOrderedSet() + : isOrderedSet(s) + ? s + : emptyOrderedSet().withMutations(function (o) { + var i = SetIterable(s) + ;(assertNotInfinite(i.size), + i.forEach(function (s) { + return o.add(s) + })) + }) + } + function isOrderedSet(s) { + return isSet(s) && isOrdered(s) + } + ;((st[nt] = !0), + (st[_] = st.remove), + (st.mergeDeep = st.merge), + (st.mergeDeepWith = st.mergeWith), + (st.withMutations = $e.withMutations), + (st.asMutable = $e.asMutable), + (st.asImmutable = $e.asImmutable), + (st.__empty = emptySet), + (st.__make = makeSet), + createClass(OrderedSet, Set), + (OrderedSet.of = function () { + return this(arguments) + }), + (OrderedSet.fromKeys = function (s) { + return this(KeyedIterable(s).keySeq()) + }), + (OrderedSet.prototype.toString = function () { + return this.__toString('OrderedSet {', '}') + }), + (OrderedSet.isOrderedSet = isOrderedSet)) + var ot, + it = OrderedSet.prototype + function makeOrderedSet(s, o) { + var i = Object.create(it) + return ((i.size = s ? s.size : 0), (i._map = s), (i.__ownerID = o), i) + } + function emptyOrderedSet() { + return ot || (ot = makeOrderedSet(emptyOrderedMap())) + } + function Stack(s) { + return null == s ? emptyStack() : isStack(s) ? s : emptyStack().unshiftAll(s) + } + function isStack(s) { + return !(!s || !s[ct]) + } + ;((it[u] = !0), + (it.__empty = emptyOrderedSet), + (it.__make = makeOrderedSet), + createClass(Stack, IndexedCollection), + (Stack.of = function () { + return this(arguments) + }), + (Stack.prototype.toString = function () { + return this.__toString('Stack [', ']') + }), + (Stack.prototype.get = function (s, o) { + var i = this._head + for (s = wrapIndex(this, s); i && s--; ) i = i.next + return i ? i.value : o + }), + (Stack.prototype.peek = function () { + return this._head && this._head.value + }), + (Stack.prototype.push = function () { + if (0 === arguments.length) return this + for ( + var s = this.size + arguments.length, o = this._head, i = arguments.length - 1; + i >= 0; + i-- + ) + o = { value: arguments[i], next: o } + return this.__ownerID + ? ((this.size = s), + (this._head = o), + (this.__hash = void 0), + (this.__altered = !0), + this) + : makeStack(s, o) + }), + (Stack.prototype.pushAll = function (s) { + if (0 === (s = IndexedIterable(s)).size) return this + assertNotInfinite(s.size) + var o = this.size, + i = this._head + return ( + s.reverse().forEach(function (s) { + ;(o++, (i = { value: s, next: i })) + }), + this.__ownerID + ? ((this.size = o), + (this._head = i), + (this.__hash = void 0), + (this.__altered = !0), + this) + : makeStack(o, i) + ) + }), + (Stack.prototype.pop = function () { + return this.slice(1) + }), + (Stack.prototype.unshift = function () { + return this.push.apply(this, arguments) + }), + (Stack.prototype.unshiftAll = function (s) { + return this.pushAll(s) + }), + (Stack.prototype.shift = function () { + return this.pop.apply(this, arguments) + }), + (Stack.prototype.clear = function () { + return 0 === this.size + ? this + : this.__ownerID + ? ((this.size = 0), + (this._head = void 0), + (this.__hash = void 0), + (this.__altered = !0), + this) + : emptyStack() + }), + (Stack.prototype.slice = function (s, o) { + if (wholeSlice(s, o, this.size)) return this + var i = resolveBegin(s, this.size) + if (resolveEnd(o, this.size) !== this.size) + return IndexedCollection.prototype.slice.call(this, s, o) + for (var a = this.size - i, u = this._head; i--; ) u = u.next + return this.__ownerID + ? ((this.size = a), + (this._head = u), + (this.__hash = void 0), + (this.__altered = !0), + this) + : makeStack(a, u) + }), + (Stack.prototype.__ensureOwner = function (s) { + return s === this.__ownerID + ? this + : s + ? makeStack(this.size, this._head, s, this.__hash) + : ((this.__ownerID = s), (this.__altered = !1), this) + }), + (Stack.prototype.__iterate = function (s, o) { + if (o) return this.reverse().__iterate(s) + for (var i = 0, a = this._head; a && !1 !== s(a.value, i++, this); ) a = a.next + return i + }), + (Stack.prototype.__iterator = function (s, o) { + if (o) return this.reverse().__iterator(s) + var i = 0, + a = this._head + return new Iterator(function () { + if (a) { + var o = a.value + return ((a = a.next), iteratorValue(s, i++, o)) + } + return iteratorDone() + }) + }), + (Stack.isStack = isStack)) + var at, + ct = '@@__IMMUTABLE_STACK__@@', + lt = Stack.prototype + function makeStack(s, o, i, a) { + var u = Object.create(lt) + return ( + (u.size = s), + (u._head = o), + (u.__ownerID = i), + (u.__hash = a), + (u.__altered = !1), + u + ) + } + function emptyStack() { + return at || (at = makeStack(0)) + } + function mixin(s, o) { + var keyCopier = function (i) { + s.prototype[i] = o[i] + } + return ( + Object.keys(o).forEach(keyCopier), + Object.getOwnPropertySymbols && Object.getOwnPropertySymbols(o).forEach(keyCopier), + s + ) + } + ;((lt[ct] = !0), + (lt.withMutations = $e.withMutations), + (lt.asMutable = $e.asMutable), + (lt.asImmutable = $e.asImmutable), + (lt.wasAltered = $e.wasAltered), + (Iterable.Iterator = Iterator), + mixin(Iterable, { + toArray: function () { + assertNotInfinite(this.size) + var s = new Array(this.size || 0) + return ( + this.valueSeq().__iterate(function (o, i) { + s[i] = o + }), + s + ) + }, + toIndexedSeq: function () { + return new ToIndexedSequence(this) + }, + toJS: function () { + return this.toSeq() + .map(function (s) { + return s && 'function' == typeof s.toJS ? s.toJS() : s + }) + .__toJS() + }, + toJSON: function () { + return this.toSeq() + .map(function (s) { + return s && 'function' == typeof s.toJSON ? s.toJSON() : s + }) + .__toJS() + }, + toKeyedSeq: function () { + return new ToKeyedSequence(this, !0) + }, + toMap: function () { + return Map(this.toKeyedSeq()) + }, + toObject: function () { + assertNotInfinite(this.size) + var s = {} + return ( + this.__iterate(function (o, i) { + s[i] = o + }), + s + ) + }, + toOrderedMap: function () { + return OrderedMap(this.toKeyedSeq()) + }, + toOrderedSet: function () { + return OrderedSet(isKeyed(this) ? this.valueSeq() : this) + }, + toSet: function () { + return Set(isKeyed(this) ? this.valueSeq() : this) + }, + toSetSeq: function () { + return new ToSetSequence(this) + }, + toSeq: function () { + return isIndexed(this) + ? this.toIndexedSeq() + : isKeyed(this) + ? this.toKeyedSeq() + : this.toSetSeq() + }, + toStack: function () { + return Stack(isKeyed(this) ? this.valueSeq() : this) + }, + toList: function () { + return List(isKeyed(this) ? this.valueSeq() : this) + }, + toString: function () { + return '[Iterable]' + }, + __toString: function (s, o) { + return 0 === this.size + ? s + o + : s + ' ' + this.toSeq().map(this.__toStringMapper).join(', ') + ' ' + o + }, + concat: function () { + return reify(this, concatFactory(this, s.call(arguments, 0))) + }, + includes: function (s) { + return this.some(function (o) { + return is(o, s) + }) + }, + entries: function () { + return this.__iterator(V) + }, + every: function (s, o) { + assertNotInfinite(this.size) + var i = !0 + return ( + this.__iterate(function (a, u, _) { + if (!s.call(o, a, u, _)) return ((i = !1), !1) + }), + i + ) + }, + filter: function (s, o) { + return reify(this, filterFactory(this, s, o, !0)) + }, + find: function (s, o, i) { + var a = this.findEntry(s, o) + return a ? a[1] : i + }, + forEach: function (s, o) { + return (assertNotInfinite(this.size), this.__iterate(o ? s.bind(o) : s)) + }, + join: function (s) { + ;(assertNotInfinite(this.size), (s = void 0 !== s ? '' + s : ',')) + var o = '', + i = !0 + return ( + this.__iterate(function (a) { + ;(i ? (i = !1) : (o += s), (o += null != a ? a.toString() : '')) + }), + o + ) + }, + keys: function () { + return this.__iterator($) + }, + map: function (s, o) { + return reify(this, mapFactory(this, s, o)) + }, + reduce: function (s, o, i) { + var a, u + return ( + assertNotInfinite(this.size), + arguments.length < 2 ? (u = !0) : (a = o), + this.__iterate(function (o, _, w) { + u ? ((u = !1), (a = o)) : (a = s.call(i, a, o, _, w)) + }), + a + ) + }, + reduceRight: function (s, o, i) { + var a = this.toKeyedSeq().reverse() + return a.reduce.apply(a, arguments) + }, + reverse: function () { + return reify(this, reverseFactory(this, !0)) + }, + slice: function (s, o) { + return reify(this, sliceFactory(this, s, o, !0)) + }, + some: function (s, o) { + return !this.every(not(s), o) + }, + sort: function (s) { + return reify(this, sortFactory(this, s)) + }, + values: function () { + return this.__iterator(U) + }, + butLast: function () { + return this.slice(0, -1) + }, + isEmpty: function () { + return void 0 !== this.size + ? 0 === this.size + : !this.some(function () { + return !0 + }) + }, + count: function (s, o) { + return ensureSize(s ? this.toSeq().filter(s, o) : this) + }, + countBy: function (s, o) { + return countByFactory(this, s, o) + }, + equals: function (s) { + return deepEqual(this, s) + }, + entrySeq: function () { + var s = this + if (s._cache) return new ArraySeq(s._cache) + var o = s.toSeq().map(entryMapper).toIndexedSeq() + return ( + (o.fromEntrySeq = function () { + return s.toSeq() + }), + o + ) + }, + filterNot: function (s, o) { + return this.filter(not(s), o) + }, + findEntry: function (s, o, i) { + var a = i + return ( + this.__iterate(function (i, u, _) { + if (s.call(o, i, u, _)) return ((a = [u, i]), !1) + }), + a + ) + }, + findKey: function (s, o) { + var i = this.findEntry(s, o) + return i && i[0] + }, + findLast: function (s, o, i) { + return this.toKeyedSeq().reverse().find(s, o, i) + }, + findLastEntry: function (s, o, i) { + return this.toKeyedSeq().reverse().findEntry(s, o, i) + }, + findLastKey: function (s, o) { + return this.toKeyedSeq().reverse().findKey(s, o) + }, + first: function () { + return this.find(returnTrue) + }, + flatMap: function (s, o) { + return reify(this, flatMapFactory(this, s, o)) + }, + flatten: function (s) { + return reify(this, flattenFactory(this, s, !0)) + }, + fromEntrySeq: function () { + return new FromEntriesSequence(this) + }, + get: function (s, o) { + return this.find( + function (o, i) { + return is(i, s) + }, + void 0, + o + ) + }, + getIn: function (s, o) { + for (var i, a = this, u = forceIterator(s); !(i = u.next()).done; ) { + var _ = i.value + if ((a = a && a.get ? a.get(_, j) : j) === j) return o + } + return a + }, + groupBy: function (s, o) { + return groupByFactory(this, s, o) + }, + has: function (s) { + return this.get(s, j) !== j + }, + hasIn: function (s) { + return this.getIn(s, j) !== j + }, + isSubset: function (s) { + return ( + (s = 'function' == typeof s.includes ? s : Iterable(s)), + this.every(function (o) { + return s.includes(o) + }) + ) + }, + isSuperset: function (s) { + return (s = 'function' == typeof s.isSubset ? s : Iterable(s)).isSubset(this) + }, + keyOf: function (s) { + return this.findKey(function (o) { + return is(o, s) + }) + }, + keySeq: function () { + return this.toSeq().map(keyMapper).toIndexedSeq() + }, + last: function () { + return this.toSeq().reverse().first() + }, + lastKeyOf: function (s) { + return this.toKeyedSeq().reverse().keyOf(s) + }, + max: function (s) { + return maxFactory(this, s) + }, + maxBy: function (s, o) { + return maxFactory(this, o, s) + }, + min: function (s) { + return maxFactory(this, s ? neg(s) : defaultNegComparator) + }, + minBy: function (s, o) { + return maxFactory(this, o ? neg(o) : defaultNegComparator, s) + }, + rest: function () { + return this.slice(1) + }, + skip: function (s) { + return this.slice(Math.max(0, s)) + }, + skipLast: function (s) { + return reify(this, this.toSeq().reverse().skip(s).reverse()) + }, + skipWhile: function (s, o) { + return reify(this, skipWhileFactory(this, s, o, !0)) + }, + skipUntil: function (s, o) { + return this.skipWhile(not(s), o) + }, + sortBy: function (s, o) { + return reify(this, sortFactory(this, o, s)) + }, + take: function (s) { + return this.slice(0, Math.max(0, s)) + }, + takeLast: function (s) { + return reify(this, this.toSeq().reverse().take(s).reverse()) + }, + takeWhile: function (s, o) { + return reify(this, takeWhileFactory(this, s, o)) + }, + takeUntil: function (s, o) { + return this.takeWhile(not(s), o) + }, + valueSeq: function () { + return this.toIndexedSeq() + }, + hashCode: function () { + return this.__hash || (this.__hash = hashIterable(this)) + }, + })) + var ut = Iterable.prototype + ;((ut[o] = !0), + (ut[Z] = ut.values), + (ut.__toJS = ut.toArray), + (ut.__toStringMapper = quoteString), + (ut.inspect = ut.toSource = + function () { + return this.toString() + }), + (ut.chain = ut.flatMap), + (ut.contains = ut.includes), + mixin(KeyedIterable, { + flip: function () { + return reify(this, flipFactory(this)) + }, + mapEntries: function (s, o) { + var i = this, + a = 0 + return reify( + this, + this.toSeq() + .map(function (u, _) { + return s.call(o, [_, u], a++, i) + }) + .fromEntrySeq() + ) + }, + mapKeys: function (s, o) { + var i = this + return reify( + this, + this.toSeq() + .flip() + .map(function (a, u) { + return s.call(o, a, u, i) + }) + .flip() + ) + }, + })) + var pt = KeyedIterable.prototype + function keyMapper(s, o) { + return o + } + function entryMapper(s, o) { + return [o, s] + } + function not(s) { + return function () { + return !s.apply(this, arguments) + } + } + function neg(s) { + return function () { + return -s.apply(this, arguments) + } + } + function quoteString(s) { + return 'string' == typeof s ? JSON.stringify(s) : String(s) + } + function defaultZipper() { + return arrCopy(arguments) + } + function defaultNegComparator(s, o) { + return s < o ? 1 : s > o ? -1 : 0 + } + function hashIterable(s) { + if (s.size === 1 / 0) return 0 + var o = isOrdered(s), + i = isKeyed(s), + a = o ? 1 : 0 + return murmurHashOfSize( + s.__iterate( + i + ? o + ? function (s, o) { + a = (31 * a + hashMerge(hash(s), hash(o))) | 0 + } + : function (s, o) { + a = (a + hashMerge(hash(s), hash(o))) | 0 + } + : o + ? function (s) { + a = (31 * a + hash(s)) | 0 + } + : function (s) { + a = (a + hash(s)) | 0 + } + ), + a + ) + } + function murmurHashOfSize(s, o) { + return ( + (o = le(o, 3432918353)), + (o = le((o << 15) | (o >>> -15), 461845907)), + (o = le((o << 13) | (o >>> -13), 5)), + (o = le((o = (o + 3864292196) ^ s) ^ (o >>> 16), 2246822507)), + (o = smi((o = le(o ^ (o >>> 13), 3266489909)) ^ (o >>> 16))) + ) + } + function hashMerge(s, o) { + return s ^ (o + 2654435769 + (s << 6) + (s >> 2)) + } + return ( + (pt[i] = !0), + (pt[Z] = ut.entries), + (pt.__toJS = ut.toObject), + (pt.__toStringMapper = function (s, o) { + return JSON.stringify(o) + ': ' + quoteString(s) + }), + mixin(IndexedIterable, { + toKeyedSeq: function () { + return new ToKeyedSequence(this, !1) + }, + filter: function (s, o) { + return reify(this, filterFactory(this, s, o, !1)) + }, + findIndex: function (s, o) { + var i = this.findEntry(s, o) + return i ? i[0] : -1 + }, + indexOf: function (s) { + var o = this.keyOf(s) + return void 0 === o ? -1 : o + }, + lastIndexOf: function (s) { + var o = this.lastKeyOf(s) + return void 0 === o ? -1 : o + }, + reverse: function () { + return reify(this, reverseFactory(this, !1)) + }, + slice: function (s, o) { + return reify(this, sliceFactory(this, s, o, !1)) + }, + splice: function (s, o) { + var i = arguments.length + if (((o = Math.max(0 | o, 0)), 0 === i || (2 === i && !o))) return this + s = resolveBegin(s, s < 0 ? this.count() : this.size) + var a = this.slice(0, s) + return reify( + this, + 1 === i ? a : a.concat(arrCopy(arguments, 2), this.slice(s + o)) + ) + }, + findLastIndex: function (s, o) { + var i = this.findLastEntry(s, o) + return i ? i[0] : -1 + }, + first: function () { + return this.get(0) + }, + flatten: function (s) { + return reify(this, flattenFactory(this, s, !1)) + }, + get: function (s, o) { + return (s = wrapIndex(this, s)) < 0 || + this.size === 1 / 0 || + (void 0 !== this.size && s > this.size) + ? o + : this.find( + function (o, i) { + return i === s + }, + void 0, + o + ) + }, + has: function (s) { + return ( + (s = wrapIndex(this, s)) >= 0 && + (void 0 !== this.size + ? this.size === 1 / 0 || s < this.size + : -1 !== this.indexOf(s)) + ) + }, + interpose: function (s) { + return reify(this, interposeFactory(this, s)) + }, + interleave: function () { + var s = [this].concat(arrCopy(arguments)), + o = zipWithFactory(this.toSeq(), IndexedSeq.of, s), + i = o.flatten(!0) + return (o.size && (i.size = o.size * s.length), reify(this, i)) + }, + keySeq: function () { + return Range(0, this.size) + }, + last: function () { + return this.get(-1) + }, + skipWhile: function (s, o) { + return reify(this, skipWhileFactory(this, s, o, !1)) + }, + zip: function () { + return reify( + this, + zipWithFactory(this, defaultZipper, [this].concat(arrCopy(arguments))) + ) + }, + zipWith: function (s) { + var o = arrCopy(arguments) + return ((o[0] = this), reify(this, zipWithFactory(this, s, o))) + }, + }), + (IndexedIterable.prototype[a] = !0), + (IndexedIterable.prototype[u] = !0), + mixin(SetIterable, { + get: function (s, o) { + return this.has(s) ? s : o + }, + includes: function (s) { + return this.has(s) + }, + keySeq: function () { + return this.valueSeq() + }, + }), + (SetIterable.prototype.has = ut.includes), + (SetIterable.prototype.contains = SetIterable.prototype.includes), + mixin(KeyedSeq, KeyedIterable.prototype), + mixin(IndexedSeq, IndexedIterable.prototype), + mixin(SetSeq, SetIterable.prototype), + mixin(KeyedCollection, KeyedIterable.prototype), + mixin(IndexedCollection, IndexedIterable.prototype), + mixin(SetCollection, SetIterable.prototype), + { + Iterable, + Seq, + Collection, + Map, + OrderedMap, + List, + Stack, + Set, + OrderedSet, + Record, + Range, + Repeat, + is, + fromJS, + } + ) + })() + }, + 9748: (s, o, i) => { + 'use strict' + i(71340) + var a = i(92046) + s.exports = a.Object.assign + }, + 9957: (s, o, i) => { + 'use strict' + var a = Function.prototype.call, + u = Object.prototype.hasOwnProperty, + _ = i(66743) + s.exports = _.call(a, u) + }, + 9999: (s, o, i) => { + var a = i(37217), + u = i(83729), + _ = i(16547), + w = i(74733), + x = i(43838), + C = i(93290), + j = i(23007), + L = i(92271), + B = i(48948), + $ = i(50002), + U = i(83349), + V = i(5861), + z = i(76189), + Y = i(77199), + Z = i(35529), + ee = i(56449), + ie = i(3656), + ae = i(87730), + ce = i(23805), + le = i(38440), + pe = i(95950), + de = i(37241), + fe = '[object Arguments]', + ye = '[object Function]', + be = '[object Object]', + _e = {} + ;((_e[fe] = + _e['[object Array]'] = + _e['[object ArrayBuffer]'] = + _e['[object DataView]'] = + _e['[object Boolean]'] = + _e['[object Date]'] = + _e['[object Float32Array]'] = + _e['[object Float64Array]'] = + _e['[object Int8Array]'] = + _e['[object Int16Array]'] = + _e['[object Int32Array]'] = + _e['[object Map]'] = + _e['[object Number]'] = + _e[be] = + _e['[object RegExp]'] = + _e['[object Set]'] = + _e['[object String]'] = + _e['[object Symbol]'] = + _e['[object Uint8Array]'] = + _e['[object Uint8ClampedArray]'] = + _e['[object Uint16Array]'] = + _e['[object Uint32Array]'] = + !0), + (_e['[object Error]'] = _e[ye] = _e['[object WeakMap]'] = !1), + (s.exports = function baseClone(s, o, i, Se, we, xe) { + var Pe, + Te = 1 & o, + Re = 2 & o, + $e = 4 & o + if ((i && (Pe = we ? i(s, Se, we, xe) : i(s)), void 0 !== Pe)) return Pe + if (!ce(s)) return s + var qe = ee(s) + if (qe) { + if (((Pe = z(s)), !Te)) return j(s, Pe) + } else { + var ze = V(s), + We = ze == ye || '[object GeneratorFunction]' == ze + if (ie(s)) return C(s, Te) + if (ze == be || ze == fe || (We && !we)) { + if (((Pe = Re || We ? {} : Z(s)), !Te)) + return Re ? B(s, x(Pe, s)) : L(s, w(Pe, s)) + } else { + if (!_e[ze]) return we ? s : {} + Pe = Y(s, ze, Te) + } + } + xe || (xe = new a()) + var He = xe.get(s) + if (He) return He + ;(xe.set(s, Pe), + le(s) + ? s.forEach(function (a) { + Pe.add(baseClone(a, o, i, a, s, xe)) + }) + : ae(s) && + s.forEach(function (a, u) { + Pe.set(u, baseClone(a, o, i, u, s, xe)) + })) + var Ye = qe ? void 0 : ($e ? (Re ? U : $) : Re ? de : pe)(s) + return ( + u(Ye || s, function (a, u) { + ;(Ye && (a = s[(u = a)]), _(Pe, u, baseClone(a, o, i, u, s, xe))) + }), + Pe + ) + })) + }, + 10023: (s, o, i) => { + const a = i(6205), + INTS = () => [{ type: a.RANGE, from: 48, to: 57 }], + WORDS = () => + [ + { type: a.CHAR, value: 95 }, + { type: a.RANGE, from: 97, to: 122 }, + { type: a.RANGE, from: 65, to: 90 }, + ].concat(INTS()), + WHITESPACE = () => [ + { type: a.CHAR, value: 9 }, + { type: a.CHAR, value: 10 }, + { type: a.CHAR, value: 11 }, + { type: a.CHAR, value: 12 }, + { type: a.CHAR, value: 13 }, + { type: a.CHAR, value: 32 }, + { type: a.CHAR, value: 160 }, + { type: a.CHAR, value: 5760 }, + { type: a.RANGE, from: 8192, to: 8202 }, + { type: a.CHAR, value: 8232 }, + { type: a.CHAR, value: 8233 }, + { type: a.CHAR, value: 8239 }, + { type: a.CHAR, value: 8287 }, + { type: a.CHAR, value: 12288 }, + { type: a.CHAR, value: 65279 }, + ] + ;((o.words = () => ({ type: a.SET, set: WORDS(), not: !1 })), + (o.notWords = () => ({ type: a.SET, set: WORDS(), not: !0 })), + (o.ints = () => ({ type: a.SET, set: INTS(), not: !1 })), + (o.notInts = () => ({ type: a.SET, set: INTS(), not: !0 })), + (o.whitespace = () => ({ type: a.SET, set: WHITESPACE(), not: !1 })), + (o.notWhitespace = () => ({ type: a.SET, set: WHITESPACE(), not: !0 })), + (o.anyChar = () => ({ + type: a.SET, + set: [ + { type: a.CHAR, value: 10 }, + { type: a.CHAR, value: 13 }, + { type: a.CHAR, value: 8232 }, + { type: a.CHAR, value: 8233 }, + ], + not: !0, + }))) + }, + 10043: (s, o, i) => { + 'use strict' + var a = i(54018), + u = String, + _ = TypeError + s.exports = function (s) { + if (a(s)) return s + throw new _("Can't set " + u(s) + ' as a prototype') + } + }, + 10076: (s) => { + 'use strict' + s.exports = Function.prototype.call + }, + 10124: (s, o, i) => { + var a = i(9325) + s.exports = function () { + return a.Date.now() + } + }, + 10300: (s, o, i) => { + 'use strict' + var a = i(13930), + u = i(82159), + _ = i(36624), + w = i(4640), + x = i(73448), + C = TypeError + s.exports = function (s, o) { + var i = arguments.length < 2 ? x(s) : o + if (u(i)) return _(a(i, s)) + throw new C(w(s) + ' is not iterable') + } + }, + 10316: (s, o, i) => { + const a = i(2404), + u = i(55973), + _ = i(92340) + class Element { + constructor(s, o, i) { + ;(o && (this.meta = o), i && (this.attributes = i), (this.content = s)) + } + freeze() { + Object.isFrozen(this) || + (this._meta && ((this.meta.parent = this), this.meta.freeze()), + this._attributes && ((this.attributes.parent = this), this.attributes.freeze()), + this.children.forEach((s) => { + ;((s.parent = this), s.freeze()) + }, this), + this.content && Array.isArray(this.content) && Object.freeze(this.content), + Object.freeze(this)) + } + primitive() {} + clone() { + const s = new this.constructor() + return ( + (s.element = this.element), + this.meta.length && (s._meta = this.meta.clone()), + this.attributes.length && (s._attributes = this.attributes.clone()), + this.content + ? this.content.clone + ? (s.content = this.content.clone()) + : Array.isArray(this.content) + ? (s.content = this.content.map((s) => s.clone())) + : (s.content = this.content) + : (s.content = this.content), + s + ) + } + toValue() { + return this.content instanceof Element + ? this.content.toValue() + : this.content instanceof u + ? { + key: this.content.key.toValue(), + value: this.content.value ? this.content.value.toValue() : void 0, + } + : this.content && this.content.map + ? this.content.map((s) => s.toValue(), this) + : this.content + } + toRef(s) { + if ('' === this.id.toValue()) + throw Error('Cannot create reference to an element that does not contain an ID') + const o = new this.RefElement(this.id.toValue()) + return (s && (o.path = s), o) + } + findRecursive(...s) { + if (arguments.length > 1 && !this.isFrozen) + throw new Error( + 'Cannot find recursive with multiple element names without first freezing the element. Call `element.freeze()`' + ) + const o = s.pop() + let i = new _() + const append = (s, o) => (s.push(o), s), + checkElement = (s, i) => { + i.element === o && s.push(i) + const a = i.findRecursive(o) + return ( + a && a.reduce(append, s), + i.content instanceof u && + (i.content.key && checkElement(s, i.content.key), + i.content.value && checkElement(s, i.content.value)), + s + ) + } + return ( + this.content && + (this.content.element && checkElement(i, this.content), + Array.isArray(this.content) && this.content.reduce(checkElement, i)), + s.isEmpty || + (i = i.filter((o) => { + let i = o.parents.map((s) => s.element) + for (const o in s) { + const a = s[o], + u = i.indexOf(a) + if (-1 === u) return !1 + i = i.splice(0, u) + } + return !0 + })), + i + ) + } + set(s) { + return ((this.content = s), this) + } + equals(s) { + return a(this.toValue(), s) + } + getMetaProperty(s, o) { + if (!this.meta.hasKey(s)) { + if (this.isFrozen) { + const s = this.refract(o) + return (s.freeze(), s) + } + this.meta.set(s, o) + } + return this.meta.get(s) + } + setMetaProperty(s, o) { + this.meta.set(s, o) + } + get element() { + return this._storedElement || 'element' + } + set element(s) { + this._storedElement = s + } + get content() { + return this._content + } + set content(s) { + if (s instanceof Element) this._content = s + else if (s instanceof _) this.content = s.elements + else if ( + 'string' == typeof s || + 'number' == typeof s || + 'boolean' == typeof s || + 'null' === s || + null == s + ) + this._content = s + else if (s instanceof u) this._content = s + else if (Array.isArray(s)) this._content = s.map(this.refract) + else { + if ('object' != typeof s) throw new Error('Cannot set content to given value') + this._content = Object.keys(s).map((o) => new this.MemberElement(o, s[o])) + } + } + get meta() { + if (!this._meta) { + if (this.isFrozen) { + const s = new this.ObjectElement() + return (s.freeze(), s) + } + this._meta = new this.ObjectElement() + } + return this._meta + } + set meta(s) { + s instanceof this.ObjectElement ? (this._meta = s) : this.meta.set(s || {}) + } + get attributes() { + if (!this._attributes) { + if (this.isFrozen) { + const s = new this.ObjectElement() + return (s.freeze(), s) + } + this._attributes = new this.ObjectElement() + } + return this._attributes + } + set attributes(s) { + s instanceof this.ObjectElement + ? (this._attributes = s) + : this.attributes.set(s || {}) + } + get id() { + return this.getMetaProperty('id', '') + } + set id(s) { + this.setMetaProperty('id', s) + } + get classes() { + return this.getMetaProperty('classes', []) + } + set classes(s) { + this.setMetaProperty('classes', s) + } + get title() { + return this.getMetaProperty('title', '') + } + set title(s) { + this.setMetaProperty('title', s) + } + get description() { + return this.getMetaProperty('description', '') + } + set description(s) { + this.setMetaProperty('description', s) + } + get links() { + return this.getMetaProperty('links', []) + } + set links(s) { + this.setMetaProperty('links', s) + } + get isFrozen() { + return Object.isFrozen(this) + } + get parents() { + let { parent: s } = this + const o = new _() + for (; s; ) (o.push(s), (s = s.parent)) + return o + } + get children() { + if (Array.isArray(this.content)) return new _(this.content) + if (this.content instanceof u) { + const s = new _([this.content.key]) + return (this.content.value && s.push(this.content.value), s) + } + return this.content instanceof Element ? new _([this.content]) : new _() + } + get recursiveChildren() { + const s = new _() + return ( + this.children.forEach((o) => { + ;(s.push(o), + o.recursiveChildren.forEach((o) => { + s.push(o) + })) + }), + s + ) + } + } + s.exports = Element + }, + 10392: (s) => { + s.exports = function getValue(s, o) { + return null == s ? void 0 : s[o] + } + }, + 10487: (s, o, i) => { + 'use strict' + var a = i(96897), + u = i(30655), + _ = i(73126), + w = i(12205) + ;((s.exports = function callBind(s) { + var o = _(arguments), + i = s.length - (arguments.length - 1) + return a(o, 1 + (i > 0 ? i : 0), !0) + }), + u ? u(s.exports, 'apply', { value: w }) : (s.exports.apply = w)) + }, + 10776: (s, o, i) => { + var a = i(30756), + u = i(95950) + s.exports = function getMatchData(s) { + for (var o = u(s), i = o.length; i--; ) { + var _ = o[i], + w = s[_] + o[i] = [_, w, a(w)] + } + return o + } + }, + 10866: (s, o, i) => { + const a = i(6048), + u = i(92340) + class ObjectSlice extends u { + map(s, o) { + return this.elements.map((i) => s.bind(o)(i.value, i.key, i)) + } + filter(s, o) { + return new ObjectSlice(this.elements.filter((i) => s.bind(o)(i.value, i.key, i))) + } + reject(s, o) { + return this.filter(a(s.bind(o))) + } + forEach(s, o) { + return this.elements.forEach((i, a) => { + s.bind(o)(i.value, i.key, i, a) + }) + } + keys() { + return this.map((s, o) => o.toValue()) + } + values() { + return this.map((s) => s.toValue()) + } + } + s.exports = ObjectSlice + }, + 11002: (s) => { + 'use strict' + s.exports = Function.prototype.apply + }, + 11042: (s, o, i) => { + 'use strict' + var a = i(85582), + u = i(1907), + _ = i(24443), + w = i(87170), + x = i(36624), + C = u([].concat) + s.exports = + a('Reflect', 'ownKeys') || + function ownKeys(s) { + var o = _.f(x(s)), + i = w.f + return i ? C(o, i(s)) : o + } + }, + 11091: (s, o, i) => { + 'use strict' + var a = i(45951), + u = i(76024), + _ = i(92361), + w = i(62250), + x = i(13846).f, + C = i(7463), + j = i(92046), + L = i(28311), + B = i(61626), + $ = i(49724) + i(36128) + var wrapConstructor = function (s) { + var Wrapper = function (o, i, a) { + if (this instanceof Wrapper) { + switch (arguments.length) { + case 0: + return new s() + case 1: + return new s(o) + case 2: + return new s(o, i) + } + return new s(o, i, a) + } + return u(s, this, arguments) + } + return ((Wrapper.prototype = s.prototype), Wrapper) + } + s.exports = function (s, o) { + var i, + u, + U, + V, + z, + Y, + Z, + ee, + ie, + ae = s.target, + ce = s.global, + le = s.stat, + pe = s.proto, + de = ce ? a : le ? a[ae] : a[ae] && a[ae].prototype, + fe = ce ? j : j[ae] || B(j, ae, {})[ae], + ye = fe.prototype + for (V in o) + ((u = !(i = C(ce ? V : ae + (le ? '.' : '#') + V, s.forced)) && de && $(de, V)), + (Y = fe[V]), + u && (Z = s.dontCallGetSet ? (ie = x(de, V)) && ie.value : de[V]), + (z = u && Z ? Z : o[V]), + (i || pe || typeof Y != typeof z) && + ((ee = + s.bind && u + ? L(z, a) + : s.wrap && u + ? wrapConstructor(z) + : pe && w(z) + ? _(z) + : z), + (s.sham || (z && z.sham) || (Y && Y.sham)) && B(ee, 'sham', !0), + B(fe, V, ee), + pe && + ($(j, (U = ae + 'Prototype')) || B(j, U, {}), + B(j[U], V, z), + s.real && ye && (i || !ye[V]) && B(ye, V, z)))) + } + }, + 11287: (s) => { + s.exports = function getHolder(s) { + return s.placeholder + } + }, + 11331: (s, o, i) => { + var a = i(72552), + u = i(28879), + _ = i(40346), + w = Function.prototype, + x = Object.prototype, + C = w.toString, + j = x.hasOwnProperty, + L = C.call(Object) + s.exports = function isPlainObject(s) { + if (!_(s) || '[object Object]' != a(s)) return !1 + var o = u(s) + if (null === o) return !0 + var i = j.call(o, 'constructor') && o.constructor + return 'function' == typeof i && i instanceof i && C.call(i) == L + } + }, + 11470: (s, o, i) => { + 'use strict' + var a = i(1907), + u = i(65482), + _ = i(90160), + w = i(74239), + x = a(''.charAt), + C = a(''.charCodeAt), + j = a(''.slice), + createMethod = function (s) { + return function (o, i) { + var a, + L, + B = _(w(o)), + $ = u(i), + U = B.length + return $ < 0 || $ >= U + ? s + ? '' + : void 0 + : (a = C(B, $)) < 55296 || + a > 56319 || + $ + 1 === U || + (L = C(B, $ + 1)) < 56320 || + L > 57343 + ? s + ? x(B, $) + : a + : s + ? j(B, $, $ + 2) + : L - 56320 + ((a - 55296) << 10) + 65536 + } + } + s.exports = { codeAt: createMethod(!1), charAt: createMethod(!0) } + }, + 11842: (s, o, i) => { + var a = i(82819), + u = i(9325) + s.exports = function createBind(s, o, i) { + var _ = 1 & o, + w = a(s) + return function wrapper() { + return (this && this !== u && this instanceof wrapper ? w : s).apply( + _ ? i : this, + arguments + ) + } + } + }, + 12205: (s, o, i) => { + 'use strict' + var a = i(66743), + u = i(11002), + _ = i(13144) + s.exports = function applyBind() { + return _(a, u, arguments) + } + }, + 12242: (s, o, i) => { + const a = i(10316) + s.exports = class BooleanElement extends a { + constructor(s, o, i) { + ;(super(s, o, i), (this.element = 'boolean')) + } + primitive() { + return 'boolean' + } + } + }, + 12507: (s, o, i) => { + var a = i(28754), + u = i(49698), + _ = i(63912), + w = i(13222) + s.exports = function createCaseFirst(s) { + return function (o) { + o = w(o) + var i = u(o) ? _(o) : void 0, + x = i ? i[0] : o.charAt(0), + C = i ? a(i, 1).join('') : o.slice(1) + return x[s]() + C + } + } + }, + 12560: (s, o, i) => { + 'use strict' + i(99363) + var a = i(19287), + u = i(45951), + _ = i(14840), + w = i(93742) + for (var x in a) (_(u[x], x), (w[x] = w.Array)) + }, + 12651: (s, o, i) => { + var a = i(74218) + s.exports = function getMapData(s, o) { + var i = s.__data__ + return a(o) ? i['string' == typeof o ? 'string' : 'hash'] : i.map + } + }, + 12749: (s, o, i) => { + var a = i(81042), + u = Object.prototype.hasOwnProperty + s.exports = function hashHas(s) { + var o = this.__data__ + return a ? void 0 !== o[s] : u.call(o, s) + } + }, + 13144: (s, o, i) => { + 'use strict' + var a = i(66743), + u = i(11002), + _ = i(10076), + w = i(47119) + s.exports = w || a.call(_, u) + }, + 13222: (s, o, i) => { + var a = i(77556) + s.exports = function toString(s) { + return null == s ? '' : a(s) + } + }, + 13846: (s, o, i) => { + 'use strict' + var a = i(39447), + u = i(13930), + _ = i(22574), + w = i(75817), + x = i(4993), + C = i(70470), + j = i(49724), + L = i(73648), + B = Object.getOwnPropertyDescriptor + o.f = a + ? B + : function getOwnPropertyDescriptor(s, o) { + if (((s = x(s)), (o = C(o)), L)) + try { + return B(s, o) + } catch (s) {} + if (j(s, o)) return w(!u(_.f, s, o), s[o]) + } + }, + 13930: (s, o, i) => { + 'use strict' + var a = i(41505), + u = Function.prototype.call + s.exports = a + ? u.bind(u) + : function () { + return u.apply(u, arguments) + } + }, + 14248: (s) => { + s.exports = function arraySome(s, o) { + for (var i = -1, a = null == s ? 0 : s.length; ++i < a; ) if (o(s[i], i, s)) return !0 + return !1 + } + }, + 14528: (s) => { + s.exports = function arrayPush(s, o) { + for (var i = -1, a = o.length, u = s.length; ++i < a; ) s[u + i] = o[i] + return s + } + }, + 14540: (s, o, i) => { + const a = i(10316) + s.exports = class RefElement extends a { + constructor(s, o, i) { + ;(super(s || [], o, i), (this.element = 'ref'), this.path || (this.path = 'element')) + } + get path() { + return this.attributes.get('path') + } + set path(s) { + this.attributes.set('path', s) + } + } + }, + 14744: (s) => { + 'use strict' + var o = function isMergeableObject(s) { + return ( + (function isNonNullObject(s) { + return !!s && 'object' == typeof s + })(s) && + !(function isSpecial(s) { + var o = Object.prototype.toString.call(s) + return ( + '[object RegExp]' === o || + '[object Date]' === o || + (function isReactElement(s) { + return s.$$typeof === i + })(s) + ) + })(s) + ) + } + var i = 'function' == typeof Symbol && Symbol.for ? Symbol.for('react.element') : 60103 + function cloneUnlessOtherwiseSpecified(s, o) { + return !1 !== o.clone && o.isMergeableObject(s) + ? deepmerge( + (function emptyTarget(s) { + return Array.isArray(s) ? [] : {} + })(s), + s, + o + ) + : s + } + function defaultArrayMerge(s, o, i) { + return s.concat(o).map(function (s) { + return cloneUnlessOtherwiseSpecified(s, i) + }) + } + function getKeys(s) { + return Object.keys(s).concat( + (function getEnumerableOwnPropertySymbols(s) { + return Object.getOwnPropertySymbols + ? Object.getOwnPropertySymbols(s).filter(function (o) { + return Object.propertyIsEnumerable.call(s, o) + }) + : [] + })(s) + ) + } + function propertyIsOnObject(s, o) { + try { + return o in s + } catch (s) { + return !1 + } + } + function mergeObject(s, o, i) { + var a = {} + return ( + i.isMergeableObject(s) && + getKeys(s).forEach(function (o) { + a[o] = cloneUnlessOtherwiseSpecified(s[o], i) + }), + getKeys(o).forEach(function (u) { + ;(function propertyIsUnsafe(s, o) { + return ( + propertyIsOnObject(s, o) && + !(Object.hasOwnProperty.call(s, o) && Object.propertyIsEnumerable.call(s, o)) + ) + })(s, u) || + (propertyIsOnObject(s, u) && i.isMergeableObject(o[u]) + ? (a[u] = (function getMergeFunction(s, o) { + if (!o.customMerge) return deepmerge + var i = o.customMerge(s) + return 'function' == typeof i ? i : deepmerge + })(u, i)(s[u], o[u], i)) + : (a[u] = cloneUnlessOtherwiseSpecified(o[u], i))) + }), + a + ) + } + function deepmerge(s, i, a) { + ;(((a = a || {}).arrayMerge = a.arrayMerge || defaultArrayMerge), + (a.isMergeableObject = a.isMergeableObject || o), + (a.cloneUnlessOtherwiseSpecified = cloneUnlessOtherwiseSpecified)) + var u = Array.isArray(i) + return u === Array.isArray(s) + ? u + ? a.arrayMerge(s, i, a) + : mergeObject(s, i, a) + : cloneUnlessOtherwiseSpecified(i, a) + } + deepmerge.all = function deepmergeAll(s, o) { + if (!Array.isArray(s)) throw new Error('first argument should be an array') + return s.reduce(function (s, i) { + return deepmerge(s, i, o) + }, {}) + } + var a = deepmerge + s.exports = a + }, + 14792: (s, o, i) => { + var a = i(13222), + u = i(55808) + s.exports = function capitalize(s) { + return u(a(s).toLowerCase()) + } + }, + 14840: (s, o, i) => { + 'use strict' + var a = i(52623), + u = i(74284).f, + _ = i(61626), + w = i(49724), + x = i(54878), + C = i(76264)('toStringTag') + s.exports = function (s, o, i, j) { + var L = i ? s : s && s.prototype + L && + (w(L, C) || u(L, C, { configurable: !0, value: o }), j && !a && _(L, 'toString', x)) + } + }, + 14974: (s) => { + s.exports = function safeGet(s, o) { + if (('constructor' !== o || 'function' != typeof s[o]) && '__proto__' != o) return s[o] + } + }, + 15287: (s, o) => { + 'use strict' + var i = Symbol.for('react.element'), + a = Symbol.for('react.portal'), + u = Symbol.for('react.fragment'), + _ = Symbol.for('react.strict_mode'), + w = Symbol.for('react.profiler'), + x = Symbol.for('react.provider'), + C = Symbol.for('react.context'), + j = Symbol.for('react.forward_ref'), + L = Symbol.for('react.suspense'), + B = Symbol.for('react.memo'), + $ = Symbol.for('react.lazy'), + U = Symbol.iterator + var V = { + isMounted: function () { + return !1 + }, + enqueueForceUpdate: function () {}, + enqueueReplaceState: function () {}, + enqueueSetState: function () {}, + }, + z = Object.assign, + Y = {} + function E(s, o, i) { + ;((this.props = s), (this.context = o), (this.refs = Y), (this.updater = i || V)) + } + function F() {} + function G(s, o, i) { + ;((this.props = s), (this.context = o), (this.refs = Y), (this.updater = i || V)) + } + ;((E.prototype.isReactComponent = {}), + (E.prototype.setState = function (s, o) { + if ('object' != typeof s && 'function' != typeof s && null != s) + throw Error( + 'setState(...): takes an object of state variables to update or a function which returns an object of state variables.' + ) + this.updater.enqueueSetState(this, s, o, 'setState') + }), + (E.prototype.forceUpdate = function (s) { + this.updater.enqueueForceUpdate(this, s, 'forceUpdate') + }), + (F.prototype = E.prototype)) + var Z = (G.prototype = new F()) + ;((Z.constructor = G), z(Z, E.prototype), (Z.isPureReactComponent = !0)) + var ee = Array.isArray, + ie = Object.prototype.hasOwnProperty, + ae = { current: null }, + ce = { key: !0, ref: !0, __self: !0, __source: !0 } + function M(s, o, a) { + var u, + _ = {}, + w = null, + x = null + if (null != o) + for (u in (void 0 !== o.ref && (x = o.ref), void 0 !== o.key && (w = '' + o.key), o)) + ie.call(o, u) && !ce.hasOwnProperty(u) && (_[u] = o[u]) + var C = arguments.length - 2 + if (1 === C) _.children = a + else if (1 < C) { + for (var j = Array(C), L = 0; L < C; L++) j[L] = arguments[L + 2] + _.children = j + } + if (s && s.defaultProps) + for (u in (C = s.defaultProps)) void 0 === _[u] && (_[u] = C[u]) + return { $$typeof: i, type: s, key: w, ref: x, props: _, _owner: ae.current } + } + function O(s) { + return 'object' == typeof s && null !== s && s.$$typeof === i + } + var le = /\/+/g + function Q(s, o) { + return 'object' == typeof s && null !== s && null != s.key + ? (function escape(s) { + var o = { '=': '=0', ':': '=2' } + return ( + '$' + + s.replace(/[=:]/g, function (s) { + return o[s] + }) + ) + })('' + s.key) + : o.toString(36) + } + function R(s, o, u, _, w) { + var x = typeof s + ;('undefined' !== x && 'boolean' !== x) || (s = null) + var C = !1 + if (null === s) C = !0 + else + switch (x) { + case 'string': + case 'number': + C = !0 + break + case 'object': + switch (s.$$typeof) { + case i: + case a: + C = !0 + } + } + if (C) + return ( + (w = w((C = s))), + (s = '' === _ ? '.' + Q(C, 0) : _), + ee(w) + ? ((u = ''), + null != s && (u = s.replace(le, '$&/') + '/'), + R(w, o, u, '', function (s) { + return s + })) + : null != w && + (O(w) && + (w = (function N(s, o) { + return { + $$typeof: i, + type: s.type, + key: o, + ref: s.ref, + props: s.props, + _owner: s._owner, + } + })( + w, + u + + (!w.key || (C && C.key === w.key) + ? '' + : ('' + w.key).replace(le, '$&/') + '/') + + s + )), + o.push(w)), + 1 + ) + if (((C = 0), (_ = '' === _ ? '.' : _ + ':'), ee(s))) + for (var j = 0; j < s.length; j++) { + var L = _ + Q((x = s[j]), j) + C += R(x, o, u, L, w) + } + else if ( + ((L = (function A(s) { + return null === s || 'object' != typeof s + ? null + : 'function' == typeof (s = (U && s[U]) || s['@@iterator']) + ? s + : null + })(s)), + 'function' == typeof L) + ) + for (s = L.call(s), j = 0; !(x = s.next()).done; ) + C += R((x = x.value), o, u, (L = _ + Q(x, j++)), w) + else if ('object' === x) + throw ( + (o = String(s)), + Error( + 'Objects are not valid as a React child (found: ' + + ('[object Object]' === o + ? 'object with keys {' + Object.keys(s).join(', ') + '}' + : o) + + '). If you meant to render a collection of children, use an array instead.' + ) + ) + return C + } + function S(s, o, i) { + if (null == s) return s + var a = [], + u = 0 + return ( + R(s, a, '', '', function (s) { + return o.call(i, s, u++) + }), + a + ) + } + function T(s) { + if (-1 === s._status) { + var o = s._result + ;((o = o()).then( + function (o) { + ;(0 !== s._status && -1 !== s._status) || ((s._status = 1), (s._result = o)) + }, + function (o) { + ;(0 !== s._status && -1 !== s._status) || ((s._status = 2), (s._result = o)) + } + ), + -1 === s._status && ((s._status = 0), (s._result = o))) + } + if (1 === s._status) return s._result.default + throw s._result + } + var pe = { current: null }, + de = { transition: null }, + fe = { ReactCurrentDispatcher: pe, ReactCurrentBatchConfig: de, ReactCurrentOwner: ae } + function X() { + throw Error('act(...) is not supported in production builds of React.') + } + ;((o.Children = { + map: S, + forEach: function (s, o, i) { + S( + s, + function () { + o.apply(this, arguments) + }, + i + ) + }, + count: function (s) { + var o = 0 + return ( + S(s, function () { + o++ + }), + o + ) + }, + toArray: function (s) { + return ( + S(s, function (s) { + return s + }) || [] + ) + }, + only: function (s) { + if (!O(s)) + throw Error('React.Children.only expected to receive a single React element child.') + return s + }, + }), + (o.Component = E), + (o.Fragment = u), + (o.Profiler = w), + (o.PureComponent = G), + (o.StrictMode = _), + (o.Suspense = L), + (o.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED = fe), + (o.act = X), + (o.cloneElement = function (s, o, a) { + if (null == s) + throw Error( + 'React.cloneElement(...): The argument must be a React element, but you passed ' + + s + + '.' + ) + var u = z({}, s.props), + _ = s.key, + w = s.ref, + x = s._owner + if (null != o) { + if ( + (void 0 !== o.ref && ((w = o.ref), (x = ae.current)), + void 0 !== o.key && (_ = '' + o.key), + s.type && s.type.defaultProps) + ) + var C = s.type.defaultProps + for (j in o) + ie.call(o, j) && + !ce.hasOwnProperty(j) && + (u[j] = void 0 === o[j] && void 0 !== C ? C[j] : o[j]) + } + var j = arguments.length - 2 + if (1 === j) u.children = a + else if (1 < j) { + C = Array(j) + for (var L = 0; L < j; L++) C[L] = arguments[L + 2] + u.children = C + } + return { $$typeof: i, type: s.type, key: _, ref: w, props: u, _owner: x } + }), + (o.createContext = function (s) { + return ( + ((s = { + $$typeof: C, + _currentValue: s, + _currentValue2: s, + _threadCount: 0, + Provider: null, + Consumer: null, + _defaultValue: null, + _globalName: null, + }).Provider = { $$typeof: x, _context: s }), + (s.Consumer = s) + ) + }), + (o.createElement = M), + (o.createFactory = function (s) { + var o = M.bind(null, s) + return ((o.type = s), o) + }), + (o.createRef = function () { + return { current: null } + }), + (o.forwardRef = function (s) { + return { $$typeof: j, render: s } + }), + (o.isValidElement = O), + (o.lazy = function (s) { + return { $$typeof: $, _payload: { _status: -1, _result: s }, _init: T } + }), + (o.memo = function (s, o) { + return { $$typeof: B, type: s, compare: void 0 === o ? null : o } + }), + (o.startTransition = function (s) { + var o = de.transition + de.transition = {} + try { + s() + } finally { + de.transition = o + } + }), + (o.unstable_act = X), + (o.useCallback = function (s, o) { + return pe.current.useCallback(s, o) + }), + (o.useContext = function (s) { + return pe.current.useContext(s) + }), + (o.useDebugValue = function () {}), + (o.useDeferredValue = function (s) { + return pe.current.useDeferredValue(s) + }), + (o.useEffect = function (s, o) { + return pe.current.useEffect(s, o) + }), + (o.useId = function () { + return pe.current.useId() + }), + (o.useImperativeHandle = function (s, o, i) { + return pe.current.useImperativeHandle(s, o, i) + }), + (o.useInsertionEffect = function (s, o) { + return pe.current.useInsertionEffect(s, o) + }), + (o.useLayoutEffect = function (s, o) { + return pe.current.useLayoutEffect(s, o) + }), + (o.useMemo = function (s, o) { + return pe.current.useMemo(s, o) + }), + (o.useReducer = function (s, o, i) { + return pe.current.useReducer(s, o, i) + }), + (o.useRef = function (s) { + return pe.current.useRef(s) + }), + (o.useState = function (s) { + return pe.current.useState(s) + }), + (o.useSyncExternalStore = function (s, o, i) { + return pe.current.useSyncExternalStore(s, o, i) + }), + (o.useTransition = function () { + return pe.current.useTransition() + }), + (o.version = '18.3.1')) + }, + 15325: (s, o, i) => { + var a = i(96131) + s.exports = function arrayIncludes(s, o) { + return !!(null == s ? 0 : s.length) && a(s, o, 0) > -1 + } + }, + 15340: () => {}, + 15377: (s, o, i) => { + 'use strict' + var a = i(92861).Buffer, + u = i(64634), + _ = i(74372), + w = + ArrayBuffer.isView || + function isView(s) { + try { + return (_(s), !0) + } catch (s) { + return !1 + } + }, + x = 'undefined' != typeof Uint8Array, + C = 'undefined' != typeof ArrayBuffer && 'undefined' != typeof Uint8Array, + j = C && (a.prototype instanceof Uint8Array || a.TYPED_ARRAY_SUPPORT) + s.exports = function toBuffer(s, o) { + if (s instanceof a) return s + if ('string' == typeof s) return a.from(s, o) + if (C && w(s)) { + if (0 === s.byteLength) return a.alloc(0) + if (j) { + var i = a.from(s.buffer, s.byteOffset, s.byteLength) + if (i.byteLength === s.byteLength) return i + } + var _ = + s instanceof Uint8Array + ? s + : new Uint8Array(s.buffer, s.byteOffset, s.byteLength), + L = a.from(_) + if (L.length === s.byteLength) return L + } + if (x && s instanceof Uint8Array) return a.from(s) + var B = u(s) + if (B) + for (var $ = 0; $ < s.length; $ += 1) { + var U = s[$] + if ('number' != typeof U || U < 0 || U > 255 || ~~U !== U) + throw new RangeError('Array items must be numbers in the range 0-255.') + } + if ( + B || + (a.isBuffer(s) && + s.constructor && + 'function' == typeof s.constructor.isBuffer && + s.constructor.isBuffer(s)) + ) + return a.from(s) + throw new TypeError( + 'The "data" argument must be a string, an Array, a Buffer, a Uint8Array, or a DataView.' + ) + } + }, + 15389: (s, o, i) => { + var a = i(93663), + u = i(87978), + _ = i(83488), + w = i(56449), + x = i(50583) + s.exports = function baseIteratee(s) { + return 'function' == typeof s + ? s + : null == s + ? _ + : 'object' == typeof s + ? w(s) + ? u(s[0], s[1]) + : a(s) + : x(s) + } + }, + 15972: (s, o, i) => { + 'use strict' + var a = i(49724), + u = i(62250), + _ = i(39298), + w = i(92522), + x = i(57382), + C = w('IE_PROTO'), + j = Object, + L = j.prototype + s.exports = x + ? j.getPrototypeOf + : function (s) { + var o = _(s) + if (a(o, C)) return o[C] + var i = o.constructor + return u(i) && o instanceof i ? i.prototype : o instanceof j ? L : null + } + }, + 16038: (s, o, i) => { + var a = i(5861), + u = i(40346) + s.exports = function baseIsSet(s) { + return u(s) && '[object Set]' == a(s) + } + }, + 16426: (s) => { + s.exports = function () { + var s = document.getSelection() + if (!s.rangeCount) return function () {} + for (var o = document.activeElement, i = [], a = 0; a < s.rangeCount; a++) + i.push(s.getRangeAt(a)) + switch (o.tagName.toUpperCase()) { + case 'INPUT': + case 'TEXTAREA': + o.blur() + break + default: + o = null + } + return ( + s.removeAllRanges(), + function () { + ;('Caret' === s.type && s.removeAllRanges(), + s.rangeCount || + i.forEach(function (o) { + s.addRange(o) + }), + o && o.focus()) + } + ) + } + }, + 16547: (s, o, i) => { + var a = i(43360), + u = i(75288), + _ = Object.prototype.hasOwnProperty + s.exports = function assignValue(s, o, i) { + var w = s[o] + ;(_.call(s, o) && u(w, i) && (void 0 !== i || o in s)) || a(s, o, i) + } + }, + 16708: (s, o, i) => { + 'use strict' + var a, + u = i(65606) + function CorkedRequest(s) { + var o = this + ;((this.next = null), + (this.entry = null), + (this.finish = function () { + !(function onCorkedFinish(s, o, i) { + var a = s.entry + s.entry = null + for (; a; ) { + var u = a.callback + ;(o.pendingcb--, u(i), (a = a.next)) + } + o.corkedRequestsFree.next = s + })(o, s) + })) + } + ;((s.exports = Writable), (Writable.WritableState = WritableState)) + var _ = { deprecate: i(94643) }, + w = i(40345), + x = i(48287).Buffer, + C = + (void 0 !== i.g + ? i.g + : 'undefined' != typeof window + ? window + : 'undefined' != typeof self + ? self + : {} + ).Uint8Array || function () {} + var j, + L = i(75896), + B = i(65291).getHighWaterMark, + $ = i(86048).F, + U = $.ERR_INVALID_ARG_TYPE, + V = $.ERR_METHOD_NOT_IMPLEMENTED, + z = $.ERR_MULTIPLE_CALLBACK, + Y = $.ERR_STREAM_CANNOT_PIPE, + Z = $.ERR_STREAM_DESTROYED, + ee = $.ERR_STREAM_NULL_VALUES, + ie = $.ERR_STREAM_WRITE_AFTER_END, + ae = $.ERR_UNKNOWN_ENCODING, + ce = L.errorOrDestroy + function nop() {} + function WritableState(s, o, _) { + ;((a = a || i(25382)), + (s = s || {}), + 'boolean' != typeof _ && (_ = o instanceof a), + (this.objectMode = !!s.objectMode), + _ && (this.objectMode = this.objectMode || !!s.writableObjectMode), + (this.highWaterMark = B(this, s, 'writableHighWaterMark', _)), + (this.finalCalled = !1), + (this.needDrain = !1), + (this.ending = !1), + (this.ended = !1), + (this.finished = !1), + (this.destroyed = !1)) + var w = !1 === s.decodeStrings + ;((this.decodeStrings = !w), + (this.defaultEncoding = s.defaultEncoding || 'utf8'), + (this.length = 0), + (this.writing = !1), + (this.corked = 0), + (this.sync = !0), + (this.bufferProcessing = !1), + (this.onwrite = function (s) { + !(function onwrite(s, o) { + var i = s._writableState, + a = i.sync, + _ = i.writecb + if ('function' != typeof _) throw new z() + if ( + ((function onwriteStateUpdate(s) { + ;((s.writing = !1), + (s.writecb = null), + (s.length -= s.writelen), + (s.writelen = 0)) + })(i), + o) + ) + !(function onwriteError(s, o, i, a, _) { + ;(--o.pendingcb, + i + ? (u.nextTick(_, a), + u.nextTick(finishMaybe, s, o), + (s._writableState.errorEmitted = !0), + ce(s, a)) + : (_(a), + (s._writableState.errorEmitted = !0), + ce(s, a), + finishMaybe(s, o))) + })(s, i, a, o, _) + else { + var w = needFinish(i) || s.destroyed + ;(w || + i.corked || + i.bufferProcessing || + !i.bufferedRequest || + clearBuffer(s, i), + a ? u.nextTick(afterWrite, s, i, w, _) : afterWrite(s, i, w, _)) + } + })(o, s) + }), + (this.writecb = null), + (this.writelen = 0), + (this.bufferedRequest = null), + (this.lastBufferedRequest = null), + (this.pendingcb = 0), + (this.prefinished = !1), + (this.errorEmitted = !1), + (this.emitClose = !1 !== s.emitClose), + (this.autoDestroy = !!s.autoDestroy), + (this.bufferedRequestCount = 0), + (this.corkedRequestsFree = new CorkedRequest(this))) + } + function Writable(s) { + var o = this instanceof (a = a || i(25382)) + if (!o && !j.call(Writable, this)) return new Writable(s) + ;((this._writableState = new WritableState(s, this, o)), + (this.writable = !0), + s && + ('function' == typeof s.write && (this._write = s.write), + 'function' == typeof s.writev && (this._writev = s.writev), + 'function' == typeof s.destroy && (this._destroy = s.destroy), + 'function' == typeof s.final && (this._final = s.final)), + w.call(this)) + } + function doWrite(s, o, i, a, u, _, w) { + ;((o.writelen = a), + (o.writecb = w), + (o.writing = !0), + (o.sync = !0), + o.destroyed + ? o.onwrite(new Z('write')) + : i + ? s._writev(u, o.onwrite) + : s._write(u, _, o.onwrite), + (o.sync = !1)) + } + function afterWrite(s, o, i, a) { + ;(i || + (function onwriteDrain(s, o) { + 0 === o.length && o.needDrain && ((o.needDrain = !1), s.emit('drain')) + })(s, o), + o.pendingcb--, + a(), + finishMaybe(s, o)) + } + function clearBuffer(s, o) { + o.bufferProcessing = !0 + var i = o.bufferedRequest + if (s._writev && i && i.next) { + var a = o.bufferedRequestCount, + u = new Array(a), + _ = o.corkedRequestsFree + _.entry = i + for (var w = 0, x = !0; i; ) ((u[w] = i), i.isBuf || (x = !1), (i = i.next), (w += 1)) + ;((u.allBuffers = x), + doWrite(s, o, !0, o.length, u, '', _.finish), + o.pendingcb++, + (o.lastBufferedRequest = null), + _.next + ? ((o.corkedRequestsFree = _.next), (_.next = null)) + : (o.corkedRequestsFree = new CorkedRequest(o)), + (o.bufferedRequestCount = 0)) + } else { + for (; i; ) { + var C = i.chunk, + j = i.encoding, + L = i.callback + if ( + (doWrite(s, o, !1, o.objectMode ? 1 : C.length, C, j, L), + (i = i.next), + o.bufferedRequestCount--, + o.writing) + ) + break + } + null === i && (o.lastBufferedRequest = null) + } + ;((o.bufferedRequest = i), (o.bufferProcessing = !1)) + } + function needFinish(s) { + return ( + s.ending && 0 === s.length && null === s.bufferedRequest && !s.finished && !s.writing + ) + } + function callFinal(s, o) { + s._final(function (i) { + ;(o.pendingcb--, + i && ce(s, i), + (o.prefinished = !0), + s.emit('prefinish'), + finishMaybe(s, o)) + }) + } + function finishMaybe(s, o) { + var i = needFinish(o) + if ( + i && + ((function prefinish(s, o) { + o.prefinished || + o.finalCalled || + ('function' != typeof s._final || o.destroyed + ? ((o.prefinished = !0), s.emit('prefinish')) + : (o.pendingcb++, (o.finalCalled = !0), u.nextTick(callFinal, s, o))) + })(s, o), + 0 === o.pendingcb && ((o.finished = !0), s.emit('finish'), o.autoDestroy)) + ) { + var a = s._readableState + ;(!a || (a.autoDestroy && a.endEmitted)) && s.destroy() + } + return i + } + ;(i(56698)(Writable, w), + (WritableState.prototype.getBuffer = function getBuffer() { + for (var s = this.bufferedRequest, o = []; s; ) (o.push(s), (s = s.next)) + return o + }), + (function () { + try { + Object.defineProperty(WritableState.prototype, 'buffer', { + get: _.deprecate( + function writableStateBufferGetter() { + return this.getBuffer() + }, + '_writableState.buffer is deprecated. Use _writableState.getBuffer instead.', + 'DEP0003' + ), + }) + } catch (s) {} + })(), + 'function' == typeof Symbol && + Symbol.hasInstance && + 'function' == typeof Function.prototype[Symbol.hasInstance] + ? ((j = Function.prototype[Symbol.hasInstance]), + Object.defineProperty(Writable, Symbol.hasInstance, { + value: function value(s) { + return ( + !!j.call(this, s) || + (this === Writable && s && s._writableState instanceof WritableState) + ) + }, + })) + : (j = function realHasInstance(s) { + return s instanceof this + }), + (Writable.prototype.pipe = function () { + ce(this, new Y()) + }), + (Writable.prototype.write = function (s, o, i) { + var a = this._writableState, + _ = !1, + w = + !a.objectMode && + (function _isUint8Array(s) { + return x.isBuffer(s) || s instanceof C + })(s) + return ( + w && + !x.isBuffer(s) && + (s = (function _uint8ArrayToBuffer(s) { + return x.from(s) + })(s)), + 'function' == typeof o && ((i = o), (o = null)), + w ? (o = 'buffer') : o || (o = a.defaultEncoding), + 'function' != typeof i && (i = nop), + a.ending + ? (function writeAfterEnd(s, o) { + var i = new ie() + ;(ce(s, i), u.nextTick(o, i)) + })(this, i) + : (w || + (function validChunk(s, o, i, a) { + var _ + return ( + null === i + ? (_ = new ee()) + : 'string' == typeof i || + o.objectMode || + (_ = new U('chunk', ['string', 'Buffer'], i)), + !_ || (ce(s, _), u.nextTick(a, _), !1) + ) + })(this, a, s, i)) && + (a.pendingcb++, + (_ = (function writeOrBuffer(s, o, i, a, u, _) { + if (!i) { + var w = (function decodeChunk(s, o, i) { + s.objectMode || + !1 === s.decodeStrings || + 'string' != typeof o || + (o = x.from(o, i)) + return o + })(o, a, u) + a !== w && ((i = !0), (u = 'buffer'), (a = w)) + } + var C = o.objectMode ? 1 : a.length + o.length += C + var j = o.length < o.highWaterMark + j || (o.needDrain = !0) + if (o.writing || o.corked) { + var L = o.lastBufferedRequest + ;((o.lastBufferedRequest = { + chunk: a, + encoding: u, + isBuf: i, + callback: _, + next: null, + }), + L + ? (L.next = o.lastBufferedRequest) + : (o.bufferedRequest = o.lastBufferedRequest), + (o.bufferedRequestCount += 1)) + } else doWrite(s, o, !1, C, a, u, _) + return j + })(this, a, w, s, o, i))), + _ + ) + }), + (Writable.prototype.cork = function () { + this._writableState.corked++ + }), + (Writable.prototype.uncork = function () { + var s = this._writableState + s.corked && + (s.corked--, + s.writing || + s.corked || + s.bufferProcessing || + !s.bufferedRequest || + clearBuffer(this, s)) + }), + (Writable.prototype.setDefaultEncoding = function setDefaultEncoding(s) { + if ( + ('string' == typeof s && (s = s.toLowerCase()), + !( + [ + 'hex', + 'utf8', + 'utf-8', + 'ascii', + 'binary', + 'base64', + 'ucs2', + 'ucs-2', + 'utf16le', + 'utf-16le', + 'raw', + ].indexOf((s + '').toLowerCase()) > -1 + )) + ) + throw new ae(s) + return ((this._writableState.defaultEncoding = s), this) + }), + Object.defineProperty(Writable.prototype, 'writableBuffer', { + enumerable: !1, + get: function get() { + return this._writableState && this._writableState.getBuffer() + }, + }), + Object.defineProperty(Writable.prototype, 'writableHighWaterMark', { + enumerable: !1, + get: function get() { + return this._writableState.highWaterMark + }, + }), + (Writable.prototype._write = function (s, o, i) { + i(new V('_write()')) + }), + (Writable.prototype._writev = null), + (Writable.prototype.end = function (s, o, i) { + var a = this._writableState + return ( + 'function' == typeof s + ? ((i = s), (s = null), (o = null)) + : 'function' == typeof o && ((i = o), (o = null)), + null != s && this.write(s, o), + a.corked && ((a.corked = 1), this.uncork()), + a.ending || + (function endWritable(s, o, i) { + ;((o.ending = !0), + finishMaybe(s, o), + i && (o.finished ? u.nextTick(i) : s.once('finish', i))) + ;((o.ended = !0), (s.writable = !1)) + })(this, a, i), + this + ) + }), + Object.defineProperty(Writable.prototype, 'writableLength', { + enumerable: !1, + get: function get() { + return this._writableState.length + }, + }), + Object.defineProperty(Writable.prototype, 'destroyed', { + enumerable: !1, + get: function get() { + return void 0 !== this._writableState && this._writableState.destroyed + }, + set: function set(s) { + this._writableState && (this._writableState.destroyed = s) + }, + }), + (Writable.prototype.destroy = L.destroy), + (Writable.prototype._undestroy = L.undestroy), + (Writable.prototype._destroy = function (s, o) { + o(s) + })) + }, + 16946: (s, o, i) => { + 'use strict' + var a = i(1907), + u = i(98828), + _ = i(45807), + w = Object, + x = a(''.split) + s.exports = u(function () { + return !w('z').propertyIsEnumerable(0) + }) + ? function (s) { + return 'String' === _(s) ? x(s, '') : w(s) + } + : w + }, + 16962: (s, o) => { + ;((o.aliasToReal = { + each: 'forEach', + eachRight: 'forEachRight', + entries: 'toPairs', + entriesIn: 'toPairsIn', + extend: 'assignIn', + extendAll: 'assignInAll', + extendAllWith: 'assignInAllWith', + extendWith: 'assignInWith', + first: 'head', + conforms: 'conformsTo', + matches: 'isMatch', + property: 'get', + __: 'placeholder', + F: 'stubFalse', + T: 'stubTrue', + all: 'every', + allPass: 'overEvery', + always: 'constant', + any: 'some', + anyPass: 'overSome', + apply: 'spread', + assoc: 'set', + assocPath: 'set', + complement: 'negate', + compose: 'flowRight', + contains: 'includes', + dissoc: 'unset', + dissocPath: 'unset', + dropLast: 'dropRight', + dropLastWhile: 'dropRightWhile', + equals: 'isEqual', + identical: 'eq', + indexBy: 'keyBy', + init: 'initial', + invertObj: 'invert', + juxt: 'over', + omitAll: 'omit', + nAry: 'ary', + path: 'get', + pathEq: 'matchesProperty', + pathOr: 'getOr', + paths: 'at', + pickAll: 'pick', + pipe: 'flow', + pluck: 'map', + prop: 'get', + propEq: 'matchesProperty', + propOr: 'getOr', + props: 'at', + symmetricDifference: 'xor', + symmetricDifferenceBy: 'xorBy', + symmetricDifferenceWith: 'xorWith', + takeLast: 'takeRight', + takeLastWhile: 'takeRightWhile', + unapply: 'rest', + unnest: 'flatten', + useWith: 'overArgs', + where: 'conformsTo', + whereEq: 'isMatch', + zipObj: 'zipObject', + }), + (o.aryMethod = { + 1: [ + 'assignAll', + 'assignInAll', + 'attempt', + 'castArray', + 'ceil', + 'create', + 'curry', + 'curryRight', + 'defaultsAll', + 'defaultsDeepAll', + 'floor', + 'flow', + 'flowRight', + 'fromPairs', + 'invert', + 'iteratee', + 'memoize', + 'method', + 'mergeAll', + 'methodOf', + 'mixin', + 'nthArg', + 'over', + 'overEvery', + 'overSome', + 'rest', + 'reverse', + 'round', + 'runInContext', + 'spread', + 'template', + 'trim', + 'trimEnd', + 'trimStart', + 'uniqueId', + 'words', + 'zipAll', + ], + 2: [ + 'add', + 'after', + 'ary', + 'assign', + 'assignAllWith', + 'assignIn', + 'assignInAllWith', + 'at', + 'before', + 'bind', + 'bindAll', + 'bindKey', + 'chunk', + 'cloneDeepWith', + 'cloneWith', + 'concat', + 'conformsTo', + 'countBy', + 'curryN', + 'curryRightN', + 'debounce', + 'defaults', + 'defaultsDeep', + 'defaultTo', + 'delay', + 'difference', + 'divide', + 'drop', + 'dropRight', + 'dropRightWhile', + 'dropWhile', + 'endsWith', + 'eq', + 'every', + 'filter', + 'find', + 'findIndex', + 'findKey', + 'findLast', + 'findLastIndex', + 'findLastKey', + 'flatMap', + 'flatMapDeep', + 'flattenDepth', + 'forEach', + 'forEachRight', + 'forIn', + 'forInRight', + 'forOwn', + 'forOwnRight', + 'get', + 'groupBy', + 'gt', + 'gte', + 'has', + 'hasIn', + 'includes', + 'indexOf', + 'intersection', + 'invertBy', + 'invoke', + 'invokeMap', + 'isEqual', + 'isMatch', + 'join', + 'keyBy', + 'lastIndexOf', + 'lt', + 'lte', + 'map', + 'mapKeys', + 'mapValues', + 'matchesProperty', + 'maxBy', + 'meanBy', + 'merge', + 'mergeAllWith', + 'minBy', + 'multiply', + 'nth', + 'omit', + 'omitBy', + 'overArgs', + 'pad', + 'padEnd', + 'padStart', + 'parseInt', + 'partial', + 'partialRight', + 'partition', + 'pick', + 'pickBy', + 'propertyOf', + 'pull', + 'pullAll', + 'pullAt', + 'random', + 'range', + 'rangeRight', + 'rearg', + 'reject', + 'remove', + 'repeat', + 'restFrom', + 'result', + 'sampleSize', + 'some', + 'sortBy', + 'sortedIndex', + 'sortedIndexOf', + 'sortedLastIndex', + 'sortedLastIndexOf', + 'sortedUniqBy', + 'split', + 'spreadFrom', + 'startsWith', + 'subtract', + 'sumBy', + 'take', + 'takeRight', + 'takeRightWhile', + 'takeWhile', + 'tap', + 'throttle', + 'thru', + 'times', + 'trimChars', + 'trimCharsEnd', + 'trimCharsStart', + 'truncate', + 'union', + 'uniqBy', + 'uniqWith', + 'unset', + 'unzipWith', + 'without', + 'wrap', + 'xor', + 'zip', + 'zipObject', + 'zipObjectDeep', + ], + 3: [ + 'assignInWith', + 'assignWith', + 'clamp', + 'differenceBy', + 'differenceWith', + 'findFrom', + 'findIndexFrom', + 'findLastFrom', + 'findLastIndexFrom', + 'getOr', + 'includesFrom', + 'indexOfFrom', + 'inRange', + 'intersectionBy', + 'intersectionWith', + 'invokeArgs', + 'invokeArgsMap', + 'isEqualWith', + 'isMatchWith', + 'flatMapDepth', + 'lastIndexOfFrom', + 'mergeWith', + 'orderBy', + 'padChars', + 'padCharsEnd', + 'padCharsStart', + 'pullAllBy', + 'pullAllWith', + 'rangeStep', + 'rangeStepRight', + 'reduce', + 'reduceRight', + 'replace', + 'set', + 'slice', + 'sortedIndexBy', + 'sortedLastIndexBy', + 'transform', + 'unionBy', + 'unionWith', + 'update', + 'xorBy', + 'xorWith', + 'zipWith', + ], + 4: ['fill', 'setWith', 'updateWith'], + }), + (o.aryRearg = { 2: [1, 0], 3: [2, 0, 1], 4: [3, 2, 0, 1] }), + (o.iterateeAry = { + dropRightWhile: 1, + dropWhile: 1, + every: 1, + filter: 1, + find: 1, + findFrom: 1, + findIndex: 1, + findIndexFrom: 1, + findKey: 1, + findLast: 1, + findLastFrom: 1, + findLastIndex: 1, + findLastIndexFrom: 1, + findLastKey: 1, + flatMap: 1, + flatMapDeep: 1, + flatMapDepth: 1, + forEach: 1, + forEachRight: 1, + forIn: 1, + forInRight: 1, + forOwn: 1, + forOwnRight: 1, + map: 1, + mapKeys: 1, + mapValues: 1, + partition: 1, + reduce: 2, + reduceRight: 2, + reject: 1, + remove: 1, + some: 1, + takeRightWhile: 1, + takeWhile: 1, + times: 1, + transform: 2, + }), + (o.iterateeRearg = { mapKeys: [1], reduceRight: [1, 0] }), + (o.methodRearg = { + assignInAllWith: [1, 0], + assignInWith: [1, 2, 0], + assignAllWith: [1, 0], + assignWith: [1, 2, 0], + differenceBy: [1, 2, 0], + differenceWith: [1, 2, 0], + getOr: [2, 1, 0], + intersectionBy: [1, 2, 0], + intersectionWith: [1, 2, 0], + isEqualWith: [1, 2, 0], + isMatchWith: [2, 1, 0], + mergeAllWith: [1, 0], + mergeWith: [1, 2, 0], + padChars: [2, 1, 0], + padCharsEnd: [2, 1, 0], + padCharsStart: [2, 1, 0], + pullAllBy: [2, 1, 0], + pullAllWith: [2, 1, 0], + rangeStep: [1, 2, 0], + rangeStepRight: [1, 2, 0], + setWith: [3, 1, 2, 0], + sortedIndexBy: [2, 1, 0], + sortedLastIndexBy: [2, 1, 0], + unionBy: [1, 2, 0], + unionWith: [1, 2, 0], + updateWith: [3, 1, 2, 0], + xorBy: [1, 2, 0], + xorWith: [1, 2, 0], + zipWith: [1, 2, 0], + }), + (o.methodSpread = { + assignAll: { start: 0 }, + assignAllWith: { start: 0 }, + assignInAll: { start: 0 }, + assignInAllWith: { start: 0 }, + defaultsAll: { start: 0 }, + defaultsDeepAll: { start: 0 }, + invokeArgs: { start: 2 }, + invokeArgsMap: { start: 2 }, + mergeAll: { start: 0 }, + mergeAllWith: { start: 0 }, + partial: { start: 1 }, + partialRight: { start: 1 }, + without: { start: 1 }, + zipAll: { start: 0 }, + }), + (o.mutate = { + array: { + fill: !0, + pull: !0, + pullAll: !0, + pullAllBy: !0, + pullAllWith: !0, + pullAt: !0, + remove: !0, + reverse: !0, + }, + object: { + assign: !0, + assignAll: !0, + assignAllWith: !0, + assignIn: !0, + assignInAll: !0, + assignInAllWith: !0, + assignInWith: !0, + assignWith: !0, + defaults: !0, + defaultsAll: !0, + defaultsDeep: !0, + defaultsDeepAll: !0, + merge: !0, + mergeAll: !0, + mergeAllWith: !0, + mergeWith: !0, + }, + set: { set: !0, setWith: !0, unset: !0, update: !0, updateWith: !0 }, + }), + (o.realToAlias = (function () { + var s = Object.prototype.hasOwnProperty, + i = o.aliasToReal, + a = {} + for (var u in i) { + var _ = i[u] + s.call(a, _) ? a[_].push(u) : (a[_] = [u]) + } + return a + })()), + (o.remap = { + assignAll: 'assign', + assignAllWith: 'assignWith', + assignInAll: 'assignIn', + assignInAllWith: 'assignInWith', + curryN: 'curry', + curryRightN: 'curryRight', + defaultsAll: 'defaults', + defaultsDeepAll: 'defaultsDeep', + findFrom: 'find', + findIndexFrom: 'findIndex', + findLastFrom: 'findLast', + findLastIndexFrom: 'findLastIndex', + getOr: 'get', + includesFrom: 'includes', + indexOfFrom: 'indexOf', + invokeArgs: 'invoke', + invokeArgsMap: 'invokeMap', + lastIndexOfFrom: 'lastIndexOf', + mergeAll: 'merge', + mergeAllWith: 'mergeWith', + padChars: 'pad', + padCharsEnd: 'padEnd', + padCharsStart: 'padStart', + propertyOf: 'get', + rangeStep: 'range', + rangeStepRight: 'rangeRight', + restFrom: 'rest', + spreadFrom: 'spread', + trimChars: 'trim', + trimCharsEnd: 'trimEnd', + trimCharsStart: 'trimStart', + zipAll: 'zip', + }), + (o.skipFixed = { + castArray: !0, + flow: !0, + flowRight: !0, + iteratee: !0, + mixin: !0, + rearg: !0, + runInContext: !0, + }), + (o.skipRearg = { + add: !0, + assign: !0, + assignIn: !0, + bind: !0, + bindKey: !0, + concat: !0, + difference: !0, + divide: !0, + eq: !0, + gt: !0, + gte: !0, + isEqual: !0, + lt: !0, + lte: !0, + matchesProperty: !0, + merge: !0, + multiply: !0, + overArgs: !0, + partial: !0, + partialRight: !0, + propertyOf: !0, + random: !0, + range: !0, + rangeRight: !0, + subtract: !0, + zip: !0, + zipObject: !0, + zipObjectDeep: !0, + })) + }, + 17255: (s, o, i) => { + var a = i(47422) + s.exports = function basePropertyDeep(s) { + return function (o) { + return a(o, s) + } + } + }, + 17285: (s) => { + function source(s) { + return s ? ('string' == typeof s ? s : s.source) : null + } + function lookahead(s) { + return concat('(?=', s, ')') + } + function concat(...s) { + return s.map((s) => source(s)).join('') + } + function either(...s) { + return '(' + s.map((s) => source(s)).join('|') + ')' + } + s.exports = function xml(s) { + const o = concat( + /[A-Z_]/, + (function optional(s) { + return concat('(', s, ')?') + })(/[A-Z0-9_.-]*:/), + /[A-Z0-9_.-]*/ + ), + i = { className: 'symbol', begin: /&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;/ }, + a = { + begin: /\s/, + contains: [ + { className: 'meta-keyword', begin: /#?[a-z_][a-z1-9_-]+/, illegal: /\n/ }, + ], + }, + u = s.inherit(a, { begin: /\(/, end: /\)/ }), + _ = s.inherit(s.APOS_STRING_MODE, { className: 'meta-string' }), + w = s.inherit(s.QUOTE_STRING_MODE, { className: 'meta-string' }), + x = { + endsWithParent: !0, + illegal: /`]+/ }, + ], + }, + ], + }, + ], + } + return { + name: 'HTML, XML', + aliases: ['html', 'xhtml', 'rss', 'atom', 'xjb', 'xsd', 'xsl', 'plist', 'wsf', 'svg'], + case_insensitive: !0, + contains: [ + { + className: 'meta', + begin: //, + relevance: 10, + contains: [ + a, + w, + _, + u, + { + begin: /\[/, + end: /\]/, + contains: [ + { className: 'meta', begin: //, contains: [a, u, w, _] }, + ], + }, + ], + }, + s.COMMENT(//, { relevance: 10 }), + { begin: //, relevance: 10 }, + i, + { className: 'meta', begin: /<\?xml/, end: /\?>/, relevance: 10 }, + { + className: 'tag', + begin: /)/, + end: />/, + keywords: { name: 'style' }, + contains: [x], + starts: { end: /<\/style>/, returnEnd: !0, subLanguage: ['css', 'xml'] }, + }, + { + className: 'tag', + begin: /)/, + end: />/, + keywords: { name: 'script' }, + contains: [x], + starts: { + end: /<\/script>/, + returnEnd: !0, + subLanguage: ['javascript', 'handlebars', 'xml'], + }, + }, + { className: 'tag', begin: /<>|<\/>/ }, + { + className: 'tag', + begin: concat(//, />/, /\s/)))), + end: /\/?>/, + contains: [{ className: 'name', begin: o, relevance: 0, starts: x }], + }, + { + className: 'tag', + begin: concat(/<\//, lookahead(concat(o, />/))), + contains: [ + { className: 'name', begin: o, relevance: 0 }, + { begin: />/, relevance: 0, endsParent: !0 }, + ], + }, + ], + } + } + }, + 17400: (s, o, i) => { + var a = i(99374), + u = 1 / 0 + s.exports = function toFinite(s) { + return s + ? (s = a(s)) === u || s === -1 / 0 + ? 17976931348623157e292 * (s < 0 ? -1 : 1) + : s == s + ? s + : 0 + : 0 === s + ? s + : 0 + } + }, + 17533: (s) => { + s.exports = function yaml(s) { + var o = 'true false yes no null', + i = "[\\w#;/?:@&=+$,.~*'()[\\]]+", + a = { + className: 'string', + relevance: 0, + variants: [{ begin: /'/, end: /'/ }, { begin: /"/, end: /"/ }, { begin: /\S+/ }], + contains: [ + s.BACKSLASH_ESCAPE, + { + className: 'template-variable', + variants: [ + { begin: /\{\{/, end: /\}\}/ }, + { begin: /%\{/, end: /\}/ }, + ], + }, + ], + }, + u = s.inherit(a, { + variants: [ + { begin: /'/, end: /'/ }, + { begin: /"/, end: /"/ }, + { begin: /[^\s,{}[\]]+/ }, + ], + }), + _ = { + className: 'number', + begin: + '\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b', + }, + w = { end: ',', endsWithParent: !0, excludeEnd: !0, keywords: o, relevance: 0 }, + x = { begin: /\{/, end: /\}/, contains: [w], illegal: '\\n', relevance: 0 }, + C = { begin: '\\[', end: '\\]', contains: [w], illegal: '\\n', relevance: 0 }, + j = [ + { + className: 'attr', + variants: [ + { begin: '\\w[\\w :\\/.-]*:(?=[ \t]|$)' }, + { begin: '"\\w[\\w :\\/.-]*":(?=[ \t]|$)' }, + { begin: "'\\w[\\w :\\/.-]*':(?=[ \t]|$)" }, + ], + }, + { className: 'meta', begin: '^---\\s*$', relevance: 10 }, + { + className: 'string', + begin: '[\\|>]([1-9]?[+-])?[ ]*\\n( +)[^ ][^\\n]*\\n(\\2[^\\n]+\\n?)*', + }, + { + begin: '<%[%=-]?', + end: '[%-]?%>', + subLanguage: 'ruby', + excludeBegin: !0, + excludeEnd: !0, + relevance: 0, + }, + { className: 'type', begin: '!\\w+!' + i }, + { className: 'type', begin: '!<' + i + '>' }, + { className: 'type', begin: '!' + i }, + { className: 'type', begin: '!!' + i }, + { className: 'meta', begin: '&' + s.UNDERSCORE_IDENT_RE + '$' }, + { className: 'meta', begin: '\\*' + s.UNDERSCORE_IDENT_RE + '$' }, + { className: 'bullet', begin: '-(?=[ ]|$)', relevance: 0 }, + s.HASH_COMMENT_MODE, + { beginKeywords: o, keywords: { literal: o } }, + _, + { className: 'number', begin: s.C_NUMBER_RE + '\\b', relevance: 0 }, + x, + C, + a, + ], + L = [...j] + return ( + L.pop(), + L.push(u), + (w.contains = L), + { name: 'YAML', case_insensitive: !0, aliases: ['yml'], contains: j } + ) + } + }, + 17670: (s, o, i) => { + var a = i(12651) + s.exports = function mapCacheDelete(s) { + var o = a(this, s).delete(s) + return ((this.size -= o ? 1 : 0), o) + } + }, + 17965: (s, o, i) => { + 'use strict' + var a = i(16426), + u = { 'text/plain': 'Text', 'text/html': 'Url', default: 'Text' } + s.exports = function copy(s, o) { + var i, + _, + w, + x, + C, + j, + L = !1 + ;(o || (o = {}), (i = o.debug || !1)) + try { + if ( + ((w = a()), + (x = document.createRange()), + (C = document.getSelection()), + ((j = document.createElement('span')).textContent = s), + (j.ariaHidden = 'true'), + (j.style.all = 'unset'), + (j.style.position = 'fixed'), + (j.style.top = 0), + (j.style.clip = 'rect(0, 0, 0, 0)'), + (j.style.whiteSpace = 'pre'), + (j.style.webkitUserSelect = 'text'), + (j.style.MozUserSelect = 'text'), + (j.style.msUserSelect = 'text'), + (j.style.userSelect = 'text'), + j.addEventListener('copy', function (a) { + if ((a.stopPropagation(), o.format)) + if ((a.preventDefault(), void 0 === a.clipboardData)) { + ;(i && console.warn('unable to use e.clipboardData'), + i && console.warn('trying IE specific stuff'), + window.clipboardData.clearData()) + var _ = u[o.format] || u.default + window.clipboardData.setData(_, s) + } else (a.clipboardData.clearData(), a.clipboardData.setData(o.format, s)) + o.onCopy && (a.preventDefault(), o.onCopy(a.clipboardData)) + }), + document.body.appendChild(j), + x.selectNodeContents(j), + C.addRange(x), + !document.execCommand('copy')) + ) + throw new Error('copy command was unsuccessful') + L = !0 + } catch (a) { + ;(i && console.error('unable to copy using execCommand: ', a), + i && console.warn('trying IE specific stuff')) + try { + ;(window.clipboardData.setData(o.format || 'text', s), + o.onCopy && o.onCopy(window.clipboardData), + (L = !0)) + } catch (a) { + ;(i && console.error('unable to copy using clipboardData: ', a), + i && console.error('falling back to prompt'), + (_ = (function format(s) { + var o = (/mac os x/i.test(navigator.userAgent) ? '⌘' : 'Ctrl') + '+C' + return s.replace(/#{\s*key\s*}/g, o) + })('message' in o ? o.message : 'Copy to clipboard: #{key}, Enter')), + window.prompt(_, s)) + } + } finally { + ;(C && ('function' == typeof C.removeRange ? C.removeRange(x) : C.removeAllRanges()), + j && document.body.removeChild(j), + w()) + } + return L + } + }, + 18073: (s, o, i) => { + var a = i(85087), + u = i(54641), + _ = i(70981) + s.exports = function createRecurry(s, o, i, w, x, C, j, L, B, $) { + var U = 8 & o + ;((o |= U ? 32 : 64), 4 & (o &= ~(U ? 64 : 32)) || (o &= -4)) + var V = [ + s, + o, + x, + U ? C : void 0, + U ? j : void 0, + U ? void 0 : C, + U ? void 0 : j, + L, + B, + $, + ], + z = i.apply(void 0, V) + return (a(s) && u(z, V), (z.placeholder = w), _(z, s, o)) + } + }, + 19123: (s, o, i) => { + var a = i(65606), + u = i(31499), + _ = i(88310).Stream + function resolve(s, o, i) { + var a, + _ = (function create_indent(s, o) { + return new Array(o || 0).join(s || '') + })(o, (i = i || 0)), + w = s + if ('object' == typeof s && (w = s[(a = Object.keys(s)[0])]) && w._elem) + return ( + (w._elem.name = a), + (w._elem.icount = i), + (w._elem.indent = o), + (w._elem.indents = _), + (w._elem.interrupt = w), + w._elem + ) + var x, + C = [], + j = [] + function get_attributes(s) { + Object.keys(s).forEach(function (o) { + C.push( + (function attribute(s, o) { + return s + '="' + u(o) + '"' + })(o, s[o]) + ) + }) + } + switch (typeof w) { + case 'object': + if (null === w) break + ;(w._attr && get_attributes(w._attr), + w._cdata && + j.push(('/g, ']]]]>') + ']]>'), + w.forEach && + ((x = !1), + j.push(''), + w.forEach(function (s) { + 'object' == typeof s + ? '_attr' == Object.keys(s)[0] + ? get_attributes(s._attr) + : j.push(resolve(s, o, i + 1)) + : (j.pop(), (x = !0), j.push(u(s))) + }), + x || j.push(''))) + break + default: + j.push(u(w)) + } + return { + name: a, + interrupt: !1, + attributes: C, + content: j, + icount: i, + indents: _, + indent: o, + } + } + function format(s, o, i) { + if ('object' != typeof o) return s(!1, o) + var a = o.interrupt ? 1 : o.content.length + function proceed() { + for (; o.content.length; ) { + var u = o.content.shift() + if (void 0 !== u) { + if (interrupt(u)) return + format(s, u) + } + } + ;(s( + !1, + (a > 1 ? o.indents : '') + + (o.name ? '' : '') + + (o.indent && !i ? '\n' : '') + ), + i && i()) + } + function interrupt(o) { + return ( + !!o.interrupt && + ((o.interrupt.append = s), + (o.interrupt.end = proceed), + (o.interrupt = !1), + s(!0), + !0) + ) + } + if ( + (s( + !1, + o.indents + + (o.name ? '<' + o.name : '') + + (o.attributes.length ? ' ' + o.attributes.join(' ') : '') + + (a ? (o.name ? '>' : '') : o.name ? '/>' : '') + + (o.indent && a > 1 ? '\n' : '') + ), + !a) + ) + return s(!1, o.indent ? '\n' : '') + interrupt(o) || proceed() + } + ;((s.exports = function xml(s, o) { + 'object' != typeof o && (o = { indent: o }) + var i = o.stream ? new _() : null, + u = '', + w = !1, + x = o.indent ? (!0 === o.indent ? ' ' : o.indent) : '', + C = !0 + function delay(s) { + C ? a.nextTick(s) : s() + } + function append(s, o) { + if ((void 0 !== o && (u += o), s && !w && ((i = i || new _()), (w = !0)), s && w)) { + var a = u + ;(delay(function () { + i.emit('data', a) + }), + (u = '')) + } + } + function add(s, o) { + format(append, resolve(s, x, x ? 1 : 0), o) + } + function end() { + if (i) { + var s = u + delay(function () { + ;(i.emit('data', s), i.emit('end'), (i.readable = !1), i.emit('close')) + }) + } + } + return ( + delay(function () { + C = !1 + }), + o.declaration && + (function addXmlDeclaration(s) { + var o = { version: '1.0', encoding: s.encoding || 'UTF-8' } + ;(s.standalone && (o.standalone = s.standalone), + add({ '?xml': { _attr: o } }), + (u = u.replace('/>', '?>'))) + })(o.declaration), + s && s.forEach + ? s.forEach(function (o, i) { + var a + ;(i + 1 === s.length && (a = end), add(o, a)) + }) + : add(s, end), + i ? ((i.readable = !0), i) : u + ) + }), + (s.exports.element = s.exports.Element = + function element() { + var s = { + _elem: resolve(Array.prototype.slice.call(arguments)), + push: function (s) { + if (!this.append) throw new Error('not assigned to a parent!') + var o = this, + i = this._elem.indent + format( + this.append, + resolve(s, i, this._elem.icount + (i ? 1 : 0)), + function () { + o.append(!0) + } + ) + }, + close: function (s) { + ;(void 0 !== s && this.push(s), this.end && this.end()) + }, + } + return s + })) + }, + 19219: (s) => { + s.exports = function cacheHas(s, o) { + return s.has(o) + } + }, + 19287: (s) => { + 'use strict' + s.exports = { + CSSRuleList: 0, + CSSStyleDeclaration: 0, + CSSValueList: 0, + ClientRectList: 0, + DOMRectList: 0, + DOMStringList: 0, + DOMTokenList: 1, + DataTransferItemList: 0, + FileList: 0, + HTMLAllCollection: 0, + HTMLCollection: 0, + HTMLFormElement: 0, + HTMLSelectElement: 0, + MediaList: 0, + MimeTypeArray: 0, + NamedNodeMap: 0, + NodeList: 1, + PaintRequestList: 0, + Plugin: 0, + PluginArray: 0, + SVGLengthList: 0, + SVGNumberList: 0, + SVGPathSegList: 0, + SVGPointList: 0, + SVGStringList: 0, + SVGTransformList: 0, + SourceBufferList: 0, + StyleSheetList: 0, + TextTrackCueList: 0, + TextTrackList: 0, + TouchList: 0, + } + }, + 19358: (s, o, i) => { + 'use strict' + var a = i(85582), + u = i(49724), + _ = i(61626), + w = i(88280), + x = i(79192), + C = i(19595), + j = i(54829), + L = i(34084), + B = i(32096), + $ = i(39259), + U = i(85884), + V = i(39447), + z = i(7376) + s.exports = function (s, o, i, Y) { + var Z = 'stackTraceLimit', + ee = Y ? 2 : 1, + ie = s.split('.'), + ae = ie[ie.length - 1], + ce = a.apply(null, ie) + if (ce) { + var le = ce.prototype + if ((!z && u(le, 'cause') && delete le.cause, !i)) return ce + var pe = a('Error'), + de = o(function (s, o) { + var i = B(Y ? o : s, void 0), + a = Y ? new ce(s) : new ce() + return ( + void 0 !== i && _(a, 'message', i), + U(a, de, a.stack, 2), + this && w(le, this) && L(a, this, de), + arguments.length > ee && $(a, arguments[ee]), + a + ) + }) + if ( + ((de.prototype = le), + 'Error' !== ae + ? x + ? x(de, pe) + : C(de, pe, { name: !0 }) + : V && Z in ce && (j(de, ce, Z), j(de, ce, 'prepareStackTrace')), + C(de, ce), + !z) + ) + try { + ;(le.name !== ae && _(le, 'name', ae), (le.constructor = de)) + } catch (s) {} + return de + } + } + }, + 19570: (s, o, i) => { + var a = i(37334), + u = i(93243), + _ = i(83488), + w = u + ? function (s, o) { + return u(s, 'toString', { + configurable: !0, + enumerable: !1, + value: a(o), + writable: !0, + }) + } + : _ + s.exports = w + }, + 19595: (s, o, i) => { + 'use strict' + var a = i(49724), + u = i(11042), + _ = i(13846), + w = i(74284) + s.exports = function (s, o, i) { + for (var x = u(o), C = w.f, j = _.f, L = 0; L < x.length; L++) { + var B = x[L] + a(s, B) || (i && a(i, B)) || C(s, B, j(o, B)) + } + } + }, + 19709: (s, o, i) => { + 'use strict' + var a = i(23034) + s.exports = a + }, + 19846: (s, o, i) => { + 'use strict' + var a = i(20798), + u = i(98828), + _ = i(45951).String + s.exports = + !!Object.getOwnPropertySymbols && + !u(function () { + var s = Symbol('symbol detection') + return !_(s) || !(Object(s) instanceof Symbol) || (!Symbol.sham && a && a < 41) + }) + }, + 19931: (s, o, i) => { + var a = i(31769), + u = i(68090), + _ = i(68969), + w = i(77797) + s.exports = function baseUnset(s, o) { + return ((o = a(o, s)), null == (s = _(s, o)) || delete s[w(u(o))]) + } + }, + 20181: (s, o, i) => { + var a = /^\s+|\s+$/g, + u = /^[-+]0x[0-9a-f]+$/i, + _ = /^0b[01]+$/i, + w = /^0o[0-7]+$/i, + x = parseInt, + C = 'object' == typeof i.g && i.g && i.g.Object === Object && i.g, + j = 'object' == typeof self && self && self.Object === Object && self, + L = C || j || Function('return this')(), + B = Object.prototype.toString, + $ = Math.max, + U = Math.min, + now = function () { + return L.Date.now() + } + function isObject(s) { + var o = typeof s + return !!s && ('object' == o || 'function' == o) + } + function toNumber(s) { + if ('number' == typeof s) return s + if ( + (function isSymbol(s) { + return ( + 'symbol' == typeof s || + ((function isObjectLike(s) { + return !!s && 'object' == typeof s + })(s) && + '[object Symbol]' == B.call(s)) + ) + })(s) + ) + return NaN + if (isObject(s)) { + var o = 'function' == typeof s.valueOf ? s.valueOf() : s + s = isObject(o) ? o + '' : o + } + if ('string' != typeof s) return 0 === s ? s : +s + s = s.replace(a, '') + var i = _.test(s) + return i || w.test(s) ? x(s.slice(2), i ? 2 : 8) : u.test(s) ? NaN : +s + } + s.exports = function debounce(s, o, i) { + var a, + u, + _, + w, + x, + C, + j = 0, + L = !1, + B = !1, + V = !0 + if ('function' != typeof s) throw new TypeError('Expected a function') + function invokeFunc(o) { + var i = a, + _ = u + return ((a = u = void 0), (j = o), (w = s.apply(_, i))) + } + function shouldInvoke(s) { + var i = s - C + return void 0 === C || i >= o || i < 0 || (B && s - j >= _) + } + function timerExpired() { + var s = now() + if (shouldInvoke(s)) return trailingEdge(s) + x = setTimeout( + timerExpired, + (function remainingWait(s) { + var i = o - (s - C) + return B ? U(i, _ - (s - j)) : i + })(s) + ) + } + function trailingEdge(s) { + return ((x = void 0), V && a ? invokeFunc(s) : ((a = u = void 0), w)) + } + function debounced() { + var s = now(), + i = shouldInvoke(s) + if (((a = arguments), (u = this), (C = s), i)) { + if (void 0 === x) + return (function leadingEdge(s) { + return ((j = s), (x = setTimeout(timerExpired, o)), L ? invokeFunc(s) : w) + })(C) + if (B) return ((x = setTimeout(timerExpired, o)), invokeFunc(C)) + } + return (void 0 === x && (x = setTimeout(timerExpired, o)), w) + } + return ( + (o = toNumber(o) || 0), + isObject(i) && + ((L = !!i.leading), + (_ = (B = 'maxWait' in i) ? $(toNumber(i.maxWait) || 0, o) : _), + (V = 'trailing' in i ? !!i.trailing : V)), + (debounced.cancel = function cancel() { + ;(void 0 !== x && clearTimeout(x), (j = 0), (a = C = u = x = void 0)) + }), + (debounced.flush = function flush() { + return void 0 === x ? w : trailingEdge(now()) + }), + debounced + ) + } + }, + 20317: (s) => { + s.exports = function mapToArray(s) { + var o = -1, + i = Array(s.size) + return ( + s.forEach(function (s, a) { + i[++o] = [a, s] + }), + i + ) + } + }, + 20334: (s, o, i) => { + 'use strict' + var a = i(48287).Buffer + class NonError extends Error { + constructor(s) { + ;(super(NonError._prepareSuperMessage(s)), + Object.defineProperty(this, 'name', { + value: 'NonError', + configurable: !0, + writable: !0, + }), + Error.captureStackTrace && Error.captureStackTrace(this, NonError)) + } + static _prepareSuperMessage(s) { + try { + return JSON.stringify(s) + } catch { + return String(s) + } + } + } + const u = [ + { property: 'name', enumerable: !1 }, + { property: 'message', enumerable: !1 }, + { property: 'stack', enumerable: !1 }, + { property: 'code', enumerable: !0 }, + ], + _ = Symbol('.toJSON called'), + destroyCircular = ({ + from: s, + seen: o, + to_: i, + forceEnumerable: w, + maxDepth: x, + depth: C, + }) => { + const j = i || (Array.isArray(s) ? [] : {}) + if ((o.push(s), C >= x)) return j + if ('function' == typeof s.toJSON && !0 !== s[_]) + return ((s) => { + s[_] = !0 + const o = s.toJSON() + return (delete s[_], o) + })(s) + for (const [i, u] of Object.entries(s)) + 'function' == typeof a && a.isBuffer(u) + ? (j[i] = '[object Buffer]') + : 'function' != typeof u && + (u && 'object' == typeof u + ? o.includes(s[i]) + ? (j[i] = '[Circular]') + : (C++, + (j[i] = destroyCircular({ + from: s[i], + seen: o.slice(), + forceEnumerable: w, + maxDepth: x, + depth: C, + }))) + : (j[i] = u)) + for (const { property: o, enumerable: i } of u) + 'string' == typeof s[o] && + Object.defineProperty(j, o, { + value: s[o], + enumerable: !!w || i, + configurable: !0, + writable: !0, + }) + return j + } + s.exports = { + serializeError: (s, o = {}) => { + const { maxDepth: i = Number.POSITIVE_INFINITY } = o + return 'object' == typeof s && null !== s + ? destroyCircular({ from: s, seen: [], forceEnumerable: !0, maxDepth: i, depth: 0 }) + : 'function' == typeof s + ? `[Function: ${s.name || 'anonymous'}]` + : s + }, + deserializeError: (s, o = {}) => { + const { maxDepth: i = Number.POSITIVE_INFINITY } = o + if (s instanceof Error) return s + if ('object' == typeof s && null !== s && !Array.isArray(s)) { + const o = new Error() + return (destroyCircular({ from: s, seen: [], to_: o, maxDepth: i, depth: 0 }), o) + } + return new NonError(s) + }, + } + }, + 20426: (s) => { + var o = Object.prototype.hasOwnProperty + s.exports = function baseHas(s, i) { + return null != s && o.call(s, i) + } + }, + 20575: (s, o, i) => { + 'use strict' + var a = i(3121) + s.exports = function (s) { + return a(s.length) + } + }, + 20798: (s, o, i) => { + 'use strict' + var a, + u, + _ = i(45951), + w = i(96794), + x = _.process, + C = _.Deno, + j = (x && x.versions) || (C && C.version), + L = j && j.v8 + ;(L && (u = (a = L.split('.'))[0] > 0 && a[0] < 4 ? 1 : +(a[0] + a[1])), + !u && + w && + (!(a = w.match(/Edge\/(\d+)/)) || a[1] >= 74) && + (a = w.match(/Chrome\/(\d+)/)) && + (u = +a[1]), + (s.exports = u)) + }, + 20850: (s, o, i) => { + 'use strict' + s.exports = i(46076) + }, + 20999: (s, o, i) => { + var a = i(69302), + u = i(36800) + s.exports = function createAssigner(s) { + return a(function (o, i) { + var a = -1, + _ = i.length, + w = _ > 1 ? i[_ - 1] : void 0, + x = _ > 2 ? i[2] : void 0 + for ( + w = s.length > 3 && 'function' == typeof w ? (_--, w) : void 0, + x && u(i[0], i[1], x) && ((w = _ < 3 ? void 0 : w), (_ = 1)), + o = Object(o); + ++a < _; + ) { + var C = i[a] + C && s(o, C, a, w) + } + return o + }) + } + }, + 21549: (s, o, i) => { + var a = i(22032), + u = i(63862), + _ = i(66721), + w = i(12749), + x = i(35749) + function Hash(s) { + var o = -1, + i = null == s ? 0 : s.length + for (this.clear(); ++o < i; ) { + var a = s[o] + this.set(a[0], a[1]) + } + } + ;((Hash.prototype.clear = a), + (Hash.prototype.delete = u), + (Hash.prototype.get = _), + (Hash.prototype.has = w), + (Hash.prototype.set = x), + (s.exports = Hash)) + }, + 21791: (s, o, i) => { + var a = i(16547), + u = i(43360) + s.exports = function copyObject(s, o, i, _) { + var w = !i + i || (i = {}) + for (var x = -1, C = o.length; ++x < C; ) { + var j = o[x], + L = _ ? _(i[j], s[j], j, i, s) : void 0 + ;(void 0 === L && (L = s[j]), w ? u(i, j, L) : a(i, j, L)) + } + return i + } + }, + 21986: (s, o, i) => { + var a = i(51873), + u = i(37828), + _ = i(75288), + w = i(25911), + x = i(20317), + C = i(84247), + j = a ? a.prototype : void 0, + L = j ? j.valueOf : void 0 + s.exports = function equalByTag(s, o, i, a, j, B, $) { + switch (i) { + case '[object DataView]': + if (s.byteLength != o.byteLength || s.byteOffset != o.byteOffset) return !1 + ;((s = s.buffer), (o = o.buffer)) + case '[object ArrayBuffer]': + return !(s.byteLength != o.byteLength || !B(new u(s), new u(o))) + case '[object Boolean]': + case '[object Date]': + case '[object Number]': + return _(+s, +o) + case '[object Error]': + return s.name == o.name && s.message == o.message + case '[object RegExp]': + case '[object String]': + return s == o + '' + case '[object Map]': + var U = x + case '[object Set]': + var V = 1 & a + if ((U || (U = C), s.size != o.size && !V)) return !1 + var z = $.get(s) + if (z) return z == o + ;((a |= 2), $.set(s, o)) + var Y = w(U(s), U(o), a, j, B, $) + return ($.delete(s), Y) + case '[object Symbol]': + if (L) return L.call(s) == L.call(o) + } + return !1 + } + }, + 22032: (s, o, i) => { + var a = i(81042) + s.exports = function hashClear() { + ;((this.__data__ = a ? a(null) : {}), (this.size = 0)) + } + }, + 22225: (s) => { + var o = '\\ud800-\\udfff', + i = '\\u2700-\\u27bf', + a = 'a-z\\xdf-\\xf6\\xf8-\\xff', + u = 'A-Z\\xc0-\\xd6\\xd8-\\xde', + _ = + '\\xac\\xb1\\xd7\\xf7\\x00-\\x2f\\x3a-\\x40\\x5b-\\x60\\x7b-\\xbf\\u2000-\\u206f \\t\\x0b\\f\\xa0\\ufeff\\n\\r\\u2028\\u2029\\u1680\\u180e\\u2000\\u2001\\u2002\\u2003\\u2004\\u2005\\u2006\\u2007\\u2008\\u2009\\u200a\\u202f\\u205f\\u3000', + w = '[' + _ + ']', + x = '\\d+', + C = '[' + i + ']', + j = '[' + a + ']', + L = '[^' + o + _ + x + i + a + u + ']', + B = '(?:\\ud83c[\\udde6-\\uddff]){2}', + $ = '[\\ud800-\\udbff][\\udc00-\\udfff]', + U = '[' + u + ']', + V = '(?:' + j + '|' + L + ')', + z = '(?:' + U + '|' + L + ')', + Y = "(?:['’](?:d|ll|m|re|s|t|ve))?", + Z = "(?:['’](?:D|LL|M|RE|S|T|VE))?", + ee = '(?:[\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff]|\\ud83c[\\udffb-\\udfff])?', + ie = '[\\ufe0e\\ufe0f]?', + ae = + ie + ee + ('(?:\\u200d(?:' + ['[^' + o + ']', B, $].join('|') + ')' + ie + ee + ')*'), + ce = '(?:' + [C, B, $].join('|') + ')' + ae, + le = RegExp( + [ + U + '?' + j + '+' + Y + '(?=' + [w, U, '$'].join('|') + ')', + z + '+' + Z + '(?=' + [w, U + V, '$'].join('|') + ')', + U + '?' + V + '+' + Y, + U + '+' + Z, + '\\d*(?:1ST|2ND|3RD|(?![123])\\dTH)(?=\\b|[a-z_])', + '\\d*(?:1st|2nd|3rd|(?![123])\\dth)(?=\\b|[A-Z_])', + x, + ce, + ].join('|'), + 'g' + ) + s.exports = function unicodeWords(s) { + return s.match(le) || [] + } + }, + 22551: (s, o, i) => { + 'use strict' + var a = i(96540), + u = i(69982) + function p(s) { + for ( + var o = 'https://reactjs.org/docs/error-decoder.html?invariant=' + s, i = 1; + i < arguments.length; + i++ + ) + o += '&args[]=' + encodeURIComponent(arguments[i]) + return ( + 'Minified React error #' + + s + + '; visit ' + + o + + ' for the full message or use the non-minified dev environment for full errors and additional helpful warnings.' + ) + } + var _ = new Set(), + w = {} + function fa(s, o) { + ;(ha(s, o), ha(s + 'Capture', o)) + } + function ha(s, o) { + for (w[s] = o, s = 0; s < o.length; s++) _.add(o[s]) + } + var x = !( + 'undefined' == typeof window || + void 0 === window.document || + void 0 === window.document.createElement + ), + C = Object.prototype.hasOwnProperty, + j = + /^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/, + L = {}, + B = {} + function v(s, o, i, a, u, _, w) { + ;((this.acceptsBooleans = 2 === o || 3 === o || 4 === o), + (this.attributeName = a), + (this.attributeNamespace = u), + (this.mustUseProperty = i), + (this.propertyName = s), + (this.type = o), + (this.sanitizeURL = _), + (this.removeEmptyString = w)) + } + var $ = {} + ;('children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style' + .split(' ') + .forEach(function (s) { + $[s] = new v(s, 0, !1, s, null, !1, !1) + }), + [ + ['acceptCharset', 'accept-charset'], + ['className', 'class'], + ['htmlFor', 'for'], + ['httpEquiv', 'http-equiv'], + ].forEach(function (s) { + var o = s[0] + $[o] = new v(o, 1, !1, s[1], null, !1, !1) + }), + ['contentEditable', 'draggable', 'spellCheck', 'value'].forEach(function (s) { + $[s] = new v(s, 2, !1, s.toLowerCase(), null, !1, !1) + }), + ['autoReverse', 'externalResourcesRequired', 'focusable', 'preserveAlpha'].forEach( + function (s) { + $[s] = new v(s, 2, !1, s, null, !1, !1) + } + ), + 'allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope' + .split(' ') + .forEach(function (s) { + $[s] = new v(s, 3, !1, s.toLowerCase(), null, !1, !1) + }), + ['checked', 'multiple', 'muted', 'selected'].forEach(function (s) { + $[s] = new v(s, 3, !0, s, null, !1, !1) + }), + ['capture', 'download'].forEach(function (s) { + $[s] = new v(s, 4, !1, s, null, !1, !1) + }), + ['cols', 'rows', 'size', 'span'].forEach(function (s) { + $[s] = new v(s, 6, !1, s, null, !1, !1) + }), + ['rowSpan', 'start'].forEach(function (s) { + $[s] = new v(s, 5, !1, s.toLowerCase(), null, !1, !1) + })) + var U = /[\-:]([a-z])/g + function sa(s) { + return s[1].toUpperCase() + } + function ta(s, o, i, a) { + var u = $.hasOwnProperty(o) ? $[o] : null + ;(null !== u + ? 0 !== u.type + : a || + !(2 < o.length) || + ('o' !== o[0] && 'O' !== o[0]) || + ('n' !== o[1] && 'N' !== o[1])) && + ((function qa(s, o, i, a) { + if ( + null == o || + (function pa(s, o, i, a) { + if (null !== i && 0 === i.type) return !1 + switch (typeof o) { + case 'function': + case 'symbol': + return !0 + case 'boolean': + return ( + !a && + (null !== i + ? !i.acceptsBooleans + : 'data-' !== (s = s.toLowerCase().slice(0, 5)) && 'aria-' !== s) + ) + default: + return !1 + } + })(s, o, i, a) + ) + return !0 + if (a) return !1 + if (null !== i) + switch (i.type) { + case 3: + return !o + case 4: + return !1 === o + case 5: + return isNaN(o) + case 6: + return isNaN(o) || 1 > o + } + return !1 + })(o, i, u, a) && (i = null), + a || null === u + ? (function oa(s) { + return ( + !!C.call(B, s) || + (!C.call(L, s) && (j.test(s) ? (B[s] = !0) : ((L[s] = !0), !1))) + ) + })(o) && (null === i ? s.removeAttribute(o) : s.setAttribute(o, '' + i)) + : u.mustUseProperty + ? (s[u.propertyName] = null === i ? 3 !== u.type && '' : i) + : ((o = u.attributeName), + (a = u.attributeNamespace), + null === i + ? s.removeAttribute(o) + : ((i = 3 === (u = u.type) || (4 === u && !0 === i) ? '' : '' + i), + a ? s.setAttributeNS(a, o, i) : s.setAttribute(o, i)))) + } + ;('accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height' + .split(' ') + .forEach(function (s) { + var o = s.replace(U, sa) + $[o] = new v(o, 1, !1, s, null, !1, !1) + }), + 'xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type' + .split(' ') + .forEach(function (s) { + var o = s.replace(U, sa) + $[o] = new v(o, 1, !1, s, 'http://www.w3.org/1999/xlink', !1, !1) + }), + ['xml:base', 'xml:lang', 'xml:space'].forEach(function (s) { + var o = s.replace(U, sa) + $[o] = new v(o, 1, !1, s, 'http://www.w3.org/XML/1998/namespace', !1, !1) + }), + ['tabIndex', 'crossOrigin'].forEach(function (s) { + $[s] = new v(s, 1, !1, s.toLowerCase(), null, !1, !1) + }), + ($.xlinkHref = new v( + 'xlinkHref', + 1, + !1, + 'xlink:href', + 'http://www.w3.org/1999/xlink', + !0, + !1 + )), + ['src', 'href', 'action', 'formAction'].forEach(function (s) { + $[s] = new v(s, 1, !1, s.toLowerCase(), null, !0, !0) + })) + var V = a.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED, + z = Symbol.for('react.element'), + Y = Symbol.for('react.portal'), + Z = Symbol.for('react.fragment'), + ee = Symbol.for('react.strict_mode'), + ie = Symbol.for('react.profiler'), + ae = Symbol.for('react.provider'), + ce = Symbol.for('react.context'), + le = Symbol.for('react.forward_ref'), + pe = Symbol.for('react.suspense'), + de = Symbol.for('react.suspense_list'), + fe = Symbol.for('react.memo'), + ye = Symbol.for('react.lazy') + ;(Symbol.for('react.scope'), Symbol.for('react.debug_trace_mode')) + var be = Symbol.for('react.offscreen') + ;(Symbol.for('react.legacy_hidden'), + Symbol.for('react.cache'), + Symbol.for('react.tracing_marker')) + var _e = Symbol.iterator + function Ka(s) { + return null === s || 'object' != typeof s + ? null + : 'function' == typeof (s = (_e && s[_e]) || s['@@iterator']) + ? s + : null + } + var Se, + we = Object.assign + function Ma(s) { + if (void 0 === Se) + try { + throw Error() + } catch (s) { + var o = s.stack.trim().match(/\n( *(at )?)/) + Se = (o && o[1]) || '' + } + return '\n' + Se + s + } + var xe = !1 + function Oa(s, o) { + if (!s || xe) return '' + xe = !0 + var i = Error.prepareStackTrace + Error.prepareStackTrace = void 0 + try { + if (o) + if ( + ((o = function () { + throw Error() + }), + Object.defineProperty(o.prototype, 'props', { + set: function () { + throw Error() + }, + }), + 'object' == typeof Reflect && Reflect.construct) + ) { + try { + Reflect.construct(o, []) + } catch (s) { + var a = s + } + Reflect.construct(s, [], o) + } else { + try { + o.call() + } catch (s) { + a = s + } + s.call(o.prototype) + } + else { + try { + throw Error() + } catch (s) { + a = s + } + s() + } + } catch (o) { + if (o && a && 'string' == typeof o.stack) { + for ( + var u = o.stack.split('\n'), + _ = a.stack.split('\n'), + w = u.length - 1, + x = _.length - 1; + 1 <= w && 0 <= x && u[w] !== _[x]; + ) + x-- + for (; 1 <= w && 0 <= x; w--, x--) + if (u[w] !== _[x]) { + if (1 !== w || 1 !== x) + do { + if ((w--, 0 > --x || u[w] !== _[x])) { + var C = '\n' + u[w].replace(' at new ', ' at ') + return ( + s.displayName && + C.includes('') && + (C = C.replace('', s.displayName)), + C + ) + } + } while (1 <= w && 0 <= x) + break + } + } + } finally { + ;((xe = !1), (Error.prepareStackTrace = i)) + } + return (s = s ? s.displayName || s.name : '') ? Ma(s) : '' + } + function Pa(s) { + switch (s.tag) { + case 5: + return Ma(s.type) + case 16: + return Ma('Lazy') + case 13: + return Ma('Suspense') + case 19: + return Ma('SuspenseList') + case 0: + case 2: + case 15: + return (s = Oa(s.type, !1)) + case 11: + return (s = Oa(s.type.render, !1)) + case 1: + return (s = Oa(s.type, !0)) + default: + return '' + } + } + function Qa(s) { + if (null == s) return null + if ('function' == typeof s) return s.displayName || s.name || null + if ('string' == typeof s) return s + switch (s) { + case Z: + return 'Fragment' + case Y: + return 'Portal' + case ie: + return 'Profiler' + case ee: + return 'StrictMode' + case pe: + return 'Suspense' + case de: + return 'SuspenseList' + } + if ('object' == typeof s) + switch (s.$$typeof) { + case ce: + return (s.displayName || 'Context') + '.Consumer' + case ae: + return (s._context.displayName || 'Context') + '.Provider' + case le: + var o = s.render + return ( + (s = s.displayName) || + (s = + '' !== (s = o.displayName || o.name || '') + ? 'ForwardRef(' + s + ')' + : 'ForwardRef'), + s + ) + case fe: + return null !== (o = s.displayName || null) ? o : Qa(s.type) || 'Memo' + case ye: + ;((o = s._payload), (s = s._init)) + try { + return Qa(s(o)) + } catch (s) {} + } + return null + } + function Ra(s) { + var o = s.type + switch (s.tag) { + case 24: + return 'Cache' + case 9: + return (o.displayName || 'Context') + '.Consumer' + case 10: + return (o._context.displayName || 'Context') + '.Provider' + case 18: + return 'DehydratedFragment' + case 11: + return ( + (s = (s = o.render).displayName || s.name || ''), + o.displayName || ('' !== s ? 'ForwardRef(' + s + ')' : 'ForwardRef') + ) + case 7: + return 'Fragment' + case 5: + return o + case 4: + return 'Portal' + case 3: + return 'Root' + case 6: + return 'Text' + case 16: + return Qa(o) + case 8: + return o === ee ? 'StrictMode' : 'Mode' + case 22: + return 'Offscreen' + case 12: + return 'Profiler' + case 21: + return 'Scope' + case 13: + return 'Suspense' + case 19: + return 'SuspenseList' + case 25: + return 'TracingMarker' + case 1: + case 0: + case 17: + case 2: + case 14: + case 15: + if ('function' == typeof o) return o.displayName || o.name || null + if ('string' == typeof o) return o + } + return null + } + function Sa(s) { + switch (typeof s) { + case 'boolean': + case 'number': + case 'string': + case 'undefined': + case 'object': + return s + default: + return '' + } + } + function Ta(s) { + var o = s.type + return ( + (s = s.nodeName) && 'input' === s.toLowerCase() && ('checkbox' === o || 'radio' === o) + ) + } + function Va(s) { + s._valueTracker || + (s._valueTracker = (function Ua(s) { + var o = Ta(s) ? 'checked' : 'value', + i = Object.getOwnPropertyDescriptor(s.constructor.prototype, o), + a = '' + s[o] + if ( + !s.hasOwnProperty(o) && + void 0 !== i && + 'function' == typeof i.get && + 'function' == typeof i.set + ) { + var u = i.get, + _ = i.set + return ( + Object.defineProperty(s, o, { + configurable: !0, + get: function () { + return u.call(this) + }, + set: function (s) { + ;((a = '' + s), _.call(this, s)) + }, + }), + Object.defineProperty(s, o, { enumerable: i.enumerable }), + { + getValue: function () { + return a + }, + setValue: function (s) { + a = '' + s + }, + stopTracking: function () { + ;((s._valueTracker = null), delete s[o]) + }, + } + ) + } + })(s)) + } + function Wa(s) { + if (!s) return !1 + var o = s._valueTracker + if (!o) return !0 + var i = o.getValue(), + a = '' + return ( + s && (a = Ta(s) ? (s.checked ? 'true' : 'false') : s.value), + (s = a) !== i && (o.setValue(s), !0) + ) + } + function Xa(s) { + if (void 0 === (s = s || ('undefined' != typeof document ? document : void 0))) + return null + try { + return s.activeElement || s.body + } catch (o) { + return s.body + } + } + function Ya(s, o) { + var i = o.checked + return we({}, o, { + defaultChecked: void 0, + defaultValue: void 0, + value: void 0, + checked: null != i ? i : s._wrapperState.initialChecked, + }) + } + function Za(s, o) { + var i = null == o.defaultValue ? '' : o.defaultValue, + a = null != o.checked ? o.checked : o.defaultChecked + ;((i = Sa(null != o.value ? o.value : i)), + (s._wrapperState = { + initialChecked: a, + initialValue: i, + controlled: + 'checkbox' === o.type || 'radio' === o.type ? null != o.checked : null != o.value, + })) + } + function ab(s, o) { + null != (o = o.checked) && ta(s, 'checked', o, !1) + } + function bb(s, o) { + ab(s, o) + var i = Sa(o.value), + a = o.type + if (null != i) + 'number' === a + ? ((0 === i && '' === s.value) || s.value != i) && (s.value = '' + i) + : s.value !== '' + i && (s.value = '' + i) + else if ('submit' === a || 'reset' === a) return void s.removeAttribute('value') + ;(o.hasOwnProperty('value') + ? cb(s, o.type, i) + : o.hasOwnProperty('defaultValue') && cb(s, o.type, Sa(o.defaultValue)), + null == o.checked && + null != o.defaultChecked && + (s.defaultChecked = !!o.defaultChecked)) + } + function db(s, o, i) { + if (o.hasOwnProperty('value') || o.hasOwnProperty('defaultValue')) { + var a = o.type + if (!(('submit' !== a && 'reset' !== a) || (void 0 !== o.value && null !== o.value))) + return + ;((o = '' + s._wrapperState.initialValue), + i || o === s.value || (s.value = o), + (s.defaultValue = o)) + } + ;('' !== (i = s.name) && (s.name = ''), + (s.defaultChecked = !!s._wrapperState.initialChecked), + '' !== i && (s.name = i)) + } + function cb(s, o, i) { + ;('number' === o && Xa(s.ownerDocument) === s) || + (null == i + ? (s.defaultValue = '' + s._wrapperState.initialValue) + : s.defaultValue !== '' + i && (s.defaultValue = '' + i)) + } + var Pe = Array.isArray + function fb(s, o, i, a) { + if (((s = s.options), o)) { + o = {} + for (var u = 0; u < i.length; u++) o['$' + i[u]] = !0 + for (i = 0; i < s.length; i++) + ((u = o.hasOwnProperty('$' + s[i].value)), + s[i].selected !== u && (s[i].selected = u), + u && a && (s[i].defaultSelected = !0)) + } else { + for (i = '' + Sa(i), o = null, u = 0; u < s.length; u++) { + if (s[u].value === i) + return ((s[u].selected = !0), void (a && (s[u].defaultSelected = !0))) + null !== o || s[u].disabled || (o = s[u]) + } + null !== o && (o.selected = !0) + } + } + function gb(s, o) { + if (null != o.dangerouslySetInnerHTML) throw Error(p(91)) + return we({}, o, { + value: void 0, + defaultValue: void 0, + children: '' + s._wrapperState.initialValue, + }) + } + function hb(s, o) { + var i = o.value + if (null == i) { + if (((i = o.children), (o = o.defaultValue), null != i)) { + if (null != o) throw Error(p(92)) + if (Pe(i)) { + if (1 < i.length) throw Error(p(93)) + i = i[0] + } + o = i + } + ;(null == o && (o = ''), (i = o)) + } + s._wrapperState = { initialValue: Sa(i) } + } + function ib(s, o) { + var i = Sa(o.value), + a = Sa(o.defaultValue) + ;(null != i && + ((i = '' + i) !== s.value && (s.value = i), + null == o.defaultValue && s.defaultValue !== i && (s.defaultValue = i)), + null != a && (s.defaultValue = '' + a)) + } + function jb(s) { + var o = s.textContent + o === s._wrapperState.initialValue && '' !== o && null !== o && (s.value = o) + } + function kb(s) { + switch (s) { + case 'svg': + return 'http://www.w3.org/2000/svg' + case 'math': + return 'http://www.w3.org/1998/Math/MathML' + default: + return 'http://www.w3.org/1999/xhtml' + } + } + function lb(s, o) { + return null == s || 'http://www.w3.org/1999/xhtml' === s + ? kb(o) + : 'http://www.w3.org/2000/svg' === s && 'foreignObject' === o + ? 'http://www.w3.org/1999/xhtml' + : s + } + var Te, + Re, + $e = + ((Re = function (s, o) { + if ('http://www.w3.org/2000/svg' !== s.namespaceURI || 'innerHTML' in s) + s.innerHTML = o + else { + for ( + (Te = Te || document.createElement('div')).innerHTML = + '' + o.valueOf().toString() + '', + o = Te.firstChild; + s.firstChild; + ) + s.removeChild(s.firstChild) + for (; o.firstChild; ) s.appendChild(o.firstChild) + } + }), + 'undefined' != typeof MSApp && MSApp.execUnsafeLocalFunction + ? function (s, o, i, a) { + MSApp.execUnsafeLocalFunction(function () { + return Re(s, o) + }) + } + : Re) + function ob(s, o) { + if (o) { + var i = s.firstChild + if (i && i === s.lastChild && 3 === i.nodeType) return void (i.nodeValue = o) + } + s.textContent = o + } + var qe = { + animationIterationCount: !0, + aspectRatio: !0, + borderImageOutset: !0, + borderImageSlice: !0, + borderImageWidth: !0, + boxFlex: !0, + boxFlexGroup: !0, + boxOrdinalGroup: !0, + columnCount: !0, + columns: !0, + flex: !0, + flexGrow: !0, + flexPositive: !0, + flexShrink: !0, + flexNegative: !0, + flexOrder: !0, + gridArea: !0, + gridRow: !0, + gridRowEnd: !0, + gridRowSpan: !0, + gridRowStart: !0, + gridColumn: !0, + gridColumnEnd: !0, + gridColumnSpan: !0, + gridColumnStart: !0, + fontWeight: !0, + lineClamp: !0, + lineHeight: !0, + opacity: !0, + order: !0, + orphans: !0, + tabSize: !0, + widows: !0, + zIndex: !0, + zoom: !0, + fillOpacity: !0, + floodOpacity: !0, + stopOpacity: !0, + strokeDasharray: !0, + strokeDashoffset: !0, + strokeMiterlimit: !0, + strokeOpacity: !0, + strokeWidth: !0, + }, + ze = ['Webkit', 'ms', 'Moz', 'O'] + function rb(s, o, i) { + return null == o || 'boolean' == typeof o || '' === o + ? '' + : i || 'number' != typeof o || 0 === o || (qe.hasOwnProperty(s) && qe[s]) + ? ('' + o).trim() + : o + 'px' + } + function sb(s, o) { + for (var i in ((s = s.style), o)) + if (o.hasOwnProperty(i)) { + var a = 0 === i.indexOf('--'), + u = rb(i, o[i], a) + ;('float' === i && (i = 'cssFloat'), a ? s.setProperty(i, u) : (s[i] = u)) + } + } + Object.keys(qe).forEach(function (s) { + ze.forEach(function (o) { + ;((o = o + s.charAt(0).toUpperCase() + s.substring(1)), (qe[o] = qe[s])) + }) + }) + var We = we( + { menuitem: !0 }, + { + area: !0, + base: !0, + br: !0, + col: !0, + embed: !0, + hr: !0, + img: !0, + input: !0, + keygen: !0, + link: !0, + meta: !0, + param: !0, + source: !0, + track: !0, + wbr: !0, + } + ) + function ub(s, o) { + if (o) { + if (We[s] && (null != o.children || null != o.dangerouslySetInnerHTML)) + throw Error(p(137, s)) + if (null != o.dangerouslySetInnerHTML) { + if (null != o.children) throw Error(p(60)) + if ( + 'object' != typeof o.dangerouslySetInnerHTML || + !('__html' in o.dangerouslySetInnerHTML) + ) + throw Error(p(61)) + } + if (null != o.style && 'object' != typeof o.style) throw Error(p(62)) + } + } + function vb(s, o) { + if (-1 === s.indexOf('-')) return 'string' == typeof o.is + switch (s) { + case 'annotation-xml': + case 'color-profile': + case 'font-face': + case 'font-face-src': + case 'font-face-uri': + case 'font-face-format': + case 'font-face-name': + case 'missing-glyph': + return !1 + default: + return !0 + } + } + var He = null + function xb(s) { + return ( + (s = s.target || s.srcElement || window).correspondingUseElement && + (s = s.correspondingUseElement), + 3 === s.nodeType ? s.parentNode : s + ) + } + var Ye = null, + Xe = null, + Qe = null + function Bb(s) { + if ((s = Cb(s))) { + if ('function' != typeof Ye) throw Error(p(280)) + var o = s.stateNode + o && ((o = Db(o)), Ye(s.stateNode, s.type, o)) + } + } + function Eb(s) { + Xe ? (Qe ? Qe.push(s) : (Qe = [s])) : (Xe = s) + } + function Fb() { + if (Xe) { + var s = Xe, + o = Qe + if (((Qe = Xe = null), Bb(s), o)) for (s = 0; s < o.length; s++) Bb(o[s]) + } + } + function Gb(s, o) { + return s(o) + } + function Hb() {} + var et = !1 + function Jb(s, o, i) { + if (et) return s(o, i) + et = !0 + try { + return Gb(s, o, i) + } finally { + ;((et = !1), (null !== Xe || null !== Qe) && (Hb(), Fb())) + } + } + function Kb(s, o) { + var i = s.stateNode + if (null === i) return null + var a = Db(i) + if (null === a) return null + i = a[o] + e: switch (o) { + case 'onClick': + case 'onClickCapture': + case 'onDoubleClick': + case 'onDoubleClickCapture': + case 'onMouseDown': + case 'onMouseDownCapture': + case 'onMouseMove': + case 'onMouseMoveCapture': + case 'onMouseUp': + case 'onMouseUpCapture': + case 'onMouseEnter': + ;((a = !a.disabled) || + (a = !( + 'button' === (s = s.type) || + 'input' === s || + 'select' === s || + 'textarea' === s + )), + (s = !a)) + break e + default: + s = !1 + } + if (s) return null + if (i && 'function' != typeof i) throw Error(p(231, o, typeof i)) + return i + } + var tt = !1 + if (x) + try { + var rt = {} + ;(Object.defineProperty(rt, 'passive', { + get: function () { + tt = !0 + }, + }), + window.addEventListener('test', rt, rt), + window.removeEventListener('test', rt, rt)) + } catch (Re) { + tt = !1 + } + function Nb(s, o, i, a, u, _, w, x, C) { + var j = Array.prototype.slice.call(arguments, 3) + try { + o.apply(i, j) + } catch (s) { + this.onError(s) + } + } + var nt = !1, + st = null, + ot = !1, + it = null, + at = { + onError: function (s) { + ;((nt = !0), (st = s)) + }, + } + function Tb(s, o, i, a, u, _, w, x, C) { + ;((nt = !1), (st = null), Nb.apply(at, arguments)) + } + function Vb(s) { + var o = s, + i = s + if (s.alternate) for (; o.return; ) o = o.return + else { + s = o + do { + ;(!!(4098 & (o = s).flags) && (i = o.return), (s = o.return)) + } while (s) + } + return 3 === o.tag ? i : null + } + function Wb(s) { + if (13 === s.tag) { + var o = s.memoizedState + if ((null === o && null !== (s = s.alternate) && (o = s.memoizedState), null !== o)) + return o.dehydrated + } + return null + } + function Xb(s) { + if (Vb(s) !== s) throw Error(p(188)) + } + function Zb(s) { + return null !== + (s = (function Yb(s) { + var o = s.alternate + if (!o) { + if (null === (o = Vb(s))) throw Error(p(188)) + return o !== s ? null : s + } + for (var i = s, a = o; ; ) { + var u = i.return + if (null === u) break + var _ = u.alternate + if (null === _) { + if (null !== (a = u.return)) { + i = a + continue + } + break + } + if (u.child === _.child) { + for (_ = u.child; _; ) { + if (_ === i) return (Xb(u), s) + if (_ === a) return (Xb(u), o) + _ = _.sibling + } + throw Error(p(188)) + } + if (i.return !== a.return) ((i = u), (a = _)) + else { + for (var w = !1, x = u.child; x; ) { + if (x === i) { + ;((w = !0), (i = u), (a = _)) + break + } + if (x === a) { + ;((w = !0), (a = u), (i = _)) + break + } + x = x.sibling + } + if (!w) { + for (x = _.child; x; ) { + if (x === i) { + ;((w = !0), (i = _), (a = u)) + break + } + if (x === a) { + ;((w = !0), (a = _), (i = u)) + break + } + x = x.sibling + } + if (!w) throw Error(p(189)) + } + } + if (i.alternate !== a) throw Error(p(190)) + } + if (3 !== i.tag) throw Error(p(188)) + return i.stateNode.current === i ? s : o + })(s)) + ? $b(s) + : null + } + function $b(s) { + if (5 === s.tag || 6 === s.tag) return s + for (s = s.child; null !== s; ) { + var o = $b(s) + if (null !== o) return o + s = s.sibling + } + return null + } + var ct = u.unstable_scheduleCallback, + lt = u.unstable_cancelCallback, + ut = u.unstable_shouldYield, + pt = u.unstable_requestPaint, + ht = u.unstable_now, + dt = u.unstable_getCurrentPriorityLevel, + mt = u.unstable_ImmediatePriority, + gt = u.unstable_UserBlockingPriority, + yt = u.unstable_NormalPriority, + vt = u.unstable_LowPriority, + bt = u.unstable_IdlePriority, + _t = null, + St = null + var Et = Math.clz32 + ? Math.clz32 + : function nc(s) { + return ((s >>>= 0), 0 === s ? 32 : (31 - ((wt(s) / xt) | 0)) | 0) + }, + wt = Math.log, + xt = Math.LN2 + var kt = 64, + Ot = 4194304 + function tc(s) { + switch (s & -s) { + case 1: + return 1 + case 2: + return 2 + case 4: + return 4 + case 8: + return 8 + case 16: + return 16 + case 32: + return 32 + case 64: + case 128: + case 256: + case 512: + case 1024: + case 2048: + case 4096: + case 8192: + case 16384: + case 32768: + case 65536: + case 131072: + case 262144: + case 524288: + case 1048576: + case 2097152: + return 4194240 & s + case 4194304: + case 8388608: + case 16777216: + case 33554432: + case 67108864: + return 130023424 & s + case 134217728: + return 134217728 + case 268435456: + return 268435456 + case 536870912: + return 536870912 + case 1073741824: + return 1073741824 + default: + return s + } + } + function uc(s, o) { + var i = s.pendingLanes + if (0 === i) return 0 + var a = 0, + u = s.suspendedLanes, + _ = s.pingedLanes, + w = 268435455 & i + if (0 !== w) { + var x = w & ~u + 0 !== x ? (a = tc(x)) : 0 !== (_ &= w) && (a = tc(_)) + } else 0 !== (w = i & ~u) ? (a = tc(w)) : 0 !== _ && (a = tc(_)) + if (0 === a) return 0 + if ( + 0 !== o && + o !== a && + !(o & u) && + ((u = a & -a) >= (_ = o & -o) || (16 === u && 4194240 & _)) + ) + return o + if ((4 & a && (a |= 16 & i), 0 !== (o = s.entangledLanes))) + for (s = s.entanglements, o &= a; 0 < o; ) + ((u = 1 << (i = 31 - Et(o))), (a |= s[i]), (o &= ~u)) + return a + } + function vc(s, o) { + switch (s) { + case 1: + case 2: + case 4: + return o + 250 + case 8: + case 16: + case 32: + case 64: + case 128: + case 256: + case 512: + case 1024: + case 2048: + case 4096: + case 8192: + case 16384: + case 32768: + case 65536: + case 131072: + case 262144: + case 524288: + case 1048576: + case 2097152: + return o + 5e3 + default: + return -1 + } + } + function xc(s) { + return 0 !== (s = -1073741825 & s.pendingLanes) ? s : 1073741824 & s ? 1073741824 : 0 + } + function yc() { + var s = kt + return (!(4194240 & (kt <<= 1)) && (kt = 64), s) + } + function zc(s) { + for (var o = [], i = 0; 31 > i; i++) o.push(s) + return o + } + function Ac(s, o, i) { + ;((s.pendingLanes |= o), + 536870912 !== o && ((s.suspendedLanes = 0), (s.pingedLanes = 0)), + ((s = s.eventTimes)[(o = 31 - Et(o))] = i)) + } + function Cc(s, o) { + var i = (s.entangledLanes |= o) + for (s = s.entanglements; i; ) { + var a = 31 - Et(i), + u = 1 << a + ;((u & o) | (s[a] & o) && (s[a] |= o), (i &= ~u)) + } + } + var At = 0 + function Dc(s) { + return 1 < (s &= -s) ? (4 < s ? (268435455 & s ? 16 : 536870912) : 4) : 1 + } + var Ct, + jt, + Pt, + It, + Tt, + Nt = !1, + Mt = [], + Rt = null, + Dt = null, + Lt = null, + Ft = new Map(), + Bt = new Map(), + $t = [], + qt = + 'mousedown mouseup touchcancel touchend touchstart auxclick dblclick pointercancel pointerdown pointerup dragend dragstart drop compositionend compositionstart keydown keypress keyup input textInput copy cut paste click change contextmenu reset submit'.split( + ' ' + ) + function Sc(s, o) { + switch (s) { + case 'focusin': + case 'focusout': + Rt = null + break + case 'dragenter': + case 'dragleave': + Dt = null + break + case 'mouseover': + case 'mouseout': + Lt = null + break + case 'pointerover': + case 'pointerout': + Ft.delete(o.pointerId) + break + case 'gotpointercapture': + case 'lostpointercapture': + Bt.delete(o.pointerId) + } + } + function Tc(s, o, i, a, u, _) { + return null === s || s.nativeEvent !== _ + ? ((s = { + blockedOn: o, + domEventName: i, + eventSystemFlags: a, + nativeEvent: _, + targetContainers: [u], + }), + null !== o && null !== (o = Cb(o)) && jt(o), + s) + : ((s.eventSystemFlags |= a), + (o = s.targetContainers), + null !== u && -1 === o.indexOf(u) && o.push(u), + s) + } + function Vc(s) { + var o = Wc(s.target) + if (null !== o) { + var i = Vb(o) + if (null !== i) + if (13 === (o = i.tag)) { + if (null !== (o = Wb(i))) + return ( + (s.blockedOn = o), + void Tt(s.priority, function () { + Pt(i) + }) + ) + } else if (3 === o && i.stateNode.current.memoizedState.isDehydrated) + return void (s.blockedOn = 3 === i.tag ? i.stateNode.containerInfo : null) + } + s.blockedOn = null + } + function Xc(s) { + if (null !== s.blockedOn) return !1 + for (var o = s.targetContainers; 0 < o.length; ) { + var i = Yc(s.domEventName, s.eventSystemFlags, o[0], s.nativeEvent) + if (null !== i) return (null !== (o = Cb(i)) && jt(o), (s.blockedOn = i), !1) + var a = new (i = s.nativeEvent).constructor(i.type, i) + ;((He = a), i.target.dispatchEvent(a), (He = null), o.shift()) + } + return !0 + } + function Zc(s, o, i) { + Xc(s) && i.delete(o) + } + function $c() { + ;((Nt = !1), + null !== Rt && Xc(Rt) && (Rt = null), + null !== Dt && Xc(Dt) && (Dt = null), + null !== Lt && Xc(Lt) && (Lt = null), + Ft.forEach(Zc), + Bt.forEach(Zc)) + } + function ad(s, o) { + s.blockedOn === o && + ((s.blockedOn = null), + Nt || ((Nt = !0), u.unstable_scheduleCallback(u.unstable_NormalPriority, $c))) + } + function bd(s) { + function b(o) { + return ad(o, s) + } + if (0 < Mt.length) { + ad(Mt[0], s) + for (var o = 1; o < Mt.length; o++) { + var i = Mt[o] + i.blockedOn === s && (i.blockedOn = null) + } + } + for ( + null !== Rt && ad(Rt, s), + null !== Dt && ad(Dt, s), + null !== Lt && ad(Lt, s), + Ft.forEach(b), + Bt.forEach(b), + o = 0; + o < $t.length; + o++ + ) + (i = $t[o]).blockedOn === s && (i.blockedOn = null) + for (; 0 < $t.length && null === (o = $t[0]).blockedOn; ) + (Vc(o), null === o.blockedOn && $t.shift()) + } + var Ut = V.ReactCurrentBatchConfig, + Vt = !0 + function ed(s, o, i, a) { + var u = At, + _ = Ut.transition + Ut.transition = null + try { + ;((At = 1), fd(s, o, i, a)) + } finally { + ;((At = u), (Ut.transition = _)) + } + } + function gd(s, o, i, a) { + var u = At, + _ = Ut.transition + Ut.transition = null + try { + ;((At = 4), fd(s, o, i, a)) + } finally { + ;((At = u), (Ut.transition = _)) + } + } + function fd(s, o, i, a) { + if (Vt) { + var u = Yc(s, o, i, a) + if (null === u) (hd(s, o, a, zt, i), Sc(s, a)) + else if ( + (function Uc(s, o, i, a, u) { + switch (o) { + case 'focusin': + return ((Rt = Tc(Rt, s, o, i, a, u)), !0) + case 'dragenter': + return ((Dt = Tc(Dt, s, o, i, a, u)), !0) + case 'mouseover': + return ((Lt = Tc(Lt, s, o, i, a, u)), !0) + case 'pointerover': + var _ = u.pointerId + return (Ft.set(_, Tc(Ft.get(_) || null, s, o, i, a, u)), !0) + case 'gotpointercapture': + return ( + (_ = u.pointerId), + Bt.set(_, Tc(Bt.get(_) || null, s, o, i, a, u)), + !0 + ) + } + return !1 + })(u, s, o, i, a) + ) + a.stopPropagation() + else if ((Sc(s, a), 4 & o && -1 < qt.indexOf(s))) { + for (; null !== u; ) { + var _ = Cb(u) + if ( + (null !== _ && Ct(_), + null === (_ = Yc(s, o, i, a)) && hd(s, o, a, zt, i), + _ === u) + ) + break + u = _ + } + null !== u && a.stopPropagation() + } else hd(s, o, a, null, i) + } + } + var zt = null + function Yc(s, o, i, a) { + if (((zt = null), null !== (s = Wc((s = xb(a)))))) + if (null === (o = Vb(s))) s = null + else if (13 === (i = o.tag)) { + if (null !== (s = Wb(o))) return s + s = null + } else if (3 === i) { + if (o.stateNode.current.memoizedState.isDehydrated) + return 3 === o.tag ? o.stateNode.containerInfo : null + s = null + } else o !== s && (s = null) + return ((zt = s), null) + } + function jd(s) { + switch (s) { + case 'cancel': + case 'click': + case 'close': + case 'contextmenu': + case 'copy': + case 'cut': + case 'auxclick': + case 'dblclick': + case 'dragend': + case 'dragstart': + case 'drop': + case 'focusin': + case 'focusout': + case 'input': + case 'invalid': + case 'keydown': + case 'keypress': + case 'keyup': + case 'mousedown': + case 'mouseup': + case 'paste': + case 'pause': + case 'play': + case 'pointercancel': + case 'pointerdown': + case 'pointerup': + case 'ratechange': + case 'reset': + case 'resize': + case 'seeked': + case 'submit': + case 'touchcancel': + case 'touchend': + case 'touchstart': + case 'volumechange': + case 'change': + case 'selectionchange': + case 'textInput': + case 'compositionstart': + case 'compositionend': + case 'compositionupdate': + case 'beforeblur': + case 'afterblur': + case 'beforeinput': + case 'blur': + case 'fullscreenchange': + case 'focus': + case 'hashchange': + case 'popstate': + case 'select': + case 'selectstart': + return 1 + case 'drag': + case 'dragenter': + case 'dragexit': + case 'dragleave': + case 'dragover': + case 'mousemove': + case 'mouseout': + case 'mouseover': + case 'pointermove': + case 'pointerout': + case 'pointerover': + case 'scroll': + case 'toggle': + case 'touchmove': + case 'wheel': + case 'mouseenter': + case 'mouseleave': + case 'pointerenter': + case 'pointerleave': + return 4 + case 'message': + switch (dt()) { + case mt: + return 1 + case gt: + return 4 + case yt: + case vt: + return 16 + case bt: + return 536870912 + default: + return 16 + } + default: + return 16 + } + } + var Wt = null, + Jt = null, + Ht = null + function nd() { + if (Ht) return Ht + var s, + o, + i = Jt, + a = i.length, + u = 'value' in Wt ? Wt.value : Wt.textContent, + _ = u.length + for (s = 0; s < a && i[s] === u[s]; s++); + var w = a - s + for (o = 1; o <= w && i[a - o] === u[_ - o]; o++); + return (Ht = u.slice(s, 1 < o ? 1 - o : void 0)) + } + function od(s) { + var o = s.keyCode + return ( + 'charCode' in s ? 0 === (s = s.charCode) && 13 === o && (s = 13) : (s = o), + 10 === s && (s = 13), + 32 <= s || 13 === s ? s : 0 + ) + } + function pd() { + return !0 + } + function qd() { + return !1 + } + function rd(s) { + function b(o, i, a, u, _) { + for (var w in ((this._reactName = o), + (this._targetInst = a), + (this.type = i), + (this.nativeEvent = u), + (this.target = _), + (this.currentTarget = null), + s)) + s.hasOwnProperty(w) && ((o = s[w]), (this[w] = o ? o(u) : u[w])) + return ( + (this.isDefaultPrevented = ( + null != u.defaultPrevented ? u.defaultPrevented : !1 === u.returnValue + ) + ? pd + : qd), + (this.isPropagationStopped = qd), + this + ) + } + return ( + we(b.prototype, { + preventDefault: function () { + this.defaultPrevented = !0 + var s = this.nativeEvent + s && + (s.preventDefault + ? s.preventDefault() + : 'unknown' != typeof s.returnValue && (s.returnValue = !1), + (this.isDefaultPrevented = pd)) + }, + stopPropagation: function () { + var s = this.nativeEvent + s && + (s.stopPropagation + ? s.stopPropagation() + : 'unknown' != typeof s.cancelBubble && (s.cancelBubble = !0), + (this.isPropagationStopped = pd)) + }, + persist: function () {}, + isPersistent: pd, + }), + b + ) + } + var Kt, + Gt, + Yt, + Xt = { + eventPhase: 0, + bubbles: 0, + cancelable: 0, + timeStamp: function (s) { + return s.timeStamp || Date.now() + }, + defaultPrevented: 0, + isTrusted: 0, + }, + Qt = rd(Xt), + Zt = we({}, Xt, { view: 0, detail: 0 }), + er = rd(Zt), + tr = we({}, Zt, { + screenX: 0, + screenY: 0, + clientX: 0, + clientY: 0, + pageX: 0, + pageY: 0, + ctrlKey: 0, + shiftKey: 0, + altKey: 0, + metaKey: 0, + getModifierState: zd, + button: 0, + buttons: 0, + relatedTarget: function (s) { + return void 0 === s.relatedTarget + ? s.fromElement === s.srcElement + ? s.toElement + : s.fromElement + : s.relatedTarget + }, + movementX: function (s) { + return 'movementX' in s + ? s.movementX + : (s !== Yt && + (Yt && 'mousemove' === s.type + ? ((Kt = s.screenX - Yt.screenX), (Gt = s.screenY - Yt.screenY)) + : (Gt = Kt = 0), + (Yt = s)), + Kt) + }, + movementY: function (s) { + return 'movementY' in s ? s.movementY : Gt + }, + }), + rr = rd(tr), + nr = rd(we({}, tr, { dataTransfer: 0 })), + sr = rd(we({}, Zt, { relatedTarget: 0 })), + ir = rd(we({}, Xt, { animationName: 0, elapsedTime: 0, pseudoElement: 0 })), + ar = we({}, Xt, { + clipboardData: function (s) { + return 'clipboardData' in s ? s.clipboardData : window.clipboardData + }, + }), + cr = rd(ar), + lr = rd(we({}, Xt, { data: 0 })), + ur = { + Esc: 'Escape', + Spacebar: ' ', + Left: 'ArrowLeft', + Up: 'ArrowUp', + Right: 'ArrowRight', + Down: 'ArrowDown', + Del: 'Delete', + Win: 'OS', + Menu: 'ContextMenu', + Apps: 'ContextMenu', + Scroll: 'ScrollLock', + MozPrintableKey: 'Unidentified', + }, + pr = { + 8: 'Backspace', + 9: 'Tab', + 12: 'Clear', + 13: 'Enter', + 16: 'Shift', + 17: 'Control', + 18: 'Alt', + 19: 'Pause', + 20: 'CapsLock', + 27: 'Escape', + 32: ' ', + 33: 'PageUp', + 34: 'PageDown', + 35: 'End', + 36: 'Home', + 37: 'ArrowLeft', + 38: 'ArrowUp', + 39: 'ArrowRight', + 40: 'ArrowDown', + 45: 'Insert', + 46: 'Delete', + 112: 'F1', + 113: 'F2', + 114: 'F3', + 115: 'F4', + 116: 'F5', + 117: 'F6', + 118: 'F7', + 119: 'F8', + 120: 'F9', + 121: 'F10', + 122: 'F11', + 123: 'F12', + 144: 'NumLock', + 145: 'ScrollLock', + 224: 'Meta', + }, + dr = { Alt: 'altKey', Control: 'ctrlKey', Meta: 'metaKey', Shift: 'shiftKey' } + function Pd(s) { + var o = this.nativeEvent + return o.getModifierState ? o.getModifierState(s) : !!(s = dr[s]) && !!o[s] + } + function zd() { + return Pd + } + var fr = we({}, Zt, { + key: function (s) { + if (s.key) { + var o = ur[s.key] || s.key + if ('Unidentified' !== o) return o + } + return 'keypress' === s.type + ? 13 === (s = od(s)) + ? 'Enter' + : String.fromCharCode(s) + : 'keydown' === s.type || 'keyup' === s.type + ? pr[s.keyCode] || 'Unidentified' + : '' + }, + code: 0, + location: 0, + ctrlKey: 0, + shiftKey: 0, + altKey: 0, + metaKey: 0, + repeat: 0, + locale: 0, + getModifierState: zd, + charCode: function (s) { + return 'keypress' === s.type ? od(s) : 0 + }, + keyCode: function (s) { + return 'keydown' === s.type || 'keyup' === s.type ? s.keyCode : 0 + }, + which: function (s) { + return 'keypress' === s.type + ? od(s) + : 'keydown' === s.type || 'keyup' === s.type + ? s.keyCode + : 0 + }, + }), + mr = rd(fr), + gr = rd( + we({}, tr, { + pointerId: 0, + width: 0, + height: 0, + pressure: 0, + tangentialPressure: 0, + tiltX: 0, + tiltY: 0, + twist: 0, + pointerType: 0, + isPrimary: 0, + }) + ), + yr = rd( + we({}, Zt, { + touches: 0, + targetTouches: 0, + changedTouches: 0, + altKey: 0, + metaKey: 0, + ctrlKey: 0, + shiftKey: 0, + getModifierState: zd, + }) + ), + vr = rd(we({}, Xt, { propertyName: 0, elapsedTime: 0, pseudoElement: 0 })), + br = we({}, tr, { + deltaX: function (s) { + return 'deltaX' in s ? s.deltaX : 'wheelDeltaX' in s ? -s.wheelDeltaX : 0 + }, + deltaY: function (s) { + return 'deltaY' in s + ? s.deltaY + : 'wheelDeltaY' in s + ? -s.wheelDeltaY + : 'wheelDelta' in s + ? -s.wheelDelta + : 0 + }, + deltaZ: 0, + deltaMode: 0, + }), + _r = rd(br), + Sr = [9, 13, 27, 32], + Er = x && 'CompositionEvent' in window, + wr = null + x && 'documentMode' in document && (wr = document.documentMode) + var xr = x && 'TextEvent' in window && !wr, + kr = x && (!Er || (wr && 8 < wr && 11 >= wr)), + Or = String.fromCharCode(32), + Ar = !1 + function ge(s, o) { + switch (s) { + case 'keyup': + return -1 !== Sr.indexOf(o.keyCode) + case 'keydown': + return 229 !== o.keyCode + case 'keypress': + case 'mousedown': + case 'focusout': + return !0 + default: + return !1 + } + } + function he(s) { + return 'object' == typeof (s = s.detail) && 'data' in s ? s.data : null + } + var Cr = !1 + var jr = { + color: !0, + date: !0, + datetime: !0, + 'datetime-local': !0, + email: !0, + month: !0, + number: !0, + password: !0, + range: !0, + search: !0, + tel: !0, + text: !0, + time: !0, + url: !0, + week: !0, + } + function me(s) { + var o = s && s.nodeName && s.nodeName.toLowerCase() + return 'input' === o ? !!jr[s.type] : 'textarea' === o + } + function ne(s, o, i, a) { + ;(Eb(a), + 0 < (o = oe(o, 'onChange')).length && + ((i = new Qt('onChange', 'change', null, i, a)), + s.push({ event: i, listeners: o }))) + } + var Pr = null, + Ir = null + function re(s) { + se(s, 0) + } + function te(s) { + if (Wa(ue(s))) return s + } + function ve(s, o) { + if ('change' === s) return o + } + var Tr = !1 + if (x) { + var Nr + if (x) { + var Mr = 'oninput' in document + if (!Mr) { + var Rr = document.createElement('div') + ;(Rr.setAttribute('oninput', 'return;'), (Mr = 'function' == typeof Rr.oninput)) + } + Nr = Mr + } else Nr = !1 + Tr = Nr && (!document.documentMode || 9 < document.documentMode) + } + function Ae() { + Pr && (Pr.detachEvent('onpropertychange', Be), (Ir = Pr = null)) + } + function Be(s) { + if ('value' === s.propertyName && te(Ir)) { + var o = [] + ;(ne(o, Ir, s, xb(s)), Jb(re, o)) + } + } + function Ce(s, o, i) { + 'focusin' === s + ? (Ae(), (Ir = i), (Pr = o).attachEvent('onpropertychange', Be)) + : 'focusout' === s && Ae() + } + function De(s) { + if ('selectionchange' === s || 'keyup' === s || 'keydown' === s) return te(Ir) + } + function Ee(s, o) { + if ('click' === s) return te(o) + } + function Fe(s, o) { + if ('input' === s || 'change' === s) return te(o) + } + var Dr = + 'function' == typeof Object.is + ? Object.is + : function Ge(s, o) { + return (s === o && (0 !== s || 1 / s == 1 / o)) || (s != s && o != o) + } + function Ie(s, o) { + if (Dr(s, o)) return !0 + if ('object' != typeof s || null === s || 'object' != typeof o || null === o) return !1 + var i = Object.keys(s), + a = Object.keys(o) + if (i.length !== a.length) return !1 + for (a = 0; a < i.length; a++) { + var u = i[a] + if (!C.call(o, u) || !Dr(s[u], o[u])) return !1 + } + return !0 + } + function Je(s) { + for (; s && s.firstChild; ) s = s.firstChild + return s + } + function Ke(s, o) { + var i, + a = Je(s) + for (s = 0; a; ) { + if (3 === a.nodeType) { + if (((i = s + a.textContent.length), s <= o && i >= o)) + return { node: a, offset: o - s } + s = i + } + e: { + for (; a; ) { + if (a.nextSibling) { + a = a.nextSibling + break e + } + a = a.parentNode + } + a = void 0 + } + a = Je(a) + } + } + function Le(s, o) { + return ( + !(!s || !o) && + (s === o || + ((!s || 3 !== s.nodeType) && + (o && 3 === o.nodeType + ? Le(s, o.parentNode) + : 'contains' in s + ? s.contains(o) + : !!s.compareDocumentPosition && !!(16 & s.compareDocumentPosition(o))))) + ) + } + function Me() { + for (var s = window, o = Xa(); o instanceof s.HTMLIFrameElement; ) { + try { + var i = 'string' == typeof o.contentWindow.location.href + } catch (s) { + i = !1 + } + if (!i) break + o = Xa((s = o.contentWindow).document) + } + return o + } + function Ne(s) { + var o = s && s.nodeName && s.nodeName.toLowerCase() + return ( + o && + (('input' === o && + ('text' === s.type || + 'search' === s.type || + 'tel' === s.type || + 'url' === s.type || + 'password' === s.type)) || + 'textarea' === o || + 'true' === s.contentEditable) + ) + } + function Oe(s) { + var o = Me(), + i = s.focusedElem, + a = s.selectionRange + if (o !== i && i && i.ownerDocument && Le(i.ownerDocument.documentElement, i)) { + if (null !== a && Ne(i)) + if (((o = a.start), void 0 === (s = a.end) && (s = o), 'selectionStart' in i)) + ((i.selectionStart = o), (i.selectionEnd = Math.min(s, i.value.length))) + else if ( + (s = ((o = i.ownerDocument || document) && o.defaultView) || window).getSelection + ) { + s = s.getSelection() + var u = i.textContent.length, + _ = Math.min(a.start, u) + ;((a = void 0 === a.end ? _ : Math.min(a.end, u)), + !s.extend && _ > a && ((u = a), (a = _), (_ = u)), + (u = Ke(i, _))) + var w = Ke(i, a) + u && + w && + (1 !== s.rangeCount || + s.anchorNode !== u.node || + s.anchorOffset !== u.offset || + s.focusNode !== w.node || + s.focusOffset !== w.offset) && + ((o = o.createRange()).setStart(u.node, u.offset), + s.removeAllRanges(), + _ > a + ? (s.addRange(o), s.extend(w.node, w.offset)) + : (o.setEnd(w.node, w.offset), s.addRange(o))) + } + for (o = [], s = i; (s = s.parentNode); ) + 1 === s.nodeType && o.push({ element: s, left: s.scrollLeft, top: s.scrollTop }) + for ('function' == typeof i.focus && i.focus(), i = 0; i < o.length; i++) + (((s = o[i]).element.scrollLeft = s.left), (s.element.scrollTop = s.top)) + } + } + var Lr = x && 'documentMode' in document && 11 >= document.documentMode, + Fr = null, + Br = null, + $r = null, + qr = !1 + function Ue(s, o, i) { + var a = i.window === i ? i.document : 9 === i.nodeType ? i : i.ownerDocument + qr || + null == Fr || + Fr !== Xa(a) || + ('selectionStart' in (a = Fr) && Ne(a) + ? (a = { start: a.selectionStart, end: a.selectionEnd }) + : (a = { + anchorNode: (a = ( + (a.ownerDocument && a.ownerDocument.defaultView) || + window + ).getSelection()).anchorNode, + anchorOffset: a.anchorOffset, + focusNode: a.focusNode, + focusOffset: a.focusOffset, + }), + ($r && Ie($r, a)) || + (($r = a), + 0 < (a = oe(Br, 'onSelect')).length && + ((o = new Qt('onSelect', 'select', null, o, i)), + s.push({ event: o, listeners: a }), + (o.target = Fr)))) + } + function Ve(s, o) { + var i = {} + return ( + (i[s.toLowerCase()] = o.toLowerCase()), + (i['Webkit' + s] = 'webkit' + o), + (i['Moz' + s] = 'moz' + o), + i + ) + } + var Ur = { + animationend: Ve('Animation', 'AnimationEnd'), + animationiteration: Ve('Animation', 'AnimationIteration'), + animationstart: Ve('Animation', 'AnimationStart'), + transitionend: Ve('Transition', 'TransitionEnd'), + }, + Vr = {}, + zr = {} + function Ze(s) { + if (Vr[s]) return Vr[s] + if (!Ur[s]) return s + var o, + i = Ur[s] + for (o in i) if (i.hasOwnProperty(o) && o in zr) return (Vr[s] = i[o]) + return s + } + x && + ((zr = document.createElement('div').style), + 'AnimationEvent' in window || + (delete Ur.animationend.animation, + delete Ur.animationiteration.animation, + delete Ur.animationstart.animation), + 'TransitionEvent' in window || delete Ur.transitionend.transition) + var Wr = Ze('animationend'), + Jr = Ze('animationiteration'), + Hr = Ze('animationstart'), + Kr = Ze('transitionend'), + Gr = new Map(), + Yr = + 'abort auxClick cancel canPlay canPlayThrough click close contextMenu copy cut drag dragEnd dragEnter dragExit dragLeave dragOver dragStart drop durationChange emptied encrypted ended error gotPointerCapture input invalid keyDown keyPress keyUp load loadedData loadedMetadata loadStart lostPointerCapture mouseDown mouseMove mouseOut mouseOver mouseUp paste pause play playing pointerCancel pointerDown pointerMove pointerOut pointerOver pointerUp progress rateChange reset resize seeked seeking stalled submit suspend timeUpdate touchCancel touchEnd touchStart volumeChange scroll toggle touchMove waiting wheel'.split( + ' ' + ) + function ff(s, o) { + ;(Gr.set(s, o), fa(o, [s])) + } + for (var Xr = 0; Xr < Yr.length; Xr++) { + var Qr = Yr[Xr] + ff(Qr.toLowerCase(), 'on' + (Qr[0].toUpperCase() + Qr.slice(1))) + } + ;(ff(Wr, 'onAnimationEnd'), + ff(Jr, 'onAnimationIteration'), + ff(Hr, 'onAnimationStart'), + ff('dblclick', 'onDoubleClick'), + ff('focusin', 'onFocus'), + ff('focusout', 'onBlur'), + ff(Kr, 'onTransitionEnd'), + ha('onMouseEnter', ['mouseout', 'mouseover']), + ha('onMouseLeave', ['mouseout', 'mouseover']), + ha('onPointerEnter', ['pointerout', 'pointerover']), + ha('onPointerLeave', ['pointerout', 'pointerover']), + fa( + 'onChange', + 'change click focusin focusout input keydown keyup selectionchange'.split(' ') + ), + fa( + 'onSelect', + 'focusout contextmenu dragend focusin keydown keyup mousedown mouseup selectionchange'.split( + ' ' + ) + ), + fa('onBeforeInput', ['compositionend', 'keypress', 'textInput', 'paste']), + fa( + 'onCompositionEnd', + 'compositionend focusout keydown keypress keyup mousedown'.split(' ') + ), + fa( + 'onCompositionStart', + 'compositionstart focusout keydown keypress keyup mousedown'.split(' ') + ), + fa( + 'onCompositionUpdate', + 'compositionupdate focusout keydown keypress keyup mousedown'.split(' ') + )) + var Zr = + 'abort canplay canplaythrough durationchange emptied encrypted ended error loadeddata loadedmetadata loadstart pause play playing progress ratechange resize seeked seeking stalled suspend timeupdate volumechange waiting'.split( + ' ' + ), + en = new Set('cancel close invalid load scroll toggle'.split(' ').concat(Zr)) + function nf(s, o, i) { + var a = s.type || 'unknown-event' + ;((s.currentTarget = i), + (function Ub(s, o, i, a, u, _, w, x, C) { + if ((Tb.apply(this, arguments), nt)) { + if (!nt) throw Error(p(198)) + var j = st + ;((nt = !1), (st = null), ot || ((ot = !0), (it = j))) + } + })(a, o, void 0, s), + (s.currentTarget = null)) + } + function se(s, o) { + o = !!(4 & o) + for (var i = 0; i < s.length; i++) { + var a = s[i], + u = a.event + a = a.listeners + e: { + var _ = void 0 + if (o) + for (var w = a.length - 1; 0 <= w; w--) { + var x = a[w], + C = x.instance, + j = x.currentTarget + if (((x = x.listener), C !== _ && u.isPropagationStopped())) break e + ;(nf(u, x, j), (_ = C)) + } + else + for (w = 0; w < a.length; w++) { + if ( + ((C = (x = a[w]).instance), + (j = x.currentTarget), + (x = x.listener), + C !== _ && u.isPropagationStopped()) + ) + break e + ;(nf(u, x, j), (_ = C)) + } + } + } + if (ot) throw ((s = it), (ot = !1), (it = null), s) + } + function D(s, o) { + var i = o[mn] + void 0 === i && (i = o[mn] = new Set()) + var a = s + '__bubble' + i.has(a) || (pf(o, s, 2, !1), i.add(a)) + } + function qf(s, o, i) { + var a = 0 + ;(o && (a |= 4), pf(i, s, a, o)) + } + var tn = '_reactListening' + Math.random().toString(36).slice(2) + function sf(s) { + if (!s[tn]) { + ;((s[tn] = !0), + _.forEach(function (o) { + 'selectionchange' !== o && (en.has(o) || qf(o, !1, s), qf(o, !0, s)) + })) + var o = 9 === s.nodeType ? s : s.ownerDocument + null === o || o[tn] || ((o[tn] = !0), qf('selectionchange', !1, o)) + } + } + function pf(s, o, i, a) { + switch (jd(o)) { + case 1: + var u = ed + break + case 4: + u = gd + break + default: + u = fd + } + ;((i = u.bind(null, o, i, s)), + (u = void 0), + !tt || ('touchstart' !== o && 'touchmove' !== o && 'wheel' !== o) || (u = !0), + a + ? void 0 !== u + ? s.addEventListener(o, i, { capture: !0, passive: u }) + : s.addEventListener(o, i, !0) + : void 0 !== u + ? s.addEventListener(o, i, { passive: u }) + : s.addEventListener(o, i, !1)) + } + function hd(s, o, i, a, u) { + var _ = a + if (!(1 & o || 2 & o || null === a)) + e: for (;;) { + if (null === a) return + var w = a.tag + if (3 === w || 4 === w) { + var x = a.stateNode.containerInfo + if (x === u || (8 === x.nodeType && x.parentNode === u)) break + if (4 === w) + for (w = a.return; null !== w; ) { + var C = w.tag + if ( + (3 === C || 4 === C) && + ((C = w.stateNode.containerInfo) === u || + (8 === C.nodeType && C.parentNode === u)) + ) + return + w = w.return + } + for (; null !== x; ) { + if (null === (w = Wc(x))) return + if (5 === (C = w.tag) || 6 === C) { + a = _ = w + continue e + } + x = x.parentNode + } + } + a = a.return + } + Jb(function () { + var a = _, + u = xb(i), + w = [] + e: { + var x = Gr.get(s) + if (void 0 !== x) { + var C = Qt, + j = s + switch (s) { + case 'keypress': + if (0 === od(i)) break e + case 'keydown': + case 'keyup': + C = mr + break + case 'focusin': + ;((j = 'focus'), (C = sr)) + break + case 'focusout': + ;((j = 'blur'), (C = sr)) + break + case 'beforeblur': + case 'afterblur': + C = sr + break + case 'click': + if (2 === i.button) break e + case 'auxclick': + case 'dblclick': + case 'mousedown': + case 'mousemove': + case 'mouseup': + case 'mouseout': + case 'mouseover': + case 'contextmenu': + C = rr + break + case 'drag': + case 'dragend': + case 'dragenter': + case 'dragexit': + case 'dragleave': + case 'dragover': + case 'dragstart': + case 'drop': + C = nr + break + case 'touchcancel': + case 'touchend': + case 'touchmove': + case 'touchstart': + C = yr + break + case Wr: + case Jr: + case Hr: + C = ir + break + case Kr: + C = vr + break + case 'scroll': + C = er + break + case 'wheel': + C = _r + break + case 'copy': + case 'cut': + case 'paste': + C = cr + break + case 'gotpointercapture': + case 'lostpointercapture': + case 'pointercancel': + case 'pointerdown': + case 'pointermove': + case 'pointerout': + case 'pointerover': + case 'pointerup': + C = gr + } + var L = !!(4 & o), + B = !L && 'scroll' === s, + $ = L ? (null !== x ? x + 'Capture' : null) : x + L = [] + for (var U, V = a; null !== V; ) { + var z = (U = V).stateNode + if ( + (5 === U.tag && + null !== z && + ((U = z), null !== $ && null != (z = Kb(V, $)) && L.push(tf(V, z, U))), + B) + ) + break + V = V.return + } + 0 < L.length && + ((x = new C(x, j, null, i, u)), w.push({ event: x, listeners: L })) + } + } + if (!(7 & o)) { + if ( + ((C = 'mouseout' === s || 'pointerout' === s), + (!(x = 'mouseover' === s || 'pointerover' === s) || + i === He || + !(j = i.relatedTarget || i.fromElement) || + (!Wc(j) && !j[fn])) && + (C || x) && + ((x = + u.window === u + ? u + : (x = u.ownerDocument) + ? x.defaultView || x.parentWindow + : window), + C + ? ((C = a), + null !== (j = (j = i.relatedTarget || i.toElement) ? Wc(j) : null) && + (j !== (B = Vb(j)) || (5 !== j.tag && 6 !== j.tag)) && + (j = null)) + : ((C = null), (j = a)), + C !== j)) + ) { + if ( + ((L = rr), + (z = 'onMouseLeave'), + ($ = 'onMouseEnter'), + (V = 'mouse'), + ('pointerout' !== s && 'pointerover' !== s) || + ((L = gr), (z = 'onPointerLeave'), ($ = 'onPointerEnter'), (V = 'pointer')), + (B = null == C ? x : ue(C)), + (U = null == j ? x : ue(j)), + ((x = new L(z, V + 'leave', C, i, u)).target = B), + (x.relatedTarget = U), + (z = null), + Wc(u) === a && + (((L = new L($, V + 'enter', j, i, u)).target = U), + (L.relatedTarget = B), + (z = L)), + (B = z), + C && j) + ) + e: { + for ($ = j, V = 0, U = L = C; U; U = vf(U)) V++ + for (U = 0, z = $; z; z = vf(z)) U++ + for (; 0 < V - U; ) ((L = vf(L)), V--) + for (; 0 < U - V; ) (($ = vf($)), U--) + for (; V--; ) { + if (L === $ || (null !== $ && L === $.alternate)) break e + ;((L = vf(L)), ($ = vf($))) + } + L = null + } + else L = null + ;(null !== C && wf(w, x, C, L, !1), + null !== j && null !== B && wf(w, B, j, L, !0)) + } + if ( + 'select' === + (C = (x = a ? ue(a) : window).nodeName && x.nodeName.toLowerCase()) || + ('input' === C && 'file' === x.type) + ) + var Y = ve + else if (me(x)) + if (Tr) Y = Fe + else { + Y = De + var Z = Ce + } + else + (C = x.nodeName) && + 'input' === C.toLowerCase() && + ('checkbox' === x.type || 'radio' === x.type) && + (Y = Ee) + switch ( + (Y && (Y = Y(s, a)) + ? ne(w, Y, i, u) + : (Z && Z(s, x, a), + 'focusout' === s && + (Z = x._wrapperState) && + Z.controlled && + 'number' === x.type && + cb(x, 'number', x.value)), + (Z = a ? ue(a) : window), + s) + ) { + case 'focusin': + ;(me(Z) || 'true' === Z.contentEditable) && ((Fr = Z), (Br = a), ($r = null)) + break + case 'focusout': + $r = Br = Fr = null + break + case 'mousedown': + qr = !0 + break + case 'contextmenu': + case 'mouseup': + case 'dragend': + ;((qr = !1), Ue(w, i, u)) + break + case 'selectionchange': + if (Lr) break + case 'keydown': + case 'keyup': + Ue(w, i, u) + } + var ee + if (Er) + e: { + switch (s) { + case 'compositionstart': + var ie = 'onCompositionStart' + break e + case 'compositionend': + ie = 'onCompositionEnd' + break e + case 'compositionupdate': + ie = 'onCompositionUpdate' + break e + } + ie = void 0 + } + else + Cr + ? ge(s, i) && (ie = 'onCompositionEnd') + : 'keydown' === s && 229 === i.keyCode && (ie = 'onCompositionStart') + ;(ie && + (kr && + 'ko' !== i.locale && + (Cr || 'onCompositionStart' !== ie + ? 'onCompositionEnd' === ie && Cr && (ee = nd()) + : ((Jt = 'value' in (Wt = u) ? Wt.value : Wt.textContent), (Cr = !0))), + 0 < (Z = oe(a, ie)).length && + ((ie = new lr(ie, s, null, i, u)), + w.push({ event: ie, listeners: Z }), + ee ? (ie.data = ee) : null !== (ee = he(i)) && (ie.data = ee))), + (ee = xr + ? (function je(s, o) { + switch (s) { + case 'compositionend': + return he(o) + case 'keypress': + return 32 !== o.which ? null : ((Ar = !0), Or) + case 'textInput': + return (s = o.data) === Or && Ar ? null : s + default: + return null + } + })(s, i) + : (function ke(s, o) { + if (Cr) + return 'compositionend' === s || (!Er && ge(s, o)) + ? ((s = nd()), (Ht = Jt = Wt = null), (Cr = !1), s) + : null + switch (s) { + case 'paste': + default: + return null + case 'keypress': + if (!(o.ctrlKey || o.altKey || o.metaKey) || (o.ctrlKey && o.altKey)) { + if (o.char && 1 < o.char.length) return o.char + if (o.which) return String.fromCharCode(o.which) + } + return null + case 'compositionend': + return kr && 'ko' !== o.locale ? null : o.data + } + })(s, i)) && + 0 < (a = oe(a, 'onBeforeInput')).length && + ((u = new lr('onBeforeInput', 'beforeinput', null, i, u)), + w.push({ event: u, listeners: a }), + (u.data = ee))) + } + se(w, o) + }) + } + function tf(s, o, i) { + return { instance: s, listener: o, currentTarget: i } + } + function oe(s, o) { + for (var i = o + 'Capture', a = []; null !== s; ) { + var u = s, + _ = u.stateNode + ;(5 === u.tag && + null !== _ && + ((u = _), + null != (_ = Kb(s, i)) && a.unshift(tf(s, _, u)), + null != (_ = Kb(s, o)) && a.push(tf(s, _, u))), + (s = s.return)) + } + return a + } + function vf(s) { + if (null === s) return null + do { + s = s.return + } while (s && 5 !== s.tag) + return s || null + } + function wf(s, o, i, a, u) { + for (var _ = o._reactName, w = []; null !== i && i !== a; ) { + var x = i, + C = x.alternate, + j = x.stateNode + if (null !== C && C === a) break + ;(5 === x.tag && + null !== j && + ((x = j), + u + ? null != (C = Kb(i, _)) && w.unshift(tf(i, C, x)) + : u || (null != (C = Kb(i, _)) && w.push(tf(i, C, x)))), + (i = i.return)) + } + 0 !== w.length && s.push({ event: o, listeners: w }) + } + var rn = /\r\n?/g, + nn = /\u0000|\uFFFD/g + function zf(s) { + return ('string' == typeof s ? s : '' + s).replace(rn, '\n').replace(nn, '') + } + function Af(s, o, i) { + if (((o = zf(o)), zf(s) !== o && i)) throw Error(p(425)) + } + function Bf() {} + var sn = null, + on = null + function Ef(s, o) { + return ( + 'textarea' === s || + 'noscript' === s || + 'string' == typeof o.children || + 'number' == typeof o.children || + ('object' == typeof o.dangerouslySetInnerHTML && + null !== o.dangerouslySetInnerHTML && + null != o.dangerouslySetInnerHTML.__html) + ) + } + var an = 'function' == typeof setTimeout ? setTimeout : void 0, + cn = 'function' == typeof clearTimeout ? clearTimeout : void 0, + ln = 'function' == typeof Promise ? Promise : void 0, + un = + 'function' == typeof queueMicrotask + ? queueMicrotask + : void 0 !== ln + ? function (s) { + return ln.resolve(null).then(s).catch(If) + } + : an + function If(s) { + setTimeout(function () { + throw s + }) + } + function Kf(s, o) { + var i = o, + a = 0 + do { + var u = i.nextSibling + if ((s.removeChild(i), u && 8 === u.nodeType)) + if ('/$' === (i = u.data)) { + if (0 === a) return (s.removeChild(u), void bd(o)) + a-- + } else ('$' !== i && '$?' !== i && '$!' !== i) || a++ + i = u + } while (i) + bd(o) + } + function Lf(s) { + for (; null != s; s = s.nextSibling) { + var o = s.nodeType + if (1 === o || 3 === o) break + if (8 === o) { + if ('$' === (o = s.data) || '$!' === o || '$?' === o) break + if ('/$' === o) return null + } + } + return s + } + function Mf(s) { + s = s.previousSibling + for (var o = 0; s; ) { + if (8 === s.nodeType) { + var i = s.data + if ('$' === i || '$!' === i || '$?' === i) { + if (0 === o) return s + o-- + } else '/$' === i && o++ + } + s = s.previousSibling + } + return null + } + var pn = Math.random().toString(36).slice(2), + hn = '__reactFiber$' + pn, + dn = '__reactProps$' + pn, + fn = '__reactContainer$' + pn, + mn = '__reactEvents$' + pn, + gn = '__reactListeners$' + pn, + yn = '__reactHandles$' + pn + function Wc(s) { + var o = s[hn] + if (o) return o + for (var i = s.parentNode; i; ) { + if ((o = i[fn] || i[hn])) { + if (((i = o.alternate), null !== o.child || (null !== i && null !== i.child))) + for (s = Mf(s); null !== s; ) { + if ((i = s[hn])) return i + s = Mf(s) + } + return o + } + i = (s = i).parentNode + } + return null + } + function Cb(s) { + return !(s = s[hn] || s[fn]) || + (5 !== s.tag && 6 !== s.tag && 13 !== s.tag && 3 !== s.tag) + ? null + : s + } + function ue(s) { + if (5 === s.tag || 6 === s.tag) return s.stateNode + throw Error(p(33)) + } + function Db(s) { + return s[dn] || null + } + var vn = [], + bn = -1 + function Uf(s) { + return { current: s } + } + function E(s) { + 0 > bn || ((s.current = vn[bn]), (vn[bn] = null), bn--) + } + function G(s, o) { + ;(bn++, (vn[bn] = s.current), (s.current = o)) + } + var _n = {}, + Sn = Uf(_n), + En = Uf(!1), + wn = _n + function Yf(s, o) { + var i = s.type.contextTypes + if (!i) return _n + var a = s.stateNode + if (a && a.__reactInternalMemoizedUnmaskedChildContext === o) + return a.__reactInternalMemoizedMaskedChildContext + var u, + _ = {} + for (u in i) _[u] = o[u] + return ( + a && + (((s = s.stateNode).__reactInternalMemoizedUnmaskedChildContext = o), + (s.__reactInternalMemoizedMaskedChildContext = _)), + _ + ) + } + function Zf(s) { + return null != (s = s.childContextTypes) + } + function $f() { + ;(E(En), E(Sn)) + } + function ag(s, o, i) { + if (Sn.current !== _n) throw Error(p(168)) + ;(G(Sn, o), G(En, i)) + } + function bg(s, o, i) { + var a = s.stateNode + if (((o = o.childContextTypes), 'function' != typeof a.getChildContext)) return i + for (var u in (a = a.getChildContext())) + if (!(u in o)) throw Error(p(108, Ra(s) || 'Unknown', u)) + return we({}, i, a) + } + function cg(s) { + return ( + (s = ((s = s.stateNode) && s.__reactInternalMemoizedMergedChildContext) || _n), + (wn = Sn.current), + G(Sn, s), + G(En, En.current), + !0 + ) + } + function dg(s, o, i) { + var a = s.stateNode + if (!a) throw Error(p(169)) + ;(i + ? ((s = bg(s, o, wn)), + (a.__reactInternalMemoizedMergedChildContext = s), + E(En), + E(Sn), + G(Sn, s)) + : E(En), + G(En, i)) + } + var xn = null, + kn = !1, + On = !1 + function hg(s) { + null === xn ? (xn = [s]) : xn.push(s) + } + function jg() { + if (!On && null !== xn) { + On = !0 + var s = 0, + o = At + try { + var i = xn + for (At = 1; s < i.length; s++) { + var a = i[s] + do { + a = a(!0) + } while (null !== a) + } + ;((xn = null), (kn = !1)) + } catch (o) { + throw (null !== xn && (xn = xn.slice(s + 1)), ct(mt, jg), o) + } finally { + ;((At = o), (On = !1)) + } + } + return null + } + var An = [], + Cn = 0, + jn = null, + Pn = 0, + In = [], + Tn = 0, + Nn = null, + Mn = 1, + Rn = '' + function tg(s, o) { + ;((An[Cn++] = Pn), (An[Cn++] = jn), (jn = s), (Pn = o)) + } + function ug(s, o, i) { + ;((In[Tn++] = Mn), (In[Tn++] = Rn), (In[Tn++] = Nn), (Nn = s)) + var a = Mn + s = Rn + var u = 32 - Et(a) - 1 + ;((a &= ~(1 << u)), (i += 1)) + var _ = 32 - Et(o) + u + if (30 < _) { + var w = u - (u % 5) + ;((_ = (a & ((1 << w) - 1)).toString(32)), + (a >>= w), + (u -= w), + (Mn = (1 << (32 - Et(o) + u)) | (i << u) | a), + (Rn = _ + s)) + } else ((Mn = (1 << _) | (i << u) | a), (Rn = s)) + } + function vg(s) { + null !== s.return && (tg(s, 1), ug(s, 1, 0)) + } + function wg(s) { + for (; s === jn; ) ((jn = An[--Cn]), (An[Cn] = null), (Pn = An[--Cn]), (An[Cn] = null)) + for (; s === Nn; ) + ((Nn = In[--Tn]), + (In[Tn] = null), + (Rn = In[--Tn]), + (In[Tn] = null), + (Mn = In[--Tn]), + (In[Tn] = null)) + } + var Dn = null, + Ln = null, + Fn = !1, + Bn = null + function Ag(s, o) { + var i = Bg(5, null, null, 0) + ;((i.elementType = 'DELETED'), + (i.stateNode = o), + (i.return = s), + null === (o = s.deletions) ? ((s.deletions = [i]), (s.flags |= 16)) : o.push(i)) + } + function Cg(s, o) { + switch (s.tag) { + case 5: + var i = s.type + return ( + null !== + (o = + 1 !== o.nodeType || i.toLowerCase() !== o.nodeName.toLowerCase() + ? null + : o) && ((s.stateNode = o), (Dn = s), (Ln = Lf(o.firstChild)), !0) + ) + case 6: + return ( + null !== (o = '' === s.pendingProps || 3 !== o.nodeType ? null : o) && + ((s.stateNode = o), (Dn = s), (Ln = null), !0) + ) + case 13: + return ( + null !== (o = 8 !== o.nodeType ? null : o) && + ((i = null !== Nn ? { id: Mn, overflow: Rn } : null), + (s.memoizedState = { dehydrated: o, treeContext: i, retryLane: 1073741824 }), + ((i = Bg(18, null, null, 0)).stateNode = o), + (i.return = s), + (s.child = i), + (Dn = s), + (Ln = null), + !0) + ) + default: + return !1 + } + } + function Dg(s) { + return !(!(1 & s.mode) || 128 & s.flags) + } + function Eg(s) { + if (Fn) { + var o = Ln + if (o) { + var i = o + if (!Cg(s, o)) { + if (Dg(s)) throw Error(p(418)) + o = Lf(i.nextSibling) + var a = Dn + o && Cg(s, o) + ? Ag(a, i) + : ((s.flags = (-4097 & s.flags) | 2), (Fn = !1), (Dn = s)) + } + } else { + if (Dg(s)) throw Error(p(418)) + ;((s.flags = (-4097 & s.flags) | 2), (Fn = !1), (Dn = s)) + } + } + } + function Fg(s) { + for (s = s.return; null !== s && 5 !== s.tag && 3 !== s.tag && 13 !== s.tag; ) + s = s.return + Dn = s + } + function Gg(s) { + if (s !== Dn) return !1 + if (!Fn) return (Fg(s), (Fn = !0), !1) + var o + if ( + ((o = 3 !== s.tag) && + !(o = 5 !== s.tag) && + (o = 'head' !== (o = s.type) && 'body' !== o && !Ef(s.type, s.memoizedProps)), + o && (o = Ln)) + ) { + if (Dg(s)) throw (Hg(), Error(p(418))) + for (; o; ) (Ag(s, o), (o = Lf(o.nextSibling))) + } + if ((Fg(s), 13 === s.tag)) { + if (!(s = null !== (s = s.memoizedState) ? s.dehydrated : null)) throw Error(p(317)) + e: { + for (s = s.nextSibling, o = 0; s; ) { + if (8 === s.nodeType) { + var i = s.data + if ('/$' === i) { + if (0 === o) { + Ln = Lf(s.nextSibling) + break e + } + o-- + } else ('$' !== i && '$!' !== i && '$?' !== i) || o++ + } + s = s.nextSibling + } + Ln = null + } + } else Ln = Dn ? Lf(s.stateNode.nextSibling) : null + return !0 + } + function Hg() { + for (var s = Ln; s; ) s = Lf(s.nextSibling) + } + function Ig() { + ;((Ln = Dn = null), (Fn = !1)) + } + function Jg(s) { + null === Bn ? (Bn = [s]) : Bn.push(s) + } + var $n = V.ReactCurrentBatchConfig + function Lg(s, o, i) { + if (null !== (s = i.ref) && 'function' != typeof s && 'object' != typeof s) { + if (i._owner) { + if ((i = i._owner)) { + if (1 !== i.tag) throw Error(p(309)) + var a = i.stateNode + } + if (!a) throw Error(p(147, s)) + var u = a, + _ = '' + s + return null !== o && + null !== o.ref && + 'function' == typeof o.ref && + o.ref._stringRef === _ + ? o.ref + : ((o = function (s) { + var o = u.refs + null === s ? delete o[_] : (o[_] = s) + }), + (o._stringRef = _), + o) + } + if ('string' != typeof s) throw Error(p(284)) + if (!i._owner) throw Error(p(290, s)) + } + return s + } + function Mg(s, o) { + throw ( + (s = Object.prototype.toString.call(o)), + Error( + p( + 31, + '[object Object]' === s + ? 'object with keys {' + Object.keys(o).join(', ') + '}' + : s + ) + ) + ) + } + function Ng(s) { + return (0, s._init)(s._payload) + } + function Og(s) { + function b(o, i) { + if (s) { + var a = o.deletions + null === a ? ((o.deletions = [i]), (o.flags |= 16)) : a.push(i) + } + } + function c(o, i) { + if (!s) return null + for (; null !== i; ) (b(o, i), (i = i.sibling)) + return null + } + function d(s, o) { + for (s = new Map(); null !== o; ) + (null !== o.key ? s.set(o.key, o) : s.set(o.index, o), (o = o.sibling)) + return s + } + function e(s, o) { + return (((s = Pg(s, o)).index = 0), (s.sibling = null), s) + } + function f(o, i, a) { + return ( + (o.index = a), + s + ? null !== (a = o.alternate) + ? (a = a.index) < i + ? ((o.flags |= 2), i) + : a + : ((o.flags |= 2), i) + : ((o.flags |= 1048576), i) + ) + } + function g(o) { + return (s && null === o.alternate && (o.flags |= 2), o) + } + function h(s, o, i, a) { + return null === o || 6 !== o.tag + ? (((o = Qg(i, s.mode, a)).return = s), o) + : (((o = e(o, i)).return = s), o) + } + function k(s, o, i, a) { + var u = i.type + return u === Z + ? m(s, o, i.props.children, a, i.key) + : null !== o && + (o.elementType === u || + ('object' == typeof u && null !== u && u.$$typeof === ye && Ng(u) === o.type)) + ? (((a = e(o, i.props)).ref = Lg(s, o, i)), (a.return = s), a) + : (((a = Rg(i.type, i.key, i.props, null, s.mode, a)).ref = Lg(s, o, i)), + (a.return = s), + a) + } + function l(s, o, i, a) { + return null === o || + 4 !== o.tag || + o.stateNode.containerInfo !== i.containerInfo || + o.stateNode.implementation !== i.implementation + ? (((o = Sg(i, s.mode, a)).return = s), o) + : (((o = e(o, i.children || [])).return = s), o) + } + function m(s, o, i, a, u) { + return null === o || 7 !== o.tag + ? (((o = Tg(i, s.mode, a, u)).return = s), o) + : (((o = e(o, i)).return = s), o) + } + function q(s, o, i) { + if (('string' == typeof o && '' !== o) || 'number' == typeof o) + return (((o = Qg('' + o, s.mode, i)).return = s), o) + if ('object' == typeof o && null !== o) { + switch (o.$$typeof) { + case z: + return ( + ((i = Rg(o.type, o.key, o.props, null, s.mode, i)).ref = Lg(s, null, o)), + (i.return = s), + i + ) + case Y: + return (((o = Sg(o, s.mode, i)).return = s), o) + case ye: + return q(s, (0, o._init)(o._payload), i) + } + if (Pe(o) || Ka(o)) return (((o = Tg(o, s.mode, i, null)).return = s), o) + Mg(s, o) + } + return null + } + function r(s, o, i, a) { + var u = null !== o ? o.key : null + if (('string' == typeof i && '' !== i) || 'number' == typeof i) + return null !== u ? null : h(s, o, '' + i, a) + if ('object' == typeof i && null !== i) { + switch (i.$$typeof) { + case z: + return i.key === u ? k(s, o, i, a) : null + case Y: + return i.key === u ? l(s, o, i, a) : null + case ye: + return r(s, o, (u = i._init)(i._payload), a) + } + if (Pe(i) || Ka(i)) return null !== u ? null : m(s, o, i, a, null) + Mg(s, i) + } + return null + } + function y(s, o, i, a, u) { + if (('string' == typeof a && '' !== a) || 'number' == typeof a) + return h(o, (s = s.get(i) || null), '' + a, u) + if ('object' == typeof a && null !== a) { + switch (a.$$typeof) { + case z: + return k(o, (s = s.get(null === a.key ? i : a.key) || null), a, u) + case Y: + return l(o, (s = s.get(null === a.key ? i : a.key) || null), a, u) + case ye: + return y(s, o, i, (0, a._init)(a._payload), u) + } + if (Pe(a) || Ka(a)) return m(o, (s = s.get(i) || null), a, u, null) + Mg(o, a) + } + return null + } + function n(o, i, a, u) { + for ( + var _ = null, w = null, x = i, C = (i = 0), j = null; + null !== x && C < a.length; + C++ + ) { + x.index > C ? ((j = x), (x = null)) : (j = x.sibling) + var L = r(o, x, a[C], u) + if (null === L) { + null === x && (x = j) + break + } + ;(s && x && null === L.alternate && b(o, x), + (i = f(L, i, C)), + null === w ? (_ = L) : (w.sibling = L), + (w = L), + (x = j)) + } + if (C === a.length) return (c(o, x), Fn && tg(o, C), _) + if (null === x) { + for (; C < a.length; C++) + null !== (x = q(o, a[C], u)) && + ((i = f(x, i, C)), null === w ? (_ = x) : (w.sibling = x), (w = x)) + return (Fn && tg(o, C), _) + } + for (x = d(o, x); C < a.length; C++) + null !== (j = y(x, o, C, a[C], u)) && + (s && null !== j.alternate && x.delete(null === j.key ? C : j.key), + (i = f(j, i, C)), + null === w ? (_ = j) : (w.sibling = j), + (w = j)) + return ( + s && + x.forEach(function (s) { + return b(o, s) + }), + Fn && tg(o, C), + _ + ) + } + function t(o, i, a, u) { + var _ = Ka(a) + if ('function' != typeof _) throw Error(p(150)) + if (null == (a = _.call(a))) throw Error(p(151)) + for ( + var w = (_ = null), x = i, C = (i = 0), j = null, L = a.next(); + null !== x && !L.done; + C++, L = a.next() + ) { + x.index > C ? ((j = x), (x = null)) : (j = x.sibling) + var B = r(o, x, L.value, u) + if (null === B) { + null === x && (x = j) + break + } + ;(s && x && null === B.alternate && b(o, x), + (i = f(B, i, C)), + null === w ? (_ = B) : (w.sibling = B), + (w = B), + (x = j)) + } + if (L.done) return (c(o, x), Fn && tg(o, C), _) + if (null === x) { + for (; !L.done; C++, L = a.next()) + null !== (L = q(o, L.value, u)) && + ((i = f(L, i, C)), null === w ? (_ = L) : (w.sibling = L), (w = L)) + return (Fn && tg(o, C), _) + } + for (x = d(o, x); !L.done; C++, L = a.next()) + null !== (L = y(x, o, C, L.value, u)) && + (s && null !== L.alternate && x.delete(null === L.key ? C : L.key), + (i = f(L, i, C)), + null === w ? (_ = L) : (w.sibling = L), + (w = L)) + return ( + s && + x.forEach(function (s) { + return b(o, s) + }), + Fn && tg(o, C), + _ + ) + } + return function J(s, o, i, a) { + if ( + ('object' == typeof i && + null !== i && + i.type === Z && + null === i.key && + (i = i.props.children), + 'object' == typeof i && null !== i) + ) { + switch (i.$$typeof) { + case z: + e: { + for (var u = i.key, _ = o; null !== _; ) { + if (_.key === u) { + if ((u = i.type) === Z) { + if (7 === _.tag) { + ;(c(s, _.sibling), ((o = e(_, i.props.children)).return = s), (s = o)) + break e + } + } else if ( + _.elementType === u || + ('object' == typeof u && + null !== u && + u.$$typeof === ye && + Ng(u) === _.type) + ) { + ;(c(s, _.sibling), + ((o = e(_, i.props)).ref = Lg(s, _, i)), + (o.return = s), + (s = o)) + break e + } + c(s, _) + break + } + ;(b(s, _), (_ = _.sibling)) + } + i.type === Z + ? (((o = Tg(i.props.children, s.mode, a, i.key)).return = s), (s = o)) + : (((a = Rg(i.type, i.key, i.props, null, s.mode, a)).ref = Lg(s, o, i)), + (a.return = s), + (s = a)) + } + return g(s) + case Y: + e: { + for (_ = i.key; null !== o; ) { + if (o.key === _) { + if ( + 4 === o.tag && + o.stateNode.containerInfo === i.containerInfo && + o.stateNode.implementation === i.implementation + ) { + ;(c(s, o.sibling), ((o = e(o, i.children || [])).return = s), (s = o)) + break e + } + c(s, o) + break + } + ;(b(s, o), (o = o.sibling)) + } + ;(((o = Sg(i, s.mode, a)).return = s), (s = o)) + } + return g(s) + case ye: + return J(s, o, (_ = i._init)(i._payload), a) + } + if (Pe(i)) return n(s, o, i, a) + if (Ka(i)) return t(s, o, i, a) + Mg(s, i) + } + return ('string' == typeof i && '' !== i) || 'number' == typeof i + ? ((i = '' + i), + null !== o && 6 === o.tag + ? (c(s, o.sibling), ((o = e(o, i)).return = s), (s = o)) + : (c(s, o), ((o = Qg(i, s.mode, a)).return = s), (s = o)), + g(s)) + : c(s, o) + } + } + var qn = Og(!0), + Un = Og(!1), + Vn = Uf(null), + zn = null, + Wn = null, + Jn = null + function $g() { + Jn = Wn = zn = null + } + function ah(s) { + var o = Vn.current + ;(E(Vn), (s._currentValue = o)) + } + function bh(s, o, i) { + for (; null !== s; ) { + var a = s.alternate + if ( + ((s.childLanes & o) !== o + ? ((s.childLanes |= o), null !== a && (a.childLanes |= o)) + : null !== a && (a.childLanes & o) !== o && (a.childLanes |= o), + s === i) + ) + break + s = s.return + } + } + function ch(s, o) { + ;((zn = s), + (Jn = Wn = null), + null !== (s = s.dependencies) && + null !== s.firstContext && + (!!(s.lanes & o) && (bs = !0), (s.firstContext = null))) + } + function eh(s) { + var o = s._currentValue + if (Jn !== s) + if (((s = { context: s, memoizedValue: o, next: null }), null === Wn)) { + if (null === zn) throw Error(p(308)) + ;((Wn = s), (zn.dependencies = { lanes: 0, firstContext: s })) + } else Wn = Wn.next = s + return o + } + var Hn = null + function gh(s) { + null === Hn ? (Hn = [s]) : Hn.push(s) + } + function hh(s, o, i, a) { + var u = o.interleaved + return ( + null === u ? ((i.next = i), gh(o)) : ((i.next = u.next), (u.next = i)), + (o.interleaved = i), + ih(s, a) + ) + } + function ih(s, o) { + s.lanes |= o + var i = s.alternate + for (null !== i && (i.lanes |= o), i = s, s = s.return; null !== s; ) + ((s.childLanes |= o), + null !== (i = s.alternate) && (i.childLanes |= o), + (i = s), + (s = s.return)) + return 3 === i.tag ? i.stateNode : null + } + var Kn = !1 + function kh(s) { + s.updateQueue = { + baseState: s.memoizedState, + firstBaseUpdate: null, + lastBaseUpdate: null, + shared: { pending: null, interleaved: null, lanes: 0 }, + effects: null, + } + } + function lh(s, o) { + ;((s = s.updateQueue), + o.updateQueue === s && + (o.updateQueue = { + baseState: s.baseState, + firstBaseUpdate: s.firstBaseUpdate, + lastBaseUpdate: s.lastBaseUpdate, + shared: s.shared, + effects: s.effects, + })) + } + function mh(s, o) { + return { eventTime: s, lane: o, tag: 0, payload: null, callback: null, next: null } + } + function nh(s, o, i) { + var a = s.updateQueue + if (null === a) return null + if (((a = a.shared), 2 & Ls)) { + var u = a.pending + return ( + null === u ? (o.next = o) : ((o.next = u.next), (u.next = o)), + (a.pending = o), + ih(s, i) + ) + } + return ( + null === (u = a.interleaved) + ? ((o.next = o), gh(a)) + : ((o.next = u.next), (u.next = o)), + (a.interleaved = o), + ih(s, i) + ) + } + function oh(s, o, i) { + if (null !== (o = o.updateQueue) && ((o = o.shared), 4194240 & i)) { + var a = o.lanes + ;((i |= a &= s.pendingLanes), (o.lanes = i), Cc(s, i)) + } + } + function ph(s, o) { + var i = s.updateQueue, + a = s.alternate + if (null !== a && i === (a = a.updateQueue)) { + var u = null, + _ = null + if (null !== (i = i.firstBaseUpdate)) { + do { + var w = { + eventTime: i.eventTime, + lane: i.lane, + tag: i.tag, + payload: i.payload, + callback: i.callback, + next: null, + } + ;(null === _ ? (u = _ = w) : (_ = _.next = w), (i = i.next)) + } while (null !== i) + null === _ ? (u = _ = o) : (_ = _.next = o) + } else u = _ = o + return ( + (i = { + baseState: a.baseState, + firstBaseUpdate: u, + lastBaseUpdate: _, + shared: a.shared, + effects: a.effects, + }), + void (s.updateQueue = i) + ) + } + ;(null === (s = i.lastBaseUpdate) ? (i.firstBaseUpdate = o) : (s.next = o), + (i.lastBaseUpdate = o)) + } + function qh(s, o, i, a) { + var u = s.updateQueue + Kn = !1 + var _ = u.firstBaseUpdate, + w = u.lastBaseUpdate, + x = u.shared.pending + if (null !== x) { + u.shared.pending = null + var C = x, + j = C.next + ;((C.next = null), null === w ? (_ = j) : (w.next = j), (w = C)) + var L = s.alternate + null !== L && + (x = (L = L.updateQueue).lastBaseUpdate) !== w && + (null === x ? (L.firstBaseUpdate = j) : (x.next = j), (L.lastBaseUpdate = C)) + } + if (null !== _) { + var B = u.baseState + for (w = 0, L = j = C = null, x = _; ; ) { + var $ = x.lane, + U = x.eventTime + if ((a & $) === $) { + null !== L && + (L = L.next = + { + eventTime: U, + lane: 0, + tag: x.tag, + payload: x.payload, + callback: x.callback, + next: null, + }) + e: { + var V = s, + z = x + switch ((($ = o), (U = i), z.tag)) { + case 1: + if ('function' == typeof (V = z.payload)) { + B = V.call(U, B, $) + break e + } + B = V + break e + case 3: + V.flags = (-65537 & V.flags) | 128 + case 0: + if ( + null == ($ = 'function' == typeof (V = z.payload) ? V.call(U, B, $) : V) + ) + break e + B = we({}, B, $) + break e + case 2: + Kn = !0 + } + } + null !== x.callback && + 0 !== x.lane && + ((s.flags |= 64), null === ($ = u.effects) ? (u.effects = [x]) : $.push(x)) + } else + ((U = { + eventTime: U, + lane: $, + tag: x.tag, + payload: x.payload, + callback: x.callback, + next: null, + }), + null === L ? ((j = L = U), (C = B)) : (L = L.next = U), + (w |= $)) + if (null === (x = x.next)) { + if (null === (x = u.shared.pending)) break + ;((x = ($ = x).next), + ($.next = null), + (u.lastBaseUpdate = $), + (u.shared.pending = null)) + } + } + if ( + (null === L && (C = B), + (u.baseState = C), + (u.firstBaseUpdate = j), + (u.lastBaseUpdate = L), + null !== (o = u.shared.interleaved)) + ) { + u = o + do { + ;((w |= u.lane), (u = u.next)) + } while (u !== o) + } else null === _ && (u.shared.lanes = 0) + ;((Ws |= w), (s.lanes = w), (s.memoizedState = B)) + } + } + function sh(s, o, i) { + if (((s = o.effects), (o.effects = null), null !== s)) + for (o = 0; o < s.length; o++) { + var a = s[o], + u = a.callback + if (null !== u) { + if (((a.callback = null), (a = i), 'function' != typeof u)) throw Error(p(191, u)) + u.call(a) + } + } + } + var Gn = {}, + Yn = Uf(Gn), + Xn = Uf(Gn), + Qn = Uf(Gn) + function xh(s) { + if (s === Gn) throw Error(p(174)) + return s + } + function yh(s, o) { + switch ((G(Qn, o), G(Xn, s), G(Yn, Gn), (s = o.nodeType))) { + case 9: + case 11: + o = (o = o.documentElement) ? o.namespaceURI : lb(null, '') + break + default: + o = lb((o = (s = 8 === s ? o.parentNode : o).namespaceURI || null), (s = s.tagName)) + } + ;(E(Yn), G(Yn, o)) + } + function zh() { + ;(E(Yn), E(Xn), E(Qn)) + } + function Ah(s) { + xh(Qn.current) + var o = xh(Yn.current), + i = lb(o, s.type) + o !== i && (G(Xn, s), G(Yn, i)) + } + function Bh(s) { + Xn.current === s && (E(Yn), E(Xn)) + } + var Zn = Uf(0) + function Ch(s) { + for (var o = s; null !== o; ) { + if (13 === o.tag) { + var i = o.memoizedState + if ( + null !== i && + (null === (i = i.dehydrated) || '$?' === i.data || '$!' === i.data) + ) + return o + } else if (19 === o.tag && void 0 !== o.memoizedProps.revealOrder) { + if (128 & o.flags) return o + } else if (null !== o.child) { + ;((o.child.return = o), (o = o.child)) + continue + } + if (o === s) break + for (; null === o.sibling; ) { + if (null === o.return || o.return === s) return null + o = o.return + } + ;((o.sibling.return = o.return), (o = o.sibling)) + } + return null + } + var es = [] + function Eh() { + for (var s = 0; s < es.length; s++) es[s]._workInProgressVersionPrimary = null + es.length = 0 + } + var ts = V.ReactCurrentDispatcher, + rs = V.ReactCurrentBatchConfig, + ns = 0, + ss = null, + os = null, + as = null, + cs = !1, + ls = !1, + us = 0, + ps = 0 + function P() { + throw Error(p(321)) + } + function Mh(s, o) { + if (null === o) return !1 + for (var i = 0; i < o.length && i < s.length; i++) if (!Dr(s[i], o[i])) return !1 + return !0 + } + function Nh(s, o, i, a, u, _) { + if ( + ((ns = _), + (ss = o), + (o.memoizedState = null), + (o.updateQueue = null), + (o.lanes = 0), + (ts.current = null === s || null === s.memoizedState ? ds : fs), + (s = i(a, u)), + ls) + ) { + _ = 0 + do { + if (((ls = !1), (us = 0), 25 <= _)) throw Error(p(301)) + ;((_ += 1), + (as = os = null), + (o.updateQueue = null), + (ts.current = ms), + (s = i(a, u))) + } while (ls) + } + if ( + ((ts.current = hs), + (o = null !== os && null !== os.next), + (ns = 0), + (as = os = ss = null), + (cs = !1), + o) + ) + throw Error(p(300)) + return s + } + function Sh() { + var s = 0 !== us + return ((us = 0), s) + } + function Th() { + var s = { + memoizedState: null, + baseState: null, + baseQueue: null, + queue: null, + next: null, + } + return (null === as ? (ss.memoizedState = as = s) : (as = as.next = s), as) + } + function Uh() { + if (null === os) { + var s = ss.alternate + s = null !== s ? s.memoizedState : null + } else s = os.next + var o = null === as ? ss.memoizedState : as.next + if (null !== o) ((as = o), (os = s)) + else { + if (null === s) throw Error(p(310)) + ;((s = { + memoizedState: (os = s).memoizedState, + baseState: os.baseState, + baseQueue: os.baseQueue, + queue: os.queue, + next: null, + }), + null === as ? (ss.memoizedState = as = s) : (as = as.next = s)) + } + return as + } + function Vh(s, o) { + return 'function' == typeof o ? o(s) : o + } + function Wh(s) { + var o = Uh(), + i = o.queue + if (null === i) throw Error(p(311)) + i.lastRenderedReducer = s + var a = os, + u = a.baseQueue, + _ = i.pending + if (null !== _) { + if (null !== u) { + var w = u.next + ;((u.next = _.next), (_.next = w)) + } + ;((a.baseQueue = u = _), (i.pending = null)) + } + if (null !== u) { + ;((_ = u.next), (a = a.baseState)) + var x = (w = null), + C = null, + j = _ + do { + var L = j.lane + if ((ns & L) === L) + (null !== C && + (C = C.next = + { + lane: 0, + action: j.action, + hasEagerState: j.hasEagerState, + eagerState: j.eagerState, + next: null, + }), + (a = j.hasEagerState ? j.eagerState : s(a, j.action))) + else { + var B = { + lane: L, + action: j.action, + hasEagerState: j.hasEagerState, + eagerState: j.eagerState, + next: null, + } + ;(null === C ? ((x = C = B), (w = a)) : (C = C.next = B), + (ss.lanes |= L), + (Ws |= L)) + } + j = j.next + } while (null !== j && j !== _) + ;(null === C ? (w = a) : (C.next = x), + Dr(a, o.memoizedState) || (bs = !0), + (o.memoizedState = a), + (o.baseState = w), + (o.baseQueue = C), + (i.lastRenderedState = a)) + } + if (null !== (s = i.interleaved)) { + u = s + do { + ;((_ = u.lane), (ss.lanes |= _), (Ws |= _), (u = u.next)) + } while (u !== s) + } else null === u && (i.lanes = 0) + return [o.memoizedState, i.dispatch] + } + function Xh(s) { + var o = Uh(), + i = o.queue + if (null === i) throw Error(p(311)) + i.lastRenderedReducer = s + var a = i.dispatch, + u = i.pending, + _ = o.memoizedState + if (null !== u) { + i.pending = null + var w = (u = u.next) + do { + ;((_ = s(_, w.action)), (w = w.next)) + } while (w !== u) + ;(Dr(_, o.memoizedState) || (bs = !0), + (o.memoizedState = _), + null === o.baseQueue && (o.baseState = _), + (i.lastRenderedState = _)) + } + return [_, a] + } + function Yh() {} + function Zh(s, o) { + var i = ss, + a = Uh(), + u = o(), + _ = !Dr(a.memoizedState, u) + if ( + (_ && ((a.memoizedState = u), (bs = !0)), + (a = a.queue), + $h(ai.bind(null, i, a, s), [s]), + a.getSnapshot !== o || _ || (null !== as && 1 & as.memoizedState.tag)) + ) { + if (((i.flags |= 2048), bi(9, ci.bind(null, i, a, u, o), void 0, null), null === Fs)) + throw Error(p(349)) + 30 & ns || di(i, o, u) + } + return u + } + function di(s, o, i) { + ;((s.flags |= 16384), + (s = { getSnapshot: o, value: i }), + null === (o = ss.updateQueue) + ? ((o = { lastEffect: null, stores: null }), (ss.updateQueue = o), (o.stores = [s])) + : null === (i = o.stores) + ? (o.stores = [s]) + : i.push(s)) + } + function ci(s, o, i, a) { + ;((o.value = i), (o.getSnapshot = a), ei(o) && fi(s)) + } + function ai(s, o, i) { + return i(function () { + ei(o) && fi(s) + }) + } + function ei(s) { + var o = s.getSnapshot + s = s.value + try { + var i = o() + return !Dr(s, i) + } catch (s) { + return !0 + } + } + function fi(s) { + var o = ih(s, 1) + null !== o && gi(o, s, 1, -1) + } + function hi(s) { + var o = Th() + return ( + 'function' == typeof s && (s = s()), + (o.memoizedState = o.baseState = s), + (s = { + pending: null, + interleaved: null, + lanes: 0, + dispatch: null, + lastRenderedReducer: Vh, + lastRenderedState: s, + }), + (o.queue = s), + (s = s.dispatch = ii.bind(null, ss, s)), + [o.memoizedState, s] + ) + } + function bi(s, o, i, a) { + return ( + (s = { tag: s, create: o, destroy: i, deps: a, next: null }), + null === (o = ss.updateQueue) + ? ((o = { lastEffect: null, stores: null }), + (ss.updateQueue = o), + (o.lastEffect = s.next = s)) + : null === (i = o.lastEffect) + ? (o.lastEffect = s.next = s) + : ((a = i.next), (i.next = s), (s.next = a), (o.lastEffect = s)), + s + ) + } + function ji() { + return Uh().memoizedState + } + function ki(s, o, i, a) { + var u = Th() + ;((ss.flags |= s), (u.memoizedState = bi(1 | o, i, void 0, void 0 === a ? null : a))) + } + function li(s, o, i, a) { + var u = Uh() + a = void 0 === a ? null : a + var _ = void 0 + if (null !== os) { + var w = os.memoizedState + if (((_ = w.destroy), null !== a && Mh(a, w.deps))) + return void (u.memoizedState = bi(o, i, _, a)) + } + ;((ss.flags |= s), (u.memoizedState = bi(1 | o, i, _, a))) + } + function mi(s, o) { + return ki(8390656, 8, s, o) + } + function $h(s, o) { + return li(2048, 8, s, o) + } + function ni(s, o) { + return li(4, 2, s, o) + } + function oi(s, o) { + return li(4, 4, s, o) + } + function pi(s, o) { + return 'function' == typeof o + ? ((s = s()), + o(s), + function () { + o(null) + }) + : null != o + ? ((s = s()), + (o.current = s), + function () { + o.current = null + }) + : void 0 + } + function qi(s, o, i) { + return ((i = null != i ? i.concat([s]) : null), li(4, 4, pi.bind(null, o, s), i)) + } + function ri() {} + function si(s, o) { + var i = Uh() + o = void 0 === o ? null : o + var a = i.memoizedState + return null !== a && null !== o && Mh(o, a[1]) ? a[0] : ((i.memoizedState = [s, o]), s) + } + function ti(s, o) { + var i = Uh() + o = void 0 === o ? null : o + var a = i.memoizedState + return null !== a && null !== o && Mh(o, a[1]) + ? a[0] + : ((s = s()), (i.memoizedState = [s, o]), s) + } + function ui(s, o, i) { + return 21 & ns + ? (Dr(i, o) || ((i = yc()), (ss.lanes |= i), (Ws |= i), (s.baseState = !0)), o) + : (s.baseState && ((s.baseState = !1), (bs = !0)), (s.memoizedState = i)) + } + function vi(s, o) { + var i = At + ;((At = 0 !== i && 4 > i ? i : 4), s(!0)) + var a = rs.transition + rs.transition = {} + try { + ;(s(!1), o()) + } finally { + ;((At = i), (rs.transition = a)) + } + } + function wi() { + return Uh().memoizedState + } + function xi(s, o, i) { + var a = yi(s) + if ( + ((i = { lane: a, action: i, hasEagerState: !1, eagerState: null, next: null }), zi(s)) + ) + Ai(o, i) + else if (null !== (i = hh(s, o, i, a))) { + ;(gi(i, s, a, R()), Bi(i, o, a)) + } + } + function ii(s, o, i) { + var a = yi(s), + u = { lane: a, action: i, hasEagerState: !1, eagerState: null, next: null } + if (zi(s)) Ai(o, u) + else { + var _ = s.alternate + if ( + 0 === s.lanes && + (null === _ || 0 === _.lanes) && + null !== (_ = o.lastRenderedReducer) + ) + try { + var w = o.lastRenderedState, + x = _(w, i) + if (((u.hasEagerState = !0), (u.eagerState = x), Dr(x, w))) { + var C = o.interleaved + return ( + null === C ? ((u.next = u), gh(o)) : ((u.next = C.next), (C.next = u)), + void (o.interleaved = u) + ) + } + } catch (s) {} + null !== (i = hh(s, o, u, a)) && (gi(i, s, a, (u = R())), Bi(i, o, a)) + } + } + function zi(s) { + var o = s.alternate + return s === ss || (null !== o && o === ss) + } + function Ai(s, o) { + ls = cs = !0 + var i = s.pending + ;(null === i ? (o.next = o) : ((o.next = i.next), (i.next = o)), (s.pending = o)) + } + function Bi(s, o, i) { + if (4194240 & i) { + var a = o.lanes + ;((i |= a &= s.pendingLanes), (o.lanes = i), Cc(s, i)) + } + } + var hs = { + readContext: eh, + useCallback: P, + useContext: P, + useEffect: P, + useImperativeHandle: P, + useInsertionEffect: P, + useLayoutEffect: P, + useMemo: P, + useReducer: P, + useRef: P, + useState: P, + useDebugValue: P, + useDeferredValue: P, + useTransition: P, + useMutableSource: P, + useSyncExternalStore: P, + useId: P, + unstable_isNewReconciler: !1, + }, + ds = { + readContext: eh, + useCallback: function (s, o) { + return ((Th().memoizedState = [s, void 0 === o ? null : o]), s) + }, + useContext: eh, + useEffect: mi, + useImperativeHandle: function (s, o, i) { + return ( + (i = null != i ? i.concat([s]) : null), + ki(4194308, 4, pi.bind(null, o, s), i) + ) + }, + useLayoutEffect: function (s, o) { + return ki(4194308, 4, s, o) + }, + useInsertionEffect: function (s, o) { + return ki(4, 2, s, o) + }, + useMemo: function (s, o) { + var i = Th() + return ((o = void 0 === o ? null : o), (s = s()), (i.memoizedState = [s, o]), s) + }, + useReducer: function (s, o, i) { + var a = Th() + return ( + (o = void 0 !== i ? i(o) : o), + (a.memoizedState = a.baseState = o), + (s = { + pending: null, + interleaved: null, + lanes: 0, + dispatch: null, + lastRenderedReducer: s, + lastRenderedState: o, + }), + (a.queue = s), + (s = s.dispatch = xi.bind(null, ss, s)), + [a.memoizedState, s] + ) + }, + useRef: function (s) { + return ((s = { current: s }), (Th().memoizedState = s)) + }, + useState: hi, + useDebugValue: ri, + useDeferredValue: function (s) { + return (Th().memoizedState = s) + }, + useTransition: function () { + var s = hi(!1), + o = s[0] + return ((s = vi.bind(null, s[1])), (Th().memoizedState = s), [o, s]) + }, + useMutableSource: function () {}, + useSyncExternalStore: function (s, o, i) { + var a = ss, + u = Th() + if (Fn) { + if (void 0 === i) throw Error(p(407)) + i = i() + } else { + if (((i = o()), null === Fs)) throw Error(p(349)) + 30 & ns || di(a, o, i) + } + u.memoizedState = i + var _ = { value: i, getSnapshot: o } + return ( + (u.queue = _), + mi(ai.bind(null, a, _, s), [s]), + (a.flags |= 2048), + bi(9, ci.bind(null, a, _, i, o), void 0, null), + i + ) + }, + useId: function () { + var s = Th(), + o = Fs.identifierPrefix + if (Fn) { + var i = Rn + ;((o = ':' + o + 'R' + (i = (Mn & ~(1 << (32 - Et(Mn) - 1))).toString(32) + i)), + 0 < (i = us++) && (o += 'H' + i.toString(32)), + (o += ':')) + } else o = ':' + o + 'r' + (i = ps++).toString(32) + ':' + return (s.memoizedState = o) + }, + unstable_isNewReconciler: !1, + }, + fs = { + readContext: eh, + useCallback: si, + useContext: eh, + useEffect: $h, + useImperativeHandle: qi, + useInsertionEffect: ni, + useLayoutEffect: oi, + useMemo: ti, + useReducer: Wh, + useRef: ji, + useState: function () { + return Wh(Vh) + }, + useDebugValue: ri, + useDeferredValue: function (s) { + return ui(Uh(), os.memoizedState, s) + }, + useTransition: function () { + return [Wh(Vh)[0], Uh().memoizedState] + }, + useMutableSource: Yh, + useSyncExternalStore: Zh, + useId: wi, + unstable_isNewReconciler: !1, + }, + ms = { + readContext: eh, + useCallback: si, + useContext: eh, + useEffect: $h, + useImperativeHandle: qi, + useInsertionEffect: ni, + useLayoutEffect: oi, + useMemo: ti, + useReducer: Xh, + useRef: ji, + useState: function () { + return Xh(Vh) + }, + useDebugValue: ri, + useDeferredValue: function (s) { + var o = Uh() + return null === os ? (o.memoizedState = s) : ui(o, os.memoizedState, s) + }, + useTransition: function () { + return [Xh(Vh)[0], Uh().memoizedState] + }, + useMutableSource: Yh, + useSyncExternalStore: Zh, + useId: wi, + unstable_isNewReconciler: !1, + } + function Ci(s, o) { + if (s && s.defaultProps) { + for (var i in ((o = we({}, o)), (s = s.defaultProps))) + void 0 === o[i] && (o[i] = s[i]) + return o + } + return o + } + function Di(s, o, i, a) { + ;((i = null == (i = i(a, (o = s.memoizedState))) ? o : we({}, o, i)), + (s.memoizedState = i), + 0 === s.lanes && (s.updateQueue.baseState = i)) + } + var gs = { + isMounted: function (s) { + return !!(s = s._reactInternals) && Vb(s) === s + }, + enqueueSetState: function (s, o, i) { + s = s._reactInternals + var a = R(), + u = yi(s), + _ = mh(a, u) + ;((_.payload = o), + null != i && (_.callback = i), + null !== (o = nh(s, _, u)) && (gi(o, s, u, a), oh(o, s, u))) + }, + enqueueReplaceState: function (s, o, i) { + s = s._reactInternals + var a = R(), + u = yi(s), + _ = mh(a, u) + ;((_.tag = 1), + (_.payload = o), + null != i && (_.callback = i), + null !== (o = nh(s, _, u)) && (gi(o, s, u, a), oh(o, s, u))) + }, + enqueueForceUpdate: function (s, o) { + s = s._reactInternals + var i = R(), + a = yi(s), + u = mh(i, a) + ;((u.tag = 2), + null != o && (u.callback = o), + null !== (o = nh(s, u, a)) && (gi(o, s, a, i), oh(o, s, a))) + }, + } + function Fi(s, o, i, a, u, _, w) { + return 'function' == typeof (s = s.stateNode).shouldComponentUpdate + ? s.shouldComponentUpdate(a, _, w) + : !o.prototype || !o.prototype.isPureReactComponent || !Ie(i, a) || !Ie(u, _) + } + function Gi(s, o, i) { + var a = !1, + u = _n, + _ = o.contextType + return ( + 'object' == typeof _ && null !== _ + ? (_ = eh(_)) + : ((u = Zf(o) ? wn : Sn.current), + (_ = (a = null != (a = o.contextTypes)) ? Yf(s, u) : _n)), + (o = new o(i, _)), + (s.memoizedState = null !== o.state && void 0 !== o.state ? o.state : null), + (o.updater = gs), + (s.stateNode = o), + (o._reactInternals = s), + a && + (((s = s.stateNode).__reactInternalMemoizedUnmaskedChildContext = u), + (s.__reactInternalMemoizedMaskedChildContext = _)), + o + ) + } + function Hi(s, o, i, a) { + ;((s = o.state), + 'function' == typeof o.componentWillReceiveProps && o.componentWillReceiveProps(i, a), + 'function' == typeof o.UNSAFE_componentWillReceiveProps && + o.UNSAFE_componentWillReceiveProps(i, a), + o.state !== s && gs.enqueueReplaceState(o, o.state, null)) + } + function Ii(s, o, i, a) { + var u = s.stateNode + ;((u.props = i), (u.state = s.memoizedState), (u.refs = {}), kh(s)) + var _ = o.contextType + ;('object' == typeof _ && null !== _ + ? (u.context = eh(_)) + : ((_ = Zf(o) ? wn : Sn.current), (u.context = Yf(s, _))), + (u.state = s.memoizedState), + 'function' == typeof (_ = o.getDerivedStateFromProps) && + (Di(s, o, _, i), (u.state = s.memoizedState)), + 'function' == typeof o.getDerivedStateFromProps || + 'function' == typeof u.getSnapshotBeforeUpdate || + ('function' != typeof u.UNSAFE_componentWillMount && + 'function' != typeof u.componentWillMount) || + ((o = u.state), + 'function' == typeof u.componentWillMount && u.componentWillMount(), + 'function' == typeof u.UNSAFE_componentWillMount && u.UNSAFE_componentWillMount(), + o !== u.state && gs.enqueueReplaceState(u, u.state, null), + qh(s, i, u, a), + (u.state = s.memoizedState)), + 'function' == typeof u.componentDidMount && (s.flags |= 4194308)) + } + function Ji(s, o) { + try { + var i = '', + a = o + do { + ;((i += Pa(a)), (a = a.return)) + } while (a) + var u = i + } catch (s) { + u = '\nError generating stack: ' + s.message + '\n' + s.stack + } + return { value: s, source: o, stack: u, digest: null } + } + function Ki(s, o, i) { + return { + value: s, + source: null, + stack: null != i ? i : null, + digest: null != o ? o : null, + } + } + function Li(s, o) { + try { + console.error(o.value) + } catch (s) { + setTimeout(function () { + throw s + }) + } + } + var ys = 'function' == typeof WeakMap ? WeakMap : Map + function Ni(s, o, i) { + ;(((i = mh(-1, i)).tag = 3), (i.payload = { element: null })) + var a = o.value + return ( + (i.callback = function () { + ;(Zs || ((Zs = !0), (eo = a)), Li(0, o)) + }), + i + ) + } + function Qi(s, o, i) { + ;(i = mh(-1, i)).tag = 3 + var a = s.type.getDerivedStateFromError + if ('function' == typeof a) { + var u = o.value + ;((i.payload = function () { + return a(u) + }), + (i.callback = function () { + Li(0, o) + })) + } + var _ = s.stateNode + return ( + null !== _ && + 'function' == typeof _.componentDidCatch && + (i.callback = function () { + ;(Li(0, o), + 'function' != typeof a && (null === to ? (to = new Set([this])) : to.add(this))) + var s = o.stack + this.componentDidCatch(o.value, { componentStack: null !== s ? s : '' }) + }), + i + ) + } + function Si(s, o, i) { + var a = s.pingCache + if (null === a) { + a = s.pingCache = new ys() + var u = new Set() + a.set(o, u) + } else void 0 === (u = a.get(o)) && ((u = new Set()), a.set(o, u)) + u.has(i) || (u.add(i), (s = Ti.bind(null, s, o, i)), o.then(s, s)) + } + function Ui(s) { + do { + var o + if ( + ((o = 13 === s.tag) && + (o = null === (o = s.memoizedState) || null !== o.dehydrated), + o) + ) + return s + s = s.return + } while (null !== s) + return null + } + function Vi(s, o, i, a, u) { + return 1 & s.mode + ? ((s.flags |= 65536), (s.lanes = u), s) + : (s === o + ? (s.flags |= 65536) + : ((s.flags |= 128), + (i.flags |= 131072), + (i.flags &= -52805), + 1 === i.tag && + (null === i.alternate + ? (i.tag = 17) + : (((o = mh(-1, 1)).tag = 2), nh(i, o, 1))), + (i.lanes |= 1)), + s) + } + var vs = V.ReactCurrentOwner, + bs = !1 + function Xi(s, o, i, a) { + o.child = null === s ? Un(o, null, i, a) : qn(o, s.child, i, a) + } + function Yi(s, o, i, a, u) { + i = i.render + var _ = o.ref + return ( + ch(o, u), + (a = Nh(s, o, i, a, _, u)), + (i = Sh()), + null === s || bs + ? (Fn && i && vg(o), (o.flags |= 1), Xi(s, o, a, u), o.child) + : ((o.updateQueue = s.updateQueue), + (o.flags &= -2053), + (s.lanes &= ~u), + Zi(s, o, u)) + ) + } + function $i(s, o, i, a, u) { + if (null === s) { + var _ = i.type + return 'function' != typeof _ || + aj(_) || + void 0 !== _.defaultProps || + null !== i.compare || + void 0 !== i.defaultProps + ? (((s = Rg(i.type, null, a, o, o.mode, u)).ref = o.ref), + (s.return = o), + (o.child = s)) + : ((o.tag = 15), (o.type = _), bj(s, o, _, a, u)) + } + if (((_ = s.child), !(s.lanes & u))) { + var w = _.memoizedProps + if ((i = null !== (i = i.compare) ? i : Ie)(w, a) && s.ref === o.ref) + return Zi(s, o, u) + } + return ((o.flags |= 1), ((s = Pg(_, a)).ref = o.ref), (s.return = o), (o.child = s)) + } + function bj(s, o, i, a, u) { + if (null !== s) { + var _ = s.memoizedProps + if (Ie(_, a) && s.ref === o.ref) { + if (((bs = !1), (o.pendingProps = a = _), !(s.lanes & u))) + return ((o.lanes = s.lanes), Zi(s, o, u)) + 131072 & s.flags && (bs = !0) + } + } + return cj(s, o, i, a, u) + } + function dj(s, o, i) { + var a = o.pendingProps, + u = a.children, + _ = null !== s ? s.memoizedState : null + if ('hidden' === a.mode) + if (1 & o.mode) { + if (!(1073741824 & i)) + return ( + (s = null !== _ ? _.baseLanes | i : i), + (o.lanes = o.childLanes = 1073741824), + (o.memoizedState = { baseLanes: s, cachePool: null, transitions: null }), + (o.updateQueue = null), + G(Us, qs), + (qs |= s), + null + ) + ;((o.memoizedState = { baseLanes: 0, cachePool: null, transitions: null }), + (a = null !== _ ? _.baseLanes : i), + G(Us, qs), + (qs |= a)) + } else + ((o.memoizedState = { baseLanes: 0, cachePool: null, transitions: null }), + G(Us, qs), + (qs |= i)) + else + (null !== _ ? ((a = _.baseLanes | i), (o.memoizedState = null)) : (a = i), + G(Us, qs), + (qs |= a)) + return (Xi(s, o, u, i), o.child) + } + function gj(s, o) { + var i = o.ref + ;((null === s && null !== i) || (null !== s && s.ref !== i)) && + ((o.flags |= 512), (o.flags |= 2097152)) + } + function cj(s, o, i, a, u) { + var _ = Zf(i) ? wn : Sn.current + return ( + (_ = Yf(o, _)), + ch(o, u), + (i = Nh(s, o, i, a, _, u)), + (a = Sh()), + null === s || bs + ? (Fn && a && vg(o), (o.flags |= 1), Xi(s, o, i, u), o.child) + : ((o.updateQueue = s.updateQueue), + (o.flags &= -2053), + (s.lanes &= ~u), + Zi(s, o, u)) + ) + } + function hj(s, o, i, a, u) { + if (Zf(i)) { + var _ = !0 + cg(o) + } else _ = !1 + if ((ch(o, u), null === o.stateNode)) (ij(s, o), Gi(o, i, a), Ii(o, i, a, u), (a = !0)) + else if (null === s) { + var w = o.stateNode, + x = o.memoizedProps + w.props = x + var C = w.context, + j = i.contextType + 'object' == typeof j && null !== j + ? (j = eh(j)) + : (j = Yf(o, (j = Zf(i) ? wn : Sn.current))) + var L = i.getDerivedStateFromProps, + B = 'function' == typeof L || 'function' == typeof w.getSnapshotBeforeUpdate + ;(B || + ('function' != typeof w.UNSAFE_componentWillReceiveProps && + 'function' != typeof w.componentWillReceiveProps) || + ((x !== a || C !== j) && Hi(o, w, a, j)), + (Kn = !1)) + var $ = o.memoizedState + ;((w.state = $), + qh(o, a, w, u), + (C = o.memoizedState), + x !== a || $ !== C || En.current || Kn + ? ('function' == typeof L && (Di(o, i, L, a), (C = o.memoizedState)), + (x = Kn || Fi(o, i, x, a, $, C, j)) + ? (B || + ('function' != typeof w.UNSAFE_componentWillMount && + 'function' != typeof w.componentWillMount) || + ('function' == typeof w.componentWillMount && w.componentWillMount(), + 'function' == typeof w.UNSAFE_componentWillMount && + w.UNSAFE_componentWillMount()), + 'function' == typeof w.componentDidMount && (o.flags |= 4194308)) + : ('function' == typeof w.componentDidMount && (o.flags |= 4194308), + (o.memoizedProps = a), + (o.memoizedState = C)), + (w.props = a), + (w.state = C), + (w.context = j), + (a = x)) + : ('function' == typeof w.componentDidMount && (o.flags |= 4194308), (a = !1))) + } else { + ;((w = o.stateNode), + lh(s, o), + (x = o.memoizedProps), + (j = o.type === o.elementType ? x : Ci(o.type, x)), + (w.props = j), + (B = o.pendingProps), + ($ = w.context), + 'object' == typeof (C = i.contextType) && null !== C + ? (C = eh(C)) + : (C = Yf(o, (C = Zf(i) ? wn : Sn.current)))) + var U = i.getDerivedStateFromProps + ;((L = 'function' == typeof U || 'function' == typeof w.getSnapshotBeforeUpdate) || + ('function' != typeof w.UNSAFE_componentWillReceiveProps && + 'function' != typeof w.componentWillReceiveProps) || + ((x !== B || $ !== C) && Hi(o, w, a, C)), + (Kn = !1), + ($ = o.memoizedState), + (w.state = $), + qh(o, a, w, u)) + var V = o.memoizedState + x !== B || $ !== V || En.current || Kn + ? ('function' == typeof U && (Di(o, i, U, a), (V = o.memoizedState)), + (j = Kn || Fi(o, i, j, a, $, V, C) || !1) + ? (L || + ('function' != typeof w.UNSAFE_componentWillUpdate && + 'function' != typeof w.componentWillUpdate) || + ('function' == typeof w.componentWillUpdate && + w.componentWillUpdate(a, V, C), + 'function' == typeof w.UNSAFE_componentWillUpdate && + w.UNSAFE_componentWillUpdate(a, V, C)), + 'function' == typeof w.componentDidUpdate && (o.flags |= 4), + 'function' == typeof w.getSnapshotBeforeUpdate && (o.flags |= 1024)) + : ('function' != typeof w.componentDidUpdate || + (x === s.memoizedProps && $ === s.memoizedState) || + (o.flags |= 4), + 'function' != typeof w.getSnapshotBeforeUpdate || + (x === s.memoizedProps && $ === s.memoizedState) || + (o.flags |= 1024), + (o.memoizedProps = a), + (o.memoizedState = V)), + (w.props = a), + (w.state = V), + (w.context = C), + (a = j)) + : ('function' != typeof w.componentDidUpdate || + (x === s.memoizedProps && $ === s.memoizedState) || + (o.flags |= 4), + 'function' != typeof w.getSnapshotBeforeUpdate || + (x === s.memoizedProps && $ === s.memoizedState) || + (o.flags |= 1024), + (a = !1)) + } + return jj(s, o, i, a, _, u) + } + function jj(s, o, i, a, u, _) { + gj(s, o) + var w = !!(128 & o.flags) + if (!a && !w) return (u && dg(o, i, !1), Zi(s, o, _)) + ;((a = o.stateNode), (vs.current = o)) + var x = w && 'function' != typeof i.getDerivedStateFromError ? null : a.render() + return ( + (o.flags |= 1), + null !== s && w + ? ((o.child = qn(o, s.child, null, _)), (o.child = qn(o, null, x, _))) + : Xi(s, o, x, _), + (o.memoizedState = a.state), + u && dg(o, i, !0), + o.child + ) + } + function kj(s) { + var o = s.stateNode + ;(o.pendingContext + ? ag(0, o.pendingContext, o.pendingContext !== o.context) + : o.context && ag(0, o.context, !1), + yh(s, o.containerInfo)) + } + function lj(s, o, i, a, u) { + return (Ig(), Jg(u), (o.flags |= 256), Xi(s, o, i, a), o.child) + } + var _s, + Ss, + Es, + ws, + xs = { dehydrated: null, treeContext: null, retryLane: 0 } + function nj(s) { + return { baseLanes: s, cachePool: null, transitions: null } + } + function oj(s, o, i) { + var a, + u = o.pendingProps, + _ = Zn.current, + w = !1, + x = !!(128 & o.flags) + if ( + ((a = x) || (a = (null === s || null !== s.memoizedState) && !!(2 & _)), + a + ? ((w = !0), (o.flags &= -129)) + : (null !== s && null === s.memoizedState) || (_ |= 1), + G(Zn, 1 & _), + null === s) + ) + return ( + Eg(o), + null !== (s = o.memoizedState) && null !== (s = s.dehydrated) + ? (1 & o.mode + ? '$!' === s.data + ? (o.lanes = 8) + : (o.lanes = 1073741824) + : (o.lanes = 1), + null) + : ((x = u.children), + (s = u.fallback), + w + ? ((u = o.mode), + (w = o.child), + (x = { mode: 'hidden', children: x }), + 1 & u || null === w + ? (w = pj(x, u, 0, null)) + : ((w.childLanes = 0), (w.pendingProps = x)), + (s = Tg(s, u, i, null)), + (w.return = o), + (s.return = o), + (w.sibling = s), + (o.child = w), + (o.child.memoizedState = nj(i)), + (o.memoizedState = xs), + s) + : qj(o, x)) + ) + if (null !== (_ = s.memoizedState) && null !== (a = _.dehydrated)) + return (function rj(s, o, i, a, u, _, w) { + if (i) + return 256 & o.flags + ? ((o.flags &= -257), sj(s, o, w, (a = Ki(Error(p(422)))))) + : null !== o.memoizedState + ? ((o.child = s.child), (o.flags |= 128), null) + : ((_ = a.fallback), + (u = o.mode), + (a = pj({ mode: 'visible', children: a.children }, u, 0, null)), + ((_ = Tg(_, u, w, null)).flags |= 2), + (a.return = o), + (_.return = o), + (a.sibling = _), + (o.child = a), + 1 & o.mode && qn(o, s.child, null, w), + (o.child.memoizedState = nj(w)), + (o.memoizedState = xs), + _) + if (!(1 & o.mode)) return sj(s, o, w, null) + if ('$!' === u.data) { + if ((a = u.nextSibling && u.nextSibling.dataset)) var x = a.dgst + return ((a = x), sj(s, o, w, (a = Ki((_ = Error(p(419))), a, void 0)))) + } + if (((x = !!(w & s.childLanes)), bs || x)) { + if (null !== (a = Fs)) { + switch (w & -w) { + case 4: + u = 2 + break + case 16: + u = 8 + break + case 64: + case 128: + case 256: + case 512: + case 1024: + case 2048: + case 4096: + case 8192: + case 16384: + case 32768: + case 65536: + case 131072: + case 262144: + case 524288: + case 1048576: + case 2097152: + case 4194304: + case 8388608: + case 16777216: + case 33554432: + case 67108864: + u = 32 + break + case 536870912: + u = 268435456 + break + default: + u = 0 + } + 0 !== (u = u & (a.suspendedLanes | w) ? 0 : u) && + u !== _.retryLane && + ((_.retryLane = u), ih(s, u), gi(a, s, u, -1)) + } + return (tj(), sj(s, o, w, (a = Ki(Error(p(421)))))) + } + return '$?' === u.data + ? ((o.flags |= 128), + (o.child = s.child), + (o = uj.bind(null, s)), + (u._reactRetry = o), + null) + : ((s = _.treeContext), + (Ln = Lf(u.nextSibling)), + (Dn = o), + (Fn = !0), + (Bn = null), + null !== s && + ((In[Tn++] = Mn), + (In[Tn++] = Rn), + (In[Tn++] = Nn), + (Mn = s.id), + (Rn = s.overflow), + (Nn = o)), + (o = qj(o, a.children)), + (o.flags |= 4096), + o) + })(s, o, x, u, a, _, i) + if (w) { + ;((w = u.fallback), (x = o.mode), (a = (_ = s.child).sibling)) + var C = { mode: 'hidden', children: u.children } + return ( + 1 & x || o.child === _ + ? ((u = Pg(_, C)).subtreeFlags = 14680064 & _.subtreeFlags) + : (((u = o.child).childLanes = 0), (u.pendingProps = C), (o.deletions = null)), + null !== a ? (w = Pg(a, w)) : ((w = Tg(w, x, i, null)).flags |= 2), + (w.return = o), + (u.return = o), + (u.sibling = w), + (o.child = u), + (u = w), + (w = o.child), + (x = + null === (x = s.child.memoizedState) + ? nj(i) + : { baseLanes: x.baseLanes | i, cachePool: null, transitions: x.transitions }), + (w.memoizedState = x), + (w.childLanes = s.childLanes & ~i), + (o.memoizedState = xs), + u + ) + } + return ( + (s = (w = s.child).sibling), + (u = Pg(w, { mode: 'visible', children: u.children })), + !(1 & o.mode) && (u.lanes = i), + (u.return = o), + (u.sibling = null), + null !== s && + (null === (i = o.deletions) ? ((o.deletions = [s]), (o.flags |= 16)) : i.push(s)), + (o.child = u), + (o.memoizedState = null), + u + ) + } + function qj(s, o) { + return ( + ((o = pj({ mode: 'visible', children: o }, s.mode, 0, null)).return = s), + (s.child = o) + ) + } + function sj(s, o, i, a) { + return ( + null !== a && Jg(a), + qn(o, s.child, null, i), + ((s = qj(o, o.pendingProps.children)).flags |= 2), + (o.memoizedState = null), + s + ) + } + function vj(s, o, i) { + s.lanes |= o + var a = s.alternate + ;(null !== a && (a.lanes |= o), bh(s.return, o, i)) + } + function wj(s, o, i, a, u) { + var _ = s.memoizedState + null === _ + ? (s.memoizedState = { + isBackwards: o, + rendering: null, + renderingStartTime: 0, + last: a, + tail: i, + tailMode: u, + }) + : ((_.isBackwards = o), + (_.rendering = null), + (_.renderingStartTime = 0), + (_.last = a), + (_.tail = i), + (_.tailMode = u)) + } + function xj(s, o, i) { + var a = o.pendingProps, + u = a.revealOrder, + _ = a.tail + if ((Xi(s, o, a.children, i), 2 & (a = Zn.current))) + ((a = (1 & a) | 2), (o.flags |= 128)) + else { + if (null !== s && 128 & s.flags) + e: for (s = o.child; null !== s; ) { + if (13 === s.tag) null !== s.memoizedState && vj(s, i, o) + else if (19 === s.tag) vj(s, i, o) + else if (null !== s.child) { + ;((s.child.return = s), (s = s.child)) + continue + } + if (s === o) break e + for (; null === s.sibling; ) { + if (null === s.return || s.return === o) break e + s = s.return + } + ;((s.sibling.return = s.return), (s = s.sibling)) + } + a &= 1 + } + if ((G(Zn, a), 1 & o.mode)) + switch (u) { + case 'forwards': + for (i = o.child, u = null; null !== i; ) + (null !== (s = i.alternate) && null === Ch(s) && (u = i), (i = i.sibling)) + ;(null === (i = u) + ? ((u = o.child), (o.child = null)) + : ((u = i.sibling), (i.sibling = null)), + wj(o, !1, u, i, _)) + break + case 'backwards': + for (i = null, u = o.child, o.child = null; null !== u; ) { + if (null !== (s = u.alternate) && null === Ch(s)) { + o.child = u + break + } + ;((s = u.sibling), (u.sibling = i), (i = u), (u = s)) + } + wj(o, !0, i, null, _) + break + case 'together': + wj(o, !1, null, null, void 0) + break + default: + o.memoizedState = null + } + else o.memoizedState = null + return o.child + } + function ij(s, o) { + !(1 & o.mode) && + null !== s && + ((s.alternate = null), (o.alternate = null), (o.flags |= 2)) + } + function Zi(s, o, i) { + if ( + (null !== s && (o.dependencies = s.dependencies), + (Ws |= o.lanes), + !(i & o.childLanes)) + ) + return null + if (null !== s && o.child !== s.child) throw Error(p(153)) + if (null !== o.child) { + for ( + i = Pg((s = o.child), s.pendingProps), o.child = i, i.return = o; + null !== s.sibling; + ) + ((s = s.sibling), ((i = i.sibling = Pg(s, s.pendingProps)).return = o)) + i.sibling = null + } + return o.child + } + function Dj(s, o) { + if (!Fn) + switch (s.tailMode) { + case 'hidden': + o = s.tail + for (var i = null; null !== o; ) + (null !== o.alternate && (i = o), (o = o.sibling)) + null === i ? (s.tail = null) : (i.sibling = null) + break + case 'collapsed': + i = s.tail + for (var a = null; null !== i; ) + (null !== i.alternate && (a = i), (i = i.sibling)) + null === a + ? o || null === s.tail + ? (s.tail = null) + : (s.tail.sibling = null) + : (a.sibling = null) + } + } + function S(s) { + var o = null !== s.alternate && s.alternate.child === s.child, + i = 0, + a = 0 + if (o) + for (var u = s.child; null !== u; ) + ((i |= u.lanes | u.childLanes), + (a |= 14680064 & u.subtreeFlags), + (a |= 14680064 & u.flags), + (u.return = s), + (u = u.sibling)) + else + for (u = s.child; null !== u; ) + ((i |= u.lanes | u.childLanes), + (a |= u.subtreeFlags), + (a |= u.flags), + (u.return = s), + (u = u.sibling)) + return ((s.subtreeFlags |= a), (s.childLanes = i), o) + } + function Ej(s, o, i) { + var a = o.pendingProps + switch ((wg(o), o.tag)) { + case 2: + case 16: + case 15: + case 0: + case 11: + case 7: + case 8: + case 12: + case 9: + case 14: + return (S(o), null) + case 1: + case 17: + return (Zf(o.type) && $f(), S(o), null) + case 3: + return ( + (a = o.stateNode), + zh(), + E(En), + E(Sn), + Eh(), + a.pendingContext && ((a.context = a.pendingContext), (a.pendingContext = null)), + (null !== s && null !== s.child) || + (Gg(o) + ? (o.flags |= 4) + : null === s || + (s.memoizedState.isDehydrated && !(256 & o.flags)) || + ((o.flags |= 1024), null !== Bn && (Fj(Bn), (Bn = null)))), + Ss(s, o), + S(o), + null + ) + case 5: + Bh(o) + var u = xh(Qn.current) + if (((i = o.type), null !== s && null != o.stateNode)) + (Es(s, o, i, a, u), s.ref !== o.ref && ((o.flags |= 512), (o.flags |= 2097152))) + else { + if (!a) { + if (null === o.stateNode) throw Error(p(166)) + return (S(o), null) + } + if (((s = xh(Yn.current)), Gg(o))) { + ;((a = o.stateNode), (i = o.type)) + var _ = o.memoizedProps + switch (((a[hn] = o), (a[dn] = _), (s = !!(1 & o.mode)), i)) { + case 'dialog': + ;(D('cancel', a), D('close', a)) + break + case 'iframe': + case 'object': + case 'embed': + D('load', a) + break + case 'video': + case 'audio': + for (u = 0; u < Zr.length; u++) D(Zr[u], a) + break + case 'source': + D('error', a) + break + case 'img': + case 'image': + case 'link': + ;(D('error', a), D('load', a)) + break + case 'details': + D('toggle', a) + break + case 'input': + ;(Za(a, _), D('invalid', a)) + break + case 'select': + ;((a._wrapperState = { wasMultiple: !!_.multiple }), D('invalid', a)) + break + case 'textarea': + ;(hb(a, _), D('invalid', a)) + } + for (var x in (ub(i, _), (u = null), _)) + if (_.hasOwnProperty(x)) { + var C = _[x] + 'children' === x + ? 'string' == typeof C + ? a.textContent !== C && + (!0 !== _.suppressHydrationWarning && Af(a.textContent, C, s), + (u = ['children', C])) + : 'number' == typeof C && + a.textContent !== '' + C && + (!0 !== _.suppressHydrationWarning && Af(a.textContent, C, s), + (u = ['children', '' + C])) + : w.hasOwnProperty(x) && null != C && 'onScroll' === x && D('scroll', a) + } + switch (i) { + case 'input': + ;(Va(a), db(a, _, !0)) + break + case 'textarea': + ;(Va(a), jb(a)) + break + case 'select': + case 'option': + break + default: + 'function' == typeof _.onClick && (a.onclick = Bf) + } + ;((a = u), (o.updateQueue = a), null !== a && (o.flags |= 4)) + } else { + ;((x = 9 === u.nodeType ? u : u.ownerDocument), + 'http://www.w3.org/1999/xhtml' === s && (s = kb(i)), + 'http://www.w3.org/1999/xhtml' === s + ? 'script' === i + ? (((s = x.createElement('div')).innerHTML = ' + + + diff --git a/frontend/src/components/drawer-filter/src/DrawerTreeFilter.vue b/frontend/src/components/drawer-filter/src/DrawerTreeFilter.vue new file mode 100644 index 000000000..652ef11db --- /dev/null +++ b/frontend/src/components/drawer-filter/src/DrawerTreeFilter.vue @@ -0,0 +1,127 @@ + + + + diff --git a/frontend/src/components/drawer-main/src/DrawerMain.vue b/frontend/src/components/drawer-main/src/DrawerMain.vue index 6f149b5b1..7b62359b2 100644 --- a/frontend/src/components/drawer-main/src/DrawerMain.vue +++ b/frontend/src/components/drawer-main/src/DrawerMain.vue @@ -6,6 +6,8 @@ import DrawerFilter from '@/components/drawer-filter/src/DrawerFilter.vue' import DrawerEnumFilter from '@/components/drawer-filter/src/DrawerEnumFilter.vue' import { useEmitt } from '@/utils/useEmitt' import { useI18n } from 'vue-i18n' +import DrawerTreeFilter from '@/components/drawer-filter/src/DrawerTreeFilter.vue' +import DrawerTimeFilter from '@/components/drawer-filter/src/DrawerTimeFilter.vue' const { t } = useI18n() const props = defineProps({ filterOptions: propTypes.arrayOf( @@ -126,6 +128,14 @@ defineExpose({ direction="rtl" >
+ +